Skip to content

Commit 7c0308c

Browse files
jonmatumclaude
andcommitted
chore(preset): enterprise-grade token refinements + ConstructPro charts
Token system: - Radius: 0.75 rem → 0.5 rem (8 px base — Linear/Vercel/Stripe standard) - Pure-white card/surface/popover on blue-tinted background for clear visual lift - Border/input: oklch(0.895 0.009 265) — more visible in data-dense UIs - Foreground tokens aligned to brand hue 258 across light and dark themes - Dark mode: card step up from 0.155 → 0.165 for perceptible elevation - Typography: Inter first in --font-sans, font-weight tokens, --tracking-widest - preset.css: wire font-weight + tracking-widest into @theme - tokens/index.ts: add 'widest' to trackingTokens ConstructPro dashboard: - Export ChartConfig type from @jonmatum/next-shell/primitives - Expense-by-category donut chart (5 BudgetCategory buckets, chart-1…5 palette) - Budget vs. executed horizontal bar chart (up to 6 active projects) - recharts added as explicit dep in @jonmatum/construct-pro All 712 tests pass · lint clean · typecheck clean Refs #13 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c8d7fc2 commit 7c0308c

30 files changed

Lines changed: 4413 additions & 50 deletions

apps/construct-pro/app/(shell)/budgets/page.tsx

Lines changed: 506 additions & 0 deletions
Large diffs are not rendered by default.

apps/construct-pro/app/(shell)/dashboard/page.tsx

Lines changed: 478 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import {
5+
PlusIcon,
6+
WrenchIcon,
7+
CheckCircle2Icon,
8+
XCircleIcon,
9+
CircleDotIcon,
10+
PencilIcon,
11+
Trash2Icon,
12+
} from 'lucide-react';
13+
import { toast } from 'sonner';
14+
import { useLiveQuery } from 'dexie-react-hooks';
15+
16+
import { PageHeader, ContentContainer } from '@jonmatum/next-shell/layout';
17+
import {
18+
Card,
19+
CardContent,
20+
Badge,
21+
Button,
22+
Skeleton,
23+
Table,
24+
TableBody,
25+
TableCell,
26+
TableHead,
27+
TableHeader,
28+
TableRow,
29+
Input,
30+
} from '@jonmatum/next-shell/primitives';
31+
import { formatCurrency, formatDate } from '@jonmatum/next-shell/formatters';
32+
33+
import { db, deleteEquipment } from '@/lib/db';
34+
import type { Equipment, EquipmentStatus } from '@/lib/db';
35+
import { EquipmentModal } from '@/components/modals/equipment-modal';
36+
import { DeleteConfirm } from '@/components/modals/delete-confirm';
37+
38+
const STATUS_CONFIG: Record<
39+
EquipmentStatus,
40+
{
41+
label: string;
42+
variant: 'default' | 'secondary' | 'destructive' | 'outline';
43+
icon: React.ElementType;
44+
}
45+
> = {
46+
available: { label: 'Disponible', variant: 'default', icon: CheckCircle2Icon },
47+
'in-use': { label: 'En Uso', variant: 'secondary', icon: CircleDotIcon },
48+
maintenance: { label: 'Mantenimiento', variant: 'destructive', icon: WrenchIcon },
49+
retired: { label: 'Dado de Baja', variant: 'outline', icon: XCircleIcon },
50+
};
51+
52+
export default function EquipmentPage() {
53+
const [search, setSearch] = useState('');
54+
const [filterStatus, setFilterStatus] = useState<EquipmentStatus | 'all'>('all');
55+
const [modalOpen, setModalOpen] = useState(false);
56+
const [editEquipment, setEditEquipment] = useState<Equipment | undefined>();
57+
const [deleteOpen, setDeleteOpen] = useState(false);
58+
const [deleteTarget, setDeleteTarget] = useState<Equipment | undefined>();
59+
const [deleting, setDeleting] = useState(false);
60+
61+
const equipmentList = useLiveQuery(() => db.equipment.toArray(), []);
62+
const projects = useLiveQuery(() => db.projects.toArray(), []);
63+
64+
const isLoading = equipmentList === undefined || projects === undefined;
65+
66+
const projectMap = (projects ?? []).reduce<Record<number, string>>((acc, p) => {
67+
if (p.id) acc[p.id] = p.name;
68+
return acc;
69+
}, {});
70+
71+
const filtered = (equipmentList ?? []).filter((eq) => {
72+
const matchesSearch =
73+
eq.name.toLowerCase().includes(search.toLowerCase()) ||
74+
eq.model.toLowerCase().includes(search.toLowerCase()) ||
75+
eq.category.toLowerCase().includes(search.toLowerCase());
76+
const matchesStatus = filterStatus === 'all' || eq.status === filterStatus;
77+
return matchesSearch && matchesStatus;
78+
});
79+
80+
const counts = {
81+
all: (equipmentList ?? []).length,
82+
available: (equipmentList ?? []).filter((e) => e.status === 'available').length,
83+
'in-use': (equipmentList ?? []).filter((e) => e.status === 'in-use').length,
84+
maintenance: (equipmentList ?? []).filter((e) => e.status === 'maintenance').length,
85+
retired: (equipmentList ?? []).filter((e) => e.status === 'retired').length,
86+
};
87+
88+
const openCreate = () => {
89+
setEditEquipment(undefined);
90+
setModalOpen(true);
91+
};
92+
93+
const openEdit = (eq: Equipment) => {
94+
setEditEquipment(eq);
95+
setModalOpen(true);
96+
};
97+
98+
const openDelete = (eq: Equipment) => {
99+
setDeleteTarget(eq);
100+
setDeleteOpen(true);
101+
};
102+
103+
const handleDelete = async () => {
104+
if (!deleteTarget?.id) return;
105+
setDeleting(true);
106+
try {
107+
await deleteEquipment(deleteTarget.id);
108+
toast.success(`Equipo "${deleteTarget.name}" eliminado`);
109+
setDeleteOpen(false);
110+
setDeleteTarget(undefined);
111+
} catch {
112+
toast.error('Error al eliminar el equipo');
113+
} finally {
114+
setDeleting(false);
115+
}
116+
};
117+
118+
return (
119+
<>
120+
<PageHeader
121+
title="Inventario de Equipos"
122+
description="Gestión y trazabilidad de equipos de construcción"
123+
actions={
124+
<Button onClick={openCreate}>
125+
<PlusIcon className="size-4" />
126+
Registrar Equipo
127+
</Button>
128+
}
129+
/>
130+
<ContentContainer size="full" className="space-y-5 py-6">
131+
{/* Status filters */}
132+
<div className="flex flex-wrap gap-2">
133+
{(['all', 'available', 'in-use', 'maintenance', 'retired'] as const).map((s) => (
134+
<Button
135+
key={s}
136+
variant={filterStatus === s ? 'default' : 'outline'}
137+
size="sm"
138+
onClick={() => setFilterStatus(s)}
139+
className="gap-1.5"
140+
>
141+
{s !== 'all' &&
142+
(() => {
143+
const Icon = STATUS_CONFIG[s as EquipmentStatus].icon;
144+
return <Icon className="size-3.5" />;
145+
})()}
146+
{s === 'all' ? 'Todos' : STATUS_CONFIG[s as EquipmentStatus].label}
147+
<span className="text-muted-foreground ml-1 text-xs">({counts[s]})</span>
148+
</Button>
149+
))}
150+
</div>
151+
152+
{/* Search */}
153+
<Input
154+
placeholder="Buscar por nombre, modelo o categoría…"
155+
value={search}
156+
onChange={(e) => setSearch(e.target.value)}
157+
className="max-w-md"
158+
/>
159+
160+
{/* Table */}
161+
<Card>
162+
<Table>
163+
<TableHeader>
164+
<TableRow>
165+
<TableHead>Código</TableHead>
166+
<TableHead>Equipo</TableHead>
167+
<TableHead>Categoría</TableHead>
168+
<TableHead>Modelo / Serie</TableHead>
169+
<TableHead>Estado</TableHead>
170+
<TableHead>Proyecto Asignado</TableHead>
171+
<TableHead>Próx. Mantenimiento</TableHead>
172+
<TableHead className="text-right">Tarifa Diaria</TableHead>
173+
<TableHead />
174+
</TableRow>
175+
</TableHeader>
176+
<TableBody>
177+
{isLoading ? (
178+
<TableRow>
179+
<TableCell colSpan={9} className="py-8 text-center">
180+
<Skeleton className="mx-auto h-4 w-32" />
181+
</TableCell>
182+
</TableRow>
183+
) : (
184+
<>
185+
{filtered.map((eq) => {
186+
const cfg = STATUS_CONFIG[eq.status];
187+
const StatusIcon = cfg.icon;
188+
return (
189+
<TableRow key={eq.id}>
190+
<TableCell className="text-muted-foreground font-mono text-xs">
191+
{eq.code}
192+
</TableCell>
193+
<TableCell className="font-medium">{eq.name}</TableCell>
194+
<TableCell className="text-muted-foreground text-sm">
195+
{eq.category}
196+
</TableCell>
197+
<TableCell>
198+
<div className="text-sm">{eq.model}</div>
199+
<div className="text-muted-foreground font-mono text-xs">
200+
{eq.serialNumber}
201+
</div>
202+
</TableCell>
203+
<TableCell>
204+
<Badge variant={cfg.variant} className="gap-1">
205+
<StatusIcon className="size-3" />
206+
{cfg.label}
207+
</Badge>
208+
</TableCell>
209+
<TableCell className="text-sm">
210+
{eq.assignedProjectId ? (
211+
<span className="text-foreground">
212+
{projectMap[eq.assignedProjectId] ?? `ID ${eq.assignedProjectId}`}
213+
</span>
214+
) : (
215+
<span className="text-muted-foreground">Sin asignar</span>
216+
)}
217+
</TableCell>
218+
<TableCell className="text-sm">
219+
{formatDate(eq.nextMaintenance, { dateStyle: 'medium' })}
220+
</TableCell>
221+
<TableCell className="text-right font-medium">
222+
{formatCurrency(eq.dailyRate, 'USD')}
223+
</TableCell>
224+
<TableCell>
225+
<div className="flex items-center gap-1">
226+
<Button
227+
size="sm"
228+
variant="ghost"
229+
onClick={() => openEdit(eq)}
230+
aria-label="Editar equipo"
231+
>
232+
<PencilIcon className="size-3.5" />
233+
</Button>
234+
<Button
235+
size="sm"
236+
variant="ghost"
237+
className="text-destructive hover:bg-destructive/10"
238+
onClick={() => openDelete(eq)}
239+
aria-label="Eliminar equipo"
240+
>
241+
<Trash2Icon className="size-3.5" />
242+
</Button>
243+
</div>
244+
</TableCell>
245+
</TableRow>
246+
);
247+
})}
248+
{filtered.length === 0 && (
249+
<TableRow>
250+
<TableCell
251+
colSpan={9}
252+
className="text-muted-foreground py-8 text-center text-sm"
253+
>
254+
No se encontraron equipos con los filtros actuales.
255+
</TableCell>
256+
</TableRow>
257+
)}
258+
</>
259+
)}
260+
</TableBody>
261+
</Table>
262+
</Card>
263+
264+
{/* Equipment summary cards */}
265+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
266+
{(['available', 'in-use', 'maintenance', 'retired'] as const).map((s) => {
267+
const cfg = STATUS_CONFIG[s];
268+
const Icon = cfg.icon;
269+
return (
270+
<Card key={s}>
271+
<CardContent className="pt-5">
272+
<div className="flex items-center gap-3">
273+
<div className="bg-muted flex size-9 items-center justify-center rounded-md">
274+
<Icon className="text-muted-foreground size-5" />
275+
</div>
276+
<div>
277+
<p className="text-muted-foreground text-xs">{cfg.label}</p>
278+
{isLoading ? (
279+
<Skeleton className="mt-1 h-6 w-8" />
280+
) : (
281+
<p className="text-foreground text-xl font-bold">{counts[s]}</p>
282+
)}
283+
</div>
284+
</div>
285+
</CardContent>
286+
</Card>
287+
);
288+
})}
289+
</div>
290+
</ContentContainer>
291+
292+
<EquipmentModal open={modalOpen} onOpenChange={setModalOpen} equipment={editEquipment} />
293+
294+
<DeleteConfirm
295+
open={deleteOpen}
296+
onOpenChange={setDeleteOpen}
297+
title="Eliminar Equipo"
298+
description={`¿Eliminar "${deleteTarget?.name}" (${deleteTarget?.serialNumber})? Esta acción no se puede deshacer.`}
299+
onConfirm={handleDelete}
300+
loading={deleting}
301+
/>
302+
</>
303+
);
304+
}

0 commit comments

Comments
 (0)