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.
- package/package.json +1 -1
- package/templates/backend/.env.example +13 -3
- package/templates/backend/README.md +22 -3
- package/templates/backend/package-lock.json +165 -2
- package/templates/backend/package.json +2 -0
- package/templates/backend/src/app.module.ts +14 -0
- package/templates/backend/src/common/guards/github-auth.guard.ts +5 -0
- package/templates/backend/src/main.ts +2 -2
- package/templates/backend/src/modules/auth/auth.controller.ts +51 -3
- package/templates/backend/src/modules/auth/auth.module.ts +2 -1
- package/templates/backend/src/modules/auth/auth.service.ts +96 -11
- package/templates/backend/src/modules/auth/strategies/github.strategy.ts +46 -0
- package/templates/backend/src/modules/clients/clients.controller.ts +91 -0
- package/templates/backend/src/modules/clients/clients.module.ts +16 -0
- package/templates/backend/src/modules/clients/clients.repository.ts +14 -0
- package/templates/backend/src/modules/clients/clients.service.ts +52 -0
- package/templates/backend/src/modules/clients/dto/create-client.dto.ts +40 -0
- package/templates/backend/src/modules/clients/dto/query-client.dto.ts +30 -0
- package/templates/backend/src/modules/clients/dto/update-client.dto.ts +4 -0
- package/templates/backend/src/modules/clients/schemas/client.schema.ts +32 -0
- package/templates/backend/src/modules/invoices/dto/create-invoice.dto.ts +79 -0
- package/templates/backend/src/modules/invoices/dto/invoice-item.dto.ts +23 -0
- package/templates/backend/src/modules/invoices/dto/query-invoice.dto.ts +40 -0
- package/templates/backend/src/modules/invoices/dto/update-invoice.dto.ts +4 -0
- package/templates/backend/src/modules/invoices/invoices.controller.ts +91 -0
- package/templates/backend/src/modules/invoices/invoices.module.ts +18 -0
- package/templates/backend/src/modules/invoices/invoices.repository.ts +14 -0
- package/templates/backend/src/modules/invoices/invoices.service.ts +104 -0
- package/templates/backend/src/modules/invoices/schemas/invoice.schema.ts +75 -0
- package/templates/backend/src/modules/notifications/dto/create-notification.dto.ts +45 -0
- package/templates/backend/src/modules/notifications/dto/query-notification.dto.ts +30 -0
- package/templates/backend/src/modules/notifications/dto/update-notification.dto.ts +4 -0
- package/templates/backend/src/modules/notifications/notifications.controller.ts +119 -0
- package/templates/backend/src/modules/notifications/notifications.module.ts +16 -0
- package/templates/backend/src/modules/notifications/notifications.repository.ts +31 -0
- package/templates/backend/src/modules/notifications/notifications.service.ts +64 -0
- package/templates/backend/src/modules/notifications/schemas/notification.schema.ts +38 -0
- package/templates/backend/src/modules/pipeline/dto/create-deal.dto.ts +40 -0
- package/templates/backend/src/modules/pipeline/dto/query-deal.dto.ts +35 -0
- package/templates/backend/src/modules/pipeline/dto/update-deal.dto.ts +4 -0
- package/templates/backend/src/modules/pipeline/pipeline.controller.ts +91 -0
- package/templates/backend/src/modules/pipeline/pipeline.module.ts +18 -0
- package/templates/backend/src/modules/pipeline/pipeline.repository.ts +14 -0
- package/templates/backend/src/modules/pipeline/pipeline.service.ts +64 -0
- package/templates/backend/src/modules/pipeline/schemas/deal.schema.ts +39 -0
- package/templates/backend/src/modules/planner/dto/create-planner-block.dto.ts +66 -0
- package/templates/backend/src/modules/planner/dto/query-planner-block.dto.ts +48 -0
- package/templates/backend/src/modules/planner/dto/update-block-status.dto.ts +10 -0
- package/templates/backend/src/modules/planner/dto/update-planner-block.dto.ts +4 -0
- package/templates/backend/src/modules/planner/planner.controller.ts +124 -0
- package/templates/backend/src/modules/planner/planner.module.ts +16 -0
- package/templates/backend/src/modules/planner/planner.repository.ts +45 -0
- package/templates/backend/src/modules/planner/planner.service.ts +104 -0
- package/templates/backend/src/modules/planner/schemas/planner-block.schema.ts +56 -0
- package/templates/backend/src/modules/task-columns/dto/create-task-column.dto.ts +20 -0
- package/templates/backend/src/modules/task-columns/dto/reorder-columns.dto.ts +9 -0
- package/templates/backend/src/modules/task-columns/dto/update-task-column.dto.ts +4 -0
- package/templates/backend/src/modules/task-columns/schemas/task-column.schema.ts +21 -0
- package/templates/backend/src/modules/task-columns/task-columns.controller.ts +86 -0
- package/templates/backend/src/modules/task-columns/task-columns.module.ts +16 -0
- package/templates/backend/src/modules/task-columns/task-columns.repository.ts +15 -0
- package/templates/backend/src/modules/task-columns/task-columns.service.ts +49 -0
- package/templates/backend/src/modules/tasks/dto/checklist-item.dto.ts +13 -0
- package/templates/backend/src/modules/tasks/dto/create-task.dto.ts +67 -0
- package/templates/backend/src/modules/tasks/dto/label.dto.ts +12 -0
- package/templates/backend/src/modules/tasks/dto/move-task.dto.ts +15 -0
- package/templates/backend/src/modules/tasks/dto/query-task.dto.ts +40 -0
- package/templates/backend/src/modules/tasks/dto/update-task.dto.ts +4 -0
- package/templates/backend/src/modules/tasks/schemas/task.schema.ts +66 -0
- package/templates/backend/src/modules/tasks/tasks.controller.ts +104 -0
- package/templates/backend/src/modules/tasks/tasks.module.ts +18 -0
- package/templates/backend/src/modules/tasks/tasks.repository.ts +14 -0
- package/templates/backend/src/modules/tasks/tasks.service.ts +76 -0
- package/templates/backend/src/modules/users/schemas/user.schema.ts +3 -0
- package/templates/backend/src/modules/users/users.repository.ts +8 -0
- package/templates/backend/src/modules/users/users.service.ts +34 -0
- package/templates/frontend/.env.local.example +1 -1
- package/templates/frontend/README.md +43 -1
- package/templates/frontend/package.json +48 -45
- package/templates/frontend/pnpm-lock.yaml +5096 -5012
- package/templates/frontend/src/app/(auth)/layout.tsx +7 -1
- package/templates/frontend/src/app/(auth)/login/page.tsx +13 -0
- package/templates/frontend/src/app/(auth)/register/page.tsx +13 -0
- package/templates/frontend/src/app/(dashboard)/clients/page.tsx +295 -0
- package/templates/frontend/src/app/(dashboard)/invoices/page.tsx +305 -0
- package/templates/frontend/src/app/(dashboard)/notifications/page.tsx +173 -0
- package/templates/frontend/src/app/(dashboard)/pipeline/page.tsx +244 -0
- package/templates/frontend/src/app/(dashboard)/planner/page.tsx +287 -0
- package/templates/frontend/src/app/(dashboard)/settings/page.tsx +165 -128
- package/templates/frontend/src/app/(dashboard)/tasks/page.tsx +366 -0
- package/templates/frontend/src/app/auth/github/callback/page.tsx +82 -0
- package/templates/frontend/src/app/landing/page.tsx +21 -0
- package/templates/frontend/src/app/page.tsx +5 -5
- package/templates/frontend/src/app/setup/page.tsx +15 -14
- package/templates/frontend/src/components/auth/github-button.tsx +25 -0
- package/templates/frontend/src/components/dashboard/sidebar.tsx +90 -71
- package/templates/frontend/src/components/ui/alert-dialog.tsx +141 -0
- package/templates/frontend/src/components/ui/button.tsx +56 -52
- package/templates/frontend/src/components/ui/popover.tsx +31 -0
- package/templates/frontend/src/components/ui/select.tsx +160 -0
- package/templates/frontend/src/components/ui/sheet.tsx +140 -0
- package/templates/frontend/src/lib/api/auth.ts +7 -0
- package/templates/frontend/src/lib/api/clients.ts +17 -0
- package/templates/frontend/src/lib/api/invoices.ts +18 -0
- package/templates/frontend/src/lib/api/notifications.ts +27 -0
- package/templates/frontend/src/lib/api/pipeline.ts +18 -0
- package/templates/frontend/src/lib/api/planner.ts +26 -0
- package/templates/frontend/src/lib/api/task-columns.ts +17 -0
- package/templates/frontend/src/lib/api/tasks.ts +21 -0
- package/templates/frontend/src/lib/hooks/use-unread-notifications.ts +23 -0
- package/templates/frontend/src/providers/auth-provider.tsx +7 -1
- package/templates/frontend/src/types/clients.ts +38 -0
- package/templates/frontend/src/types/invoices.ts +51 -0
- package/templates/frontend/src/types/notifications.ts +30 -0
- package/templates/frontend/src/types/pipeline.ts +35 -0
- package/templates/frontend/src/types/planner.ts +49 -0
- 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
|
+
}
|