chore: estructura inicial del MVP

Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el
nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias,
dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-03 08:37:58 -05:00
commit 1d22adc42e
135 changed files with 10913 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from '../context/auth-context.jsx';
import { UiProvider } from '../context/ui-context.jsx';
import { ContentDialogHost } from '../components/ContentDialog.jsx';
import { ToastHost } from '../components/ToastHost.jsx';
import { ProtectedRoute } from './ProtectedRoute.jsx';
import AppShell from './AppShell.jsx';
import LoginPage from '../pages/Login/LoginPage.jsx';
import DashboardPage from '../pages/Dashboard/DashboardPage.jsx';
import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx';
import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
export default function App() {
return (
<UiProvider>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
<Route path="proyectos" element={<ProyectosPage />} />
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
</Routes>
</BrowserRouter>
<ContentDialogHost />
<ToastHost />
</AuthProvider>
</UiProvider>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { Menu, Sun, Moon, Bell, LogOut, User, Globe } from 'lucide-react';
import { NavigationView } from '../components/NavigationView.jsx';
import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useUi } from '../context/ui-context.jsx';
import { useAsync } from '../hooks/useAsync.js';
import { listNotifications, markAllNotificationsRead, markNotificationRead } from '../services/notifications.js';
export default function AppShell() {
const [navMode, setNavMode] = useState('expanded');
const { user, logout } = useAuth();
const { t, theme, setTheme, lang, setLang } = useUi();
const navigate = useNavigate();
const { data: notifData, reload: reloadNotifications } = useAsync(listNotifications, []);
const unread = notifData?.unread || 0;
function toggleNav() {
setNavMode((m) => (m === 'expanded' ? 'compact' : 'expanded'));
}
async function handleNotificationClick(notification) {
if (!notification.is_read) await markNotificationRead(notification.id);
await reloadNotifications();
if (notification.entity_type === 'project') navigate(`/proyectos/${notification.entity_id}`);
else if (notification.entity_type === 'activity') navigate(`/proyectos`);
}
return (
<div className="app-shell">
<NavigationView mode={navMode} />
<div className="content-column">
<div className="topbar">
<div className="topbar-left">
<button className="icon-btn" onClick={toggleNav}><Menu /></button>
</div>
<div className="topbar-right">
<Flyout trigger={({ onClick }) => (
<button className="icon-btn" onClick={onClick} style={{ position: 'relative' }}>
<Bell />
{unread > 0 && (
<span className="badge badge-danger" style={{ position: 'absolute', top: -2, right: -2, padding: '1px 5px', fontSize: '.6rem' }}>{unread}</span>
)}
</button>
)}>
<div style={{ padding: '6px 10px', fontWeight: 700, fontSize: '.78rem' }}>{t('notifications_title')}</div>
{(!notifData?.items || notifData.items.length === 0) && (
<div style={{ padding: '10px', fontSize: '.78rem', color: 'rgb(var(--muted))' }}>{t('notifications_empty')}</div>
)}
{notifData?.items?.slice(0, 8).map((n) => (
<FlyoutItem key={n.id} onClick={() => handleNotificationClick(n)}>
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.message}</span>
</FlyoutItem>
))}
{notifData?.items?.length > 0 && (
<>
<FlyoutSeparator />
<FlyoutItem onClick={async () => { await markAllNotificationsRead(); reloadNotifications(); }}>
{t('notifications_mark_all_read')}
</FlyoutItem>
</>
)}
</Flyout>
<button className="icon-btn" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? <Sun /> : <Moon />}
</button>
<button className="icon-btn" onClick={() => setLang(lang === 'es-EC' ? 'en-US' : 'es-EC')} title={lang}>
<Globe />
</button>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn" onClick={onClick}><User /></button>
)}>
<div style={{ padding: '8px 10px' }}>
<div style={{ fontWeight: 700, fontSize: '.82rem' }}>{user?.name}</div>
<div style={{ fontSize: '.7rem', color: 'rgb(var(--muted))' }}>{user?.roleName || user?.roleCode}</div>
</div>
<FlyoutSeparator />
<FlyoutItem icon={LogOut} danger onClick={() => { logout(); navigate('/login'); }}>
{t('logout')}
</FlyoutItem>
</Flyout>
</div>
</div>
<div className="content-area">
<Outlet />
</div>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/auth-context.jsx';
export function ProtectedRoute({ children }) {
const { user, ready } = useAuth();
if (!ready) return null;
if (!user) return <Navigate to="/login" replace />;
return children;
}
@@ -0,0 +1,58 @@
import { useRef } from 'react';
import { Upload, Download, Trash2, Paperclip } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listAttachments, uploadAttachment, deleteAttachment, downloadAttachment } from '../services/attachments.js';
import { Button } from './Button.jsx';
export function AttachmentsPanel({ entityType, entityId }) {
const { t, openDialog } = useUi();
const runAction = useAction();
const { data: files, loading, reload } = useAsync(() => listAttachments(entityType, entityId), [entityType, entityId]);
const inputRef = useRef(null);
async function handleFile(e) {
const file = e.target.files?.[0];
if (!file) return;
await runAction(async () => {
await uploadAttachment(entityType, entityId, file);
await reload();
});
e.target.value = '';
}
function confirmDelete(file) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await deleteAttachment(file.id); await reload(); }) },
],
});
}
if (loading || !files) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div style={{ marginBottom: 12 }}>
<input ref={inputRef} type="file" hidden onChange={handleFile} />
<Button variant="standard" icon={Upload} onClick={() => inputRef.current?.click()}>{t('document_upload')}</Button>
</div>
{files.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{files.map((f) => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
<Paperclip size={15} style={{ opacity: .7 }} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem' }}>{f.original_name}</div>
<div className="text-muted" style={{ fontSize: '.7rem' }}>{f.uploaded_by_name} · {(f.size / 1024).toFixed(0)} KB</div>
</div>
<button className="icon-btn" onClick={() => downloadAttachment(f.id, f.original_name)}><Download size={16} /></button>
<button className="icon-btn" onClick={() => confirmDelete(f)}><Trash2 size={16} /></button>
</div>
))}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
const COLOR_MAP = {
accent: 'badge-accent', success: 'badge-success', warning: 'badge-warning',
danger: 'badge-danger', muted: 'badge-muted',
};
export function Badge({ color = 'accent', children }) {
const cls = COLOR_MAP[color] || 'badge-accent';
return <span className={`badge ${cls}`}>{children}</span>;
}
+9
View File
@@ -0,0 +1,9 @@
export function Button({ variant = 'standard', size, icon: Icon, children, className = '', ...rest }) {
const sizeClass = size === 'sm' ? 'btn-sm' : '';
return (
<button className={`btn btn-${variant} ${sizeClass} ${className}`.trim()} {...rest}>
{Icon && <Icon />}
{children && <span className="btn-label">{children}</span>}
</button>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
// options: [{ value, label }]
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const rootRef = useRef(null);
useEffect(() => {
function onDocClick(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
const selected = options.find((o) => String(o.value) === String(value));
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
return (
<div className="combobox" ref={rootRef}>
<button type="button" className="input" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }} onClick={() => setOpen((v) => !v)}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
<ChevronDown size={14} />
</button>
{open && (
<div className="combobox-panel">
{options.length >= searchThreshold && (
<div className="combobox-search" style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
<input
autoFocus
className="input"
style={{ paddingLeft: 26 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
</div>
)}
{filtered.length === 0 && <div className="combobox-empty">Sin resultados</div>}
{filtered.map((o) => (
<div
key={o.value}
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
>
{o.label}
</div>
))}
</div>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { Send } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listComments, createComment } from '../services/comments.js';
import { Button } from './Button.jsx';
export function CommentsPanel({ entityType, entityId }) {
const { t } = useUi();
const runAction = useAction();
const { data: comments, loading, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
const [message, setMessage] = useState('');
async function send() {
if (!message.trim()) return;
await runAction(async () => {
await createComment(entityType, entityId, message.trim());
setMessage('');
await reload();
});
}
if (loading || !comments) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
{comments.length === 0 && <div className="text-muted" style={{ marginBottom: 12 }}>{t('common_empty')}</div>}
{comments.map((c) => (
<div key={c.id} style={{ padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
<div style={{ fontSize: '.78rem', fontWeight: 700 }}>{c.user_name} <span className="text-muted" style={{ fontWeight: 400 }}>· {c.created_at}</span></div>
<div style={{ fontSize: '.82rem', marginTop: 2 }}>{c.message}</div>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="input" value={message} placeholder={t('comment_placeholder')} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} />
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useUi } from '../context/ui-context.jsx';
import { Button } from './Button.jsx';
const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' };
// ContentDialog global — equivalente al AppDialog de la identidad web. Contrato:
// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] })
export function ContentDialogHost() {
const { dialog, closeDialog, t } = useUi();
if (!dialog) return null;
const tone = dialog.tone || 'info';
const actions = dialog.actions?.length ? dialog.actions : [{ label: t('common_close'), variant: 'accent' }];
async function handleClick(action) {
if (action.onClick) await action.onClick();
if (!action.keepOpen) closeDialog();
}
return (
<div className="dialog-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) closeDialog(); }}>
<div className="dialog-surface">
<div className="dialog-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
<div className="dialog-body">
<div className="dialog-title">{dialog.title}</div>
<div className="dialog-message">{dialog.message}</div>
</div>
<div className="dialog-actions">
{actions.map((action, i) => (
<Button key={i} variant={action.variant || 'standard'} onClick={() => handleClick(action)}>
{action.label}
</Button>
))}
</div>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`expander ${open ? 'open' : ''}`}>
<div className="expander-header" onClick={() => setOpen((v) => !v)}>
{Icon && <Icon size={18} />}
<div>
<div className="expander-title">{title}</div>
{description && <div className="expander-desc">{description}</div>}
</div>
<ChevronRight className="chevron" size={16} />
</div>
{open && <div className="expander-body">{children}</div>}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export function Field({ label, children, hint }) {
return (
<div className="field">
{label && <label className="field-label">{label}</label>}
{children}
{hint && <span className="text-muted" style={{ fontSize: '.72rem' }}>{hint}</span>}
</div>
);
}
export function FieldRow({ children }) {
return <div className="field-row">{children}</div>;
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// Flyout generico con light-dismiss: click fuera o Esc cierra. trigger recibe { onClick, ref }.
export function Flyout({ trigger, children, align = 'end' }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const anchorRef = useRef(null);
function openFlyout() {
const rect = anchorRef.current.getBoundingClientRect();
setPos({
top: rect.bottom + 6,
left: align === 'end' ? rect.right : rect.left,
});
setOpen(true);
}
useEffect(() => {
if (!open) return;
function onKey(e) { if (e.key === 'Escape') setOpen(false); }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
return (
<>
<span ref={anchorRef}>{trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })}</span>
{open && createPortal(
<>
<div className="flyout-backdrop" onClick={() => setOpen(false)} />
<div
className="flyout-panel"
style={{ top: pos.top, left: align === 'end' ? pos.left - 220 : pos.left }}
onClick={() => setOpen(false)}
>
{children}
</div>
</>,
document.body
)}
</>
);
}
export function FlyoutItem({ icon: Icon, danger, children, ...rest }) {
return (
<button className={`flyout-item ${danger ? 'danger' : ''}`} {...rest}>
{Icon && <Icon size={15} />}
<span>{children}</span>
</button>
);
}
export function FlyoutSeparator() {
return <div className="flyout-separator" />;
}
+21
View File
@@ -0,0 +1,21 @@
import { useState } from 'react';
import { Info, CheckCircle2, AlertTriangle, XCircle, X } from 'lucide-react';
const ICONS = { info: Info, success: CheckCircle2, warning: AlertTriangle, error: XCircle };
export function InfoBar({ tone = 'info', children, dismissible = true }) {
const [visible, setVisible] = useState(true);
if (!visible) return null;
const Icon = ICONS[tone] || Info;
return (
<div className={`infobar infobar-${tone}`}>
<Icon />
<div>{children}</div>
{dismissible && (
<button className="infobar-close" onClick={() => setVisible(false)}>
<X size={14} />
</button>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export function KpiCard({ label, value }) {
return (
<div className="kpi-card">
<div className="kpi-value">{value}</div>
<div className="kpi-label">{label}</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
import { MoreVertical } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { Flyout, FlyoutItem } from './Flyout.jsx';
// ListView/DataGrid: columnas ordenables, buscador ligero sobre el haystack completo, clic en
// fila abre detalle, boton "..." abre el menu de acciones (equivalente al clic derecho).
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey }) {
const { t } = useUi();
const [sort, setSort] = useState({ key: null, dir: 1 });
const filtered = useMemo(() => {
if (!searchValue) return rows;
const q = searchValue.toLowerCase();
return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q));
}, [rows, searchValue, getSearchHaystack]);
const sorted = useMemo(() => {
if (!sort.key) return filtered;
const col = columns.find((c) => c.key === sort.key);
return [...filtered].sort((a, b) => {
const va = col.sortValue ? col.sortValue(a) : a[sort.key];
const vb = col.sortValue ? col.sortValue(b) : b[sort.key];
if (va === vb) return 0;
return (va > vb ? 1 : -1) * sort.dir;
});
}, [filtered, sort, columns]);
function toggleSort(key) {
setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 }));
}
return (
<div className="list-card">
<table className="list-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
</th>
))}
{rowActions && <th style={{ width: 40 }} />}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={getRowKey(row)} onClick={() => onRowClick?.(row)}>
{columns.map((col) => (
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
))}
{rowActions && (
<td onClick={(e) => e.stopPropagation()}>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical /></button>
)}>
{rowActions(row).map((action, i) => (
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
{action.label}
</FlyoutItem>
))}
</Flyout>
</td>
)}
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
</div>
);
}
@@ -0,0 +1,31 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Building2, Settings } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
const ITEMS = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config' },
];
export function NavigationView({ mode }) {
const { t } = useUi();
return (
<nav className={`nav-view ${mode}`}>
<div className="nav-header">
<div className="nav-logo">LC</div>
{mode !== 'compact' && <div className="nav-title">{t('appName')}</div>}
</div>
<div className="nav-items">
{ITEMS.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<item.icon />
{mode !== 'compact' && <span>{t(item.labelKey)}</span>}
</NavLink>
))}
</div>
{mode !== 'compact' && <div className="nav-footer">v0.1.0</div>}
</nav>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Search } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
export function SearchInput({ value, onChange, placeholder }) {
const { t } = useUi();
return (
<div className="search-input">
<Search />
<input className="input" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder || t('common_search')} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Badge } from './Badge.jsx';
import { useUi } from '../context/ui-context.jsx';
export function StatusBadge({ name, color }) {
return <Badge color={color}>{name}</Badge>;
}
const DUE_COLOR = { on_time: 'success', warning: 'warning', today: 'warning', overdue: 'danger', none: 'muted' };
const DUE_KEY = { on_time: 'due_on_time', warning: 'due_warning', today: 'due_today', overdue: 'due_overdue' };
export function DueBadge({ status }) {
const { t } = useUi();
if (!status || status === 'none') return null;
return <Badge color={DUE_COLOR[status]}>{t(DUE_KEY[status])}</Badge>;
}
export function ProgressBar({ value }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="progress-bar" style={{ flex: 1 }}>
<div className="progress-bar-fill" style={{ width: `${value}%` }} />
</div>
<span style={{ fontSize: '.72rem', width: 36, textAlign: 'right' }}>{Math.round(value)}%</span>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export function Tabs({ tabs, active, onChange }) {
return (
<div className="tabs">
{tabs.map((tab) => (
<button key={tab.key} className={`tab-item ${active === tab.key ? 'active' : ''}`} onClick={() => onChange(tab.key)}>
{tab.label}
</button>
))}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useUi } from '../context/ui-context.jsx';
export function ToastHost() {
const { toasts } = useUi();
if (!toasts.length) return null;
return (
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.tone}`}>{toast.message}</div>
))}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
function TreeNode({ node, level, onSelect, selectedId }) {
const [open, setOpen] = useState(true);
const hasChildren = node.children && node.children.length > 0;
return (
<div>
<div
className="tree-item-row"
style={{ paddingLeft: level * 18 + 6, background: selectedId === node.id ? 'rgb(var(--accent) / .14)' : undefined }}
onClick={() => onSelect?.(node)}
>
{hasChildren ? (
<button className="tree-toggle" onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="tree-toggle" />
)}
<Folder size={15} style={{ opacity: .7 }} />
<span>{node.name}</span>
</div>
{hasChildren && open && node.children.map((child) => (
<TreeNode key={child.id} node={child} level={level + 1} onSelect={onSelect} selectedId={selectedId} />
))}
</div>
);
}
export function Treeview({ nodes, onSelect, selectedId }) {
return (
<div>
{nodes.map((node) => (
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} selectedId={selectedId} />
))}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
import { api, setUnauthorizedHandler } from '../services/api.js';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [ready, setReady] = useState(false);
const logout = useCallback(() => {
api.setToken(null);
setUser(null);
}, []);
useEffect(() => {
setUnauthorizedHandler(() => logout());
}, [logout]);
useEffect(() => {
const token = localStorage.getItem('plc_token');
if (!token) { setReady(true); return; }
api.get('/auth/me')
.then((me) => setUser(me))
.catch(() => logout())
.finally(() => setReady(true));
}, [logout]);
const login = useCallback(async (email, password) => {
const result = await api.post('/auth/login', { email, password });
api.setToken(result.token);
setUser(result.user);
return result.user;
}, []);
const value = useMemo(() => ({ user, ready, login, logout }), [user, ready, login, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider');
return ctx;
}
+65
View File
@@ -0,0 +1,65 @@
import { createContext, useContext, useEffect, useMemo, useState, useCallback } from 'react';
import { messages } from '../i18n/messages.js';
const UiContext = createContext(null);
function readStored(key, fallback) {
try {
return localStorage.getItem(key) || fallback;
} catch {
return fallback;
}
}
export function UiProvider({ children }) {
const [theme, setThemeState] = useState(() => readStored('plc_theme', 'dark'));
const [lang, setLangState] = useState(() => readStored('plc_lang', 'es-EC'));
const [buttonMode, setButtonModeState] = useState(() => readStored('plc_button_mode', 'icon-text'));
const [dialog, setDialog] = useState(null);
const [toasts, setToasts] = useState([]);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('plc_theme', theme);
}, [theme]);
useEffect(() => {
document.documentElement.setAttribute('lang', lang);
localStorage.setItem('plc_lang', lang);
}, [lang]);
useEffect(() => {
document.documentElement.setAttribute('data-button-mode', buttonMode);
localStorage.setItem('plc_button_mode', buttonMode);
}, [buttonMode]);
const t = useCallback((key) => messages[lang]?.[key] ?? messages['es-EC'][key] ?? key, [lang]);
const openDialog = useCallback((config) => setDialog(config), []);
const closeDialog = useCallback(() => setDialog(null), []);
const pushToast = useCallback((toast) => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, tone: 'info', ...toast }]);
setTimeout(() => setToasts((prev) => prev.filter((t2) => t2.id !== id)), 4000);
}, []);
const value = useMemo(
() => ({
theme, setTheme: setThemeState,
lang, setLang: setLangState,
buttonMode, setButtonMode: setButtonModeState,
t, dialog, openDialog, closeDialog,
toasts, pushToast,
}),
[theme, lang, buttonMode, t, dialog, openDialog, closeDialog, toasts, pushToast]
);
return <UiContext.Provider value={value}>{children}</UiContext.Provider>;
}
export function useUi() {
const ctx = useContext(UiContext);
if (!ctx) throw new Error('useUi debe usarse dentro de UiProvider');
return ctx;
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from 'react';
import { useUi } from '../context/ui-context.jsx';
// Abstrae el patron repetido loading/try-catch/error para llamadas a la API.
export function useAsync(fn, deps = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fn();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => { reload(); }, [reload]);
return { data, loading, error, reload, setData };
}
// Ejecuta una accion mostrando error via ContentDialog si falla.
export function useAction() {
const { openDialog, t } = useUi();
return useCallback(async (fn) => {
try {
return await fn();
} catch (err) {
openDialog({ tone: 'error', title: t('common_error'), message: err.message });
throw err;
}
}, [openDialog, t]);
}
+270
View File
@@ -0,0 +1,270 @@
export const messages = {
'es-EC': {
appName: 'LinkedCorp Proyectos',
nav_dashboard: 'Panel',
nav_projects: 'Proyectos',
nav_organization: 'Organizacion',
nav_config: 'Configuracion',
login_title: 'Iniciar sesion',
login_email: 'Correo',
login_password: 'Clave',
login_submit: 'Entrar',
login_error: 'Credenciales invalidas',
logout: 'Cerrar sesion',
common_new: 'Nuevo',
common_edit: 'Editar',
common_save: 'Guardar',
common_cancel: 'Cancelar',
common_delete: 'Eliminar',
common_close: 'Cerrar',
common_search: 'Buscar...',
common_actions: 'Acciones',
common_view: 'Ver',
common_yes: 'Si',
common_no: 'No',
common_loading: 'Cargando...',
common_empty: 'No hay datos para mostrar',
common_confirm_delete_title: 'Confirmar eliminacion',
common_confirm_delete_msg: '¿Seguro que deseas eliminar este elemento? Esta accion no se puede deshacer.',
common_saved: 'Cambios guardados',
common_error: 'Ocurrio un error',
common_name: 'Nombre',
common_description: 'Descripcion',
common_status: 'Estado',
common_priority: 'Prioridad',
common_responsible: 'Responsable',
common_department: 'Departamento',
common_active: 'Activo',
common_inactive: 'Inactivo',
common_all: 'Todos',
theme_light: 'Claro',
theme_dark: 'Oscuro',
lang_es: 'Espanol',
lang_en: 'Ingles',
button_mode_icon_text: 'Icono + texto',
button_mode_icon: 'Solo icono',
button_mode_text: 'Solo texto',
dashboard_title: 'Panel principal',
dashboard_active_projects: 'Proyectos activos',
dashboard_overdue_projects: 'Proyectos atrasados',
dashboard_due_soon_projects: 'Proximos a vencer',
dashboard_pending_activities: 'Actividades pendientes',
dashboard_overdue_activities: 'Actividades vencidas',
dashboard_today_activities: 'Para hoy',
dashboard_average_progress: 'Avance promedio',
dashboard_my_activities: 'Mis actividades pendientes',
dashboard_projects_by_status: 'Proyectos por estado',
projects_title: 'Proyectos',
project_code: 'Codigo',
project_new: 'Nuevo proyecto',
project_objective: 'Objetivo',
project_type: 'Tipo de proyecto',
project_owner: 'Responsable',
project_start_date: 'Fecha de inicio',
project_estimated_end_date: 'Fin estimado',
project_actual_end_date: 'Fin real',
project_progress: 'Avance',
project_requesting_department: 'Departamento solicitante',
project_responsible_department: 'Departamento responsable',
project_risk: 'Riesgo',
project_notes: 'Observaciones',
project_members: 'Miembros',
project_add_member: 'Agregar miembro',
risk_low: 'Bajo',
risk_medium: 'Medio',
risk_high: 'Alto',
tab_info: 'Info',
tab_activities: 'Actividades',
tab_gantt: 'Gantt',
tab_calendar: 'Calendario',
tab_comments: 'Comentarios',
tab_documents: 'Documentos',
activity_new: 'Nueva actividad',
activity_code: 'Codigo',
activity_start_date: 'Fecha inicio',
activity_due_date: 'Fecha limite',
activity_actual_end_date: 'Fecha real de fin',
activity_progress: 'Avance',
activity_milestone: 'Es hito',
activity_estimated_hours: 'Horas estimadas',
activity_dependencies: 'Dependencias',
activity_add_dependency: 'Agregar dependencia',
activity_depends_on: 'Depende de',
subtasks_title: 'Subtareas',
subtask_new: 'Nueva subtarea',
subtask_done: 'Hecha',
due_on_time: 'En tiempo',
due_warning: 'Proximo a vencer',
due_today: 'Vence hoy',
due_overdue: 'Vencido',
org_departments: 'Departamentos',
org_users: 'Usuarios',
department_new: 'Nuevo departamento',
department_parent: 'Departamento padre',
user_new: 'Nuevo usuario',
user_email: 'Correo',
user_role: 'Rol',
user_position: 'Cargo',
user_password: 'Clave inicial',
config_title: 'Configuracion',
config_appearance: 'Apariencia',
config_catalogs: 'Catalogos',
config_priorities: 'Prioridades',
config_project_statuses: 'Estados de proyecto',
config_activity_statuses: 'Estados de actividad',
config_project_types: 'Tipos de proyecto',
comments_title: 'Comentarios',
comment_placeholder: 'Escribe un comentario...',
comment_send: 'Enviar',
documents_title: 'Documentos adjuntos',
document_upload: 'Subir archivo',
document_download: 'Descargar',
notifications_title: 'Notificaciones',
notifications_empty: 'Sin notificaciones',
notifications_mark_all_read: 'Marcar todas como leidas',
},
'en-US': {
appName: 'LinkedCorp Projects',
nav_dashboard: 'Dashboard',
nav_projects: 'Projects',
nav_organization: 'Organization',
nav_config: 'Settings',
login_title: 'Sign in',
login_email: 'Email',
login_password: 'Password',
login_submit: 'Sign in',
login_error: 'Invalid credentials',
logout: 'Sign out',
common_new: 'New',
common_edit: 'Edit',
common_save: 'Save',
common_cancel: 'Cancel',
common_delete: 'Delete',
common_close: 'Close',
common_search: 'Search...',
common_actions: 'Actions',
common_view: 'View',
common_yes: 'Yes',
common_no: 'No',
common_loading: 'Loading...',
common_empty: 'No data to show',
common_confirm_delete_title: 'Confirm delete',
common_confirm_delete_msg: 'Are you sure you want to delete this item? This cannot be undone.',
common_saved: 'Changes saved',
common_error: 'An error occurred',
common_name: 'Name',
common_description: 'Description',
common_status: 'Status',
common_priority: 'Priority',
common_responsible: 'Owner',
common_department: 'Department',
common_active: 'Active',
common_inactive: 'Inactive',
common_all: 'All',
theme_light: 'Light',
theme_dark: 'Dark',
lang_es: 'Spanish',
lang_en: 'English',
button_mode_icon_text: 'Icon + text',
button_mode_icon: 'Icon only',
button_mode_text: 'Text only',
dashboard_title: 'Dashboard',
dashboard_active_projects: 'Active projects',
dashboard_overdue_projects: 'Overdue projects',
dashboard_due_soon_projects: 'Due soon',
dashboard_pending_activities: 'Pending activities',
dashboard_overdue_activities: 'Overdue activities',
dashboard_today_activities: 'Due today',
dashboard_average_progress: 'Average progress',
dashboard_my_activities: 'My pending activities',
dashboard_projects_by_status: 'Projects by status',
projects_title: 'Projects',
project_code: 'Code',
project_new: 'New project',
project_objective: 'Objective',
project_type: 'Project type',
project_owner: 'Owner',
project_start_date: 'Start date',
project_estimated_end_date: 'Estimated end',
project_actual_end_date: 'Actual end',
project_progress: 'Progress',
project_requesting_department: 'Requesting department',
project_responsible_department: 'Responsible department',
project_risk: 'Risk',
project_notes: 'Notes',
project_members: 'Members',
project_add_member: 'Add member',
risk_low: 'Low',
risk_medium: 'Medium',
risk_high: 'High',
tab_info: 'Info',
tab_activities: 'Activities',
tab_gantt: 'Gantt',
tab_calendar: 'Calendar',
tab_comments: 'Comments',
tab_documents: 'Documents',
activity_new: 'New activity',
activity_code: 'Code',
activity_start_date: 'Start date',
activity_due_date: 'Due date',
activity_actual_end_date: 'Actual end date',
activity_progress: 'Progress',
activity_milestone: 'Is milestone',
activity_estimated_hours: 'Estimated hours',
activity_dependencies: 'Dependencies',
activity_add_dependency: 'Add dependency',
activity_depends_on: 'Depends on',
subtasks_title: 'Subtasks',
subtask_new: 'New subtask',
subtask_done: 'Done',
due_on_time: 'On time',
due_warning: 'Due soon',
due_today: 'Due today',
due_overdue: 'Overdue',
org_departments: 'Departments',
org_users: 'Users',
department_new: 'New department',
department_parent: 'Parent department',
user_new: 'New user',
user_email: 'Email',
user_role: 'Role',
user_position: 'Position',
user_password: 'Initial password',
config_title: 'Settings',
config_appearance: 'Appearance',
config_catalogs: 'Catalogs',
config_priorities: 'Priorities',
config_project_statuses: 'Project statuses',
config_activity_statuses: 'Activity statuses',
config_project_types: 'Project types',
comments_title: 'Comments',
comment_placeholder: 'Write a comment...',
comment_send: 'Send',
documents_title: 'Attachments',
document_upload: 'Upload file',
document_download: 'Download',
notifications_title: 'Notifications',
notifications_empty: 'No notifications',
notifications_mark_all_read: 'Mark all as read',
},
};
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app/App.jsx';
import './styles/tokens.css';
import './styles/app.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,127 @@
import { useState } from 'react';
import { Plus, Save, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listCatalog, createCatalogItem, updateCatalogItem, deleteCatalogItem } from '../../services/catalogs.js';
import { ListView } from '../../components/ListView.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
const COLOR_OPTIONS = ['accent', 'success', 'warning', 'danger', 'muted'].map((c) => ({ value: c, label: c }));
function emptyForm(fields) {
const form = {};
for (const f of fields) form[f.key] = f.type === 'checkbox' ? false : '';
return form;
}
// Editor generico reutilizable para los 4 catalogos (prioridades, estados, tipos de proyecto).
export function CatalogEditor({ catalogKey, fields, title }) {
const { t, openDialog, pushToast } = useUi();
const runAction = useAction();
const { data: items, loading, reload } = useAsync(() => listCatalog(catalogKey), [catalogKey]);
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(emptyForm(fields));
function startNew() {
setSelected(null);
setForm(emptyForm(fields));
setEditing(true);
}
function startEdit(item) {
setSelected(item);
const next = {};
for (const f of fields) next[f.key] = item[f.key];
setForm(next);
setEditing(true);
}
async function save() {
await runAction(async () => {
if (selected) await updateCatalogItem(catalogKey, selected.id, form);
else await createCatalogItem(catalogKey, form);
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
}
function confirmDelete(item) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => {
await deleteCatalogItem(catalogKey, item.id);
await reload();
setEditing(false);
}) },
],
});
}
const columns = fields.map((f) => ({
key: f.key,
label: f.label,
render: f.type === 'color' ? (row) => <Badge color={row[f.key]}>{row[f.key]}</Badge>
: f.type === 'checkbox' ? (row) => (row[f.key] ? t('common_yes') : t('common_no'))
: undefined,
}));
if (loading || !items) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<h4 style={{ margin: 0 }}>{title}</h4>
<Button size="sm" variant="accent" icon={Plus} onClick={startNew}>{t('common_new')}</Button>
</div>
<ListView
columns={columns}
rows={items}
getRowKey={(r) => r.id}
onRowClick={startEdit}
rowActions={(r) => [
{ label: t('common_edit'), onClick: () => startEdit(r) },
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) },
]}
/>
{editing && (
<div className="panel-card" style={{ marginTop: 12, maxWidth: 480 }}>
<FieldRow>
{fields.map((f) => (
<Field key={f.key} label={f.label}>
{f.type === 'color' ? (
<ComboBox options={COLOR_OPTIONS} value={form[f.key]} onChange={(v) => setForm((s) => ({ ...s, [f.key]: v }))} />
) : f.type === 'checkbox' ? (
<label className="checkbox-row">
<input type="checkbox" checked={!!form[f.key]} onChange={(e) => setForm((s) => ({ ...s, [f.key]: e.target.checked }))} />
{f.label}
</label>
) : (
<input
className="input"
type={f.type === 'number' ? 'number' : 'text'}
value={form[f.key]}
onChange={(e) => setForm((s) => ({ ...s, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))}
/>
)}
</Field>
))}
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button size="sm" variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button size="sm" variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,89 @@
import { Palette, ListChecks, Flag, FolderKanban, Layers } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { Expander } from '../../components/Expander.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { CatalogEditor } from './CatalogEditor.jsx';
export default function ConfiguracionPage() {
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
<Expander icon={Palette} title={t('config_appearance')} defaultOpen>
<FieldRow>
<Field label="Tema / Theme">
<ComboBox
options={[{ value: 'dark', label: t('theme_dark') }, { value: 'light', label: t('theme_light') }]}
value={theme} onChange={setTheme}
/>
</Field>
<Field label="Idioma / Language">
<ComboBox
options={[{ value: 'es-EC', label: t('lang_es') }, { value: 'en-US', label: t('lang_en') }]}
value={lang} onChange={setLang}
/>
</Field>
<Field label="Botones / Buttons">
<ComboBox
options={[
{ value: 'icon-text', label: t('button_mode_icon_text') },
{ value: 'icon', label: t('button_mode_icon') },
{ value: 'text', label: t('button_mode_text') },
]}
value={buttonMode} onChange={setButtonMode}
/>
</Field>
</FieldRow>
</Expander>
<Expander icon={Flag} title={t('config_priorities')}>
<CatalogEditor
catalogKey="priorities"
title={t('config_priorities')}
fields={[
{ key: 'name', label: t('common_name'), type: 'text' },
{ key: 'level', label: '#', type: 'number' },
{ key: 'color', label: 'Color', type: 'color' },
]}
/>
</Expander>
<Expander icon={ListChecks} title={t('config_project_statuses')}>
<CatalogEditor
catalogKey="project-statuses"
title={t('config_project_statuses')}
fields={[
{ key: 'name', label: t('common_name'), type: 'text' },
{ key: 'sort_order', label: '#', type: 'number' },
{ key: 'color', label: 'Color', type: 'color' },
{ key: 'is_closed', label: 'Cierra', type: 'checkbox' },
]}
/>
</Expander>
<Expander icon={Layers} title={t('config_activity_statuses')}>
<CatalogEditor
catalogKey="activity-statuses"
title={t('config_activity_statuses')}
fields={[
{ key: 'name', label: t('common_name'), type: 'text' },
{ key: 'sort_order', label: '#', type: 'number' },
{ key: 'color', label: 'Color', type: 'color' },
{ key: 'is_closed', label: 'Cierra', type: 'checkbox' },
]}
/>
</Expander>
<Expander icon={FolderKanban} title={t('config_project_types')}>
<CatalogEditor
catalogKey="project-types"
title={t('config_project_types')}
fields={[{ key: 'name', label: t('common_name'), type: 'text' }]}
/>
</Expander>
</div>
);
}
@@ -0,0 +1,57 @@
import { useNavigate } from 'react-router-dom';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { getSummary } from '../../services/dashboard.js';
import { KpiCard } from '../../components/KpiCard.jsx';
import { ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
export default function DashboardPage() {
const { t } = useUi();
const navigate = useNavigate();
const { data, loading } = useAsync(getSummary, []);
if (loading || !data) return <div className="text-muted">{t('common_loading')}</div>;
const { kpis, myActivities, projectsByStatus } = data;
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('dashboard_title')}</h2>
<div className="kpi-grid">
<KpiCard label={t('dashboard_active_projects')} value={kpis.activeProjects} />
<KpiCard label={t('dashboard_overdue_projects')} value={kpis.overdueProjects} />
<KpiCard label={t('dashboard_due_soon_projects')} value={kpis.dueSoonProjects} />
<KpiCard label={t('dashboard_pending_activities')} value={kpis.pendingActivities} />
<KpiCard label={t('dashboard_overdue_activities')} value={kpis.overdueActivities} />
<KpiCard label={t('dashboard_today_activities')} value={kpis.todayActivities} />
<KpiCard label={t('dashboard_average_progress')} value={`${kpis.averageProgress}%`} />
</div>
<div className="panel-card">
<h3>{t('dashboard_my_activities')}</h3>
{myActivities.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{myActivities.map((a) => (
<div key={a.id} onClick={() => navigate(`/proyectos/${a.project_id}`)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{a.name}</div>
<div className="text-muted" style={{ fontSize: '.72rem' }}>{a.project_name} · {a.due_date || '—'}</div>
</div>
<div style={{ width: 120 }}><ProgressBar value={Number(a.progress_pct)} /></div>
<DueBadge status={a.dueStatus} />
</div>
))}
</div>
<div className="panel-card">
<h3>{t('dashboard_projects_by_status')}</h3>
{projectsByStatus.map((s) => (
<div key={s.name} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<span className="dot" style={{ background: `rgb(var(--${s.color}))` }} />
<span style={{ flex: 1, fontSize: '.8rem' }}>{s.name}</span>
<span style={{ fontWeight: 700 }}>{s.total}</span>
</div>
))}
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { LogIn } from 'lucide-react';
import { useAuth } from '../../context/auth-context.jsx';
import { useUi } from '../../context/ui-context.jsx';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
export default function LoginPage() {
const { login } = useAuth();
const { t } = useUi();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/', { replace: true });
} catch {
setError(t('login_error'));
} finally {
setLoading(false);
}
}
return (
<div className="login-shell">
<form className="login-card" onSubmit={handleSubmit}>
<div className="login-logo">
<div className="nav-logo">LC</div>
<div className="login-title">{t('login_title')}</div>
</div>
{error && <div className="login-error">{error}</div>}
<Field label={t('login_email')}>
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
</Field>
<Field label={t('login_password')}>
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
<Button variant="accent" icon={LogIn} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
{t('login_submit')}
</Button>
</form>
</div>
);
}
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { Plus, Save, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listDepartments, createDepartment, updateDepartment, deleteDepartment } from '../../services/departments.js';
import { Treeview } from '../../components/Treeview.jsx';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) {
acc.push(n);
if (n.children?.length) flatten(n.children, acc);
}
return acc;
}
export default function DepartmentsPanel() {
const { t, openDialog, pushToast } = useUi();
const runAction = useAction();
const { data: tree, loading, reload } = useAsync(listDepartments, []);
const [selected, setSelected] = useState(null);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState({ name: '', parentId: '' });
const flatOptions = tree ? flatten(tree).map((d) => ({ value: d.id, label: d.name })) : [];
function startNew() {
setSelected(null);
setForm({ name: '', parentId: '' });
setEditing(true);
}
function startEdit(node) {
setSelected(node);
setForm({ name: node.name, parentId: node.parent_id || '' });
setEditing(true);
}
async function save() {
if (!form.name.trim()) return;
await runAction(async () => {
const payload = { name: form.name.trim(), parentId: form.parentId || null };
if (selected) await updateDepartment(selected.id, payload);
else await createDepartment(payload);
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
}
function confirmDelete(node) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteDepartment(node.id);
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
if (loading || !tree) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div className="panel-card" style={{ flex: '1 1 280px' }}>
<div className="toolbar">
<h3 style={{ margin: 0 }}>{t('org_departments')}</h3>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('department_new')}</Button>
</div>
<Treeview nodes={tree} onSelect={startEdit} selectedId={selected?.id} />
</div>
{editing && (
<div className="panel-card" style={{ flex: '1 1 280px', maxWidth: 360 }}>
<h3>{selected ? t('common_edit') : t('department_new')}</h3>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('department_parent')}>
<ComboBox
options={[{ value: '', label: '—' }, ...flatOptions.filter((o) => o.value !== selected?.id)]}
value={form.parentId}
onChange={(v) => setForm((f) => ({ ...f, parentId: v }))}
/>
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
{selected && <Button variant="danger" icon={Trash2} onClick={() => confirmDelete(selected)}>{t('common_delete')}</Button>}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,25 @@
import { useState } from 'react';
import { useUi } from '../../context/ui-context.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import DepartmentsPanel from './DepartmentsPanel.jsx';
import UsersPanel from './UsersPanel.jsx';
export default function OrganizacionPage() {
const { t } = useUi();
const [tab, setTab] = useState('departments');
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('nav_organization')}</h2>
<Tabs
tabs={[
{ key: 'departments', label: t('org_departments') },
{ key: 'users', label: t('org_users') },
]}
active={tab}
onChange={setTab}
/>
{tab === 'departments' ? <DepartmentsPanel /> : <UsersPanel />}
</div>
);
}
@@ -0,0 +1,143 @@
import { useState } from 'react';
import { Plus, Save, UserX, UserCheck } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listUsers, listRoles, createUser, updateUser, setUserActive } from '../../services/users.js';
import { listDepartments } from '../../services/departments.js';
import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
return acc;
}
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
export default function UsersPanel() {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: users, loading, reload } = useAsync(listUsers, []);
const { data: roles } = useAsync(listRoles, []);
const { data: deptTree } = useAsync(listDepartments, []);
const [search, setSearch] = useState('');
const [editing, setEditing] = useState(false);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
function startNew() {
setSelected(null);
setForm(EMPTY_FORM);
setEditing(true);
}
function startEdit(user) {
setSelected(user);
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
setEditing(true);
}
async function save() {
if (!form.name.trim() || !form.email.trim() || !form.roleId) return;
await runAction(async () => {
if (selected) {
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null });
} else {
if (!form.password) return;
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
}
await reload();
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
});
}
async function toggleActive(user) {
await runAction(async () => {
await setUserActive(user.id, !user.is_active);
await reload();
});
}
const columns = [
{ key: 'name', label: t('common_name') },
{ key: 'email', label: t('user_email') },
{ key: 'role_name', label: t('user_role') },
{ key: 'department_name', label: t('common_department'), render: (r) => r.department_name || '—' },
{ key: 'is_active', label: t('common_status'), sortable: false, render: (r) => (
<Badge color={r.is_active ? 'success' : 'muted'}>{r.is_active ? t('common_active') : t('common_inactive')}</Badge>
) },
];
if (loading || !users) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<div className="toolbar-left">
<SearchInput value={search} onChange={setSearch} />
</div>
<Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>
</div>
<ListView
columns={columns}
rows={users}
searchValue={search}
getSearchHaystack={(u) => `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`}
getRowKey={(u) => u.id}
onRowClick={startEdit}
rowActions={(u) => [
{ label: t('common_edit'), onClick: () => startEdit(u) },
u.is_active
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
]}
/>
{editing && (
<div className="panel-card" style={{ marginTop: 16, maxWidth: 520 }}>
<h3>{selected ? t('common_edit') : t('user_new')}</h3>
<FieldRow>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('user_email')}>
<input className="input" type="email" value={form.email} disabled={!!selected} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_role')}>
<ComboBox options={roleOptions} value={form.roleId} onChange={(v) => setForm((f) => ({ ...f, roleId: v }))} />
</Field>
<Field label={t('common_department')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.departmentId} onChange={(v) => setForm((f) => ({ ...f, departmentId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('user_position')}>
<input className="input" value={form.position} onChange={(e) => setForm((f) => ({ ...f, position: e.target.value }))} />
</Field>
{!selected && (
<Field label={t('user_password')}>
<input className="input" type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</Field>
)}
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react';
import { Pencil, Save, X, Plus, Trash2, Link2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { updateActivity, addDependency, removeDependency } from '../../services/activities.js';
import { listSubtasks, createSubtask, setSubtaskDone, deleteSubtask } from '../../services/subtasks.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
function toForm(a) {
return {
name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '',
priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '',
actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '',
progressPct: a.progress_pct,
};
}
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged, onClose }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []);
const { data: subtasks, reload: reloadSubtasks } = useAsync(() => listSubtasks(activity.id), [activity.id]);
const [tab, setTab] = useState('detail');
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(() => toForm(activity));
const [newSubtask, setNewSubtask] = useState('');
const [addingDep, setAddingDep] = useState('');
useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]);
const hasSubtasks = subtasks && subtasks.length > 0;
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const depOptions = (siblingActivities || [])
.filter((a) => a.id !== activity.id && !activity.dependencies?.some((d) => d.id === a.id))
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` }));
async function save() {
await runAction(async () => {
await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null });
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
onChanged();
});
}
async function addSubtaskItem() {
if (!newSubtask.trim()) return;
await runAction(async () => {
await createSubtask(activity.id, { name: newSubtask.trim() });
setNewSubtask('');
await reloadSubtasks();
onChanged();
});
}
async function toggleSubtask(s) {
await runAction(async () => { await setSubtaskDone(s.id, !s.is_done); await reloadSubtasks(); onChanged(); });
}
async function removeSubtaskItem(s) {
await runAction(async () => { await deleteSubtask(s.id); await reloadSubtasks(); onChanged(); });
}
async function addDep() {
if (!addingDep) return;
await runAction(async () => { await addDependency(activity.id, Number(addingDep)); setAddingDep(''); onChanged(); });
}
async function removeDep(depId) {
await runAction(async () => { await removeDependency(activity.id, depId); onChanged(); });
}
return (
<div className="panel-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>{activity.code} — {activity.name}</h3>
<Button variant="subtle" icon={X} onClick={onClose}>{t('common_close')}</Button>
</div>
<Tabs
tabs={[
{ key: 'detail', label: t('tab_info') },
{ key: 'comments', label: t('tab_comments') },
{ key: 'documents', label: t('tab_documents') },
]}
active={tab}
onChange={setTab}
/>
{tab === 'detail' && (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
{editing ? (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(activity)); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
) : (
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
)}
</div>
{!editing ? (
<div>
<FieldRow>
<Field label={t('common_status')}><StatusBadge name={activity.status_name} color={activity.status_color} /></Field>
<Field label={t('common_priority')}><StatusBadge name={activity.priority_name} color={activity.priority_color} /></Field>
<Field label={t('common_responsible')}><div>{activity.responsible_name || '—'}</div></Field>
</FieldRow>
<Field label={t('common_description')}><div>{activity.description || '—'}</div></Field>
<FieldRow>
<Field label={t('activity_start_date')}><div>{activity.start_date || '—'}</div></Field>
<Field label={t('activity_due_date')}><div>{activity.due_date || '—'}</div></Field>
<Field label={t('activity_actual_end_date')}><div>{activity.actual_end_date || '—'}</div></Field>
</FieldRow>
<Field label={t('activity_progress')}>
<ProgressBar value={Number(activity.progress_pct)} />
{hasSubtasks && <span className="text-muted" style={{ fontSize: '.68rem' }}>{t('subtasks_title')}</span>}
</Field>
</div>
) : (
<div>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label={t('common_description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('common_priority')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_start_date')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('activity_due_date')}>
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
</Field>
<Field label={t('activity_actual_end_date')}>
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_estimated_hours')}>
<input className="input" type="number" value={form.estimatedHours} onChange={(e) => setForm((f) => ({ ...f, estimatedHours: e.target.value }))} />
</Field>
<Field label={t('activity_milestone')}>
<label className="checkbox-row">
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
{t('activity_milestone')}
</label>
</Field>
{!hasSubtasks && (
<Field label={t('activity_progress')}>
<input className="input" type="number" min={0} max={100} value={form.progressPct} onChange={(e) => setForm((f) => ({ ...f, progressPct: e.target.value }))} />
</Field>
)}
</FieldRow>
</div>
)}
<div style={{ marginTop: 14 }}>
<h4>{t('subtasks_title')}</h4>
{(subtasks || []).map((s) => (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
<input type="checkbox" checked={!!s.is_done} onChange={() => toggleSubtask(s)} />
<span style={{ flex: 1, fontSize: '.8rem', textDecoration: s.is_done ? 'line-through' : 'none', opacity: s.is_done ? .6 : 1 }}>{s.name}</span>
<button className="icon-btn" onClick={() => removeSubtaskItem(s)}><Trash2 size={15} /></button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<input className="input" value={newSubtask} placeholder={t('subtask_new')} onChange={(e) => setNewSubtask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addSubtaskItem()} />
<Button variant="standard" icon={Plus} onClick={addSubtaskItem}>{t('common_new')}</Button>
</div>
</div>
<div style={{ marginTop: 14 }}>
<h4>{t('activity_dependencies')}</h4>
{(activity.dependencies || []).length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
{(activity.dependencies || []).map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
<Link2 size={14} style={{ opacity: .6 }} />
<span style={{ flex: 1, fontSize: '.8rem' }}>{d.code} — {d.name}</span>
<button className="icon-btn" onClick={() => removeDep(d.id)}><Trash2 size={15} /></button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<div style={{ flex: 1 }}>
<ComboBox options={depOptions} value={addingDep} onChange={setAddingDep} placeholder={t('activity_depends_on')} />
</div>
<Button variant="standard" icon={Plus} onClick={addDep}>{t('activity_add_dependency')}</Button>
</div>
</div>
</div>
)}
{tab === 'comments' && <CommentsPanel entityType="activity" entityId={activity.id} />}
{tab === 'documents' && <AttachmentsPanel entityType="activity" entityId={activity.id} />}
</div>
);
}
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { Plus, Save } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listActivitiesByProject, createActivity, getActivity } from '../../services/activities.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js';
import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
import { dueStatus } from '../../utils/dueStatus.js';
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
export default function ProjectActivitiesTab({ projectId }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: activities, loading, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []);
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [selected, setSelected] = useState(null);
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
async function save() {
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
await runAction(async () => {
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
setCreating(false);
setForm(EMPTY_FORM);
pushToast({ tone: 'success', message: t('common_saved') });
await reload();
});
}
async function openActivity(row) {
const full = await getActivity(row.id);
setSelected(full);
}
async function refreshAll() {
await reload();
if (selected) setSelected(await getActivity(selected.id));
}
const columns = [
{ key: 'code', label: t('activity_code') },
{ key: 'name', label: t('common_name') },
{ key: 'responsible_name', label: t('common_responsible'), render: (r) => r.responsible_name || '—' },
{ key: 'status_name', label: t('common_status'), render: (r) => <StatusBadge name={r.status_name} color={r.status_color} /> },
{ key: 'priority_name', label: t('common_priority'), render: (r) => <StatusBadge name={r.priority_name} color={r.priority_color} /> },
{ key: 'due_date', label: t('activity_due_date'), render: (r) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{r.due_date || '—'}</span>
<DueBadge status={dueStatus(r.due_date, r.status_is_closed)} />
</div>
) },
{ key: 'progress_pct', label: t('activity_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
];
if (loading || !activities) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<SearchInput value={search} onChange={setSearch} />
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
</div>
<ListView
columns={columns}
rows={activities}
searchValue={search}
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
getRowKey={(a) => a.id}
onRowClick={openActivity}
/>
{creating && (
<div className="panel-card" style={{ marginTop: 16 }}>
<h3>{t('activity_new')}</h3>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('common_priority')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_start_date')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('activity_due_date')}>
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
</Field>
<Field label={t('activity_milestone')}>
<label className="checkbox-row">
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
{t('activity_milestone')}
</label>
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
</div>
</div>
)}
{selected && (
<div style={{ marginTop: 16 }}>
<ActivityDetailPanel
activity={selected}
projectId={projectId}
siblingActivities={activities}
onChanged={refreshAll}
onClose={() => setSelected(null)}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import { useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { listActivitiesByProject } from '../../services/activities.js';
import { Button } from '../../components/Button.jsx';
const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'];
function buildGrid(year, month) {
const first = new Date(year, month, 1);
const startOffset = (first.getDay() + 6) % 7; // lunes = 0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells = [];
for (let i = 0; i < startOffset; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));
while (cells.length % 7 !== 0) cells.push(null);
return cells;
}
export default function ProjectCalendarTab({ projectId }) {
const { t } = useUi();
const { data: activities, loading } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
const [cursor, setCursor] = useState(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); });
const cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]);
const todayStr = new Date().toISOString().slice(0, 10);
const byDay = useMemo(() => {
const map = {};
for (const a of activities || []) {
if (!a.due_date) continue;
(map[a.due_date] ||= []).push(a);
}
return map;
}, [activities]);
if (loading || !activities) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<Button variant="standard" size="sm" icon={ChevronLeft} onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1))} />
<div style={{ fontWeight: 700 }}>{cursor.toLocaleDateString('es-EC', { month: 'long', year: 'numeric' })}</div>
<Button variant="standard" size="sm" icon={ChevronRight} onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1))} />
</div>
<div className="calendar-grid">
{DOW.map((d) => <div key={d} className="calendar-dow">{d}</div>)}
{cells.map((date, i) => {
if (!date) return <div key={i} className="calendar-cell muted" />;
const key = date.toISOString().slice(0, 10);
const items = byDay[key] || [];
return (
<div key={i} className={`calendar-cell ${key === todayStr ? 'today' : ''}`}>
<div className="calendar-day-num">{date.getDate()}</div>
{items.slice(0, 3).map((a) => (
<div key={a.id} className="calendar-chip" title={a.name}>{a.is_milestone ? '◆ ' : ''}{a.name}</div>
))}
{items.length > 3 && <div className="text-muted" style={{ fontSize: '.64rem' }}>+{items.length - 3}</div>}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { getGantt } from '../../services/projects.js';
export default function ProjectGanttTab({ projectId }) {
const { t } = useUi();
const { data: items, loading } = useAsync(() => getGantt(projectId), [projectId]);
if (loading || !items) return <div className="text-muted">{t('common_loading')}</div>;
const withDates = items.filter((i) => i.startDate && i.dueDate);
if (withDates.length === 0) return <div className="empty-state">{t('common_empty')}</div>;
const minDate = Math.min(...withDates.map((i) => new Date(i.startDate).getTime()));
const maxDate = Math.max(...withDates.map((i) => new Date(i.dueDate).getTime()));
const span = Math.max(maxDate - minDate, 86400000);
return (
<div className="panel-card">
{items.map((item) => {
const hasDates = item.startDate && item.dueDate;
const startPct = hasDates ? ((new Date(item.startDate).getTime() - minDate) / span) * 100 : 0;
const widthPct = hasDates ? Math.max(((new Date(item.dueDate).getTime() - new Date(item.startDate).getTime()) / span) * 100, 1) : 0;
return (
<div key={item.id} className="gantt-row">
<div className="gantt-label" title={item.name}>{item.isMilestone ? '◆ ' : ''}{item.code} — {item.name}</div>
<div className="gantt-track">
{hasDates && (
<div className="gantt-bar" style={{ left: `${startPct}%`, width: `${widthPct}%` }}>
<div className="gantt-bar-fill" style={{ width: `${item.progressPct}%` }} />
</div>
)}
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,204 @@
import { useEffect, useState } from 'react';
import { Pencil, Save, X, UserPlus, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { updateProject, addProjectMember, removeProjectMember } from '../../services/projects.js';
import { listCatalog } from '../../services/catalogs.js';
import { listDepartments } from '../../services/departments.js';
import { listUsers } from '../../services/users.js';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Badge } from '../../components/Badge.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
return acc;
}
const RISK_COLOR = { low: 'success', medium: 'warning', high: 'danger' };
function toForm(p) {
return {
name: p.name, description: p.description || '', objective: p.objective || '',
projectTypeId: p.project_type_id || '', priorityId: p.priority_id, statusId: p.status_id,
ownerUserId: p.owner_user_id,
requestingDepartmentId: p.requesting_department_id || '', responsibleDepartmentId: p.responsible_department_id || '',
startDate: p.start_date || '', estimatedEndDate: p.estimated_end_date || '', actualEndDate: p.actual_end_date || '',
riskLevel: p.risk_level, notes: p.notes || '',
};
}
export default function ProjectInfoTab({ project, onChanged }) {
const { t, pushToast, openDialog } = useUi();
const runAction = useAction();
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
const { data: types } = useAsync(() => listCatalog('project-types'), []);
const { data: deptTree } = useAsync(listDepartments, []);
const { data: users } = useAsync(listUsers, []);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(() => toForm(project));
const [addingMember, setAddingMember] = useState('');
useEffect(() => { setForm(toForm(project)); }, [project]);
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }));
async function save() {
const changed = JSON.stringify(form) !== JSON.stringify(toForm(project));
if (!changed) { setEditing(false); return; }
await runAction(async () => {
await updateProject(project.id, {
...form,
projectTypeId: form.projectTypeId || null,
requestingDepartmentId: form.requestingDepartmentId || null,
responsibleDepartmentId: form.responsibleDepartmentId || null,
startDate: form.startDate || null,
estimatedEndDate: form.estimatedEndDate || null,
actualEndDate: form.actualEndDate || null,
});
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
onChanged();
});
}
async function addMember() {
if (!addingMember) return;
await runAction(async () => {
await addProjectMember(project.id, Number(addingMember));
setAddingMember('');
onChanged();
});
}
function confirmRemoveMember(member) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await removeProjectMember(project.id, member.user_id); onChanged(); }) },
],
});
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
{editing ? (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(project)); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
) : (
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
)}
</div>
{!editing ? (
<div className="panel-card">
<FieldRow>
<Field label={t('common_status')}><StatusBadge name={project.status_name} color={project.status_color} /></Field>
<Field label={t('common_priority')}><StatusBadge name={project.priority_name} color={project.priority_color} /></Field>
<Field label={t('project_risk')}><Badge color={RISK_COLOR[project.risk_level]}>{t(`risk_${project.risk_level}`)}</Badge></Field>
</FieldRow>
<Field label={t('common_description')}><div>{project.description || '—'}</div></Field>
<Field label={t('project_objective')}><div>{project.objective || '—'}</div></Field>
<FieldRow>
<Field label={t('project_type')}><div>{project.project_type_name || '—'}</div></Field>
<Field label={t('project_owner')}><div>{project.owner_name}</div></Field>
</FieldRow>
<FieldRow>
<Field label={t('project_requesting_department')}><div>{project.requesting_department_name || '—'}</div></Field>
<Field label={t('project_responsible_department')}><div>{project.responsible_department_name || '—'}</div></Field>
</FieldRow>
<FieldRow>
<Field label={t('project_start_date')}><div>{project.start_date || '—'}</div></Field>
<Field label={t('project_estimated_end_date')}><div>{project.estimated_end_date || '—'}</div></Field>
<Field label={t('project_actual_end_date')}><div>{project.actual_end_date || '—'}</div></Field>
</FieldRow>
<Field label={t('project_progress')}><ProgressBar value={Number(project.progress_pct)} /></Field>
<Field label={t('project_notes')}><div>{project.notes || '—'}</div></Field>
</div>
) : (
<div className="panel-card">
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label={t('common_description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<Field label={t('project_objective')}>
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('project_type')}>
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
</Field>
<Field label={t('common_priority')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_owner')}>
<ComboBox options={(users || []).map((u) => ({ value: u.id, label: u.name }))} value={form.ownerUserId} onChange={(v) => setForm((f) => ({ ...f, ownerUserId: v }))} />
</Field>
<Field label={t('project_risk')}>
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_requesting_department')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
</Field>
<Field label={t('project_responsible_department')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_start_date')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('project_estimated_end_date')}>
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
</Field>
<Field label={t('project_actual_end_date')}>
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
</Field>
</FieldRow>
<Field label={t('project_notes')}>
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
</Field>
</div>
)}
<div className="panel-card">
<h3>{t('project_members')}</h3>
{project.members.map((m) => (
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
<span style={{ flex: 1, fontSize: '.82rem' }}>{m.name} <span className="text-muted">· {m.email}</span></span>
<button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<div style={{ flex: 1 }}>
<ComboBox options={userOptions} value={addingMember} onChange={setAddingMember} placeholder={t('project_add_member')} />
</div>
<Button variant="standard" icon={UserPlus} onClick={addMember}>{t('project_add_member')}</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync } from '../../hooks/useAsync.js';
import { getProject } from '../../services/projects.js';
import { Button } from '../../components/Button.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { StatusBadge } from '../../components/StatusBadge.jsx';
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
import ProjectInfoTab from './ProjectInfoTab.jsx';
import ProjectActivitiesTab from './ProjectActivitiesTab.jsx';
import ProjectGanttTab from './ProjectGanttTab.jsx';
import ProjectCalendarTab from './ProjectCalendarTab.jsx';
export default function ProyectoDetallePage() {
const { id } = useParams();
const projectId = Number(id);
const { t } = useUi();
const navigate = useNavigate();
const [tab, setTab] = useState('info');
const { data: project, loading, reload } = useAsync(() => getProject(projectId), [projectId]);
if (loading || !project) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<Button variant="subtle" icon={ArrowLeft} onClick={() => navigate('/proyectos')}>{t('projects_title')}</Button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<h2 className="page-title mt-0">{project.code} — {project.name}</h2>
<StatusBadge name={project.status_name} color={project.status_color} />
</div>
<Tabs
tabs={[
{ key: 'info', label: t('tab_info') },
{ key: 'activities', label: t('tab_activities') },
{ key: 'gantt', label: t('tab_gantt') },
{ key: 'calendar', label: t('tab_calendar') },
{ key: 'comments', label: t('tab_comments') },
{ key: 'documents', label: t('tab_documents') },
]}
active={tab}
onChange={setTab}
/>
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
{tab === 'activities' && <ProjectActivitiesTab projectId={projectId} />}
{tab === 'gantt' && <ProjectGanttTab projectId={projectId} />}
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
{tab === 'comments' && <CommentsPanel entityType="project" entityId={projectId} />}
{tab === 'documents' && <AttachmentsPanel entityType="project" entityId={projectId} />}
</div>
);
}
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Save } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listProjects, createProject } from '../../services/projects.js';
import { listCatalog } from '../../services/catalogs.js';
import { listDepartments } from '../../services/departments.js';
import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Badge } from '../../components/Badge.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
return acc;
}
const RISK_COLOR = { low: 'success', medium: 'warning', high: 'danger' };
const EMPTY_FORM = {
name: '', description: '', objective: '', projectTypeId: '', priorityId: '', statusId: '',
requestingDepartmentId: '', responsibleDepartmentId: '', startDate: '', estimatedEndDate: '',
riskLevel: 'low', notes: '',
};
export default function ProyectosPage() {
const { t, pushToast } = useUi();
const navigate = useNavigate();
const runAction = useAction();
const { data: projects, loading, reload } = useAsync(listProjects, []);
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
const { data: types } = useAsync(() => listCatalog('project-types'), []);
const { data: deptTree } = useAsync(listDepartments, []);
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
async function save() {
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
await runAction(async () => {
const project = await createProject({
...form,
projectTypeId: form.projectTypeId || null,
requestingDepartmentId: form.requestingDepartmentId || null,
responsibleDepartmentId: form.responsibleDepartmentId || null,
startDate: form.startDate || null,
estimatedEndDate: form.estimatedEndDate || null,
});
setCreating(false);
setForm(EMPTY_FORM);
pushToast({ tone: 'success', message: t('common_saved') });
navigate(`/proyectos/${project.id}`);
});
}
const columns = [
{ key: 'code', label: t('project_code') },
{ key: 'name', label: t('common_name') },
{ key: 'status_name', label: t('common_status'), render: (r) => <StatusBadge name={r.status_name} color={r.status_color} /> },
{ key: 'priority_name', label: t('common_priority'), render: (r) => <StatusBadge name={r.priority_name} color={r.priority_color} /> },
{ key: 'owner_name', label: t('project_owner') },
{ key: 'progress_pct', label: t('project_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
{ key: 'risk_level', label: t('project_risk'), render: (r) => <Badge color={RISK_COLOR[r.risk_level]}>{t(`risk_${r.risk_level}`)}</Badge> },
];
if (loading || !projects) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div className="toolbar">
<div className="toolbar-left">
<h2 className="page-title mt-0">{t('projects_title')}</h2>
</div>
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button>
</div>
<div className="toolbar">
<SearchInput value={search} onChange={setSearch} />
</div>
<ListView
columns={columns}
rows={projects}
searchValue={search}
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
getRowKey={(p) => p.id}
onRowClick={(p) => navigate(`/proyectos/${p.id}`)}
/>
{creating && (
<div className="panel-card" style={{ marginTop: 16 }}>
<h3>{t('project_new')}</h3>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<Field label={t('common_description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<Field label={t('project_objective')}>
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('project_type')}>
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: v }))} />
</Field>
<Field label={t('common_priority')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_requesting_department')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
</Field>
<Field label={t('project_responsible_department')}>
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
</Field>
<Field label={t('project_risk')}>
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('project_start_date')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('project_estimated_end_date')}>
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
</Field>
</FieldRow>
<Field label={t('project_notes')}>
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
</div>
</div>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { api } from './api.js';
export const listActivitiesByProject = (projectId) => api.get(`/projects/${projectId}/activities`);
export const createActivity = (projectId, data) => api.post(`/projects/${projectId}/activities`, data);
export const getActivity = (id) => api.get(`/activities/${id}`);
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
export const addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId });
export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);
+52
View File
@@ -0,0 +1,52 @@
const API_URL = import.meta.env.VITE_API_URL || '/api';
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
function getToken() {
return localStorage.getItem('plc_token');
}
async function request(method, path, { body, isForm } = {}) {
const headers = {};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
if (!isForm && body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${API_URL}${path}`, {
method,
headers,
body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
onUnauthorized();
}
if (res.status === 204) return null;
const isJson = (res.headers.get('content-type') || '').includes('application/json');
const payload = isJson ? await res.json() : null;
if (!res.ok) {
const message = payload?.error || `Error ${res.status}`;
throw new Error(message);
}
return payload?.data;
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, { body }),
put: (path, body) => request('PUT', path, { body }),
patch: (path, body) => request('PATCH', path, { body }),
delete: (path) => request('DELETE', path),
postForm: (path, formData) => request('POST', path, { body: formData, isForm: true }),
setToken: (token) => {
if (token) localStorage.setItem('plc_token', token);
else localStorage.removeItem('plc_token');
},
downloadUrl: (path) => `${API_URL}${path}`,
};
+30
View File
@@ -0,0 +1,30 @@
import { api } from './api.js';
export const listAttachments = (entityType, entityId) => api.get(`/attachments?entityType=${entityType}&entityId=${entityId}`);
export async function uploadAttachment(entityType, entityId, file) {
const form = new FormData();
form.append('entityType', entityType);
form.append('entityId', entityId);
form.append('file', file);
return api.postForm('/attachments', form);
}
export const deleteAttachment = (id) => api.delete(`/attachments/${id}`);
export async function downloadAttachment(id, filename) {
const token = localStorage.getItem('plc_token');
const res = await fetch(api.downloadUrl(`/attachments/${id}/download`), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error('No se pudo descargar el archivo');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
+6
View File
@@ -0,0 +1,6 @@
import { api } from './api.js';
export const listCatalog = (key) => api.get(`/catalogs/${key}`);
export const createCatalogItem = (key, data) => api.post(`/catalogs/${key}`, data);
export const updateCatalogItem = (key, id, data) => api.put(`/catalogs/${key}/${id}`, data);
export const deleteCatalogItem = (key, id) => api.delete(`/catalogs/${key}/${id}`);
+4
View File
@@ -0,0 +1,4 @@
import { api } from './api.js';
export const listComments = (entityType, entityId) => api.get(`/comments?entityType=${entityType}&entityId=${entityId}`);
export const createComment = (entityType, entityId, message) => api.post('/comments', { entityType, entityId, message });
+4
View File
@@ -0,0 +1,4 @@
import { api } from './api.js';
export const getSummary = () => api.get('/dashboard/summary');
export const getCalendar = (month) => api.get(`/dashboard/calendar?month=${month}`);
+6
View File
@@ -0,0 +1,6 @@
import { api } from './api.js';
export const listDepartments = () => api.get('/departments');
export const createDepartment = (data) => api.post('/departments', data);
export const updateDepartment = (id, data) => api.put(`/departments/${id}`, data);
export const deleteDepartment = (id) => api.delete(`/departments/${id}`);
+5
View File
@@ -0,0 +1,5 @@
import { api } from './api.js';
export const listNotifications = () => api.get('/notifications');
export const markNotificationRead = (id) => api.patch(`/notifications/${id}/read`);
export const markAllNotificationsRead = () => api.patch('/notifications/read-all');
+12
View File
@@ -0,0 +1,12 @@
import { api } from './api.js';
export const listProjects = (filters = {}) => {
const qs = new URLSearchParams(Object.entries(filters).filter(([, v]) => v)).toString();
return api.get(`/projects${qs ? `?${qs}` : ''}`);
};
export const getProject = (id) => api.get(`/projects/${id}`);
export const createProject = (data) => api.post('/projects', data);
export const updateProject = (id, data) => api.put(`/projects/${id}`, data);
export const addProjectMember = (id, userId, roleInProject) => api.post(`/projects/${id}/members`, { userId, roleInProject });
export const removeProjectMember = (id, userId) => api.delete(`/projects/${id}/members/${userId}`);
export const getGantt = (id) => api.get(`/projects/${id}/gantt`);
+7
View File
@@ -0,0 +1,7 @@
import { api } from './api.js';
export const listSubtasks = (activityId) => api.get(`/activities/${activityId}/subtasks`);
export const createSubtask = (activityId, data) => api.post(`/activities/${activityId}/subtasks`, data);
export const updateSubtask = (id, data) => api.put(`/subtasks/${id}`, data);
export const setSubtaskDone = (id, isDone) => api.patch(`/subtasks/${id}/done`, { isDone });
export const deleteSubtask = (id) => api.delete(`/subtasks/${id}`);
+7
View File
@@ -0,0 +1,7 @@
import { api } from './api.js';
export const listUsers = () => api.get('/users');
export const listRoles = () => api.get('/users/roles');
export const createUser = (data) => api.post('/users', data);
export const updateUser = (id, data) => api.put(`/users/${id}`, data);
export const setUserActive = (id, isActive) => api.patch(`/users/${id}/active`, { isActive });
+275
View File
@@ -0,0 +1,275 @@
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: rgb(var(--canvas));
color: rgb(var(--body));
font-family: var(--app-font);
font-size: .82rem;
}
a { color: rgb(var(--accent)); }
button { font-family: inherit; }
/* --- Botones (apariencia configurable) --- */
.btn {
display: inline-flex; align-items: center; gap: 8px;
height: 32px; padding: 0 12px;
border-radius: var(--r-sm);
border: 1px solid transparent;
font-size: .82rem; font-weight: 600;
cursor: pointer; white-space: nowrap;
transition: background .12s ease, border-color .12s ease;
}
.btn-sm { height: 26px; padding: 0 10px; font-size: .76rem; }
.btn svg { width: 16px; height: 16px; flex: none; }
.btn-accent { background: rgb(var(--accent)); color: #fff; }
.btn-accent:hover { filter: brightness(1.08); }
.btn-standard { background: rgb(var(--field)); border-color: rgb(var(--line)); color: rgb(var(--body)); }
.btn-standard:hover { background: rgb(var(--panel)); }
.btn-subtle { background: transparent; color: rgb(var(--body)); }
.btn-subtle:hover { background: rgb(var(--panel)); }
.btn-danger { background: transparent; border-color: rgb(var(--danger)); color: rgb(var(--danger)); }
.btn-danger:hover { background: rgb(var(--danger) / .12); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-icon-only .btn-label { display: none; }
:root[data-button-mode='icon'] .btn .btn-label { display: none; }
:root[data-button-mode='text'] .btn svg { display: none; }
/* --- Layout general --- */
.app-shell { display: flex; height: 100vh; overflow: hidden; }
.nav-view {
width: 260px; flex: none;
background: rgb(var(--panel));
border-right: 1px solid rgb(var(--line));
display: flex; flex-direction: column;
transition: width .15s ease;
}
.nav-view.compact { width: 56px; }
.nav-view.hidden { width: 0; overflow: hidden; border-right: none; }
.nav-header {
display: flex; align-items: center; gap: 10px;
padding: 14px 12px; border-bottom: 1px solid rgb(var(--line));
min-height: 56px;
}
.nav-logo {
width: 28px; height: 28px; border-radius: var(--r-sm);
background: rgb(var(--accent)); color: #fff;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: .8rem; flex: none;
}
.nav-title { font-weight: 700; font-size: .9rem; white-space: nowrap; overflow: hidden; }
.nav-items { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex; align-items: center; gap: 12px;
padding: 8px 10px; border-radius: var(--r-sm);
color: rgb(var(--muted)); text-decoration: none;
cursor: pointer; border: none; background: transparent; font-size: .82rem; text-align: left;
white-space: nowrap; overflow: hidden;
}
.nav-item svg { flex: none; width: 18px; height: 18px; }
.nav-item:hover { background: rgb(var(--field)); color: rgb(var(--body)); }
.nav-item.active { background: rgb(var(--accent) / .16); color: rgb(var(--accent)); font-weight: 600; }
.nav-footer { padding: 10px 12px; border-top: 1px solid rgb(var(--line)); font-size: .68rem; color: rgb(var(--muted)); }
.content-column { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar {
height: 52px; flex: none; display: flex; align-items: center; justify-content: space-between;
padding: 0 16px; border-bottom: 1px solid rgb(var(--line)); background: rgb(var(--surface));
gap: 12px;
}
.topbar-left { display: flex; align-items: center; gap: 10px; }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.page-title { font-size: 1.05rem; font-weight: 700; }
.content-area { flex: 1; overflow-y: auto; padding: 20px; }
.icon-btn {
width: 32px; height: 32px; border-radius: var(--r-sm);
display: flex; align-items: center; justify-content: center;
background: transparent; border: none; color: rgb(var(--body)); cursor: pointer; position: relative;
}
.icon-btn:hover { background: rgb(var(--panel)); }
.icon-btn svg { width: 18px; height: 18px; }
/* --- Campos --- */
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.field-label { font-size: .72rem; font-weight: 600; color: rgb(var(--muted)); }
.input, .textarea, select.input {
background: rgb(var(--field)); border: 1px solid rgb(var(--line)); color: rgb(var(--body));
border-radius: var(--r-sm); padding: 6px 10px; font-size: .82rem; font-family: inherit; width: 100%;
}
.input:focus, .textarea:focus, select.input:focus { outline: 2px solid rgb(var(--accent)); outline-offset: -1px; }
.input:disabled, .textarea:disabled { opacity: .6; }
.textarea { min-height: 70px; resize: vertical; }
.field-row { display: flex; gap: 12px; flex-wrap: wrap; }
.field-row > .field { flex: 1; min-width: 180px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: .82rem; }
/* --- ComboBox --- */
.combobox { position: relative; }
.combobox-panel {
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 40;
background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg);
box-shadow: 0 8px 24px rgb(0 0 0 / .35); max-height: 240px; overflow-y: auto; padding: 6px;
}
.combobox-search { padding: 4px 6px 8px; }
.combobox-option {
padding: 7px 10px; border-radius: var(--r-sm); cursor: pointer; font-size: .82rem;
}
.combobox-option:hover, .combobox-option.active { background: rgb(var(--accent) / .14); }
.combobox-empty { padding: 10px; color: rgb(var(--muted)); font-size: .78rem; }
/* --- Listado / DataGrid --- */
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.toolbar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.search-input { position: relative; width: 260px; max-width: 100%; }
.search-input input { padding-left: 32px; }
.search-input svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: rgb(var(--muted)); }
.list-card { background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); overflow: hidden; }
table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.list-table th {
text-align: left; padding: 10px 12px; font-size: .7rem; text-transform: uppercase; letter-spacing: .03em;
color: rgb(var(--muted)); border-bottom: 1px solid rgb(var(--line)); cursor: pointer; user-select: none;
white-space: nowrap;
}
.list-table th:hover { color: rgb(var(--body)); }
.list-table td { padding: 10px 12px; border-bottom: 1px solid rgb(var(--line) / .6); vertical-align: middle; }
.list-table tbody tr { cursor: pointer; }
.list-table tbody tr:hover { background: rgb(var(--panel)); }
.list-table tbody tr:last-child td { border-bottom: none; }
.list-empty { padding: 40px 16px; text-align: center; color: rgb(var(--muted)); }
.row-menu-btn { opacity: .6; }
.row-menu-btn:hover { opacity: 1; }
/* --- Badges --- */
.badge {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
font-size: .7rem; font-weight: 700; white-space: nowrap;
}
.badge-accent { background: rgb(var(--accent) / .16); color: rgb(var(--accent)); }
.badge-success { background: rgb(var(--success) / .18); color: rgb(var(--success)); }
.badge-warning { background: rgb(var(--warning) / .2); color: rgb(var(--warning)); }
.badge-danger { background: rgb(var(--danger) / .18); color: rgb(var(--danger)); }
.badge-muted { background: rgb(var(--muted) / .18); color: rgb(var(--muted)); }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
/* --- Pestañas reales --- */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid rgb(var(--line)); margin-bottom: 16px; overflow-x: auto; }
.tab-item {
padding: 10px 14px; font-size: .82rem; font-weight: 600; color: rgb(var(--muted));
background: transparent; border: none; border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap;
}
.tab-item:hover { color: rgb(var(--body)); }
.tab-item.active { color: rgb(var(--accent)); border-bottom-color: rgb(var(--accent)); }
/* --- Cards / KPIs --- */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 20px; }
.kpi-card { background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); padding: 16px; }
.kpi-value { font-size: 1.6rem; font-weight: 700; }
.kpi-label { font-size: .72rem; color: rgb(var(--muted)); margin-top: 4px; }
.panel-card { background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); padding: 16px; margin-bottom: 16px; }
.panel-card h3 { margin: 0 0 12px; font-size: .95rem; }
/* --- ContentDialog --- */
.dialog-overlay {
position: fixed; inset: 0; background: rgb(0 0 0 / .5);
display: flex; align-items: center; justify-content: center; z-index: 200; padding: 16px;
}
.dialog-surface {
width: 420px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4);
}
.dialog-stripe { height: 4px; }
.dialog-body { padding: 20px; }
.dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
.dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); }
/* --- InfoBar --- */
.infobar {
display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-radius: var(--r-sm);
border-left: 4px solid; margin-bottom: 14px; font-size: .8rem; background: rgb(var(--surface));
}
.infobar svg { flex: none; width: 16px; height: 16px; margin-top: 1px; }
.infobar-close { margin-left: auto; cursor: pointer; background: none; border: none; color: rgb(var(--muted)); }
.infobar-info { border-color: rgb(var(--accent)); }
.infobar-success { border-color: rgb(var(--success)); }
.infobar-warning { border-color: rgb(var(--warning)); }
.infobar-error { border-color: rgb(var(--danger)); }
/* --- Toast --- */
.toast-stack { position: fixed; bottom: 18px; right: 18px; display: flex; flex-direction: column; gap: 8px; z-index: 300; }
.toast {
background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-left: 4px solid rgb(var(--accent));
border-radius: var(--r-sm); padding: 10px 14px; font-size: .8rem; box-shadow: 0 8px 20px rgb(0 0 0 / .3);
min-width: 220px;
}
.toast-success { border-left-color: rgb(var(--success)); }
.toast-warning { border-left-color: rgb(var(--warning)); }
.toast-error { border-left-color: rgb(var(--danger)); }
/* --- Flyout / menú contextual --- */
.flyout-backdrop { position: fixed; inset: 0; z-index: 90; }
.flyout-panel {
position: absolute; z-index: 100; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px;
}
.flyout-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: var(--r-sm);
cursor: pointer; font-size: .8rem; background: none; border: none; width: 100%; text-align: left; color: rgb(var(--body));
}
.flyout-item:hover { background: rgb(var(--accent) / .14); }
.flyout-item.danger { color: rgb(var(--danger)); }
.flyout-item.danger:hover { background: rgb(var(--danger) / .14); }
.flyout-separator { height: 1px; background: rgb(var(--line)); margin: 4px 2px; }
/* --- Treeview --- */
.tree-item-row { display: flex; align-items: center; gap: 6px; padding: 6px 6px; border-radius: var(--r-sm); cursor: pointer; }
.tree-item-row:hover { background: rgb(var(--panel)); }
.tree-toggle { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; flex: none; background: none; border: none; color: rgb(var(--muted)); cursor: pointer; }
/* --- Calendario --- */
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
.calendar-dow { font-size: .68rem; color: rgb(var(--muted)); text-align: center; padding-bottom: 4px; }
.calendar-cell {
min-height: 84px; border: 1px solid rgb(var(--line)); border-radius: var(--r-sm); padding: 6px;
background: rgb(var(--surface)); font-size: .72rem; display: flex; flex-direction: column; gap: 4px;
}
.calendar-cell.today { border-color: rgb(var(--accent)); }
.calendar-cell.muted { opacity: .4; }
.calendar-day-num { font-weight: 700; }
.calendar-chip {
padding: 2px 6px; border-radius: 4px; background: rgb(var(--accent) / .16); color: rgb(var(--accent));
font-size: .66rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer;
}
/* --- Gantt --- */
.gantt-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgb(var(--line) / .5); }
.gantt-label { width: 220px; flex: none; font-size: .78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gantt-track { position: relative; flex: 1; height: 18px; background: rgb(var(--field)); border-radius: var(--r-sm); }
.gantt-bar { position: absolute; top: 0; bottom: 0; border-radius: var(--r-sm); background: rgb(var(--accent) / .35); }
.gantt-bar-fill { position: absolute; top: 0; bottom: 0; left: 0; border-radius: var(--r-sm); background: rgb(var(--accent)); }
/* --- Login --- */
.login-shell { height: 100vh; display: flex; align-items: center; justify-content: center; background: rgb(var(--canvas)); }
.login-card { width: 340px; max-width: 92vw; background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); padding: 28px; }
.login-logo { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
.login-title { font-size: 1.1rem; font-weight: 700; }
.login-error { color: rgb(var(--danger)); font-size: .78rem; margin-bottom: 10px; }
/* --- Expander (Configuracion) --- */
.expander { border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); margin-bottom: 10px; overflow: hidden; }
.expander-header { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; background: rgb(var(--surface)); }
.expander-header svg.chevron { margin-left: auto; transition: transform .15s ease; }
.expander.open .chevron { transform: rotate(90deg); }
.expander-title { font-weight: 600; font-size: .85rem; }
.expander-desc { font-size: .72rem; color: rgb(var(--muted)); }
.expander-body { padding: 14px; border-top: 1px solid rgb(var(--line)); }
.progress-bar { height: 6px; border-radius: 999px; background: rgb(var(--field)); overflow: hidden; }
.progress-bar-fill { height: 100%; background: rgb(var(--accent)); }
.text-muted { color: rgb(var(--muted)); }
.mt-0 { margin-top: 0; }
.empty-state { text-align: center; padding: 40px 16px; color: rgb(var(--muted)); }
+47
View File
@@ -0,0 +1,47 @@
:root {
--canvas: 32 32 32;
--surface: 43 43 43;
--panel: 51 51 51;
--field: 43 43 43;
--body: 255 255 255;
--muted: 168 168 168;
--line: 70 70 70;
--accent: 0 120 212;
--success: 108 203 95;
--warning: 255 185 0;
--danger: 255 105 97;
--r-sm: 4px;
--r-lg: 8px;
--app-font: 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif;
}
:root[data-theme='light'] {
--canvas: 243 243 243;
--surface: 255 255 255;
--panel: 249 249 249;
--field: 255 255 255;
--body: 32 32 32;
--muted: 96 96 96;
--line: 225 225 225;
--accent: 0 103 192;
--success: 16 124 16;
--warning: 157 93 0;
--danger: 196 43 28;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--canvas: 243 243 243;
--surface: 255 255 255;
--panel: 249 249 249;
--field: 255 255 255;
--body: 32 32 32;
--muted: 96 96 96;
--line: 225 225 225;
--accent: 0 103 192;
--success: 16 124 16;
--warning: 157 93 0;
--danger: 196 43 28;
}
}
+14
View File
@@ -0,0 +1,14 @@
// Espejo del calculo del backend (backend/src/utils/dates.js) para listas que no traen el campo ya resuelto.
export function dueStatus(dueDate, isClosed = false) {
if (!dueDate || isClosed) return 'none';
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(dueDate);
due.setHours(0, 0, 0, 0);
const diffDays = Math.round((due - today) / 86400000);
if (diffDays < 0) return 'overdue';
if (diffDays === 0) return 'today';
if (diffDays <= 3) return 'warning';
return 'on_time';
}