feat: AR-ProjectManagement initial commit — JavaScript (Node.js / React) Express + Prisma (backend) / React + Axios (frontend)

This commit is contained in:
2026-07-03 12:16:44 -04:00
commit 0c1a9a802c
100 changed files with 56764 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../services/api';
import { MOCK_USER } from '../services/mockData';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const loadUser = useCallback(async () => {
const token = localStorage.getItem('accessToken');
if (!token) { setLoading(false); return; }
if (token === 'demo-token') { setUser(MOCK_USER); setLoading(false); return; }
try {
const { data } = await api.get('/auth/me');
setUser(data.user);
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUser(); }, [loadUser]);
const login = async (email, password) => {
// Demo mode: check against configurable credentials in arpm_settings
const settings = JSON.parse(localStorage.getItem('arpm_settings') || '{}');
const adminEmail = settings.adminEmail || 'admin@arpm.com';
const adminPwd = settings.adminPassword || 'Admin123!';
if (email === adminEmail && password === adminPwd) {
try {
const { data } = await api.post('/auth/login', { email, password });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
setUser(data.user);
return data.user;
} catch {
// Fallback a demo si el backend no está disponible
localStorage.setItem('accessToken', 'demo-token');
setUser(MOCK_USER);
return MOCK_USER;
}
}
const { data } = await api.post('/auth/login', { email, password });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
setUser(data.user);
return data.user;
};
const logout = async () => {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken && refreshToken !== 'demo-token') {
await api.post('/auth/logout', { refreshToken });
}
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
}
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
return ctx;
};
+124
View File
@@ -0,0 +1,124 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
const LanguageContext = createContext(null);
// Traducciones estáticas de la UI
export const T = {
es: {
dashboard: 'Dashboard', projects: 'Proyectos', gantt: 'Gantt & CP',
wbs: 'EDT / WBS', risks: 'Riesgos', evm: 'EVM', portfolio: 'Portafolio',
burndown: 'Burndown', simulator: 'Simulador', nps: 'NPS Cliente',
exports: 'Exportar PDF', budget: 'Presupuesto', reports: 'Reportes',
accounting: 'Contabilidad', billing: 'Facturación',
team: 'Equipo', workOrders: 'Órd. Trabajo', procurement: 'Abastecimiento',
resources: 'Recursos', documents: 'Documentos', quality: 'Calidad (QC)',
hse: 'HSE / SSOMA', bids: 'Licitaciones', reception: 'Recepción',
timeTracking: 'Control Horas', meetings: 'Actas Reunión',
lessons: 'Lecc. Aprendidas', standup: 'Standup Board',
messages: 'Mensajes', contracts: 'Contratos', vendors: 'Proveedores',
changes: 'Gest. Cambios', closure: 'Cierre Proyecto',
clientPortal: 'Portal Cliente', settings: 'Configuración', users: 'Usuarios y Roles',
newProject: 'Nuevo proyecto', createProject: 'Crear proyecto',
save: 'Guardar', cancel: 'Cancelar', search: 'Buscar',
status: 'Estado', priority: 'Prioridad', assignee: 'Responsable',
dueDate: 'Fecha límite', progress: 'Avance', hours: 'Horas',
pending: 'PENDIENTE', inProgress: 'EN PROCESO', done: 'COMPLETADA',
blocked: 'BLOQUEADA', active: 'Activo', paused: 'Pausado',
atRisk: 'En riesgo', completed: 'Completado',
loading: 'Cargando...', noData: 'Sin datos',
importProject: 'Importar anteproyecto con IA',
translateContent: 'Traducir contenido',
},
en: {
dashboard: 'Dashboard', projects: 'Projects', gantt: 'Gantt & CP',
wbs: 'WBS / EDT', risks: 'Risks', evm: 'EVM', portfolio: 'Portfolio',
burndown: 'Burndown', simulator: 'Simulator', nps: 'Client NPS',
exports: 'Export PDF', budget: 'Budget', reports: 'Reports',
accounting: 'Accounting', billing: 'Billing',
team: 'Team', workOrders: 'Work Orders', procurement: 'Procurement',
resources: 'Resources', documents: 'Documents', quality: 'Quality (QC)',
hse: 'HSE / SSOMA', bids: 'Bids', reception: 'Reception',
timeTracking: 'Time Tracking', meetings: 'Meeting Minutes',
lessons: 'Lessons Learned', standup: 'Standup Board',
messages: 'Messages', contracts: 'Contracts', vendors: 'Vendors',
changes: 'Change Mgmt', closure: 'Project Closure',
clientPortal: 'Client Portal', settings: 'Settings', users: 'Users & Roles',
newProject: 'New project', createProject: 'Create project',
save: 'Save', cancel: 'Cancel', search: 'Search',
status: 'Status', priority: 'Priority', assignee: 'Assignee',
dueDate: 'Due date', progress: 'Progress', hours: 'Hours',
pending: 'PENDING', inProgress: 'IN PROGRESS', done: 'COMPLETED',
blocked: 'BLOCKED', active: 'Active', paused: 'Paused',
atRisk: 'At risk', completed: 'Completed',
loading: 'Loading...', noData: 'No data',
importProject: 'Import pre-project with AI',
translateContent: 'Translate content',
},
};
// Traduce texto dinámico con la IA configurada
export async function translateWithAI(text, targetLang) {
if (!text || !text.trim()) return text;
try {
const config = JSON.parse(localStorage.getItem('arpm_settings') || '{}');
const provider = config.aiProvider || 'none';
const langName = targetLang === 'en' ? 'English' : 'Spanish';
const prompt = `Translate the following project management text to ${langName}. Return ONLY the translated text, no explanations:\n\n${text}`;
if (provider === 'ollama') {
const base = (config.aiLocalUrl || 'http://localhost:11434').replace(/\/$/, '');
const model = config.aiLocalModel || 'llama3';
const res = await fetch(`${base}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, stream: false }),
});
if (!res.ok) return text;
return (await res.json()).response?.trim() || text;
}
if (provider === 'claude') {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': config.claudeApiKey || '', 'anthropic-version': '2023-06-01' },
body: JSON.stringify({ model: config.claudeModel || 'claude-haiku-4-5-20251001', max_tokens: 1024, messages: [{ role: 'user', content: prompt }] }),
});
if (!res.ok) return text;
return (await res.json()).content[0].text?.trim() || text;
}
if (provider === 'openai') {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.openaiApiKey || ''}` },
body: JSON.stringify({ model: config.openaiModel || 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }], max_tokens: 1024 }),
});
if (!res.ok) return text;
return (await res.json()).choices[0].message.content?.trim() || text;
}
} catch { /* sin IA, devolver original */ }
return text;
}
export function LanguageProvider({ children }) {
const [lang, setLang] = useState(() => localStorage.getItem('arpm_lang') || 'es');
const toggleLang = useCallback(() => {
setLang(l => {
const next = l === 'es' ? 'en' : 'es';
localStorage.setItem('arpm_lang', next);
return next;
});
}, []);
const t = useCallback((key) => T[lang]?.[key] || T.es[key] || key, [lang]);
return (
<LanguageContext.Provider value={{ lang, toggleLang, t, translateWithAI }}>
{children}
</LanguageContext.Provider>
);
}
export const useLang = () => {
const ctx = useContext(LanguageContext);
if (!ctx) throw new Error('useLang must be inside LanguageProvider');
return ctx;
};
@@ -0,0 +1,76 @@
import React, { createContext, useContext, useMemo } from 'react';
// ─── Roles del sistema ───────────────────────────────────────────────────────
export const ROLES = {
OWNER: 'OWNER',
ADMIN: 'ADMIN',
GERENTE: 'GERENTE',
FINANCIERO: 'FINANCIERO',
CONTADOR: 'CONTADOR',
INGENIERO: 'INGENIERO',
SUPERVISOR: 'SUPERVISOR',
TECNICO: 'TECNICO',
};
// ─── Permisos por módulo ─────────────────────────────────────────────────────
export const PERMS = {
// Financiero / contable
CASH_FLOW_ALARM: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR'],
ACCOUNTING_PAYMENTS: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR'],
ACCOUNTING_CONTRACTS: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR'],
ACCOUNTING_NOMINA: ['OWNER','ADMIN','FINANCIERO','CONTADOR'],
ACCOUNTING_LIBRO: ['OWNER','ADMIN','FINANCIERO','CONTADOR'],
ACCOUNTING_FLUJO: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR'],
BUDGET: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR','INGENIERO'],
REPORTS_FINANCIAL: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR'],
REPORTS_OPERATIONAL: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR','INGENIERO','SUPERVISOR'],
PROCUREMENT: ['OWNER','ADMIN','GERENTE','FINANCIERO','CONTADOR'],
// Gestión
PROJECTS: ['OWNER','ADMIN','GERENTE','INGENIERO','SUPERVISOR'],
TASKS: ['OWNER','ADMIN','GERENTE','INGENIERO','SUPERVISOR','TECNICO'],
TEAM: ['OWNER','ADMIN','GERENTE'],
// Sistema
USERS_ROLES: ['OWNER','ADMIN'],
SETTINGS: ['OWNER','ADMIN'],
};
// ─── Función utilitaria pura ─────────────────────────────────────────────────
export function can(permKey, userRole) {
const allowed = PERMS[permKey];
if (!allowed) return false;
return allowed.includes(userRole);
}
// ─── Context ─────────────────────────────────────────────────────────────────
const PermissionsContext = createContext(null);
export function PermissionsProvider({ children }) {
// Lee el rol desde localStorage; en prod vendría del AuthContext/user.role
const role = useMemo(() => {
try {
return localStorage.getItem('arpm_user_role') || 'OWNER';
} catch {
return 'OWNER';
}
}, []);
const value = useMemo(() => ({
role,
can: (permKey) => can(permKey, role),
isOwner: role === 'OWNER',
isAdmin: role === 'ADMIN' || role === 'OWNER',
isFinancial: ['OWNER','ADMIN','FINANCIERO','CONTADOR'].includes(role),
}), [role]);
return (
<PermissionsContext.Provider value={value}>
{children}
</PermissionsContext.Provider>
);
}
export function usePermissions() {
const ctx = useContext(PermissionsContext);
if (!ctx) throw new Error('usePermissions must be inside PermissionsProvider');
return ctx;
}
+17
View File
@@ -0,0 +1,17 @@
import React, { createContext, useContext, useState } from 'react';
const ProjectContext = createContext(null);
export function ProjectProvider({ children }) {
// null = todos los proyectos (vista portafolio)
const [selectedProjectId, setSelectedProjectId] = useState(null);
return (
<ProjectContext.Provider value={{ selectedProjectId, setSelectedProjectId }}>
{children}
</ProjectContext.Provider>
);
}
export function useProject() {
return useContext(ProjectContext);
}
+24
View File
@@ -0,0 +1,24 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [dark, setDark] = useState(() => {
const saved = localStorage.getItem('theme');
if (saved) return saved === 'dark';
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
localStorage.setItem('theme', dark ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', dark);
}, [dark]);
return (
<ThemeContext.Provider value={{ dark, toggle: () => setDark(d => !d) }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);