chore: estructura inicial del MVP

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-03 08:37:58 -05:00
commit 1d22adc42e
135 changed files with 10913 additions and 0 deletions
@@ -0,0 +1,58 @@
import { useRef } from 'react';
import { Upload, Download, Trash2, Paperclip } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listAttachments, uploadAttachment, deleteAttachment, downloadAttachment } from '../services/attachments.js';
import { Button } from './Button.jsx';
export function AttachmentsPanel({ entityType, entityId }) {
const { t, openDialog } = useUi();
const runAction = useAction();
const { data: files, loading, reload } = useAsync(() => listAttachments(entityType, entityId), [entityType, entityId]);
const inputRef = useRef(null);
async function handleFile(e) {
const file = e.target.files?.[0];
if (!file) return;
await runAction(async () => {
await uploadAttachment(entityType, entityId, file);
await reload();
});
e.target.value = '';
}
function confirmDelete(file) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await deleteAttachment(file.id); await reload(); }) },
],
});
}
if (loading || !files) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
<div style={{ marginBottom: 12 }}>
<input ref={inputRef} type="file" hidden onChange={handleFile} />
<Button variant="standard" icon={Upload} onClick={() => inputRef.current?.click()}>{t('document_upload')}</Button>
</div>
{files.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{files.map((f) => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
<Paperclip size={15} style={{ opacity: .7 }} />
<div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem' }}>{f.original_name}</div>
<div className="text-muted" style={{ fontSize: '.7rem' }}>{f.uploaded_by_name} · {(f.size / 1024).toFixed(0)} KB</div>
</div>
<button className="icon-btn" onClick={() => downloadAttachment(f.id, f.original_name)}><Download size={16} /></button>
<button className="icon-btn" onClick={() => confirmDelete(f)}><Trash2 size={16} /></button>
</div>
))}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
const COLOR_MAP = {
accent: 'badge-accent', success: 'badge-success', warning: 'badge-warning',
danger: 'badge-danger', muted: 'badge-muted',
};
export function Badge({ color = 'accent', children }) {
const cls = COLOR_MAP[color] || 'badge-accent';
return <span className={`badge ${cls}`}>{children}</span>;
}
+9
View File
@@ -0,0 +1,9 @@
export function Button({ variant = 'standard', size, icon: Icon, children, className = '', ...rest }) {
const sizeClass = size === 'sm' ? 'btn-sm' : '';
return (
<button className={`btn btn-${variant} ${sizeClass} ${className}`.trim()} {...rest}>
{Icon && <Icon />}
{children && <span className="btn-label">{children}</span>}
</button>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
// options: [{ value, label }]
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const rootRef = useRef(null);
useEffect(() => {
function onDocClick(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
}
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
const selected = options.find((o) => String(o.value) === String(value));
const filtered = query
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
: options;
return (
<div className="combobox" ref={rootRef}>
<button type="button" className="input" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }} onClick={() => setOpen((v) => !v)}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
<ChevronDown size={14} />
</button>
{open && (
<div className="combobox-panel">
{options.length >= searchThreshold && (
<div className="combobox-search" style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
<input
autoFocus
className="input"
style={{ paddingLeft: 26 }}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar..."
/>
</div>
)}
{filtered.length === 0 && <div className="combobox-empty">Sin resultados</div>}
{filtered.map((o) => (
<div
key={o.value}
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
>
{o.label}
</div>
))}
</div>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { Send } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useAsync, useAction } from '../hooks/useAsync.js';
import { listComments, createComment } from '../services/comments.js';
import { Button } from './Button.jsx';
export function CommentsPanel({ entityType, entityId }) {
const { t } = useUi();
const runAction = useAction();
const { data: comments, loading, reload } = useAsync(() => listComments(entityType, entityId), [entityType, entityId]);
const [message, setMessage] = useState('');
async function send() {
if (!message.trim()) return;
await runAction(async () => {
await createComment(entityType, entityId, message.trim());
setMessage('');
await reload();
});
}
if (loading || !comments) return <div className="text-muted">{t('common_loading')}</div>;
return (
<div>
{comments.length === 0 && <div className="text-muted" style={{ marginBottom: 12 }}>{t('common_empty')}</div>}
{comments.map((c) => (
<div key={c.id} style={{ padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)' }}>
<div style={{ fontSize: '.78rem', fontWeight: 700 }}>{c.user_name} <span className="text-muted" style={{ fontWeight: 400 }}>· {c.created_at}</span></div>
<div style={{ fontSize: '.82rem', marginTop: 2 }}>{c.message}</div>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="input" value={message} placeholder={t('comment_placeholder')} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} />
<Button variant="accent" icon={Send} onClick={send}>{t('comment_send')}</Button>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useUi } from '../context/ui-context.jsx';
import { Button } from './Button.jsx';
const STRIPE_COLOR = { info: 'accent', success: 'success', warning: 'warning', error: 'danger' };
// ContentDialog global — equivalente al AppDialog de la identidad web. Contrato:
// openDialog({ tone, title, message, actions: [{ label, variant, keepOpen, onClick }] })
export function ContentDialogHost() {
const { dialog, closeDialog, t } = useUi();
if (!dialog) return null;
const tone = dialog.tone || 'info';
const actions = dialog.actions?.length ? dialog.actions : [{ label: t('common_close'), variant: 'accent' }];
async function handleClick(action) {
if (action.onClick) await action.onClick();
if (!action.keepOpen) closeDialog();
}
return (
<div className="dialog-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) closeDialog(); }}>
<div className="dialog-surface">
<div className="dialog-stripe" style={{ background: `rgb(var(--${STRIPE_COLOR[tone]}))` }} />
<div className="dialog-body">
<div className="dialog-title">{dialog.title}</div>
<div className="dialog-message">{dialog.message}</div>
</div>
<div className="dialog-actions">
{actions.map((action, i) => (
<Button key={i} variant={action.variant || 'standard'} onClick={() => handleClick(action)}>
{action.label}
</Button>
))}
</div>
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
export function Expander({ icon: Icon, title, description, defaultOpen = false, children }) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`expander ${open ? 'open' : ''}`}>
<div className="expander-header" onClick={() => setOpen((v) => !v)}>
{Icon && <Icon size={18} />}
<div>
<div className="expander-title">{title}</div>
{description && <div className="expander-desc">{description}</div>}
</div>
<ChevronRight className="chevron" size={16} />
</div>
{open && <div className="expander-body">{children}</div>}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export function Field({ label, children, hint }) {
return (
<div className="field">
{label && <label className="field-label">{label}</label>}
{children}
{hint && <span className="text-muted" style={{ fontSize: '.72rem' }}>{hint}</span>}
</div>
);
}
export function FieldRow({ children }) {
return <div className="field-row">{children}</div>;
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// Flyout generico con light-dismiss: click fuera o Esc cierra. trigger recibe { onClick, ref }.
export function Flyout({ trigger, children, align = 'end' }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const anchorRef = useRef(null);
function openFlyout() {
const rect = anchorRef.current.getBoundingClientRect();
setPos({
top: rect.bottom + 6,
left: align === 'end' ? rect.right : rect.left,
});
setOpen(true);
}
useEffect(() => {
if (!open) return;
function onKey(e) { if (e.key === 'Escape') setOpen(false); }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
return (
<>
<span ref={anchorRef}>{trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })}</span>
{open && createPortal(
<>
<div className="flyout-backdrop" onClick={() => setOpen(false)} />
<div
className="flyout-panel"
style={{ top: pos.top, left: align === 'end' ? pos.left - 220 : pos.left }}
onClick={() => setOpen(false)}
>
{children}
</div>
</>,
document.body
)}
</>
);
}
export function FlyoutItem({ icon: Icon, danger, children, ...rest }) {
return (
<button className={`flyout-item ${danger ? 'danger' : ''}`} {...rest}>
{Icon && <Icon size={15} />}
<span>{children}</span>
</button>
);
}
export function FlyoutSeparator() {
return <div className="flyout-separator" />;
}
+21
View File
@@ -0,0 +1,21 @@
import { useState } from 'react';
import { Info, CheckCircle2, AlertTriangle, XCircle, X } from 'lucide-react';
const ICONS = { info: Info, success: CheckCircle2, warning: AlertTriangle, error: XCircle };
export function InfoBar({ tone = 'info', children, dismissible = true }) {
const [visible, setVisible] = useState(true);
if (!visible) return null;
const Icon = ICONS[tone] || Info;
return (
<div className={`infobar infobar-${tone}`}>
<Icon />
<div>{children}</div>
{dismissible && (
<button className="infobar-close" onClick={() => setVisible(false)}>
<X size={14} />
</button>
)}
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export function KpiCard({ label, value }) {
return (
<div className="kpi-card">
<div className="kpi-value">{value}</div>
<div className="kpi-label">{label}</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
import { MoreVertical } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { Flyout, FlyoutItem } from './Flyout.jsx';
// ListView/DataGrid: columnas ordenables, buscador ligero sobre el haystack completo, clic en
// fila abre detalle, boton "..." abre el menu de acciones (equivalente al clic derecho).
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey }) {
const { t } = useUi();
const [sort, setSort] = useState({ key: null, dir: 1 });
const filtered = useMemo(() => {
if (!searchValue) return rows;
const q = searchValue.toLowerCase();
return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q));
}, [rows, searchValue, getSearchHaystack]);
const sorted = useMemo(() => {
if (!sort.key) return filtered;
const col = columns.find((c) => c.key === sort.key);
return [...filtered].sort((a, b) => {
const va = col.sortValue ? col.sortValue(a) : a[sort.key];
const vb = col.sortValue ? col.sortValue(b) : b[sort.key];
if (va === vb) return 0;
return (va > vb ? 1 : -1) * sort.dir;
});
}, [filtered, sort, columns]);
function toggleSort(key) {
setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 }));
}
return (
<div className="list-card">
<table className="list-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
</th>
))}
{rowActions && <th style={{ width: 40 }} />}
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={getRowKey(row)} onClick={() => onRowClick?.(row)}>
{columns.map((col) => (
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
))}
{rowActions && (
<td onClick={(e) => e.stopPropagation()}>
<Flyout trigger={({ onClick }) => (
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical /></button>
)}>
{rowActions(row).map((action, i) => (
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
{action.label}
</FlyoutItem>
))}
</Flyout>
</td>
)}
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
</div>
);
}
@@ -0,0 +1,31 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Building2, Settings } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
const ITEMS = [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config' },
];
export function NavigationView({ mode }) {
const { t } = useUi();
return (
<nav className={`nav-view ${mode}`}>
<div className="nav-header">
<div className="nav-logo">LC</div>
{mode !== 'compact' && <div className="nav-title">{t('appName')}</div>}
</div>
<div className="nav-items">
{ITEMS.map((item) => (
<NavLink key={item.to} to={item.to} end={item.end} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<item.icon />
{mode !== 'compact' && <span>{t(item.labelKey)}</span>}
</NavLink>
))}
</div>
{mode !== 'compact' && <div className="nav-footer">v0.1.0</div>}
</nav>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Search } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
export function SearchInput({ value, onChange, placeholder }) {
const { t } = useUi();
return (
<div className="search-input">
<Search />
<input className="input" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder || t('common_search')} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Badge } from './Badge.jsx';
import { useUi } from '../context/ui-context.jsx';
export function StatusBadge({ name, color }) {
return <Badge color={color}>{name}</Badge>;
}
const DUE_COLOR = { on_time: 'success', warning: 'warning', today: 'warning', overdue: 'danger', none: 'muted' };
const DUE_KEY = { on_time: 'due_on_time', warning: 'due_warning', today: 'due_today', overdue: 'due_overdue' };
export function DueBadge({ status }) {
const { t } = useUi();
if (!status || status === 'none') return null;
return <Badge color={DUE_COLOR[status]}>{t(DUE_KEY[status])}</Badge>;
}
export function ProgressBar({ value }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="progress-bar" style={{ flex: 1 }}>
<div className="progress-bar-fill" style={{ width: `${value}%` }} />
</div>
<span style={{ fontSize: '.72rem', width: 36, textAlign: 'right' }}>{Math.round(value)}%</span>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export function Tabs({ tabs, active, onChange }) {
return (
<div className="tabs">
{tabs.map((tab) => (
<button key={tab.key} className={`tab-item ${active === tab.key ? 'active' : ''}`} onClick={() => onChange(tab.key)}>
{tab.label}
</button>
))}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useUi } from '../context/ui-context.jsx';
export function ToastHost() {
const { toasts } = useUi();
if (!toasts.length) return null;
return (
<div className="toast-stack">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.tone}`}>{toast.message}</div>
))}
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
function TreeNode({ node, level, onSelect, selectedId }) {
const [open, setOpen] = useState(true);
const hasChildren = node.children && node.children.length > 0;
return (
<div>
<div
className="tree-item-row"
style={{ paddingLeft: level * 18 + 6, background: selectedId === node.id ? 'rgb(var(--accent) / .14)' : undefined }}
onClick={() => onSelect?.(node)}
>
{hasChildren ? (
<button className="tree-toggle" onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="tree-toggle" />
)}
<Folder size={15} style={{ opacity: .7 }} />
<span>{node.name}</span>
</div>
{hasChildren && open && node.children.map((child) => (
<TreeNode key={child.id} node={child} level={level + 1} onSelect={onSelect} selectedId={selectedId} />
))}
</div>
);
}
export function Treeview({ nodes, onSelect, selectedId }) {
return (
<div>
{nodes.map((node) => (
<TreeNode key={node.id} node={node} level={0} onSelect={onSelect} selectedId={selectedId} />
))}
</div>
);
}