ar-saas 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/package.json +1 -1
  2. package/templates/backend/.env.example +13 -3
  3. package/templates/backend/README.md +22 -3
  4. package/templates/backend/package-lock.json +165 -2
  5. package/templates/backend/package.json +2 -0
  6. package/templates/backend/src/app.module.ts +14 -0
  7. package/templates/backend/src/common/guards/github-auth.guard.ts +5 -0
  8. package/templates/backend/src/main.ts +2 -2
  9. package/templates/backend/src/modules/auth/auth.controller.ts +51 -3
  10. package/templates/backend/src/modules/auth/auth.module.ts +2 -1
  11. package/templates/backend/src/modules/auth/auth.service.ts +96 -11
  12. package/templates/backend/src/modules/auth/strategies/github.strategy.ts +46 -0
  13. package/templates/backend/src/modules/clients/clients.controller.ts +91 -0
  14. package/templates/backend/src/modules/clients/clients.module.ts +16 -0
  15. package/templates/backend/src/modules/clients/clients.repository.ts +14 -0
  16. package/templates/backend/src/modules/clients/clients.service.ts +52 -0
  17. package/templates/backend/src/modules/clients/dto/create-client.dto.ts +40 -0
  18. package/templates/backend/src/modules/clients/dto/query-client.dto.ts +30 -0
  19. package/templates/backend/src/modules/clients/dto/update-client.dto.ts +4 -0
  20. package/templates/backend/src/modules/clients/schemas/client.schema.ts +32 -0
  21. package/templates/backend/src/modules/invoices/dto/create-invoice.dto.ts +79 -0
  22. package/templates/backend/src/modules/invoices/dto/invoice-item.dto.ts +23 -0
  23. package/templates/backend/src/modules/invoices/dto/query-invoice.dto.ts +40 -0
  24. package/templates/backend/src/modules/invoices/dto/update-invoice.dto.ts +4 -0
  25. package/templates/backend/src/modules/invoices/invoices.controller.ts +91 -0
  26. package/templates/backend/src/modules/invoices/invoices.module.ts +18 -0
  27. package/templates/backend/src/modules/invoices/invoices.repository.ts +14 -0
  28. package/templates/backend/src/modules/invoices/invoices.service.ts +104 -0
  29. package/templates/backend/src/modules/invoices/schemas/invoice.schema.ts +75 -0
  30. package/templates/backend/src/modules/notifications/dto/create-notification.dto.ts +45 -0
  31. package/templates/backend/src/modules/notifications/dto/query-notification.dto.ts +30 -0
  32. package/templates/backend/src/modules/notifications/dto/update-notification.dto.ts +4 -0
  33. package/templates/backend/src/modules/notifications/notifications.controller.ts +119 -0
  34. package/templates/backend/src/modules/notifications/notifications.module.ts +16 -0
  35. package/templates/backend/src/modules/notifications/notifications.repository.ts +31 -0
  36. package/templates/backend/src/modules/notifications/notifications.service.ts +64 -0
  37. package/templates/backend/src/modules/notifications/schemas/notification.schema.ts +38 -0
  38. package/templates/backend/src/modules/pipeline/dto/create-deal.dto.ts +40 -0
  39. package/templates/backend/src/modules/pipeline/dto/query-deal.dto.ts +35 -0
  40. package/templates/backend/src/modules/pipeline/dto/update-deal.dto.ts +4 -0
  41. package/templates/backend/src/modules/pipeline/pipeline.controller.ts +91 -0
  42. package/templates/backend/src/modules/pipeline/pipeline.module.ts +18 -0
  43. package/templates/backend/src/modules/pipeline/pipeline.repository.ts +14 -0
  44. package/templates/backend/src/modules/pipeline/pipeline.service.ts +64 -0
  45. package/templates/backend/src/modules/pipeline/schemas/deal.schema.ts +39 -0
  46. package/templates/backend/src/modules/planner/dto/create-planner-block.dto.ts +66 -0
  47. package/templates/backend/src/modules/planner/dto/query-planner-block.dto.ts +48 -0
  48. package/templates/backend/src/modules/planner/dto/update-block-status.dto.ts +10 -0
  49. package/templates/backend/src/modules/planner/dto/update-planner-block.dto.ts +4 -0
  50. package/templates/backend/src/modules/planner/planner.controller.ts +124 -0
  51. package/templates/backend/src/modules/planner/planner.module.ts +16 -0
  52. package/templates/backend/src/modules/planner/planner.repository.ts +45 -0
  53. package/templates/backend/src/modules/planner/planner.service.ts +104 -0
  54. package/templates/backend/src/modules/planner/schemas/planner-block.schema.ts +56 -0
  55. package/templates/backend/src/modules/task-columns/dto/create-task-column.dto.ts +20 -0
  56. package/templates/backend/src/modules/task-columns/dto/reorder-columns.dto.ts +9 -0
  57. package/templates/backend/src/modules/task-columns/dto/update-task-column.dto.ts +4 -0
  58. package/templates/backend/src/modules/task-columns/schemas/task-column.schema.ts +21 -0
  59. package/templates/backend/src/modules/task-columns/task-columns.controller.ts +86 -0
  60. package/templates/backend/src/modules/task-columns/task-columns.module.ts +16 -0
  61. package/templates/backend/src/modules/task-columns/task-columns.repository.ts +15 -0
  62. package/templates/backend/src/modules/task-columns/task-columns.service.ts +49 -0
  63. package/templates/backend/src/modules/tasks/dto/checklist-item.dto.ts +13 -0
  64. package/templates/backend/src/modules/tasks/dto/create-task.dto.ts +67 -0
  65. package/templates/backend/src/modules/tasks/dto/label.dto.ts +12 -0
  66. package/templates/backend/src/modules/tasks/dto/move-task.dto.ts +15 -0
  67. package/templates/backend/src/modules/tasks/dto/query-task.dto.ts +40 -0
  68. package/templates/backend/src/modules/tasks/dto/update-task.dto.ts +4 -0
  69. package/templates/backend/src/modules/tasks/schemas/task.schema.ts +66 -0
  70. package/templates/backend/src/modules/tasks/tasks.controller.ts +104 -0
  71. package/templates/backend/src/modules/tasks/tasks.module.ts +18 -0
  72. package/templates/backend/src/modules/tasks/tasks.repository.ts +14 -0
  73. package/templates/backend/src/modules/tasks/tasks.service.ts +76 -0
  74. package/templates/backend/src/modules/users/schemas/user.schema.ts +3 -0
  75. package/templates/backend/src/modules/users/users.repository.ts +8 -0
  76. package/templates/backend/src/modules/users/users.service.ts +34 -0
  77. package/templates/frontend/.env.local.example +1 -1
  78. package/templates/frontend/README.md +43 -1
  79. package/templates/frontend/package.json +48 -45
  80. package/templates/frontend/pnpm-lock.yaml +5096 -5012
  81. package/templates/frontend/src/app/(auth)/layout.tsx +7 -1
  82. package/templates/frontend/src/app/(auth)/login/page.tsx +13 -0
  83. package/templates/frontend/src/app/(auth)/register/page.tsx +13 -0
  84. package/templates/frontend/src/app/(dashboard)/clients/page.tsx +295 -0
  85. package/templates/frontend/src/app/(dashboard)/invoices/page.tsx +305 -0
  86. package/templates/frontend/src/app/(dashboard)/notifications/page.tsx +173 -0
  87. package/templates/frontend/src/app/(dashboard)/pipeline/page.tsx +244 -0
  88. package/templates/frontend/src/app/(dashboard)/planner/page.tsx +287 -0
  89. package/templates/frontend/src/app/(dashboard)/settings/page.tsx +165 -128
  90. package/templates/frontend/src/app/(dashboard)/tasks/page.tsx +366 -0
  91. package/templates/frontend/src/app/auth/github/callback/page.tsx +82 -0
  92. package/templates/frontend/src/app/landing/page.tsx +21 -0
  93. package/templates/frontend/src/app/page.tsx +5 -5
  94. package/templates/frontend/src/app/setup/page.tsx +15 -14
  95. package/templates/frontend/src/components/auth/github-button.tsx +25 -0
  96. package/templates/frontend/src/components/dashboard/sidebar.tsx +90 -71
  97. package/templates/frontend/src/components/ui/alert-dialog.tsx +141 -0
  98. package/templates/frontend/src/components/ui/button.tsx +56 -52
  99. package/templates/frontend/src/components/ui/popover.tsx +31 -0
  100. package/templates/frontend/src/components/ui/select.tsx +160 -0
  101. package/templates/frontend/src/components/ui/sheet.tsx +140 -0
  102. package/templates/frontend/src/lib/api/auth.ts +7 -0
  103. package/templates/frontend/src/lib/api/clients.ts +17 -0
  104. package/templates/frontend/src/lib/api/invoices.ts +18 -0
  105. package/templates/frontend/src/lib/api/notifications.ts +27 -0
  106. package/templates/frontend/src/lib/api/pipeline.ts +18 -0
  107. package/templates/frontend/src/lib/api/planner.ts +26 -0
  108. package/templates/frontend/src/lib/api/task-columns.ts +17 -0
  109. package/templates/frontend/src/lib/api/tasks.ts +21 -0
  110. package/templates/frontend/src/lib/hooks/use-unread-notifications.ts +23 -0
  111. package/templates/frontend/src/providers/auth-provider.tsx +7 -1
  112. package/templates/frontend/src/types/clients.ts +38 -0
  113. package/templates/frontend/src/types/invoices.ts +51 -0
  114. package/templates/frontend/src/types/notifications.ts +30 -0
  115. package/templates/frontend/src/types/pipeline.ts +35 -0
  116. package/templates/frontend/src/types/planner.ts +49 -0
  117. package/templates/frontend/src/types/tasks.ts +65 -0
@@ -0,0 +1,305 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { useForm, useFieldArray } from 'react-hook-form'
5
+ import { FileText, Plus, Pencil, Trash2 } from 'lucide-react'
6
+ import { createInvoice, deleteInvoice, getInvoices, updateInvoice } from '@/lib/api/invoices'
7
+ import { getClients } from '@/lib/api/clients'
8
+ import type { Client } from '@/types/clients'
9
+ import type { CreateInvoiceDto, Invoice } from '@/types/invoices'
10
+ import { Button } from '@/components/ui/button'
11
+ import { Input } from '@/components/ui/input'
12
+ import { Badge } from '@/components/ui/badge'
13
+ import { Skeleton } from '@/components/ui/skeleton'
14
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
15
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
16
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
17
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
18
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
19
+ import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
20
+ import { useToast } from '@/hooks/use-toast'
21
+
22
+ const STATUS_LABELS: Record<string, string> = {
23
+ draft: 'Borrador', pending: 'Pendiente', paid: 'Pagada', overdue: 'Vencida', cancelled: 'Cancelada',
24
+ }
25
+ const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
26
+ draft: 'outline', pending: 'secondary', paid: 'default', overdue: 'destructive', cancelled: 'secondary',
27
+ }
28
+
29
+ export default function InvoicesPage() {
30
+ const { toast } = useToast()
31
+ const [invoices, setInvoices] = useState<Invoice[]>([])
32
+ const [clients, setClients] = useState<Client[]>([])
33
+ const [loading, setLoading] = useState(true)
34
+ const [activeTab, setActiveTab] = useState<'income' | 'expense'>('income')
35
+ const [statusFilter, setStatusFilter] = useState('all')
36
+ const [sheetOpen, setSheetOpen] = useState(false)
37
+ const [editing, setEditing] = useState<Invoice | null>(null)
38
+ const [deleteTarget, setDeleteTarget] = useState<Invoice | null>(null)
39
+
40
+ const form = useForm<CreateInvoiceDto>({
41
+ defaultValues: { type: 'income', number: '', clientId: '', status: 'draft', issueDate: '', dueDate: '', items: [], taxRate: 0, currency: 'USD', notes: '', total: 0 },
42
+ })
43
+ const { fields, append, remove } = useFieldArray({ control: form.control, name: 'items' as never })
44
+ const watchItems = form.watch('items') ?? []
45
+ const watchTax = form.watch('taxRate') ?? 0
46
+ const subtotal = (watchItems as { amount: number }[]).reduce((s, i) => s + (i.amount || 0), 0)
47
+ const taxAmount = Math.round(subtotal * ((watchTax as number) / 100) * 100) / 100
48
+ const total = subtotal + taxAmount
49
+
50
+ const load = useCallback(async (type: 'income' | 'expense' = activeTab, status?: string) => {
51
+ setLoading(true)
52
+ try {
53
+ const [inv, cli] = await Promise.all([
54
+ getInvoices({ type, status: status && status !== 'all' ? status : undefined, limit: 100 }),
55
+ getClients({ limit: 100 }),
56
+ ])
57
+ setInvoices(inv.data)
58
+ setClients(cli.data)
59
+ } catch {
60
+ toast({ title: 'Error al cargar facturas', variant: 'destructive' })
61
+ } finally {
62
+ setLoading(false)
63
+ }
64
+ }, [activeTab, toast])
65
+
66
+ useEffect(() => { load(activeTab, statusFilter) }, [load, activeTab, statusFilter])
67
+
68
+ const clientName = (id?: string) => clients.find(c => c._id === id)?.name ?? '—'
69
+
70
+ const totalIncome = invoices.filter(i => i.type === 'income').reduce((s, i) => s + i.total, 0)
71
+ const totalExpense = invoices.filter(i => i.type === 'expense').reduce((s, i) => s + i.total, 0)
72
+ const balance = totalIncome - totalExpense
73
+
74
+ const openCreate = () => {
75
+ setEditing(null)
76
+ form.reset({ type: activeTab, number: '', clientId: '', status: 'draft', issueDate: new Date().toISOString().split('T')[0], items: [], taxRate: 0, currency: 'USD', notes: '', total: 0 })
77
+ setSheetOpen(true)
78
+ }
79
+
80
+ const openEdit = (inv: Invoice) => {
81
+ setEditing(inv)
82
+ form.reset({
83
+ type: inv.type, number: inv.number ?? '', clientId: inv.clientId ?? '',
84
+ status: inv.status, issueDate: inv.issueDate?.split('T')[0] ?? '',
85
+ dueDate: inv.dueDate?.split('T')[0] ?? '', items: inv.items,
86
+ taxRate: inv.taxRate, currency: inv.currency, notes: inv.notes ?? '', total: inv.total,
87
+ })
88
+ setSheetOpen(true)
89
+ }
90
+
91
+ const onSubmit = async (data: CreateInvoiceDto) => {
92
+ try {
93
+ if (editing) { await updateInvoice(editing._id, data); toast({ title: 'Factura actualizada' }) }
94
+ else { await createInvoice(data); toast({ title: 'Factura creada' }) }
95
+ setSheetOpen(false)
96
+ load(activeTab, statusFilter)
97
+ } catch {
98
+ toast({ title: 'Error al guardar', variant: 'destructive' })
99
+ }
100
+ }
101
+
102
+ const handleDelete = async () => {
103
+ if (!deleteTarget) return
104
+ try {
105
+ await deleteInvoice(deleteTarget._id)
106
+ setDeleteTarget(null)
107
+ toast({ title: 'Factura eliminada' })
108
+ load(activeTab, statusFilter)
109
+ } catch {
110
+ toast({ title: 'Error al eliminar', variant: 'destructive' })
111
+ }
112
+ }
113
+
114
+ return (
115
+ <div className="flex flex-col gap-6 p-6">
116
+ <div className="flex items-center justify-between">
117
+ <div className="flex items-center gap-2">
118
+ <FileText className="size-5" />
119
+ <h1 className="text-xl font-semibold">Facturas</h1>
120
+ </div>
121
+ <Button onClick={openCreate}><Plus className="mr-2 size-4" />Nueva factura</Button>
122
+ </div>
123
+
124
+ {/* KPIs */}
125
+ <div className="grid grid-cols-3 gap-4">
126
+ <Card><CardHeader className="pb-1"><CardTitle className="text-sm font-medium text-muted-foreground">Total ingresos</CardTitle></CardHeader>
127
+ <CardContent><p className="text-2xl font-bold text-green-600">${totalIncome.toFixed(2)}</p></CardContent></Card>
128
+ <Card><CardHeader className="pb-1"><CardTitle className="text-sm font-medium text-muted-foreground">Total gastos</CardTitle></CardHeader>
129
+ <CardContent><p className="text-2xl font-bold text-red-600">${totalExpense.toFixed(2)}</p></CardContent></Card>
130
+ <Card><CardHeader className="pb-1"><CardTitle className="text-sm font-medium text-muted-foreground">Balance</CardTitle></CardHeader>
131
+ <CardContent><p className={`text-2xl font-bold ${balance >= 0 ? 'text-green-600' : 'text-red-600'}`}>${balance.toFixed(2)}</p></CardContent></Card>
132
+ </div>
133
+
134
+ <Tabs value={activeTab} onValueChange={v => { setActiveTab(v as 'income' | 'expense'); setStatusFilter('all') }}>
135
+ <div className="flex items-center justify-between">
136
+ <TabsList>
137
+ <TabsTrigger value="income">Ingresos</TabsTrigger>
138
+ <TabsTrigger value="expense">Gastos</TabsTrigger>
139
+ </TabsList>
140
+ <Select value={statusFilter} onValueChange={setStatusFilter}>
141
+ <SelectTrigger className="w-40"><SelectValue placeholder="Estado" /></SelectTrigger>
142
+ <SelectContent>
143
+ <SelectItem value="all">Todos</SelectItem>
144
+ {Object.entries(STATUS_LABELS).map(([v, l]) => <SelectItem key={v} value={v}>{l}</SelectItem>)}
145
+ </SelectContent>
146
+ </Select>
147
+ </div>
148
+
149
+ {(['income', 'expense'] as const).map(tab => (
150
+ <TabsContent key={tab} value={tab} className="mt-4">
151
+ <div className="rounded-lg border">
152
+ <table className="w-full text-sm">
153
+ <thead className="border-b bg-muted/40">
154
+ <tr>
155
+ <th className="px-4 py-3 text-left font-medium">Número</th>
156
+ <th className="px-4 py-3 text-left font-medium">Cliente</th>
157
+ <th className="px-4 py-3 text-left font-medium">Estado</th>
158
+ <th className="px-4 py-3 text-left font-medium">Fecha</th>
159
+ <th className="px-4 py-3 text-right font-medium">Total</th>
160
+ <th className="px-4 py-3 text-right font-medium">Acciones</th>
161
+ </tr>
162
+ </thead>
163
+ <tbody>
164
+ {loading ? Array.from({ length: 4 }).map((_, i) => (
165
+ <tr key={i} className="border-b"><td colSpan={6} className="px-4 py-3"><Skeleton className="h-4 w-full" /></td></tr>
166
+ )) : invoices.length === 0 ? (
167
+ <tr><td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">No hay facturas.</td></tr>
168
+ ) : invoices.map(inv => (
169
+ <tr key={inv._id} className="border-b last:border-0 hover:bg-muted/20">
170
+ <td className="px-4 py-3 font-mono text-xs">{inv.number || '—'}</td>
171
+ <td className="px-4 py-3">{clientName(inv.clientId)}</td>
172
+ <td className="px-4 py-3"><Badge variant={STATUS_VARIANTS[inv.status]}>{STATUS_LABELS[inv.status]}</Badge></td>
173
+ <td className="px-4 py-3 text-muted-foreground">{new Date(inv.issueDate).toLocaleDateString('es-AR')}</td>
174
+ <td className="px-4 py-3 text-right font-medium">{inv.currency} {inv.total.toFixed(2)}</td>
175
+ <td className="px-4 py-3 text-right">
176
+ <Button variant="ghost" size="icon" onClick={() => openEdit(inv)}><Pencil className="size-4" /></Button>
177
+ <Button variant="ghost" size="icon" onClick={() => setDeleteTarget(inv)}><Trash2 className="size-4 text-destructive" /></Button>
178
+ </td>
179
+ </tr>
180
+ ))}
181
+ </tbody>
182
+ </table>
183
+ </div>
184
+ </TabsContent>
185
+ ))}
186
+ </Tabs>
187
+
188
+ {/* Sheet */}
189
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
190
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
191
+ <SheetHeader><SheetTitle>{editing ? 'Editar factura' : 'Nueva factura'}</SheetTitle></SheetHeader>
192
+ <Form {...form}>
193
+ <form onSubmit={form.handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4">
194
+ <div className="grid grid-cols-2 gap-3">
195
+ <FormField control={form.control} name="type" render={({ field }) => (
196
+ <FormItem><FormLabel>Tipo</FormLabel>
197
+ <Select value={field.value} onValueChange={field.onChange}>
198
+ <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
199
+ <SelectContent><SelectItem value="income">Ingreso</SelectItem><SelectItem value="expense">Gasto</SelectItem></SelectContent>
200
+ </Select></FormItem>
201
+ )} />
202
+ <FormField control={form.control} name="number" render={({ field }) => (
203
+ <FormItem><FormLabel>Número</FormLabel><FormControl><Input {...field} /></FormControl></FormItem>
204
+ )} />
205
+ </div>
206
+ <FormField control={form.control} name="clientId" render={({ field }) => (
207
+ <FormItem><FormLabel>Cliente</FormLabel>
208
+ <Select value={field.value ?? ''} onValueChange={field.onChange}>
209
+ <FormControl><SelectTrigger><SelectValue placeholder="Sin cliente" /></SelectTrigger></FormControl>
210
+ <SelectContent>
211
+ <SelectItem value="">Sin cliente</SelectItem>
212
+ {clients.map(c => <SelectItem key={c._id} value={c._id}>{c.name}</SelectItem>)}
213
+ </SelectContent>
214
+ </Select></FormItem>
215
+ )} />
216
+ <div className="grid grid-cols-2 gap-3">
217
+ <FormField control={form.control} name="issueDate" render={({ field }) => (
218
+ <FormItem><FormLabel>Fecha emisión</FormLabel><FormControl><Input type="date" {...field} /></FormControl></FormItem>
219
+ )} />
220
+ <FormField control={form.control} name="dueDate" render={({ field }) => (
221
+ <FormItem><FormLabel>Fecha vencimiento</FormLabel><FormControl><Input type="date" {...field} /></FormControl></FormItem>
222
+ )} />
223
+ </div>
224
+ <FormField control={form.control} name="status" render={({ field }) => (
225
+ <FormItem><FormLabel>Estado</FormLabel>
226
+ <Select value={field.value} onValueChange={field.onChange}>
227
+ <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
228
+ <SelectContent>{Object.entries(STATUS_LABELS).map(([v, l]) => <SelectItem key={v} value={v}>{l}</SelectItem>)}</SelectContent>
229
+ </Select></FormItem>
230
+ )} />
231
+
232
+ {/* Items */}
233
+ <div className="space-y-2">
234
+ <div className="flex items-center justify-between">
235
+ <p className="text-sm font-medium">Ítems</p>
236
+ <Button type="button" variant="outline" size="sm" onClick={() => append({ description: '', quantity: 1, unitPrice: 0, amount: 0 })}>
237
+ <Plus className="mr-1 size-3" />Agregar
238
+ </Button>
239
+ </div>
240
+ {fields.map((f, i) => (
241
+ <div key={f.id} className="grid grid-cols-[1fr_80px_80px_80px_32px] gap-2 items-end">
242
+ <FormField control={form.control} name={`items.${i}.description`} render={({ field }) => (
243
+ <FormItem><FormLabel className={i > 0 ? 'sr-only' : ''}>Descripción</FormLabel><FormControl><Input placeholder="Descripción" {...field} /></FormControl></FormItem>
244
+ )} />
245
+ <FormField control={form.control} name={`items.${i}.quantity`} render={({ field }) => (
246
+ <FormItem><FormLabel className={i > 0 ? 'sr-only' : ''}>Cant.</FormLabel><FormControl><Input type="number" {...field} onChange={e => { field.onChange(+e.target.value); const u = form.getValues(`items.${i}.unitPrice`) ?? 0; form.setValue(`items.${i}.amount`, +e.target.value * u) }} /></FormControl></FormItem>
247
+ )} />
248
+ <FormField control={form.control} name={`items.${i}.unitPrice`} render={({ field }) => (
249
+ <FormItem><FormLabel className={i > 0 ? 'sr-only' : ''}>P. unit</FormLabel><FormControl><Input type="number" {...field} onChange={e => { field.onChange(+e.target.value); const q = form.getValues(`items.${i}.quantity`) ?? 0; form.setValue(`items.${i}.amount`, q * +e.target.value) }} /></FormControl></FormItem>
250
+ )} />
251
+ <FormField control={form.control} name={`items.${i}.amount`} render={({ field }) => (
252
+ <FormItem><FormLabel className={i > 0 ? 'sr-only' : ''}>Total</FormLabel><FormControl><Input type="number" readOnly {...field} className="bg-muted/30" /></FormControl></FormItem>
253
+ )} />
254
+ <Button type="button" variant="ghost" size="icon" onClick={() => remove(i)}><Trash2 className="size-4 text-destructive" /></Button>
255
+ </div>
256
+ ))}
257
+ </div>
258
+
259
+ {/* Totales */}
260
+ {fields.length > 0 && (
261
+ <div className="rounded-lg border bg-muted/20 p-3 space-y-1 text-sm">
262
+ <div className="flex justify-between"><span className="text-muted-foreground">Subtotal</span><span>${subtotal.toFixed(2)}</span></div>
263
+ <FormField control={form.control} name="taxRate" render={({ field }) => (
264
+ <div className="flex items-center justify-between gap-2">
265
+ <span className="text-muted-foreground">IVA (%)</span>
266
+ <Input type="number" className="w-20 h-7 text-right" {...field} onChange={e => field.onChange(+e.target.value)} />
267
+ </div>
268
+ )} />
269
+ <div className="flex justify-between"><span className="text-muted-foreground">Impuesto</span><span>${taxAmount.toFixed(2)}</span></div>
270
+ <div className="flex justify-between font-semibold"><span>Total</span><span>${total.toFixed(2)}</span></div>
271
+ </div>
272
+ )}
273
+
274
+ <div className="grid grid-cols-2 gap-3">
275
+ <FormField control={form.control} name="currency" render={({ field }) => (
276
+ <FormItem><FormLabel>Moneda</FormLabel><FormControl><Input {...field} /></FormControl></FormItem>
277
+ )} />
278
+ </div>
279
+ <FormField control={form.control} name="notes" render={({ field }) => (
280
+ <FormItem><FormLabel>Notas</FormLabel><FormControl><textarea className="w-full rounded-md border px-3 py-2 text-sm" rows={2} {...field} /></FormControl></FormItem>
281
+ )} />
282
+ <div className="flex justify-end gap-2 pt-2">
283
+ <Button type="button" variant="outline" onClick={() => setSheetOpen(false)}>Cancelar</Button>
284
+ <Button type="submit" disabled={form.formState.isSubmitting}>
285
+ {form.formState.isSubmitting ? 'Guardando...' : 'Guardar'}
286
+ </Button>
287
+ </div>
288
+ </form>
289
+ </Form>
290
+ </SheetContent>
291
+ </Sheet>
292
+
293
+ <AlertDialog open={!!deleteTarget} onOpenChange={open => !open && setDeleteTarget(null)}>
294
+ <AlertDialogContent>
295
+ <AlertDialogHeader><AlertDialogTitle>¿Eliminar factura?</AlertDialogTitle>
296
+ <AlertDialogDescription>Esta acción no se puede deshacer fácilmente.</AlertDialogDescription></AlertDialogHeader>
297
+ <AlertDialogFooter>
298
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
299
+ <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">Eliminar</AlertDialogAction>
300
+ </AlertDialogFooter>
301
+ </AlertDialogContent>
302
+ </AlertDialog>
303
+ </div>
304
+ )
305
+ }
@@ -0,0 +1,173 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Bell, CheckCheck, Info, AlertTriangle, CheckCircle, XCircle, Trash2 } from 'lucide-react'
6
+ import { deleteNotification, getNotifications, markAllAsRead, markAsRead } from '@/lib/api/notifications'
7
+ import type { Notification } from '@/types/notifications'
8
+ import { Button } from '@/components/ui/button'
9
+ import { Skeleton } from '@/components/ui/skeleton'
10
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
11
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
12
+ import { useToast } from '@/hooks/use-toast'
13
+
14
+ const TYPE_ICON = {
15
+ info: <Info className="size-4 text-blue-500" />,
16
+ warning: <AlertTriangle className="size-4 text-yellow-500" />,
17
+ success: <CheckCircle className="size-4 text-green-500" />,
18
+ error: <XCircle className="size-4 text-red-500" />,
19
+ }
20
+
21
+ function timeAgo(date: string) {
22
+ const diff = Date.now() - new Date(date).getTime()
23
+ const mins = Math.floor(diff / 60000)
24
+ if (mins < 1) return 'ahora'
25
+ if (mins < 60) return `hace ${mins} min`
26
+ const hrs = Math.floor(mins / 60)
27
+ if (hrs < 24) return `hace ${hrs} h`
28
+ const days = Math.floor(hrs / 24)
29
+ return `hace ${days} d`
30
+ }
31
+
32
+ export default function NotificationsPage() {
33
+ const { toast } = useToast()
34
+ const router = useRouter()
35
+ const [all, setAll] = useState<Notification[]>([])
36
+ const [loading, setLoading] = useState(true)
37
+ const [deleteTarget, setDeleteTarget] = useState<Notification | null>(null)
38
+
39
+ const load = useCallback(async () => {
40
+ setLoading(true)
41
+ try {
42
+ const res = await getNotifications({ limit: 50 })
43
+ setAll(res.data)
44
+ } catch {
45
+ toast({ title: 'Error al cargar notificaciones', variant: 'destructive' })
46
+ } finally {
47
+ setLoading(false)
48
+ }
49
+ }, [toast])
50
+
51
+ useEffect(() => { load() }, [load])
52
+
53
+ const handleClick = async (n: Notification) => {
54
+ if (!n.isRead) {
55
+ await markAsRead(n._id).catch(() => null)
56
+ setAll(prev => prev.map(x => x._id === n._id ? { ...x, isRead: true } : x))
57
+ }
58
+ if (n.link) router.push(n.link)
59
+ }
60
+
61
+ const handleMarkAll = async () => {
62
+ try {
63
+ await markAllAsRead()
64
+ setAll(prev => prev.map(x => ({ ...x, isRead: true })))
65
+ toast({ title: 'Todas marcadas como leídas' })
66
+ } catch {
67
+ toast({ title: 'Error', variant: 'destructive' })
68
+ }
69
+ }
70
+
71
+ const handleDelete = async () => {
72
+ if (!deleteTarget) return
73
+ try {
74
+ await deleteNotification(deleteTarget._id)
75
+ setAll(prev => prev.filter(x => x._id !== deleteTarget._id))
76
+ setDeleteTarget(null)
77
+ toast({ title: 'Notificación eliminada' })
78
+ } catch {
79
+ toast({ title: 'Error al eliminar', variant: 'destructive' })
80
+ }
81
+ }
82
+
83
+ const unread = all.filter(n => !n.isRead)
84
+
85
+ const NotificationList = ({ items }: { items: Notification[] }) => {
86
+ if (loading) return (
87
+ <div className="flex flex-col gap-2 p-4">
88
+ {Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-16 w-full" />)}
89
+ </div>
90
+ )
91
+ if (items.length === 0) return (
92
+ <div className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
93
+ <Bell className="size-8 opacity-30" />
94
+ <p>No hay notificaciones</p>
95
+ </div>
96
+ )
97
+ return (
98
+ <ul className="divide-y">
99
+ {items.map(n => (
100
+ <li
101
+ key={n._id}
102
+ className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-muted/30 transition-colors"
103
+ onClick={() => handleClick(n)}
104
+ >
105
+ <div className="mt-0.5 shrink-0">{TYPE_ICON[n.type]}</div>
106
+ <div className="flex-1 min-w-0">
107
+ <p className={`text-sm ${!n.isRead ? 'font-semibold' : 'font-normal'}`}>{n.title}</p>
108
+ <p className="text-xs text-muted-foreground line-clamp-2">{n.message}</p>
109
+ <p className="mt-1 text-xs text-muted-foreground">{timeAgo(n.createdAt)}</p>
110
+ </div>
111
+ {!n.isRead && <span className="mt-2 size-2 shrink-0 rounded-full bg-blue-500" />}
112
+ <Button
113
+ variant="ghost"
114
+ size="icon"
115
+ className="shrink-0"
116
+ onClick={e => { e.stopPropagation(); setDeleteTarget(n) }}
117
+ >
118
+ <Trash2 className="size-4 text-muted-foreground" />
119
+ </Button>
120
+ </li>
121
+ ))}
122
+ </ul>
123
+ )
124
+ }
125
+
126
+ return (
127
+ <div className="flex flex-col gap-6 p-6">
128
+ <div className="flex items-center justify-between">
129
+ <div className="flex items-center gap-2">
130
+ <Bell className="size-5" />
131
+ <h1 className="text-xl font-semibold">Notificaciones</h1>
132
+ {unread.length > 0 && (
133
+ <span className="rounded-full bg-blue-500 px-2 py-0.5 text-xs font-medium text-white">
134
+ {unread.length}
135
+ </span>
136
+ )}
137
+ </div>
138
+ <Button variant="outline" size="sm" onClick={handleMarkAll} disabled={unread.length === 0}>
139
+ <CheckCheck className="mr-2 size-4" />
140
+ Marcar todas como leídas
141
+ </Button>
142
+ </div>
143
+
144
+ <Tabs defaultValue="all">
145
+ <TabsList>
146
+ <TabsTrigger value="all">Todas ({all.length})</TabsTrigger>
147
+ <TabsTrigger value="unread">No leídas ({unread.length})</TabsTrigger>
148
+ </TabsList>
149
+ <TabsContent value="all" className="mt-4 rounded-lg border">
150
+ <NotificationList items={all} />
151
+ </TabsContent>
152
+ <TabsContent value="unread" className="mt-4 rounded-lg border">
153
+ <NotificationList items={unread} />
154
+ </TabsContent>
155
+ </Tabs>
156
+
157
+ <AlertDialog open={!!deleteTarget} onOpenChange={open => !open && setDeleteTarget(null)}>
158
+ <AlertDialogContent>
159
+ <AlertDialogHeader>
160
+ <AlertDialogTitle>¿Eliminar notificación?</AlertDialogTitle>
161
+ <AlertDialogDescription>Esta acción no se puede deshacer.</AlertDialogDescription>
162
+ </AlertDialogHeader>
163
+ <AlertDialogFooter>
164
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
165
+ <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
166
+ Eliminar
167
+ </AlertDialogAction>
168
+ </AlertDialogFooter>
169
+ </AlertDialogContent>
170
+ </AlertDialog>
171
+ </div>
172
+ )
173
+ }