feat: AR-ProjectManagement initial commit — JavaScript (Node.js / React) Express + Prisma (backend) / React + Axios (frontend)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user