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,244 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { useForm } from 'react-hook-form'
5
+ import { TrendingUp, Plus, Pencil, Trash2 } from 'lucide-react'
6
+ import { createDeal, deleteDeal, getDeals, updateDeal } from '@/lib/api/pipeline'
7
+ import { getClients } from '@/lib/api/clients'
8
+ import type { Client } from '@/types/clients'
9
+ import type { CreateDealDto, Deal, DealStage } from '@/types/pipeline'
10
+ import { Button } from '@/components/ui/button'
11
+ import { Skeleton } from '@/components/ui/skeleton'
12
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
13
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
14
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
15
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
16
+ import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
17
+ import { Input } from '@/components/ui/input'
18
+ import { useToast } from '@/hooks/use-toast'
19
+
20
+ const STAGES: { key: DealStage; label: string; color: string }[] = [
21
+ { key: 'lead', label: 'Lead', color: '#6B7280' },
22
+ { key: 'contacted', label: 'Contactado', color: '#3B82F6' },
23
+ { key: 'proposal', label: 'Propuesta', color: '#F59E0B' },
24
+ { key: 'won', label: 'Ganado', color: '#10B981' },
25
+ { key: 'lost', label: 'Perdido', color: '#EF4444' },
26
+ ]
27
+
28
+ export default function PipelinePage() {
29
+ const { toast } = useToast()
30
+ const [deals, setDeals] = useState<Deal[]>([])
31
+ const [clients, setClients] = useState<Client[]>([])
32
+ const [loading, setLoading] = useState(true)
33
+ const [sheetOpen, setSheetOpen] = useState(false)
34
+ const [editing, setEditing] = useState<Deal | null>(null)
35
+ const [deleteTarget, setDeleteTarget] = useState<Deal | null>(null)
36
+ const [draggingId, setDraggingId] = useState<string | null>(null)
37
+
38
+ const form = useForm<CreateDealDto>({
39
+ defaultValues: { title: '', clientId: '', value: 0, currency: 'USD', stage: 'lead', expectedCloseDate: '', notes: '' },
40
+ })
41
+
42
+ const load = useCallback(async () => {
43
+ setLoading(true)
44
+ try {
45
+ const [d, c] = await Promise.all([getDeals({ limit: 200 }), getClients({ limit: 100 })])
46
+ setDeals(d.data)
47
+ setClients(c.data)
48
+ } catch { toast({ title: 'Error al cargar', variant: 'destructive' }) }
49
+ finally { setLoading(false) }
50
+ }, [toast])
51
+
52
+ useEffect(() => { load() }, [load])
53
+
54
+ const clientName = (id?: string) => clients.find(c => c._id === id)?.name
55
+
56
+ const dealsByStage = (stage: DealStage) => deals.filter(d => d.stage === stage)
57
+
58
+ const stageTotal = (stage: DealStage) => dealsByStage(stage).reduce((s, d) => s + d.value, 0)
59
+
60
+ const totalPipeline = deals.filter(d => d.stage !== 'lost').reduce((s, d) => s + d.value, 0)
61
+ const wonCount = deals.filter(d => d.stage === 'won').length
62
+ const conversionRate = deals.length > 0 ? Math.round((wonCount / deals.length) * 100) : 0
63
+
64
+ const openCreate = (stage: DealStage = 'lead') => {
65
+ setEditing(null)
66
+ form.reset({ title: '', clientId: '', value: 0, currency: 'USD', stage, expectedCloseDate: '', notes: '' })
67
+ setSheetOpen(true)
68
+ }
69
+
70
+ const openEdit = (deal: Deal) => {
71
+ setEditing(deal)
72
+ form.reset({ title: deal.title, clientId: deal.clientId ?? '', value: deal.value, currency: deal.currency, stage: deal.stage, expectedCloseDate: deal.expectedCloseDate?.split('T')[0] ?? '', notes: deal.notes ?? '' })
73
+ setSheetOpen(true)
74
+ }
75
+
76
+ const onSubmit = async (data: CreateDealDto) => {
77
+ try {
78
+ if (editing) { await updateDeal(editing._id, data); toast({ title: 'Deal actualizado' }) }
79
+ else { await createDeal(data); toast({ title: 'Deal creado' }) }
80
+ setSheetOpen(false)
81
+ load()
82
+ } catch { toast({ title: 'Error al guardar', variant: 'destructive' }) }
83
+ }
84
+
85
+ const handleDelete = async () => {
86
+ if (!deleteTarget) return
87
+ try {
88
+ await deleteDeal(deleteTarget._id)
89
+ setDeleteTarget(null)
90
+ toast({ title: 'Deal eliminado' })
91
+ load()
92
+ } catch { toast({ title: 'Error al eliminar', variant: 'destructive' }) }
93
+ }
94
+
95
+ const handleDrop = async (e: React.DragEvent, targetStage: DealStage) => {
96
+ e.preventDefault()
97
+ if (!draggingId) return
98
+ const deal = deals.find(d => d._id === draggingId)
99
+ if (!deal || deal.stage === targetStage) return
100
+ setDeals(prev => prev.map(d => d._id === draggingId ? { ...d, stage: targetStage } : d))
101
+ try {
102
+ await updateDeal(draggingId, { stage: targetStage })
103
+ } catch {
104
+ toast({ title: 'Error al mover deal', variant: 'destructive' })
105
+ load()
106
+ }
107
+ setDraggingId(null)
108
+ }
109
+
110
+ return (
111
+ <div className="flex flex-col gap-6 p-6">
112
+ <div className="flex items-center justify-between">
113
+ <div className="flex items-center gap-2">
114
+ <TrendingUp className="size-5" />
115
+ <h1 className="text-xl font-semibold">Pipeline</h1>
116
+ </div>
117
+ <Button onClick={() => openCreate()}><Plus className="mr-2 size-4" />Nuevo deal</Button>
118
+ </div>
119
+
120
+ {/* KPI bar */}
121
+ <div className="grid grid-cols-3 gap-4">
122
+ <Card><CardHeader className="pb-1"><CardTitle className="text-sm font-medium text-muted-foreground">Pipeline total</CardTitle></CardHeader>
123
+ <CardContent><p className="text-2xl font-bold">${totalPipeline.toFixed(0)}</p></CardContent></Card>
124
+ <Card><CardHeader className="pb-1"><CardTitle className="text-sm font-medium text-muted-foreground">Deals ganados</CardTitle></CardHeader>
125
+ <CardContent><p className="text-2xl font-bold text-green-600">{wonCount}</p></CardContent></Card>
126
+ <Card><CardHeader className="pb-1"><CardTitle className="text-sm font-medium text-muted-foreground">Tasa de conversión</CardTitle></CardHeader>
127
+ <CardContent><p className="text-2xl font-bold">{conversionRate}%</p></CardContent></Card>
128
+ </div>
129
+
130
+ {/* Kanban */}
131
+ {loading ? (
132
+ <div className="grid grid-cols-5 gap-4">
133
+ {STAGES.map(s => <Skeleton key={s.key} className="h-64 rounded-xl" />)}
134
+ </div>
135
+ ) : (
136
+ <div className="grid grid-cols-5 gap-4 overflow-x-auto">
137
+ {STAGES.map(stage => (
138
+ <div
139
+ key={stage.key}
140
+ className="flex flex-col gap-2 rounded-xl bg-muted/30 p-3 min-h-[200px]"
141
+ onDragOver={e => e.preventDefault()}
142
+ onDrop={e => handleDrop(e, stage.key)}
143
+ >
144
+ <div className="flex items-center justify-between mb-1">
145
+ <div className="flex items-center gap-1.5">
146
+ <span className="size-2 rounded-full" style={{ backgroundColor: stage.color }} />
147
+ <span className="text-xs font-semibold">{stage.label}</span>
148
+ </div>
149
+ <span className="text-xs text-muted-foreground">{dealsByStage(stage.key).length} · ${stageTotal(stage.key).toFixed(0)}</span>
150
+ </div>
151
+ {dealsByStage(stage.key).map(deal => (
152
+ <div
153
+ key={deal._id}
154
+ draggable
155
+ onDragStart={() => setDraggingId(deal._id)}
156
+ onDragEnd={() => setDraggingId(null)}
157
+ className="rounded-lg border bg-card p-3 shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
158
+ >
159
+ <p className="text-sm font-medium line-clamp-2">{deal.title}</p>
160
+ {deal.clientId && <p className="text-xs text-muted-foreground mt-1">{clientName(deal.clientId)}</p>}
161
+ <div className="mt-2 flex items-center justify-between">
162
+ <span className="text-sm font-semibold">{deal.currency} {deal.value.toFixed(0)}</span>
163
+ <div className="flex gap-1">
164
+ <Button variant="ghost" size="icon" className="size-6" onClick={() => openEdit(deal)}><Pencil className="size-3" /></Button>
165
+ <Button variant="ghost" size="icon" className="size-6" onClick={() => setDeleteTarget(deal)}><Trash2 className="size-3 text-destructive" /></Button>
166
+ </div>
167
+ </div>
168
+ {deal.expectedCloseDate && (
169
+ <p className="mt-1 text-xs text-muted-foreground">
170
+ Cierre: {new Date(deal.expectedCloseDate).toLocaleDateString('es-AR')}
171
+ </p>
172
+ )}
173
+ </div>
174
+ ))}
175
+ <Button variant="ghost" size="sm" className="mt-auto text-xs text-muted-foreground" onClick={() => openCreate(stage.key)}>
176
+ <Plus className="mr-1 size-3" />Agregar
177
+ </Button>
178
+ </div>
179
+ ))}
180
+ </div>
181
+ )}
182
+
183
+ {/* Sheet */}
184
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
185
+ <SheetContent className="w-full sm:max-w-md overflow-y-auto">
186
+ <SheetHeader><SheetTitle>{editing ? 'Editar deal' : 'Nuevo deal'}</SheetTitle></SheetHeader>
187
+ <Form {...form}>
188
+ <form onSubmit={form.handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4">
189
+ <FormField control={form.control} name="title" rules={{ required: 'El título es obligatorio' }} render={({ field }) => (
190
+ <FormItem><FormLabel>Título *</FormLabel><FormControl><Input {...field} /></FormControl></FormItem>
191
+ )} />
192
+ <FormField control={form.control} name="clientId" render={({ field }) => (
193
+ <FormItem><FormLabel>Cliente</FormLabel>
194
+ <Select value={field.value ?? ''} onValueChange={field.onChange}>
195
+ <FormControl><SelectTrigger><SelectValue placeholder="Sin cliente" /></SelectTrigger></FormControl>
196
+ <SelectContent>
197
+ <SelectItem value="">Sin cliente</SelectItem>
198
+ {clients.map(c => <SelectItem key={c._id} value={c._id}>{c.name}</SelectItem>)}
199
+ </SelectContent>
200
+ </Select></FormItem>
201
+ )} />
202
+ <div className="grid grid-cols-2 gap-3">
203
+ <FormField control={form.control} name="value" render={({ field }) => (
204
+ <FormItem><FormLabel>Valor</FormLabel><FormControl><Input type="number" {...field} onChange={e => field.onChange(+e.target.value)} /></FormControl></FormItem>
205
+ )} />
206
+ <FormField control={form.control} name="currency" render={({ field }) => (
207
+ <FormItem><FormLabel>Moneda</FormLabel><FormControl><Input {...field} /></FormControl></FormItem>
208
+ )} />
209
+ </div>
210
+ <FormField control={form.control} name="stage" render={({ field }) => (
211
+ <FormItem><FormLabel>Stage</FormLabel>
212
+ <Select value={field.value} onValueChange={field.onChange}>
213
+ <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
214
+ <SelectContent>{STAGES.map(s => <SelectItem key={s.key} value={s.key}>{s.label}</SelectItem>)}</SelectContent>
215
+ </Select></FormItem>
216
+ )} />
217
+ <FormField control={form.control} name="expectedCloseDate" render={({ field }) => (
218
+ <FormItem><FormLabel>Fecha cierre esperada</FormLabel><FormControl><Input type="date" {...field} /></FormControl></FormItem>
219
+ )} />
220
+ <FormField control={form.control} name="notes" render={({ field }) => (
221
+ <FormItem><FormLabel>Notas</FormLabel><FormControl><textarea className="w-full rounded-md border px-3 py-2 text-sm" rows={3} {...field} /></FormControl></FormItem>
222
+ )} />
223
+ <div className="flex justify-end gap-2 pt-2">
224
+ <Button type="button" variant="outline" onClick={() => setSheetOpen(false)}>Cancelar</Button>
225
+ <Button type="submit" disabled={form.formState.isSubmitting}>{form.formState.isSubmitting ? 'Guardando...' : 'Guardar'}</Button>
226
+ </div>
227
+ </form>
228
+ </Form>
229
+ </SheetContent>
230
+ </Sheet>
231
+
232
+ <AlertDialog open={!!deleteTarget} onOpenChange={open => !open && setDeleteTarget(null)}>
233
+ <AlertDialogContent>
234
+ <AlertDialogHeader><AlertDialogTitle>¿Eliminar deal?</AlertDialogTitle>
235
+ <AlertDialogDescription>Se eliminará <strong>{deleteTarget?.title}</strong>.</AlertDialogDescription></AlertDialogHeader>
236
+ <AlertDialogFooter>
237
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
238
+ <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">Eliminar</AlertDialogAction>
239
+ </AlertDialogFooter>
240
+ </AlertDialogContent>
241
+ </AlertDialog>
242
+ </div>
243
+ )
244
+ }
@@ -0,0 +1,287 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { useForm } from 'react-hook-form'
5
+ import { CalendarDays, ChevronLeft, ChevronRight, Plus, Trash2, Copy } from 'lucide-react'
6
+ import {
7
+ createPlannerBlock, deletePlannerBlock, duplicatePlannerBlock,
8
+ getPlannerBlocks, updateBlockStatus, updatePlannerBlock
9
+ } from '@/lib/api/planner'
10
+ import type { BlockStatus, CreatePlannerBlockDto, PlannerBlock } from '@/types/planner'
11
+ import { Button } from '@/components/ui/button'
12
+ import { Input } from '@/components/ui/input'
13
+ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
14
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
15
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
16
+ import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
17
+ import { useToast } from '@/hooks/use-toast'
18
+
19
+ const HOUR_HEIGHT = 64
20
+ const START_HOUR = 6
21
+ const END_HOUR = 23
22
+ const HOURS = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i)
23
+
24
+ const STATUS_COLORS: Record<BlockStatus, string> = {
25
+ pending: 'bg-blue-100 border-blue-300 text-blue-800',
26
+ 'in-progress': 'bg-yellow-100 border-yellow-300 text-yellow-800',
27
+ completed: 'bg-green-100 border-green-300 text-green-700 opacity-70',
28
+ skipped: 'bg-gray-100 border-gray-300 text-gray-500 opacity-60',
29
+ }
30
+ const STATUS_LABELS: Record<BlockStatus, string> = {
31
+ pending: 'Pendiente', 'in-progress': 'En curso', completed: 'Completado', skipped: 'Omitido',
32
+ }
33
+
34
+ function toMinutes(time: string) {
35
+ const [h, m] = time.split(':').map(Number)
36
+ return h * 60 + m
37
+ }
38
+
39
+ function blockTop(startTime: string) {
40
+ const mins = toMinutes(startTime) - START_HOUR * 60
41
+ return (mins / 60) * HOUR_HEIGHT
42
+ }
43
+
44
+ function blockHeight(startTime: string, endTime: string) {
45
+ const dur = toMinutes(endTime) - toMinutes(startTime)
46
+ return Math.max((dur / 60) * HOUR_HEIGHT, 20)
47
+ }
48
+
49
+ function dateToString(d: Date) {
50
+ return d.toISOString().split('T')[0]
51
+ }
52
+
53
+ function formatDate(dateStr: string) {
54
+ return new Date(dateStr + 'T12:00:00').toLocaleDateString('es-AR', { weekday: 'long', day: 'numeric', month: 'long' })
55
+ }
56
+
57
+ export default function PlannerPage() {
58
+ const { toast } = useToast()
59
+ const [selectedDate, setSelectedDate] = useState(dateToString(new Date()))
60
+ const [blocks, setBlocks] = useState<PlannerBlock[]>([])
61
+ const [loading, setLoading] = useState(true)
62
+ const [sheetOpen, setSheetOpen] = useState(false)
63
+ const [editing, setEditing] = useState<PlannerBlock | null>(null)
64
+ const [deleteTarget, setDeleteTarget] = useState<PlannerBlock | null>(null)
65
+ const timelineRef = useRef<HTMLDivElement>(null)
66
+
67
+ const form = useForm<CreatePlannerBlockDto>({
68
+ defaultValues: { title: '', date: selectedDate, startTime: '09:00', endTime: '10:00', status: 'pending', color: '#3B82F6', category: '', description: '' },
69
+ })
70
+
71
+ const load = useCallback(async (date: string) => {
72
+ setLoading(true)
73
+ try {
74
+ const res = await getPlannerBlocks({ date })
75
+ setBlocks(res.data)
76
+ } catch { toast({ title: 'Error al cargar planner', variant: 'destructive' }) }
77
+ finally { setLoading(false) }
78
+ }, [toast])
79
+
80
+ useEffect(() => { load(selectedDate) }, [load, selectedDate])
81
+
82
+ const changeDate = (delta: number) => {
83
+ const d = new Date(selectedDate + 'T12:00:00')
84
+ d.setDate(d.getDate() + delta)
85
+ setSelectedDate(dateToString(d))
86
+ }
87
+
88
+ const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
89
+ if ((e.target as HTMLElement).closest('[data-block]')) return
90
+ const rect = timelineRef.current?.getBoundingClientRect()
91
+ if (!rect) return
92
+ const y = e.clientY - rect.top
93
+ const totalMins = (y / HOUR_HEIGHT) * 60 + START_HOUR * 60
94
+ const h = Math.floor(totalMins / 60)
95
+ const m = Math.floor((totalMins % 60) / 15) * 15
96
+ const startTime = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
97
+ const endH = h + 1 >= END_HOUR ? END_HOUR - 1 : h + 1
98
+ const endTime = `${String(endH).padStart(2, '0')}:${String(m).padStart(2, '0')}`
99
+ setEditing(null)
100
+ form.reset({ title: '', date: selectedDate, startTime, endTime, status: 'pending', color: '#3B82F6', category: '', description: '' })
101
+ setSheetOpen(true)
102
+ }
103
+
104
+ const openEdit = (block: PlannerBlock) => {
105
+ setEditing(block)
106
+ form.reset({ title: block.title, date: block.date, startTime: block.startTime, endTime: block.endTime, status: block.status, color: block.color ?? '#3B82F6', category: block.category ?? '', description: block.description ?? '' })
107
+ setSheetOpen(true)
108
+ }
109
+
110
+ const onSubmit = async (data: CreatePlannerBlockDto) => {
111
+ try {
112
+ if (editing) {
113
+ const updated = await updatePlannerBlock(editing._id, data)
114
+ setBlocks(prev => prev.map(b => b._id === editing._id ? updated : b))
115
+ toast({ title: 'Bloque actualizado' })
116
+ } else {
117
+ const created = await createPlannerBlock(data)
118
+ setBlocks(prev => [...prev, created])
119
+ toast({ title: 'Bloque creado' })
120
+ }
121
+ setSheetOpen(false)
122
+ } catch { toast({ title: 'Error al guardar', variant: 'destructive' }) }
123
+ }
124
+
125
+ const handleCycleStatus = async (block: PlannerBlock) => {
126
+ const order: BlockStatus[] = ['pending', 'in-progress', 'completed', 'skipped']
127
+ const nextStatus = order[(order.indexOf(block.status) + 1) % order.length]
128
+ try {
129
+ const updated = await updateBlockStatus(block._id, nextStatus)
130
+ setBlocks(prev => prev.map(b => b._id === block._id ? updated : b))
131
+ } catch { toast({ title: 'Error', variant: 'destructive' }) }
132
+ }
133
+
134
+ const handleDuplicate = async (block: PlannerBlock) => {
135
+ try {
136
+ const dup = await duplicatePlannerBlock(block._id)
137
+ setBlocks(prev => [...prev, dup])
138
+ toast({ title: 'Bloque duplicado' })
139
+ } catch { toast({ title: 'Error al duplicar', variant: 'destructive' }) }
140
+ }
141
+
142
+ const handleDelete = async () => {
143
+ if (!deleteTarget) return
144
+ try {
145
+ await deletePlannerBlock(deleteTarget._id)
146
+ setBlocks(prev => prev.filter(b => b._id !== deleteTarget._id))
147
+ setDeleteTarget(null)
148
+ toast({ title: 'Bloque eliminado' })
149
+ } catch { toast({ title: 'Error al eliminar', variant: 'destructive' }) }
150
+ }
151
+
152
+ const totalHeight = (END_HOUR - START_HOUR) * HOUR_HEIGHT
153
+
154
+ return (
155
+ <div className="flex flex-col gap-6 p-6">
156
+ <div className="flex items-center justify-between">
157
+ <div className="flex items-center gap-2">
158
+ <CalendarDays className="size-5" />
159
+ <h1 className="text-xl font-semibold">Planner</h1>
160
+ </div>
161
+ <div className="flex items-center gap-2">
162
+ <Button variant="outline" size="icon" onClick={() => changeDate(-1)}><ChevronLeft className="size-4" /></Button>
163
+ <span className="text-sm font-medium capitalize min-w-[200px] text-center">{formatDate(selectedDate)}</span>
164
+ <Button variant="outline" size="icon" onClick={() => changeDate(1)}><ChevronRight className="size-4" /></Button>
165
+ <Button variant="outline" size="sm" onClick={() => setSelectedDate(dateToString(new Date()))}>Hoy</Button>
166
+ <Button size="sm" onClick={() => { setEditing(null); form.reset({ title: '', date: selectedDate, startTime: '09:00', endTime: '10:00', status: 'pending', color: '#3B82F6', category: '', description: '' }); setSheetOpen(true) }}>
167
+ <Plus className="mr-2 size-4" />Nuevo bloque
168
+ </Button>
169
+ </div>
170
+ </div>
171
+
172
+ {/* Timeline */}
173
+ <div className="rounded-xl border overflow-hidden">
174
+ <div className="flex">
175
+ {/* Hour labels */}
176
+ <div className="w-16 shrink-0 border-r bg-muted/20">
177
+ {HOURS.map(h => (
178
+ <div key={h} className="flex items-start justify-end pr-2 text-xs text-muted-foreground" style={{ height: HOUR_HEIGHT }}>
179
+ <span className="mt-1">{String(h).padStart(2, '0')}:00</span>
180
+ </div>
181
+ ))}
182
+ </div>
183
+
184
+ {/* Blocks area */}
185
+ <div
186
+ ref={timelineRef}
187
+ className="relative flex-1 cursor-pointer"
188
+ style={{ height: totalHeight }}
189
+ onClick={handleTimelineClick}
190
+ >
191
+ {/* Hour grid lines */}
192
+ {HOURS.map(h => (
193
+ <div key={h} className="absolute left-0 right-0 border-t border-muted/50" style={{ top: (h - START_HOUR) * HOUR_HEIGHT }} />
194
+ ))}
195
+
196
+ {/* Blocks */}
197
+ {loading ? null : blocks.map(block => (
198
+ <div
199
+ key={block._id}
200
+ data-block
201
+ className={`absolute left-2 right-2 rounded-lg border p-2 overflow-hidden select-none ${STATUS_COLORS[block.status]}`}
202
+ style={{ top: blockTop(block.startTime), height: blockHeight(block.startTime, block.endTime) }}
203
+ onClick={e => { e.stopPropagation(); openEdit(block) }}
204
+ >
205
+ <p className="text-xs font-semibold line-clamp-1">{block.title}</p>
206
+ <p className="text-xs opacity-70">{block.startTime} – {block.endTime}</p>
207
+ <div className="absolute right-1 top-1 flex gap-0.5" onClick={e => e.stopPropagation()}>
208
+ <button
209
+ className="rounded px-1 py-0.5 text-xs font-medium hover:bg-black/10"
210
+ onClick={() => handleCycleStatus(block)}
211
+ title="Cambiar estado"
212
+ >
213
+ {STATUS_LABELS[block.status].slice(0, 3)}
214
+ </button>
215
+ <button className="rounded px-1 py-0.5 hover:bg-black/10" onClick={() => handleDuplicate(block)} title="Duplicar">
216
+ <Copy className="size-3" />
217
+ </button>
218
+ <button className="rounded px-1 py-0.5 hover:bg-black/10" onClick={() => setDeleteTarget(block)} title="Eliminar">
219
+ <Trash2 className="size-3" />
220
+ </button>
221
+ </div>
222
+ </div>
223
+ ))}
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ {/* Sheet */}
229
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
230
+ <SheetContent className="w-full sm:max-w-md overflow-y-auto">
231
+ <SheetHeader><SheetTitle>{editing ? 'Editar bloque' : 'Nuevo bloque'}</SheetTitle></SheetHeader>
232
+ <Form {...form}>
233
+ <form onSubmit={form.handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4">
234
+ <FormField control={form.control} name="title" rules={{ required: 'Requerido' }} render={({ field }) => (
235
+ <FormItem><FormLabel>Título *</FormLabel><FormControl><Input {...field} /></FormControl></FormItem>
236
+ )} />
237
+ <div className="grid grid-cols-2 gap-3">
238
+ <FormField control={form.control} name="startTime" render={({ field }) => (
239
+ <FormItem><FormLabel>Inicio</FormLabel><FormControl><Input type="time" {...field} /></FormControl></FormItem>
240
+ )} />
241
+ <FormField control={form.control} name="endTime" render={({ field }) => (
242
+ <FormItem><FormLabel>Fin</FormLabel><FormControl><Input type="time" {...field} /></FormControl></FormItem>
243
+ )} />
244
+ </div>
245
+ <FormField control={form.control} name="status" render={({ field }) => (
246
+ <FormItem><FormLabel>Estado</FormLabel>
247
+ <Select value={field.value} onValueChange={field.onChange}>
248
+ <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
249
+ <SelectContent>
250
+ {(Object.entries(STATUS_LABELS) as [BlockStatus, string][]).map(([v, l]) => (
251
+ <SelectItem key={v} value={v}>{l}</SelectItem>
252
+ ))}
253
+ </SelectContent>
254
+ </Select></FormItem>
255
+ )} />
256
+ <FormField control={form.control} name="category" render={({ field }) => (
257
+ <FormItem><FormLabel>Categoría</FormLabel><FormControl><Input {...field} /></FormControl></FormItem>
258
+ )} />
259
+ <FormField control={form.control} name="description" render={({ field }) => (
260
+ <FormItem><FormLabel>Descripción</FormLabel><FormControl>
261
+ <textarea className="w-full rounded-md border px-3 py-2 text-sm" rows={3} {...field} />
262
+ </FormControl></FormItem>
263
+ )} />
264
+ <div className="flex justify-end gap-2 pt-2">
265
+ <Button type="button" variant="outline" onClick={() => setSheetOpen(false)}>Cancelar</Button>
266
+ <Button type="submit" disabled={form.formState.isSubmitting}>
267
+ {form.formState.isSubmitting ? 'Guardando...' : 'Guardar'}
268
+ </Button>
269
+ </div>
270
+ </form>
271
+ </Form>
272
+ </SheetContent>
273
+ </Sheet>
274
+
275
+ <AlertDialog open={!!deleteTarget} onOpenChange={open => !open && setDeleteTarget(null)}>
276
+ <AlertDialogContent>
277
+ <AlertDialogHeader><AlertDialogTitle>¿Eliminar bloque?</AlertDialogTitle>
278
+ <AlertDialogDescription>Se eliminará <strong>{deleteTarget?.title}</strong>.</AlertDialogDescription></AlertDialogHeader>
279
+ <AlertDialogFooter>
280
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
281
+ <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">Eliminar</AlertDialogAction>
282
+ </AlertDialogFooter>
283
+ </AlertDialogContent>
284
+ </AlertDialog>
285
+ </div>
286
+ )
287
+ }