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,366 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+ import { useForm } from 'react-hook-form'
5
+ import { CheckSquare, Plus, Trash2, X } from 'lucide-react'
6
+ import { createTaskColumn as createColumn, deleteTaskColumn as deleteColumn, getTaskColumns as getColumns } from '@/lib/api/task-columns'
7
+ import { createTask, deleteTask, getTask, getTasks, moveTask, updateTask } from '@/lib/api/tasks'
8
+ import type { TaskColumn } from '@/types/tasks'
9
+ import type { Task, CreateTaskDto } from '@/types/tasks'
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
15
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
16
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
17
+ import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
18
+ import { Checkbox } from '@/components/ui/checkbox'
19
+ import { useToast } from '@/hooks/use-toast'
20
+
21
+ const PRIORITY_COLORS: Record<string, 'secondary' | 'default' | 'destructive' | 'outline'> = {
22
+ low: 'secondary', medium: 'default', high: 'destructive', urgent: 'destructive',
23
+ }
24
+ const PRIORITY_LABELS: Record<string, string> = {
25
+ low: 'Baja', medium: 'Media', high: 'Alta', urgent: 'Urgente',
26
+ }
27
+
28
+ export default function TasksPage() {
29
+ const { toast } = useToast()
30
+ const [columns, setColumns] = useState<TaskColumn[]>([])
31
+ const [tasksByColumn, setTasksByColumn] = useState<Record<string, Task[]>>({})
32
+ const [loading, setLoading] = useState(true)
33
+ const [newColName, setNewColName] = useState('')
34
+ const [addingCol, setAddingCol] = useState(false)
35
+ const [sheetOpen, setSheetOpen] = useState(false)
36
+ const [selectedTask, setSelectedTask] = useState<Task | null>(null)
37
+ const [deleteTarget, setDeleteTarget] = useState<{ type: 'col' | 'task'; id: string; name: string } | null>(null)
38
+ const [draggingTask, setDraggingTask] = useState<{ id: string; fromCol: string } | null>(null)
39
+
40
+ const form = useForm<CreateTaskDto>({
41
+ defaultValues: { title: '', columnId: '', description: '', priority: 'medium', dueDate: '', labels: [], checklist: [] },
42
+ })
43
+
44
+ const load = useCallback(async () => {
45
+ setLoading(true)
46
+ try {
47
+ const cols = await getColumns()
48
+ setColumns(cols)
49
+ const entries = await Promise.all(
50
+ cols.map(async (col) => {
51
+ const res = await getTasks({ columnId: col._id, limit: 200 })
52
+ return [col._id, res.data] as [string, Task[]]
53
+ })
54
+ )
55
+ setTasksByColumn(Object.fromEntries(entries))
56
+ } catch { toast({ title: 'Error al cargar tareas', variant: 'destructive' }) }
57
+ finally { setLoading(false) }
58
+ }, [toast])
59
+
60
+ useEffect(() => { load() }, [load])
61
+
62
+ const handleAddColumn = async () => {
63
+ if (!newColName.trim()) return
64
+ try {
65
+ const col = await createColumn({ name: newColName.trim(), order: columns.length })
66
+ setColumns(prev => [...prev, col])
67
+ setTasksByColumn(prev => ({ ...prev, [col._id]: [] }))
68
+ setNewColName('')
69
+ setAddingCol(false)
70
+ } catch { toast({ title: 'Error al crear columna', variant: 'destructive' }) }
71
+ }
72
+
73
+ const handleDeleteColumn = async () => {
74
+ if (!deleteTarget || deleteTarget.type !== 'col') return
75
+ try {
76
+ await deleteColumn(deleteTarget.id)
77
+ setColumns(prev => prev.filter(c => c._id !== deleteTarget.id))
78
+ setTasksByColumn(prev => { const n = { ...prev }; delete n[deleteTarget.id]; return n })
79
+ setDeleteTarget(null)
80
+ } catch { toast({ title: 'Error al eliminar columna', variant: 'destructive' }) }
81
+ }
82
+
83
+ const handleDeleteTask = async () => {
84
+ if (!deleteTarget || deleteTarget.type !== 'task') return
85
+ try {
86
+ await deleteTask(deleteTarget.id)
87
+ setTasksByColumn(prev => {
88
+ const n = { ...prev }
89
+ for (const colId of Object.keys(n)) n[colId] = n[colId].filter(t => t._id !== deleteTarget.id)
90
+ return n
91
+ })
92
+ setDeleteTarget(null)
93
+ if (selectedTask?._id === deleteTarget.id) { setSelectedTask(null); setSheetOpen(false) }
94
+ } catch { toast({ title: 'Error al eliminar tarea', variant: 'destructive' }) }
95
+ }
96
+
97
+ const openTask = async (task: Task) => {
98
+ try {
99
+ const full = await getTask(task._id)
100
+ setSelectedTask(full)
101
+ form.reset({ title: full.title, columnId: full.columnId ?? '', description: full.description ?? '', priority: full.priority ?? 'medium', dueDate: full.dueDate?.split('T')[0] ?? '', labels: full.labels ?? [], checklist: full.checklist ?? [] })
102
+ setSheetOpen(true)
103
+ } catch { toast({ title: 'Error', variant: 'destructive' }) }
104
+ }
105
+
106
+ const openCreate = (columnId: string) => {
107
+ setSelectedTask(null)
108
+ form.reset({ title: '', columnId, description: '', priority: 'medium', dueDate: '', labels: [], checklist: [] })
109
+ setSheetOpen(true)
110
+ }
111
+
112
+ const onSubmit = async (data: CreateTaskDto) => {
113
+ try {
114
+ if (selectedTask) {
115
+ const updated = await updateTask(selectedTask._id, data)
116
+ setTasksByColumn(prev => {
117
+ const n = { ...prev }
118
+ for (const colId of Object.keys(n)) n[colId] = n[colId].map(t => t._id === selectedTask._id ? updated : t)
119
+ return n
120
+ })
121
+ toast({ title: 'Tarea actualizada' })
122
+ } else {
123
+ const task = await createTask(data)
124
+ const colId = data.columnId ?? ''
125
+ if (colId) setTasksByColumn(prev => ({ ...prev, [colId]: [...(prev[colId] ?? []), task] }))
126
+ toast({ title: 'Tarea creada' })
127
+ }
128
+ setSheetOpen(false)
129
+ } catch { toast({ title: 'Error al guardar', variant: 'destructive' }) }
130
+ }
131
+
132
+ const handleDrop = async (e: React.DragEvent, targetCol: string) => {
133
+ e.preventDefault()
134
+ if (!draggingTask || draggingTask.fromCol === targetCol) return
135
+ const { id, fromCol } = draggingTask
136
+ const task = tasksByColumn[fromCol]?.find(t => t._id === id)
137
+ if (!task) return
138
+ setTasksByColumn(prev => ({
139
+ ...prev,
140
+ [fromCol]: prev[fromCol].filter(t => t._id !== id),
141
+ [targetCol]: [...(prev[targetCol] ?? []), { ...task, columnId: targetCol }],
142
+ }))
143
+ try {
144
+ await moveTask(id, { columnId: targetCol })
145
+ } catch {
146
+ toast({ title: 'Error al mover tarea', variant: 'destructive' })
147
+ load()
148
+ }
149
+ setDraggingTask(null)
150
+ }
151
+
152
+ const toggleCheckItem = async (task: Task, idx: number) => {
153
+ const checklist = (task.checklist ?? []).map((item, i) =>
154
+ i === idx ? { ...item, done: !item.completed } : item
155
+ )
156
+ try {
157
+ const updated = await updateTask(task._id, { checklist } as Partial<CreateTaskDto>)
158
+ setTasksByColumn(prev => {
159
+ const n = { ...prev }
160
+ for (const colId of Object.keys(n)) n[colId] = n[colId].map(t => t._id === task._id ? updated : t)
161
+ return n
162
+ })
163
+ setSelectedTask(updated)
164
+ } catch { toast({ title: 'Error', variant: 'destructive' }) }
165
+ }
166
+
167
+ const checklistFields = form.watch('checklist') ?? []
168
+
169
+ return (
170
+ <div className="flex flex-col gap-6 p-6">
171
+ <div className="flex items-center justify-between">
172
+ <div className="flex items-center gap-2">
173
+ <CheckSquare className="size-5" />
174
+ <h1 className="text-xl font-semibold">Tareas</h1>
175
+ </div>
176
+ </div>
177
+
178
+ {loading ? (
179
+ <div className="flex gap-4">
180
+ {Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-64 w-64 shrink-0 rounded-xl" />)}
181
+ </div>
182
+ ) : (
183
+ <div className="flex gap-4 overflow-x-auto pb-4">
184
+ {columns.map(col => (
185
+ <div
186
+ key={col._id}
187
+ className="flex flex-col gap-2 rounded-xl bg-muted/30 p-3 w-64 shrink-0 min-h-[200px]"
188
+ onDragOver={e => e.preventDefault()}
189
+ onDrop={e => handleDrop(e, col._id)}
190
+ >
191
+ <div className="flex items-center justify-between">
192
+ <span className="text-xs font-semibold">{col.name}</span>
193
+ <div className="flex gap-1">
194
+ <span className="text-xs text-muted-foreground">{(tasksByColumn[col._id] ?? []).length}</span>
195
+ <Button variant="ghost" size="icon" className="size-5" onClick={() => setDeleteTarget({ type: 'col', id: col._id, name: col.name })}>
196
+ <X className="size-3 text-muted-foreground" />
197
+ </Button>
198
+ </div>
199
+ </div>
200
+
201
+ {(tasksByColumn[col._id] ?? []).map(task => {
202
+ const done = (task.checklist ?? []).filter(i => i.completed).length
203
+ const total = (task.checklist ?? []).length
204
+ return (
205
+ <div
206
+ key={task._id}
207
+ draggable
208
+ onDragStart={() => setDraggingTask({ id: task._id, fromCol: col._id })}
209
+ onDragEnd={() => setDraggingTask(null)}
210
+ className="rounded-lg border bg-card p-3 shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
211
+ onClick={() => openTask(task)}
212
+ >
213
+ <div className="flex items-start justify-between gap-1">
214
+ <p className="text-sm font-medium line-clamp-2 flex-1">{task.title}</p>
215
+ <Button variant="ghost" size="icon" className="size-5 shrink-0" onClick={e => { e.stopPropagation(); setDeleteTarget({ type: 'task', id: task._id, name: task.title }) }}>
216
+ <Trash2 className="size-3 text-destructive" />
217
+ </Button>
218
+ </div>
219
+ {task.priority && (
220
+ <Badge variant={PRIORITY_COLORS[task.priority]} className="mt-1 text-xs">
221
+ {PRIORITY_LABELS[task.priority]}
222
+ </Badge>
223
+ )}
224
+ {total > 0 && (
225
+ <div className="mt-2">
226
+ <div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
227
+ <span>Checklist</span><span>{done}/{total}</span>
228
+ </div>
229
+ <div className="h-1 rounded-full bg-muted">
230
+ <div className="h-1 rounded-full bg-primary transition-all" style={{ width: `${total > 0 ? (done / total) * 100 : 0}%` }} />
231
+ </div>
232
+ </div>
233
+ )}
234
+ {task.dueDate && (
235
+ <p className="mt-1 text-xs text-muted-foreground">
236
+ Vence: {new Date(task.dueDate).toLocaleDateString('es-AR')}
237
+ </p>
238
+ )}
239
+ </div>
240
+ )
241
+ })}
242
+
243
+ <Button variant="ghost" size="sm" className="mt-auto text-xs text-muted-foreground" onClick={() => openCreate(col._id)}>
244
+ <Plus className="mr-1 size-3" />Nueva tarea
245
+ </Button>
246
+ </div>
247
+ ))}
248
+
249
+ {/* Agregar columna */}
250
+ <div className="w-56 shrink-0">
251
+ {addingCol ? (
252
+ <div className="rounded-xl border bg-card p-3 flex flex-col gap-2">
253
+ <Input placeholder="Nombre de la columna" value={newColName} onChange={e => setNewColName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleAddColumn(); if (e.key === 'Escape') setAddingCol(false) }} autoFocus />
254
+ <div className="flex gap-2">
255
+ <Button size="sm" onClick={handleAddColumn}>Crear</Button>
256
+ <Button size="sm" variant="outline" onClick={() => setAddingCol(false)}>Cancelar</Button>
257
+ </div>
258
+ </div>
259
+ ) : (
260
+ <Button variant="outline" className="w-full" onClick={() => setAddingCol(true)}>
261
+ <Plus className="mr-2 size-4" />Columna
262
+ </Button>
263
+ )}
264
+ </div>
265
+ </div>
266
+ )}
267
+
268
+ {/* Sheet detalle tarea */}
269
+ <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
270
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
271
+ <SheetHeader>
272
+ <SheetTitle>{selectedTask ? 'Detalle de tarea' : 'Nueva tarea'}</SheetTitle>
273
+ </SheetHeader>
274
+ <Form {...form}>
275
+ <form onSubmit={form.handleSubmit(onSubmit)} className="mt-6 flex flex-col gap-4">
276
+ <FormField control={form.control} name="title" rules={{ required: 'Requerido' }} render={({ field }) => (
277
+ <FormItem><FormLabel>Título *</FormLabel><FormControl><Input {...field} /></FormControl></FormItem>
278
+ )} />
279
+ <FormField control={form.control} name="columnId" render={({ field }) => (
280
+ <FormItem><FormLabel>Columna</FormLabel>
281
+ <Select value={field.value ?? ''} onValueChange={field.onChange}>
282
+ <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
283
+ <SelectContent>{columns.map(c => <SelectItem key={c._id} value={c._id}>{c.name}</SelectItem>)}</SelectContent>
284
+ </Select></FormItem>
285
+ )} />
286
+ <FormField control={form.control} name="priority" render={({ field }) => (
287
+ <FormItem><FormLabel>Prioridad</FormLabel>
288
+ <Select value={field.value ?? 'medium'} onValueChange={field.onChange}>
289
+ <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
290
+ <SelectContent>
291
+ <SelectItem value="low">Baja</SelectItem>
292
+ <SelectItem value="medium">Media</SelectItem>
293
+ <SelectItem value="high">Alta</SelectItem>
294
+ </SelectContent>
295
+ </Select></FormItem>
296
+ )} />
297
+ <FormField control={form.control} name="dueDate" render={({ field }) => (
298
+ <FormItem><FormLabel>Fecha límite</FormLabel><FormControl><Input type="date" {...field} /></FormControl></FormItem>
299
+ )} />
300
+ <FormField control={form.control} name="description" render={({ field }) => (
301
+ <FormItem><FormLabel>Descripción</FormLabel><FormControl>
302
+ <textarea className="w-full rounded-md border px-3 py-2 text-sm" rows={3} {...field} />
303
+ </FormControl></FormItem>
304
+ )} />
305
+
306
+ {/* Checklist */}
307
+ <div className="space-y-2">
308
+ <div className="flex items-center justify-between">
309
+ <p className="text-sm font-medium">Checklist</p>
310
+ <Button type="button" variant="outline" size="sm" onClick={() => {
311
+ const curr = form.getValues('checklist') ?? []
312
+ form.setValue('checklist', [...curr, { text: '', completed: false }])
313
+ }}>
314
+ <Plus className="mr-1 size-3" />Ítem
315
+ </Button>
316
+ </div>
317
+ {checklistFields.map((item, i) => (
318
+ <div key={i} className="flex items-center gap-2">
319
+ <Checkbox
320
+ checked={item.completed}
321
+ onCheckedChange={checked => {
322
+ const curr = form.getValues('checklist') ?? []
323
+ form.setValue(`checklist.${i}.completed`, !!checked)
324
+ if (selectedTask) toggleCheckItem({ ...selectedTask, checklist: curr }, i)
325
+ }}
326
+ />
327
+ <FormField control={form.control} name={`checklist.${i}.text`} render={({ field }) => (
328
+ <FormItem className="flex-1"><FormControl><Input className={item.completed ? 'line-through text-muted-foreground' : ''} {...field} /></FormControl></FormItem>
329
+ )} />
330
+ <Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => {
331
+ const curr = form.getValues('checklist') ?? []
332
+ form.setValue('checklist', curr.filter((_, j) => j !== i))
333
+ }}><X className="size-3" /></Button>
334
+ </div>
335
+ ))}
336
+ </div>
337
+
338
+ <div className="flex justify-end gap-2 pt-2">
339
+ <Button type="button" variant="outline" onClick={() => setSheetOpen(false)}>Cancelar</Button>
340
+ <Button type="submit" disabled={form.formState.isSubmitting}>
341
+ {form.formState.isSubmitting ? 'Guardando...' : 'Guardar'}
342
+ </Button>
343
+ </div>
344
+ </form>
345
+ </Form>
346
+ </SheetContent>
347
+ </Sheet>
348
+
349
+ <AlertDialog open={!!deleteTarget} onOpenChange={open => !open && setDeleteTarget(null)}>
350
+ <AlertDialogContent>
351
+ <AlertDialogHeader>
352
+ <AlertDialogTitle>¿Eliminar {deleteTarget?.type === 'col' ? 'columna' : 'tarea'}?</AlertDialogTitle>
353
+ <AlertDialogDescription>Se eliminará <strong>{deleteTarget?.name}</strong>.</AlertDialogDescription>
354
+ </AlertDialogHeader>
355
+ <AlertDialogFooter>
356
+ <AlertDialogCancel>Cancelar</AlertDialogCancel>
357
+ <AlertDialogAction
358
+ onClick={deleteTarget?.type === 'col' ? handleDeleteColumn : handleDeleteTask}
359
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
360
+ >Eliminar</AlertDialogAction>
361
+ </AlertDialogFooter>
362
+ </AlertDialogContent>
363
+ </AlertDialog>
364
+ </div>
365
+ )
366
+ }
@@ -0,0 +1,82 @@
1
+ 'use client'
2
+
3
+ import { Suspense, useEffect, useState } from 'react'
4
+ import { useRouter, useSearchParams } from 'next/navigation'
5
+ import { authApi } from '@/lib/api/auth'
6
+ import { useAuth } from '@/lib/hooks/use-auth'
7
+
8
+ type Status = 'loading' | 'error'
9
+
10
+ function GithubCallbackInner() {
11
+ const router = useRouter()
12
+ const searchParams = useSearchParams()
13
+ const { refreshUser } = useAuth()
14
+ const [status, setStatus] = useState<Status>('loading')
15
+ const [errorMsg, setErrorMsg] = useState('')
16
+
17
+ useEffect(() => {
18
+ const code = searchParams.get('code')
19
+ const error = searchParams.get('error')
20
+
21
+ if (error || !code) {
22
+ setErrorMsg('No pudimos completar el inicio de sesión con GitHub. Intentá de nuevo.')
23
+ setStatus('error')
24
+ return
25
+ }
26
+
27
+ authApi
28
+ .exchangeGithubCode(code)
29
+ .then(() => refreshUser())
30
+ .then(() => router.replace('/dashboard'))
31
+ .catch(() => {
32
+ setErrorMsg('El código expiró o es inválido. Intentá iniciar sesión nuevamente.')
33
+ setStatus('error')
34
+ })
35
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
36
+
37
+ if (status === 'error') {
38
+ return (
39
+ <div className="flex min-h-screen items-center justify-center p-4">
40
+ <div className="max-w-sm space-y-4 text-center">
41
+ <div className="mx-auto flex size-12 items-center justify-center rounded-full bg-destructive/10">
42
+ <svg className="size-6 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor">
43
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
44
+ </svg>
45
+ </div>
46
+ <h1 className="text-lg font-semibold">Error al autenticar</h1>
47
+ <p className="text-sm text-muted-foreground">{errorMsg}</p>
48
+ <button
49
+ onClick={() => router.replace('/login')}
50
+ className="text-sm underline underline-offset-4 hover:text-foreground text-muted-foreground"
51
+ >
52
+ Volver al login
53
+ </button>
54
+ </div>
55
+ </div>
56
+ )
57
+ }
58
+
59
+ return (
60
+ <div className="flex min-h-screen items-center justify-center p-4">
61
+ <div className="space-y-4 text-center">
62
+ <div className="mx-auto size-8 animate-spin rounded-full border-4 border-muted border-t-foreground" />
63
+ <p className="text-sm text-muted-foreground">Autenticando con GitHub...</p>
64
+ </div>
65
+ </div>
66
+ )
67
+ }
68
+
69
+ export default function GithubCallbackPage() {
70
+ return (
71
+ <Suspense fallback={
72
+ <div className="flex min-h-screen items-center justify-center p-4">
73
+ <div className="space-y-4 text-center">
74
+ <div className="mx-auto size-8 animate-spin rounded-full border-4 border-muted border-t-foreground" />
75
+ <p className="text-sm text-muted-foreground">Cargando...</p>
76
+ </div>
77
+ </div>
78
+ }>
79
+ <GithubCallbackInner />
80
+ </Suspense>
81
+ )
82
+ }
@@ -0,0 +1,21 @@
1
+ import { LandingNavbar } from '@/components/landing/navbar'
2
+ import { LandingHero } from '@/components/landing/hero'
3
+ import { LandingFeatures } from '@/components/landing/features'
4
+ import { LandingPricing } from '@/components/landing/pricing'
5
+ import { LandingFaq } from '@/components/landing/faq'
6
+ import { LandingFooter } from '@/components/landing/footer'
7
+
8
+ export default function LandingPage() {
9
+ return (
10
+ <>
11
+ <LandingNavbar />
12
+ <main>
13
+ <LandingHero />
14
+ <LandingFeatures />
15
+ <LandingPricing />
16
+ <LandingFaq />
17
+ </main>
18
+ <LandingFooter />
19
+ </>
20
+ )
21
+ }
@@ -1,5 +1,5 @@
1
- import { redirect } from 'next/navigation'
2
-
3
- export default function RootPage() {
4
- redirect('/login')
5
- }
1
+ import { redirect } from 'next/navigation'
2
+
3
+ export default function RootPage() {
4
+ redirect('/setup')
5
+ }
@@ -31,18 +31,18 @@ const BACKEND_REQUIRED = [
31
31
  },
32
32
  {
33
33
  key: 'APP_URL',
34
- example: 'http://localhost:3001',
34
+ example: 'http://localhost:3000',
35
35
  desc: 'URL del frontend. Se usa en los links de los emails de verificación y reset.',
36
36
  },
37
37
  {
38
38
  key: 'CORS_ORIGINS',
39
- example: 'http://localhost:3001',
39
+ example: 'http://localhost:3000',
40
40
  desc: 'URL del frontend separada por coma. Debe coincidir con APP_URL.',
41
41
  },
42
42
  ]
43
43
 
44
44
  const BACKEND_OPTIONAL = [
45
- { key: 'PORT', example: '3000', desc: 'Puerto del servidor (default: 3000).' },
45
+ { key: 'PORT', example: '3001', desc: 'Puerto del servidor (default: 3001).' },
46
46
  { key: 'JWT_ACCESS_EXPIRES_IN', example: '15m', desc: 'Duración del access token (default: 15m).' },
47
47
  { key: 'JWT_REFRESH_EXPIRES_IN', example: '7d', desc: 'Duración del refresh token (default: 7d).' },
48
48
  { key: 'RESEND_FROM_NAME', example: '"Mi SaaS"', desc: 'Nombre del remitente que ven los destinatarios.' },
@@ -52,7 +52,7 @@ const BACKEND_OPTIONAL = [
52
52
  const FRONTEND_REQUIRED = [
53
53
  {
54
54
  key: 'NEXT_PUBLIC_API_URL',
55
- example: 'http://localhost:3000',
55
+ example: 'http://localhost:3001',
56
56
  desc: 'URL base del backend. Debe coincidir con el PORT del backend.',
57
57
  },
58
58
  ]
@@ -119,14 +119,14 @@ export default function SetupPage() {
119
119
  useEffect(() => {
120
120
  setMounted(true)
121
121
  if (typeof window !== 'undefined' && localStorage.getItem('setup_dismissed') === 'true') {
122
- router.replace('/login')
122
+ router.replace('/landing')
123
123
  }
124
124
  }, [router])
125
125
 
126
126
  const handleDone = () => {
127
127
  localStorage.setItem('setup_dismissed', 'true')
128
128
  setDismissed(true)
129
- router.push('/login')
129
+ router.push('/landing')
130
130
  }
131
131
 
132
132
  if (!mounted || dismissed) return null
@@ -282,8 +282,8 @@ openssl rand -hex 64`}</CodeBlock>
282
282
  npm install
283
283
  npm run start:dev`}</CodeBlock>
284
284
  <p className="text-xs text-zinc-400">
285
- El servidor levanta en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3000</code>.
286
- Swagger en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3000/api/docs</code>.
285
+ El servidor levanta en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3001</code>.
286
+ Swagger en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3001/api/docs</code>.
287
287
  </p>
288
288
  </div>
289
289
  </div>
@@ -318,7 +318,7 @@ npm run start:dev`}</CodeBlock>
318
318
  npm install
319
319
  npm run dev`}</CodeBlock>
320
320
  <p className="text-xs text-zinc-400">
321
- El frontend levanta en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3001</code> (o el puerto que indique Next.js).
321
+ El frontend levanta en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3000</code> (puerto default de Next.js).
322
322
  </p>
323
323
  </div>
324
324
  </div>
@@ -335,9 +335,9 @@ npm run dev`}</CodeBlock>
335
335
  {[
336
336
  { label: 'MongoDB corriendo', hint: 'docker ps muestra el contenedor activo, o verificá que el proceso mongod esté levantado. Si el backend da ECONNREFUSED, MongoDB no está corriendo.' },
337
337
  { label: 'Backend corre sin errores', hint: 'npm run start:dev no muestra errores rojos. Errores de JWT_SECRET faltante o MONGODB_URI indican variables sin completar.' },
338
- { label: 'Swagger disponible', hint: 'http://localhost:3000/api/docs carga correctamente y lista los endpoints de auth.' },
338
+ { label: 'Swagger disponible', hint: 'http://localhost:3001/api/docs carga correctamente y lista los endpoints de auth.' },
339
339
  { label: 'MongoDB conectado', hint: 'El log del backend dice "Connected to MongoDB successfully". Si no aparece, verificá MONGODB_URI en backend/.env.' },
340
- { label: 'Registro funciona', hint: 'Ir a http://localhost:3001/register y crear una cuenta. Si falla con CORS, verificar que CORS_ORIGINS en backend/.env incluya http://localhost:3001.' },
340
+ { label: 'Registro funciona', hint: 'Ir a http://localhost:3000/register y crear una cuenta. Si falla con CORS, verificar que CORS_ORIGINS en backend/.env incluya http://localhost:3000.' },
341
341
  { label: 'Email de verificación llega', hint: 'Revisar spam si no aparece en inbox. Si no llega, verificar RESEND_API_KEY y RESEND_FROM_EMAIL en backend/.env.' },
342
342
  ].map((item) => (
343
343
  <div key={item.label} className="flex items-start gap-3 py-1.5">
@@ -354,14 +354,15 @@ npm run dev`}</CodeBlock>
354
354
  {/* CTA */}
355
355
  <div className="flex items-center justify-between">
356
356
  <p className="text-xs text-zinc-400">
357
- Esta pantalla no vuelve a aparecer. Podés volver desde{' '}
358
- <code className="bg-zinc-100 px-1 py-0.5 rounded">/setup</code>.
357
+ Esta pantalla no vuelve a aparecer. Podés acceder nuevamente desde{' '}
358
+ <code className="bg-zinc-100 px-1 py-0.5 rounded">/setup</code>{' '}
359
+ (borrá el localStorage si querés verla de nuevo).
359
360
  </p>
360
361
  <button
361
362
  onClick={handleDone}
362
363
  className="bg-zinc-900 hover:bg-zinc-700 text-white font-medium text-sm px-6 py-2.5 rounded-lg transition-colors"
363
364
  >
364
- Todo listo, ir al login
365
+ Todo listo, ver la app
365
366
  </button>
366
367
  </div>
367
368
 
@@ -0,0 +1,25 @@
1
+ 'use client'
2
+
3
+ import { Button } from '@/components/ui/button'
4
+
5
+ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'
6
+
7
+ export function GitHubButton() {
8
+ function handleClick() {
9
+ window.location.href = `${API_URL}/api/auth/github`
10
+ }
11
+
12
+ return (
13
+ <Button
14
+ type="button"
15
+ variant="outline"
16
+ className="w-full gap-2"
17
+ onClick={handleClick}
18
+ >
19
+ <svg viewBox="0 0 24 24" className="size-4 fill-current" aria-hidden="true">
20
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.385-1.335-1.755-1.335-1.755-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12c0-6.63-5.37-12-12-12" />
21
+ </svg>
22
+ Continuar con GitHub
23
+ </Button>
24
+ )
25
+ }