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:
@@ -0,0 +1 @@
|
||||
VITE_API_URL=/api
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="es-EC">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>project.linkedcorp.net</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1925
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.4.3"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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}`,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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 });
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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');
|
||||
@@ -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`);
|
||||
@@ -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}`);
|
||||
@@ -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 });
|
||||
@@ -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)); }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5196,
|
||||
strictPort: true,
|
||||
proxy: { '/api': 'http://localhost:4019' },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user