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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user