@hed-hog/billing 0.0.2 → 0.0.285

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 (51) hide show
  1. package/README.md +420 -0
  2. package/dist/billing-contracts.controller.d.ts +85 -4
  3. package/dist/billing-contracts.controller.d.ts.map +1 -1
  4. package/dist/billing-coupons.controller.d.ts +60 -4
  5. package/dist/billing-coupons.controller.d.ts.map +1 -1
  6. package/dist/billing-dashboard.controller.d.ts +20 -5
  7. package/dist/billing-dashboard.controller.d.ts.map +1 -1
  8. package/dist/billing-entitlements.controller.d.ts +28 -2
  9. package/dist/billing-entitlements.controller.d.ts.map +1 -1
  10. package/dist/billing-gateways.controller.d.ts +18 -2
  11. package/dist/billing-gateways.controller.d.ts.map +1 -1
  12. package/dist/billing-invoices.controller.d.ts +56 -2
  13. package/dist/billing-invoices.controller.d.ts.map +1 -1
  14. package/dist/billing-offers.controller.d.ts +61 -4
  15. package/dist/billing-offers.controller.d.ts.map +1 -1
  16. package/dist/billing-orders.controller.d.ts +39 -2
  17. package/dist/billing-orders.controller.d.ts.map +1 -1
  18. package/dist/billing-payments.controller.d.ts +16 -1
  19. package/dist/billing-payments.controller.d.ts.map +1 -1
  20. package/dist/billing-prices.controller.d.ts +80 -4
  21. package/dist/billing-prices.controller.d.ts.map +1 -1
  22. package/dist/billing-products.controller.d.ts +62 -4
  23. package/dist/billing-products.controller.d.ts.map +1 -1
  24. package/dist/billing-subscriptions.controller.d.ts +138 -5
  25. package/dist/billing-subscriptions.controller.d.ts.map +1 -1
  26. package/dist/billing.service.d.ts +663 -39
  27. package/dist/billing.service.d.ts.map +1 -1
  28. package/dist/billing.service.js +135 -16
  29. package/dist/billing.service.js.map +1 -1
  30. package/hedhog/data/menu.yaml +1 -1
  31. package/hedhog/frontend/app/contracts/page.tsx.ejs +68 -64
  32. package/hedhog/frontend/app/coupons/page.tsx.ejs +81 -77
  33. package/hedhog/frontend/app/entitlements/page.tsx.ejs +59 -58
  34. package/hedhog/frontend/app/gateways/page.tsx.ejs +125 -57
  35. package/hedhog/frontend/app/invoices/page.tsx.ejs +49 -43
  36. package/hedhog/frontend/app/offers/page.tsx.ejs +68 -64
  37. package/hedhog/frontend/app/orders/page.tsx.ejs +47 -46
  38. package/hedhog/frontend/app/page.tsx.ejs +186 -186
  39. package/hedhog/frontend/app/payments/page.tsx.ejs +51 -45
  40. package/hedhog/frontend/app/prices/page.tsx.ejs +81 -75
  41. package/hedhog/frontend/app/products/page.tsx.ejs +79 -73
  42. package/hedhog/frontend/app/refunds/page.tsx.ejs +50 -44
  43. package/hedhog/frontend/app/reports/page.tsx.ejs +1 -1
  44. package/hedhog/frontend/app/seats/page.tsx.ejs +826 -0
  45. package/hedhog/frontend/app/subscriptions/page.tsx.ejs +95 -90
  46. package/hedhog/frontend/app/webhooks/page.tsx.ejs +47 -39
  47. package/hedhog/frontend/messages/en.json +640 -551
  48. package/hedhog/frontend/messages/pt.json +652 -563
  49. package/hedhog/table/billing_payment_method.yaml +1 -1
  50. package/package.json +4 -3
  51. package/src/billing.service.ts +299 -17
@@ -0,0 +1,826 @@
1
+ 'use client';
2
+
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PageHeader,
7
+ PaginationFooter,
8
+ SearchBar,
9
+ StatsCards,
10
+ type StatCardConfig,
11
+ } from '@/components/entity-list';
12
+ import {
13
+ AlertDialog,
14
+ AlertDialogAction,
15
+ AlertDialogCancel,
16
+ AlertDialogContent,
17
+ AlertDialogDescription,
18
+ AlertDialogFooter,
19
+ AlertDialogHeader,
20
+ AlertDialogTitle,
21
+ } from '@/components/ui/alert-dialog';
22
+ import { Badge } from '@/components/ui/badge';
23
+ import { Button } from '@/components/ui/button';
24
+ import {
25
+ Form,
26
+ FormControl,
27
+ FormField,
28
+ FormItem,
29
+ FormLabel,
30
+ FormMessage,
31
+ } from '@/components/ui/form';
32
+ import { Input } from '@/components/ui/input';
33
+ import {
34
+ Select,
35
+ SelectContent,
36
+ SelectItem,
37
+ SelectTrigger,
38
+ SelectValue,
39
+ } from '@/components/ui/select';
40
+ import {
41
+ Sheet,
42
+ SheetContent,
43
+ SheetDescription,
44
+ SheetHeader,
45
+ SheetTitle,
46
+ } from '@/components/ui/sheet';
47
+ import {
48
+ Table,
49
+ TableBody,
50
+ TableCell,
51
+ TableHead,
52
+ TableHeader,
53
+ TableRow,
54
+ } from '@/components/ui/table';
55
+ import { Textarea } from '@/components/ui/textarea';
56
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
57
+ import { zodResolver } from '@hookform/resolvers/zod';
58
+ import {
59
+ BadgeCheck,
60
+ Building2,
61
+ KeyRound,
62
+ Pencil,
63
+ Plus,
64
+ Trash2,
65
+ UserRound,
66
+ } from 'lucide-react';
67
+ import { useTranslations } from 'next-intl';
68
+ import { useEffect, useState } from 'react';
69
+ import { useForm } from 'react-hook-form';
70
+ import { z } from 'zod';
71
+
72
+ type ContractOption = {
73
+ id: number;
74
+ name: string;
75
+ company_name: string;
76
+ total_seats: number;
77
+ };
78
+
79
+ type ContractSeatOption = {
80
+ id: number;
81
+ contract_id: number;
82
+ label: string;
83
+ product_name: string;
84
+ };
85
+
86
+ type SeatAllocationStatus = 'active' | 'pending' | 'released';
87
+
88
+ type SeatAllocation = {
89
+ id: number;
90
+ contract_id: number;
91
+ contract_name: string;
92
+ company_name: string;
93
+ seat_id: number;
94
+ seat_label: string;
95
+ product_name: string;
96
+ assigned_to: string;
97
+ assigned_email: string;
98
+ status: SeatAllocationStatus;
99
+ allocated_at: string;
100
+ released_at?: string | null;
101
+ notes?: string;
102
+ };
103
+
104
+ type SeatFormValues = z.infer<typeof seatSchema>;
105
+
106
+ const CONTRACTS: ContractOption[] = [
107
+ {
108
+ id: 101,
109
+ name: 'Contrato Enterprise 2026',
110
+ company_name: 'Acme Industrial',
111
+ total_seats: 40,
112
+ },
113
+ {
114
+ id: 102,
115
+ name: 'Contrato Growth 2026',
116
+ company_name: 'Orbit Labs',
117
+ total_seats: 18,
118
+ },
119
+ {
120
+ id: 103,
121
+ name: 'Contrato Support 2026',
122
+ company_name: 'Nexa Services',
123
+ total_seats: 12,
124
+ },
125
+ ];
126
+
127
+ const CONTRACT_SEATS: ContractSeatOption[] = [
128
+ {
129
+ id: 1001,
130
+ contract_id: 101,
131
+ label: 'Admin Platform Seat',
132
+ product_name: 'HedHog Admin Enterprise',
133
+ },
134
+ {
135
+ id: 1002,
136
+ contract_id: 101,
137
+ label: 'Analytics Seat',
138
+ product_name: 'HedHog Analytics',
139
+ },
140
+ {
141
+ id: 1003,
142
+ contract_id: 102,
143
+ label: 'Team Manager Seat',
144
+ product_name: 'HedHog Growth',
145
+ },
146
+ {
147
+ id: 1004,
148
+ contract_id: 102,
149
+ label: 'Contributor Seat',
150
+ product_name: 'HedHog Growth',
151
+ },
152
+ {
153
+ id: 1005,
154
+ contract_id: 103,
155
+ label: 'Support Operator Seat',
156
+ product_name: 'HedHog Support Desk',
157
+ },
158
+ ];
159
+
160
+ const MOCK_ALLOCATIONS: SeatAllocation[] = [
161
+ {
162
+ id: 1,
163
+ contract_id: 101,
164
+ contract_name: 'Contrato Enterprise 2026',
165
+ company_name: 'Acme Industrial',
166
+ seat_id: 1001,
167
+ seat_label: 'Admin Platform Seat',
168
+ product_name: 'HedHog Admin Enterprise',
169
+ assigned_to: 'Marina Souza',
170
+ assigned_email: 'marina.souza@acme.example',
171
+ status: 'active',
172
+ allocated_at: '2026-02-04',
173
+ notes: 'Lider do time financeiro.',
174
+ },
175
+ {
176
+ id: 2,
177
+ contract_id: 101,
178
+ contract_name: 'Contrato Enterprise 2026',
179
+ company_name: 'Acme Industrial',
180
+ seat_id: 1002,
181
+ seat_label: 'Analytics Seat',
182
+ product_name: 'HedHog Analytics',
183
+ assigned_to: 'Bruno Lima',
184
+ assigned_email: 'bruno.lima@acme.example',
185
+ status: 'pending',
186
+ allocated_at: '2026-03-02',
187
+ notes: 'Aguardando ativacao do SSO.',
188
+ },
189
+ {
190
+ id: 3,
191
+ contract_id: 102,
192
+ contract_name: 'Contrato Growth 2026',
193
+ company_name: 'Orbit Labs',
194
+ seat_id: 1003,
195
+ seat_label: 'Team Manager Seat',
196
+ product_name: 'HedHog Growth',
197
+ assigned_to: 'Carla Nascimento',
198
+ assigned_email: 'carla@orbit.example',
199
+ status: 'active',
200
+ allocated_at: '2026-01-19',
201
+ notes: 'Gestora principal do workspace.',
202
+ },
203
+ {
204
+ id: 4,
205
+ contract_id: 103,
206
+ contract_name: 'Contrato Support 2026',
207
+ company_name: 'Nexa Services',
208
+ seat_id: 1005,
209
+ seat_label: 'Support Operator Seat',
210
+ product_name: 'HedHog Support Desk',
211
+ assigned_to: 'Thiago Martins',
212
+ assigned_email: 'thiago@nexa.example',
213
+ status: 'released',
214
+ allocated_at: '2026-01-10',
215
+ released_at: '2026-02-25',
216
+ notes: 'Assento liberado apos troca de equipe.',
217
+ },
218
+ ];
219
+
220
+ const seatSchema = z.object({
221
+ contract_id: z.coerce.number().min(1),
222
+ seat_id: z.coerce.number().min(1),
223
+ assigned_to: z.string().trim().min(2),
224
+ assigned_email: z.string().trim().email(),
225
+ status: z.enum(['active', 'pending', 'released']),
226
+ allocated_at: z.string().min(1),
227
+ notes: z.string().optional(),
228
+ });
229
+
230
+ const defaultValues: SeatFormValues = {
231
+ contract_id: CONTRACTS[0].id,
232
+ seat_id: CONTRACT_SEATS[0].id,
233
+ assigned_to: '',
234
+ assigned_email: '',
235
+ status: 'active',
236
+ allocated_at: new Date().toISOString().slice(0, 10),
237
+ notes: '',
238
+ };
239
+
240
+ const badgeClass = (value: SeatAllocationStatus) => {
241
+ if (value === 'active') {
242
+ return 'bg-green-100 text-green-800';
243
+ }
244
+
245
+ if (value === 'pending') {
246
+ return 'bg-yellow-100 text-yellow-800';
247
+ }
248
+
249
+ return 'bg-gray-100 text-gray-800';
250
+ };
251
+
252
+ const formatDate = (value?: string | null) => {
253
+ if (!value) {
254
+ return '-';
255
+ }
256
+
257
+ return new Intl.DateTimeFormat('pt-BR', {
258
+ dateStyle: 'short',
259
+ }).format(new Date(value));
260
+ };
261
+
262
+ const buildAllocationFromValues = (
263
+ values: SeatFormValues,
264
+ currentId: number,
265
+ previous?: SeatAllocation | null
266
+ ): SeatAllocation => {
267
+ const contract = CONTRACTS.find((item) => item.id === values.contract_id);
268
+ const seat = CONTRACT_SEATS.find((item) => item.id === values.seat_id);
269
+
270
+ return {
271
+ id: currentId,
272
+ contract_id: values.contract_id,
273
+ contract_name: contract?.name ?? '-',
274
+ company_name: contract?.company_name ?? '-',
275
+ seat_id: values.seat_id,
276
+ seat_label: seat?.label ?? '-',
277
+ product_name: seat?.product_name ?? '-',
278
+ assigned_to: values.assigned_to.trim(),
279
+ assigned_email: values.assigned_email.trim(),
280
+ status: values.status,
281
+ allocated_at: values.allocated_at,
282
+ released_at:
283
+ values.status === 'released'
284
+ ? (previous?.released_at ?? new Date().toISOString().slice(0, 10))
285
+ : null,
286
+ notes: values.notes?.trim() || '',
287
+ };
288
+ };
289
+
290
+ export default function BillingSeatsPage() {
291
+ const t = useTranslations('billing.BillingSeatsPage');
292
+ const { showToastHandler } = useApp();
293
+ const [search, setSearch] = useState('');
294
+ const [page, setPage] = useState(1);
295
+ const [pageSize, setPageSize] = useState(12);
296
+ const [sheetOpen, setSheetOpen] = useState(false);
297
+ const [editingItem, setEditingItem] = useState<SeatAllocation | null>(null);
298
+ const [releaseId, setReleaseId] = useState<number | null>(null);
299
+ const [allocations, setAllocations] = useState<SeatAllocation[]>([]);
300
+
301
+ const form = useForm<SeatFormValues>({
302
+ resolver: zodResolver(seatSchema),
303
+ defaultValues,
304
+ });
305
+
306
+ const { data } = useQuery<SeatAllocation[]>({
307
+ queryKey: ['billing-seats-local'],
308
+ queryFn: async () => MOCK_ALLOCATIONS,
309
+ placeholderData: MOCK_ALLOCATIONS,
310
+ });
311
+
312
+ useEffect(() => {
313
+ setAllocations(data ?? MOCK_ALLOCATIONS);
314
+ }, [data]);
315
+
316
+ useEffect(() => {
317
+ if (!sheetOpen) {
318
+ return;
319
+ }
320
+
321
+ if (editingItem) {
322
+ form.reset({
323
+ contract_id: editingItem.contract_id,
324
+ seat_id: editingItem.seat_id,
325
+ assigned_to: editingItem.assigned_to,
326
+ assigned_email: editingItem.assigned_email,
327
+ status: editingItem.status,
328
+ allocated_at: editingItem.allocated_at,
329
+ notes: editingItem.notes ?? '',
330
+ });
331
+ return;
332
+ }
333
+
334
+ form.reset(defaultValues);
335
+ }, [editingItem, form, sheetOpen]);
336
+
337
+ const selectedContractId = form.watch('contract_id');
338
+ const seatOptions = CONTRACT_SEATS.filter(
339
+ (item) => item.contract_id === selectedContractId
340
+ );
341
+
342
+ const filteredItems = allocations.filter((item) => {
343
+ const query = search.trim().toLowerCase();
344
+ if (!query) {
345
+ return true;
346
+ }
347
+
348
+ return [
349
+ item.contract_name,
350
+ item.company_name,
351
+ item.product_name,
352
+ item.seat_label,
353
+ item.assigned_to,
354
+ item.assigned_email,
355
+ item.status,
356
+ ].some((value) => value.toLowerCase().includes(query));
357
+ });
358
+
359
+ const totalItems = filteredItems.length;
360
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
361
+ const currentPage = Math.min(page, totalPages);
362
+ const pagedItems = filteredItems.slice(
363
+ (currentPage - 1) * pageSize,
364
+ currentPage * pageSize
365
+ );
366
+
367
+ useEffect(() => {
368
+ if (page > totalPages) {
369
+ setPage(totalPages);
370
+ }
371
+ }, [page, totalPages]);
372
+
373
+ const contractedSeats = CONTRACTS.reduce(
374
+ (sum, contract) => sum + contract.total_seats,
375
+ 0
376
+ );
377
+ const allocatedSeats = allocations.filter(
378
+ (item) => item.status === 'active' || item.status === 'pending'
379
+ ).length;
380
+ const releasedSeats = allocations.filter(
381
+ (item) => item.status === 'released'
382
+ ).length;
383
+ const availableSeats = Math.max(contractedSeats - allocatedSeats, 0);
384
+
385
+ const stats: StatCardConfig[] = [
386
+ {
387
+ title: t('stats.contractedSeats'),
388
+ value: contractedSeats,
389
+ icon: <KeyRound className="size-5" />,
390
+ iconBgColor: 'bg-blue-50',
391
+ iconColor: 'text-blue-600',
392
+ },
393
+ {
394
+ title: t('stats.allocatedSeats'),
395
+ value: allocatedSeats,
396
+ icon: <BadgeCheck className="size-5" />,
397
+ iconBgColor: 'bg-green-50',
398
+ iconColor: 'text-green-600',
399
+ },
400
+ {
401
+ title: t('stats.availableSeats'),
402
+ value: availableSeats,
403
+ icon: <UserRound className="size-5" />,
404
+ iconBgColor: 'bg-amber-50',
405
+ iconColor: 'text-amber-600',
406
+ },
407
+ {
408
+ title: t('stats.releasedSeats'),
409
+ value: releasedSeats,
410
+ icon: <Building2 className="size-5" />,
411
+ iconBgColor: 'bg-slate-100',
412
+ iconColor: 'text-slate-700',
413
+ },
414
+ ];
415
+
416
+ const handleSubmit = async (values: SeatFormValues) => {
417
+ try {
418
+ if (editingItem) {
419
+ setAllocations((current) =>
420
+ current.map((item) =>
421
+ item.id === editingItem.id
422
+ ? buildAllocationFromValues(values, editingItem.id, item)
423
+ : item
424
+ )
425
+ );
426
+ } else {
427
+ const nextId =
428
+ allocations.reduce((max, item) => Math.max(max, item.id), 0) + 1;
429
+ setAllocations((current) => [
430
+ buildAllocationFromValues(values, nextId),
431
+ ...current,
432
+ ]);
433
+ }
434
+
435
+ showToastHandler?.('success', t('messages.saveSuccess'));
436
+ setSheetOpen(false);
437
+ setEditingItem(null);
438
+ form.reset(defaultValues);
439
+ } catch {
440
+ showToastHandler?.('error', t('messages.saveError'));
441
+ }
442
+ };
443
+
444
+ const handleRelease = async () => {
445
+ if (!releaseId) {
446
+ return;
447
+ }
448
+
449
+ try {
450
+ setAllocations((current) =>
451
+ current.map((item) => {
452
+ if (item.id !== releaseId) {
453
+ return item;
454
+ }
455
+
456
+ return {
457
+ ...item,
458
+ status: 'released',
459
+ released_at: new Date().toISOString().slice(0, 10),
460
+ };
461
+ })
462
+ );
463
+ setReleaseId(null);
464
+ showToastHandler?.('success', t('messages.deleteSuccess'));
465
+ } catch {
466
+ showToastHandler?.('error', t('messages.deleteError'));
467
+ }
468
+ };
469
+
470
+ return (
471
+ <Page>
472
+ <PageHeader
473
+ title={t('title')}
474
+ description={t('description')}
475
+ breadcrumbs={[
476
+ { label: t('breadcrumbs.home'), href: '/' },
477
+ { label: t('breadcrumbs.billing'), href: '/billing' },
478
+ { label: t('breadcrumbs.seats') },
479
+ ]}
480
+ actions={[
481
+ {
482
+ label: t('actions.create'),
483
+ onClick: () => {
484
+ setEditingItem(null);
485
+ setSheetOpen(true);
486
+ },
487
+ icon: <Plus className="size-4" />,
488
+ },
489
+ ]}
490
+ />
491
+
492
+ <StatsCards stats={stats} />
493
+
494
+ <SearchBar
495
+ searchQuery={search}
496
+ onSearchChange={(value) => {
497
+ setSearch(value);
498
+ setPage(1);
499
+ }}
500
+ onSearch={() => setPage(1)}
501
+ placeholder={t('filters.searchPlaceholder')}
502
+ />
503
+
504
+ <div className="rounded-md border border-dashed bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
505
+ {t('messages.localMode')}
506
+ </div>
507
+
508
+ <div className="overflow-x-auto rounded-md border">
509
+ <Table>
510
+ <TableHeader>
511
+ <TableRow>
512
+ <TableHead>{t('table.columns.contract')}</TableHead>
513
+ <TableHead>{t('table.columns.company')}</TableHead>
514
+ <TableHead>{t('table.columns.seat')}</TableHead>
515
+ <TableHead>{t('table.columns.assignedTo')}</TableHead>
516
+ <TableHead>{t('table.columns.status')}</TableHead>
517
+ <TableHead>{t('table.columns.allocatedAt')}</TableHead>
518
+ <TableHead className="w-[120px] text-right">
519
+ {t('table.columns.actions')}
520
+ </TableHead>
521
+ </TableRow>
522
+ </TableHeader>
523
+ <TableBody>
524
+ {pagedItems.map((item) => (
525
+ <TableRow key={item.id}>
526
+ <TableCell>
527
+ <div className="font-medium">{item.contract_name}</div>
528
+ <div className="text-xs text-muted-foreground">
529
+ {item.product_name}
530
+ </div>
531
+ </TableCell>
532
+ <TableCell>{item.company_name}</TableCell>
533
+ <TableCell>{item.seat_label}</TableCell>
534
+ <TableCell>
535
+ <div className="font-medium">{item.assigned_to}</div>
536
+ <div className="text-xs text-muted-foreground">
537
+ {item.assigned_email}
538
+ </div>
539
+ </TableCell>
540
+ <TableCell>
541
+ <Badge className={badgeClass(item.status)}>
542
+ {t(`status.${item.status}`)}
543
+ </Badge>
544
+ </TableCell>
545
+ <TableCell>{formatDate(item.allocated_at)}</TableCell>
546
+ <TableCell>
547
+ <div className="flex justify-end gap-2">
548
+ <Button
549
+ variant="outline"
550
+ size="icon"
551
+ onClick={() => {
552
+ setEditingItem(item);
553
+ setSheetOpen(true);
554
+ }}
555
+ >
556
+ <Pencil className="size-4" />
557
+ </Button>
558
+ <Button
559
+ variant="destructive"
560
+ size="icon"
561
+ disabled={item.status === 'released'}
562
+ onClick={() => setReleaseId(item.id)}
563
+ >
564
+ <Trash2 className="size-4" />
565
+ </Button>
566
+ </div>
567
+ </TableCell>
568
+ </TableRow>
569
+ ))}
570
+ </TableBody>
571
+ </Table>
572
+ </div>
573
+
574
+ {pagedItems.length === 0 && (
575
+ <EmptyState
576
+ icon={<KeyRound className="h-12 w-12" />}
577
+ title={t('table.empty')}
578
+ description={t('empty.description')}
579
+ actionLabel={t('empty.action')}
580
+ onAction={() => {
581
+ setSearch('');
582
+ setPage(1);
583
+ }}
584
+ />
585
+ )}
586
+
587
+ <PaginationFooter
588
+ currentPage={currentPage}
589
+ pageSize={pageSize}
590
+ totalItems={totalItems}
591
+ onPageChange={setPage}
592
+ onPageSizeChange={(nextSize) => {
593
+ setPageSize(nextSize);
594
+ setPage(1);
595
+ }}
596
+ />
597
+
598
+ <Sheet
599
+ open={sheetOpen}
600
+ onOpenChange={(open) => {
601
+ setSheetOpen(open);
602
+ if (!open) {
603
+ setEditingItem(null);
604
+ }
605
+ }}
606
+ >
607
+ <SheetContent className="w-full sm:max-w-xl">
608
+ <SheetHeader>
609
+ <SheetTitle>
610
+ {editingItem ? t('sheet.editTitle') : t('sheet.createTitle')}
611
+ </SheetTitle>
612
+ <SheetDescription>{t('sheet.description')}</SheetDescription>
613
+ </SheetHeader>
614
+
615
+ <Form {...form}>
616
+ <form
617
+ className="space-y-4 p-4"
618
+ onSubmit={form.handleSubmit(handleSubmit)}
619
+ >
620
+ <div className="grid gap-4 md:grid-cols-2">
621
+ <FormField
622
+ control={form.control}
623
+ name="contract_id"
624
+ render={({ field }) => (
625
+ <FormItem>
626
+ <FormLabel>{t('form.contract')}</FormLabel>
627
+ <Select
628
+ value={String(field.value)}
629
+ onValueChange={(value) => {
630
+ const contractId = Number(value);
631
+ field.onChange(contractId);
632
+ const nextSeat = CONTRACT_SEATS.find(
633
+ (item) => item.contract_id === contractId
634
+ );
635
+ form.setValue('seat_id', nextSeat?.id ?? 0, {
636
+ shouldValidate: true,
637
+ });
638
+ }}
639
+ >
640
+ <FormControl>
641
+ <SelectTrigger className="w-full">
642
+ <SelectValue
643
+ placeholder={t('form.contractPlaceholder')}
644
+ />
645
+ </SelectTrigger>
646
+ </FormControl>
647
+ <SelectContent>
648
+ {CONTRACTS.map((contract) => (
649
+ <SelectItem
650
+ key={contract.id}
651
+ value={String(contract.id)}
652
+ >
653
+ {contract.name}
654
+ </SelectItem>
655
+ ))}
656
+ </SelectContent>
657
+ </Select>
658
+ <FormMessage />
659
+ </FormItem>
660
+ )}
661
+ />
662
+
663
+ <FormField
664
+ control={form.control}
665
+ name="seat_id"
666
+ render={({ field }) => (
667
+ <FormItem>
668
+ <FormLabel>{t('form.seat')}</FormLabel>
669
+ <Select
670
+ value={String(field.value)}
671
+ onValueChange={(value) => field.onChange(Number(value))}
672
+ >
673
+ <FormControl>
674
+ <SelectTrigger className="w-full">
675
+ <SelectValue
676
+ placeholder={t('form.seatPlaceholder')}
677
+ />
678
+ </SelectTrigger>
679
+ </FormControl>
680
+ <SelectContent>
681
+ {seatOptions.map((seat) => (
682
+ <SelectItem key={seat.id} value={String(seat.id)}>
683
+ {seat.label}
684
+ </SelectItem>
685
+ ))}
686
+ </SelectContent>
687
+ </Select>
688
+ <FormMessage />
689
+ </FormItem>
690
+ )}
691
+ />
692
+ </div>
693
+
694
+ <div className="grid gap-4 md:grid-cols-2">
695
+ <FormField
696
+ control={form.control}
697
+ name="assigned_to"
698
+ render={({ field }) => (
699
+ <FormItem>
700
+ <FormLabel>{t('form.assignedTo')}</FormLabel>
701
+ <FormControl>
702
+ <Input
703
+ {...field}
704
+ placeholder={t('form.assignedToPlaceholder')}
705
+ />
706
+ </FormControl>
707
+ <FormMessage />
708
+ </FormItem>
709
+ )}
710
+ />
711
+
712
+ <FormField
713
+ control={form.control}
714
+ name="assigned_email"
715
+ render={({ field }) => (
716
+ <FormItem>
717
+ <FormLabel>{t('form.assignedEmail')}</FormLabel>
718
+ <FormControl>
719
+ <Input
720
+ {...field}
721
+ type="email"
722
+ placeholder={t('form.assignedEmailPlaceholder')}
723
+ />
724
+ </FormControl>
725
+ <FormMessage />
726
+ </FormItem>
727
+ )}
728
+ />
729
+ </div>
730
+
731
+ <div className="grid gap-4 md:grid-cols-2">
732
+ <FormField
733
+ control={form.control}
734
+ name="status"
735
+ render={({ field }) => (
736
+ <FormItem>
737
+ <FormLabel>{t('form.status')}</FormLabel>
738
+ <Select
739
+ value={field.value}
740
+ onValueChange={field.onChange}
741
+ >
742
+ <FormControl>
743
+ <SelectTrigger className="w-full">
744
+ <SelectValue />
745
+ </SelectTrigger>
746
+ </FormControl>
747
+ <SelectContent>
748
+ <SelectItem value="active">
749
+ {t('status.active')}
750
+ </SelectItem>
751
+ <SelectItem value="pending">
752
+ {t('status.pending')}
753
+ </SelectItem>
754
+ <SelectItem value="released">
755
+ {t('status.released')}
756
+ </SelectItem>
757
+ </SelectContent>
758
+ </Select>
759
+ <FormMessage />
760
+ </FormItem>
761
+ )}
762
+ />
763
+
764
+ <FormField
765
+ control={form.control}
766
+ name="allocated_at"
767
+ render={({ field }) => (
768
+ <FormItem>
769
+ <FormLabel>{t('form.allocatedAt')}</FormLabel>
770
+ <FormControl>
771
+ <Input type="date" {...field} />
772
+ </FormControl>
773
+ <FormMessage />
774
+ </FormItem>
775
+ )}
776
+ />
777
+ </div>
778
+
779
+ <FormField
780
+ control={form.control}
781
+ name="notes"
782
+ render={({ field }) => (
783
+ <FormItem>
784
+ <FormLabel>{t('form.notes')}</FormLabel>
785
+ <FormControl>
786
+ <Textarea
787
+ {...field}
788
+ value={field.value ?? ''}
789
+ placeholder={t('form.notesPlaceholder')}
790
+ />
791
+ </FormControl>
792
+ <FormMessage />
793
+ </FormItem>
794
+ )}
795
+ />
796
+
797
+ <Button className="w-full" type="submit">
798
+ {t('actions.save')}
799
+ </Button>
800
+ </form>
801
+ </Form>
802
+ </SheetContent>
803
+ </Sheet>
804
+
805
+ <AlertDialog
806
+ open={releaseId !== null}
807
+ onOpenChange={(open) => !open && setReleaseId(null)}
808
+ >
809
+ <AlertDialogContent>
810
+ <AlertDialogHeader>
811
+ <AlertDialogTitle>{t('deleteDialog.title')}</AlertDialogTitle>
812
+ <AlertDialogDescription>
813
+ {t('deleteDialog.description')}
814
+ </AlertDialogDescription>
815
+ </AlertDialogHeader>
816
+ <AlertDialogFooter>
817
+ <AlertDialogCancel>{t('deleteDialog.cancel')}</AlertDialogCancel>
818
+ <AlertDialogAction onClick={() => void handleRelease()}>
819
+ {t('deleteDialog.confirm')}
820
+ </AlertDialogAction>
821
+ </AlertDialogFooter>
822
+ </AlertDialogContent>
823
+ </AlertDialog>
824
+ </Page>
825
+ );
826
+ }