@hed-hog/contact 0.0.301 → 0.0.303

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 (36) hide show
  1. package/dist/person/person.service.d.ts +2 -0
  2. package/dist/person/person.service.d.ts.map +1 -1
  3. package/dist/person/person.service.js +111 -127
  4. package/dist/person/person.service.js.map +1 -1
  5. package/dist/person/person.service.spec.d.ts +2 -0
  6. package/dist/person/person.service.spec.d.ts.map +1 -0
  7. package/dist/person/person.service.spec.js +106 -0
  8. package/dist/person/person.service.spec.js.map +1 -0
  9. package/dist/proposal/proposal.service.d.ts +5 -0
  10. package/dist/proposal/proposal.service.d.ts.map +1 -1
  11. package/dist/proposal/proposal.service.js +242 -19
  12. package/dist/proposal/proposal.service.js.map +1 -1
  13. package/dist/proposal/proposal.service.spec.js +153 -165
  14. package/dist/proposal/proposal.service.spec.js.map +1 -1
  15. package/hedhog/data/menu.yaml +35 -18
  16. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +517 -346
  17. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +42 -17
  18. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +1 -1
  19. package/hedhog/frontend/app/activities/page.tsx.ejs +315 -101
  20. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +172 -22
  21. package/hedhog/frontend/app/page.tsx.ejs +1 -1
  22. package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1 -1
  23. package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +1 -1
  24. package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +509 -441
  25. package/hedhog/frontend/app/pipeline/page.tsx.ejs +30 -4
  26. package/hedhog/frontend/app/proposals/_components/proposals-management-page.tsx.ejs +773 -0
  27. package/hedhog/frontend/app/proposals/approvals/page.tsx.ejs +5 -0
  28. package/hedhog/frontend/app/proposals/page.tsx.ejs +5 -0
  29. package/hedhog/frontend/app/reports/page.tsx.ejs +431 -375
  30. package/hedhog/frontend/messages/en.json +100 -1
  31. package/hedhog/frontend/messages/pt.json +100 -1
  32. package/package.json +6 -6
  33. package/src/person/person.service.spec.ts +143 -0
  34. package/src/person/person.service.ts +147 -158
  35. package/src/proposal/proposal.service.spec.ts +196 -0
  36. package/src/proposal/proposal.service.ts +348 -18
@@ -0,0 +1,773 @@
1
+ 'use client';
2
+
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PageHeader,
7
+ PaginationFooter,
8
+ SearchBar,
9
+ type SearchBarControl,
10
+ } from '@/components/entity-list';
11
+ import { Badge } from '@/components/ui/badge';
12
+ import { Button } from '@/components/ui/button';
13
+ import { Card, CardContent } from '@/components/ui/card';
14
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
15
+ import { Skeleton } from '@/components/ui/skeleton';
16
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
17
+ import {
18
+ Table,
19
+ TableBody,
20
+ TableCell,
21
+ TableHead,
22
+ TableHeader,
23
+ TableRow,
24
+ } from '@/components/ui/table';
25
+ import { cn } from '@/lib/utils';
26
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
27
+ import {
28
+ CheckCircle2,
29
+ Clock3,
30
+ FileCheck2,
31
+ FileText,
32
+ LayoutGrid,
33
+ List,
34
+ RefreshCcw,
35
+ } from 'lucide-react';
36
+ import { useTranslations } from 'next-intl';
37
+ import { useEffect, useMemo, useState } from 'react';
38
+ import { toast } from 'sonner';
39
+
40
+ import type { PaginatedResult } from '../../person/_components/person-types';
41
+
42
+ type ProposalStatus =
43
+ | 'draft'
44
+ | 'pending_approval'
45
+ | 'approved'
46
+ | 'rejected'
47
+ | 'cancelled'
48
+ | 'expired'
49
+ | 'contract_generated';
50
+
51
+ type ProposalStatusFilter = 'all' | ProposalStatus;
52
+
53
+ type ProposalPerson = {
54
+ id: number;
55
+ name?: string | null;
56
+ trade_name?: string | null;
57
+ email?: string | null;
58
+ phone?: string | null;
59
+ };
60
+
61
+ type ProposalRecord = {
62
+ id: number;
63
+ person_id: number;
64
+ code?: string | null;
65
+ title: string;
66
+ status: ProposalStatus;
67
+ currency_code?: string | null;
68
+ total_amount_cents?: number | null;
69
+ valid_until?: string | null;
70
+ updated_at?: string | null;
71
+ approved_at?: string | null;
72
+ current_revision_number?: number | null;
73
+ person?: ProposalPerson | null;
74
+ };
75
+
76
+ type ProposalStats = {
77
+ total: number;
78
+ draft: number;
79
+ pendingApproval: number;
80
+ approved: number;
81
+ rejected: number;
82
+ contractGenerated: number;
83
+ };
84
+
85
+ type ProposalsManagementPageProps = {
86
+ defaultStatus?: ProposalStatusFilter;
87
+ };
88
+
89
+ type ProposalViewMode = 'table' | 'cards';
90
+
91
+ const PROPOSALS_VIEW_STORAGE_KEY = 'contact-proposals-view-mode';
92
+
93
+ function formatMoney(
94
+ valueInCents?: number | null,
95
+ locale = 'en-US',
96
+ currency = 'BRL'
97
+ ) {
98
+ return new Intl.NumberFormat(locale, {
99
+ style: 'currency',
100
+ currency: currency || 'BRL',
101
+ minimumFractionDigits: 2,
102
+ maximumFractionDigits: 2,
103
+ }).format(Number(valueInCents || 0) / 100);
104
+ }
105
+
106
+ function formatShortDate(value?: string | null, locale = 'en-US') {
107
+ if (!value) return '—';
108
+
109
+ const parsed = new Date(value);
110
+ if (Number.isNaN(parsed.getTime())) return '—';
111
+
112
+ return new Intl.DateTimeFormat(locale, {
113
+ day: '2-digit',
114
+ month: 'short',
115
+ year: 'numeric',
116
+ }).format(parsed);
117
+ }
118
+
119
+ function getStatusBadgeClassName(status?: string | null) {
120
+ switch (status) {
121
+ case 'draft':
122
+ return 'border-slate-500/20 bg-slate-500/10 text-slate-700';
123
+ case 'pending_approval':
124
+ return 'border-amber-500/20 bg-amber-500/10 text-amber-700';
125
+ case 'approved':
126
+ return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700';
127
+ case 'rejected':
128
+ return 'border-red-500/20 bg-red-500/10 text-red-700';
129
+ case 'contract_generated':
130
+ return 'border-sky-500/20 bg-sky-500/10 text-sky-700';
131
+ case 'cancelled':
132
+ return 'border-zinc-500/20 bg-zinc-500/10 text-zinc-700';
133
+ default:
134
+ return 'border-border bg-muted/50 text-foreground';
135
+ }
136
+ }
137
+
138
+ function canSubmitProposal(status?: ProposalStatus | null) {
139
+ return (
140
+ status !== 'approved' &&
141
+ status !== 'pending_approval' &&
142
+ status !== 'contract_generated' &&
143
+ status !== 'cancelled'
144
+ );
145
+ }
146
+
147
+ export function ProposalsManagementPage({
148
+ defaultStatus = 'all',
149
+ }: ProposalsManagementPageProps) {
150
+ const t = useTranslations('contact.ProposalsPage');
151
+ const proposalT = useTranslations('contact.CrmPipeline.proposals');
152
+ const crmT = useTranslations('contact.CrmPipeline');
153
+ const { request, currentLocaleCode } = useApp();
154
+ const locale = currentLocaleCode?.startsWith('pt') ? 'pt-BR' : 'en-US';
155
+
156
+ const [searchInput, setSearchInput] = useState('');
157
+ const [statusFilter, setStatusFilter] =
158
+ useState<ProposalStatusFilter>(defaultStatus);
159
+ const [page, setPage] = useState(1);
160
+ const [pageSize, setPageSize] = useState(12);
161
+ const [viewMode, setViewMode] = useState<ProposalViewMode>('table');
162
+ const [actionKey, setActionKey] = useState<string | null>(null);
163
+
164
+ useEffect(() => {
165
+ setStatusFilter(defaultStatus);
166
+ setPage(1);
167
+ }, [defaultStatus]);
168
+
169
+ useEffect(() => {
170
+ try {
171
+ const savedViewMode = window.localStorage.getItem(
172
+ PROPOSALS_VIEW_STORAGE_KEY
173
+ );
174
+ if (savedViewMode === 'table' || savedViewMode === 'cards') {
175
+ setViewMode(savedViewMode);
176
+ }
177
+ } catch {
178
+ // Ignore storage read failures.
179
+ }
180
+ }, []);
181
+
182
+ const defaultPage = useMemo(
183
+ () => ({ data: [], total: 0, page: 1, pageSize }),
184
+ [pageSize]
185
+ );
186
+
187
+ const {
188
+ data: stats = {
189
+ total: 0,
190
+ draft: 0,
191
+ pendingApproval: 0,
192
+ approved: 0,
193
+ rejected: 0,
194
+ contractGenerated: 0,
195
+ },
196
+ isLoading: isLoadingStats,
197
+ refetch: refetchStats,
198
+ } = useQuery<ProposalStats>({
199
+ queryKey: ['contact-proposals-stats', currentLocaleCode],
200
+ queryFn: async () => {
201
+ const response = await request<ProposalStats>({
202
+ url: '/proposal/stats',
203
+ method: 'GET',
204
+ });
205
+
206
+ return response.data;
207
+ },
208
+ placeholderData: (previous) =>
209
+ previous ?? {
210
+ total: 0,
211
+ draft: 0,
212
+ pendingApproval: 0,
213
+ approved: 0,
214
+ rejected: 0,
215
+ contractGenerated: 0,
216
+ },
217
+ });
218
+
219
+ const {
220
+ data: proposalPage = defaultPage,
221
+ isLoading,
222
+ refetch,
223
+ } = useQuery<PaginatedResult<ProposalRecord>>({
224
+ queryKey: [
225
+ 'contact-proposals-page',
226
+ searchInput,
227
+ statusFilter,
228
+ page,
229
+ pageSize,
230
+ currentLocaleCode,
231
+ ],
232
+ queryFn: async () => {
233
+ const params = new URLSearchParams();
234
+ params.set('page', String(page));
235
+ params.set('pageSize', String(pageSize));
236
+
237
+ if (searchInput.trim()) {
238
+ params.set('search', searchInput.trim());
239
+ }
240
+
241
+ if (statusFilter !== 'all') {
242
+ params.set('status', statusFilter);
243
+ }
244
+
245
+ const response = await request<PaginatedResult<ProposalRecord>>({
246
+ url: `/proposal?${params.toString()}`,
247
+ method: 'GET',
248
+ });
249
+
250
+ return response.data;
251
+ },
252
+ placeholderData: (previous) => previous ?? defaultPage,
253
+ });
254
+
255
+ const hasPendingApprovals = Number(stats.pendingApproval || 0) > 0;
256
+ const visibleTotalAmountCents = proposalPage.data.reduce(
257
+ (sum, proposal) => sum + Number(proposal.total_amount_cents || 0),
258
+ 0
259
+ );
260
+
261
+ const pageTitle =
262
+ defaultStatus === 'pending_approval' ? t('pendingTitle') : t('title');
263
+ const pageDescription =
264
+ defaultStatus === 'pending_approval'
265
+ ? t('pendingDescription')
266
+ : t('description');
267
+
268
+ const controls: SearchBarControl[] = [
269
+ {
270
+ id: 'proposal-status-filter',
271
+ type: 'select',
272
+ value: statusFilter,
273
+ onChange: (value) => {
274
+ setStatusFilter(value as ProposalStatusFilter);
275
+ setPage(1);
276
+ },
277
+ placeholder: t('filters.statusPlaceholder'),
278
+ options: [
279
+ { value: 'all', label: t('filters.all') },
280
+ { value: 'pending_approval', label: t('filters.pending_approval') },
281
+ { value: 'approved', label: t('filters.approved') },
282
+ { value: 'draft', label: t('filters.draft') },
283
+ { value: 'rejected', label: t('filters.rejected') },
284
+ ],
285
+ },
286
+ ];
287
+
288
+ const getStatusLabel = (status?: string | null) => {
289
+ if (!status) return '—';
290
+
291
+ try {
292
+ return proposalT(`status.${status}` as Parameters<typeof proposalT>[0]);
293
+ } catch {
294
+ return status;
295
+ }
296
+ };
297
+
298
+ const handleViewModeChange = (value: string) => {
299
+ if (value !== 'table' && value !== 'cards') {
300
+ return;
301
+ }
302
+
303
+ setViewMode(value);
304
+ try {
305
+ window.localStorage.setItem(PROPOSALS_VIEW_STORAGE_KEY, value);
306
+ } catch {
307
+ // Ignore storage write failures.
308
+ }
309
+ };
310
+
311
+ const refreshAll = async () => {
312
+ await Promise.all([refetch(), refetchStats()]);
313
+ };
314
+
315
+ const handleStatusAction = async (
316
+ proposal: ProposalRecord,
317
+ action: 'submit' | 'approve' | 'reject'
318
+ ) => {
319
+ try {
320
+ setActionKey(`${action}-${proposal.id}`);
321
+
322
+ await request({
323
+ url: `/proposal/${proposal.id}/${action}`,
324
+ method: 'POST',
325
+ data: {},
326
+ });
327
+
328
+ toast.success(
329
+ proposalT(`toasts.${action}Success` as Parameters<typeof proposalT>[0])
330
+ );
331
+ await refreshAll();
332
+ } catch (error) {
333
+ const message =
334
+ error instanceof Error
335
+ ? error.message
336
+ : proposalT(
337
+ `toasts.${action}Error` as Parameters<typeof proposalT>[0]
338
+ );
339
+
340
+ toast.error(message);
341
+ } finally {
342
+ setActionKey(null);
343
+ }
344
+ };
345
+
346
+ const statsCards = [
347
+ {
348
+ key: 'total',
349
+ title: t('cards.total'),
350
+ value: isLoadingStats ? '—' : stats.total,
351
+ icon: FileText,
352
+ accentClassName: 'from-slate-500/20 via-slate-400/10 to-transparent',
353
+ iconContainerClassName: 'bg-slate-100 text-slate-700',
354
+ },
355
+ {
356
+ key: 'pending',
357
+ title: t('cards.pending'),
358
+ value: isLoadingStats ? '—' : stats.pendingApproval,
359
+ icon: Clock3,
360
+ accentClassName: 'from-amber-500/20 via-yellow-500/10 to-transparent',
361
+ iconContainerClassName: 'bg-amber-50 text-amber-600',
362
+ },
363
+ {
364
+ key: 'approved',
365
+ title: t('cards.approved'),
366
+ value: isLoadingStats ? '—' : stats.approved,
367
+ icon: CheckCircle2,
368
+ accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent',
369
+ iconContainerClassName: 'bg-emerald-50 text-emerald-600',
370
+ },
371
+ {
372
+ key: 'converted',
373
+ title: t('cards.converted'),
374
+ value: isLoadingStats ? '—' : stats.contractGenerated,
375
+ icon: FileCheck2,
376
+ accentClassName: 'from-sky-500/20 via-cyan-500/10 to-transparent',
377
+ iconContainerClassName: 'bg-sky-50 text-sky-600',
378
+ },
379
+ ];
380
+
381
+ return (
382
+ <Page>
383
+ <PageHeader
384
+ breadcrumbs={[
385
+ { label: 'Home', href: '/' },
386
+ { label: crmT('breadcrumbs.crm'), href: '/contact/dashboard' },
387
+ { label: pageTitle },
388
+ ]}
389
+ title={pageTitle}
390
+ description={pageDescription}
391
+ actions={[
392
+ {
393
+ label: t('actions.refresh'),
394
+ onClick: () => {
395
+ void refreshAll();
396
+ },
397
+ icon: <RefreshCcw className="h-4 w-4" />,
398
+ },
399
+ ]}
400
+ />
401
+
402
+ <KpiCardsGrid items={statsCards} />
403
+
404
+ {hasPendingApprovals && defaultStatus !== 'pending_approval' ? (
405
+ <div className="flex flex-col gap-3 rounded-md border border-amber-200/70 bg-amber-50/40 px-4 py-3 md:flex-row md:items-center md:justify-between">
406
+ <div className="space-y-1">
407
+ <p className="text-sm font-medium text-foreground">
408
+ {t('inboxTitle')}
409
+ </p>
410
+ <p className="text-sm text-muted-foreground">
411
+ {t('inboxDescription', { count: stats.pendingApproval })}
412
+ </p>
413
+ </div>
414
+
415
+ <Button
416
+ variant="outline"
417
+ className="border-amber-300 bg-background"
418
+ onClick={() => {
419
+ setStatusFilter('pending_approval');
420
+ setPage(1);
421
+ }}
422
+ >
423
+ {t('actions.viewPending')}
424
+ </Button>
425
+ </div>
426
+ ) : null}
427
+
428
+ <div className="flex flex-col gap-4 xl:flex-row xl:items-center">
429
+ <div className="flex-1">
430
+ <SearchBar
431
+ searchQuery={searchInput}
432
+ onSearchChange={(value) => {
433
+ setSearchInput(value);
434
+ setPage(1);
435
+ }}
436
+ onSearch={() => {
437
+ setPage(1);
438
+ void refetch();
439
+ }}
440
+ placeholder={t('searchPlaceholder')}
441
+ controls={controls}
442
+ />
443
+ </div>
444
+
445
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap xl:justify-end">
446
+ <div className="flex items-center justify-between gap-3 sm:justify-start">
447
+ <span className="text-xs font-medium text-muted-foreground">
448
+ {t('viewMode')}
449
+ </span>
450
+ <ToggleGroup
451
+ type="single"
452
+ value={viewMode}
453
+ onValueChange={handleViewModeChange}
454
+ variant="outline"
455
+ size="sm"
456
+ aria-label={t('viewMode')}
457
+ >
458
+ <ToggleGroupItem
459
+ value="table"
460
+ className="gap-1.5 px-2.5"
461
+ aria-label={t('viewModeTable')}
462
+ >
463
+ <List className="h-4 w-4" />
464
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
465
+ </ToggleGroupItem>
466
+ <ToggleGroupItem
467
+ value="cards"
468
+ className="gap-1.5 px-2.5"
469
+ aria-label={t('viewModeCards')}
470
+ >
471
+ <LayoutGrid className="h-4 w-4" />
472
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
473
+ </ToggleGroupItem>
474
+ </ToggleGroup>
475
+ </div>
476
+ </div>
477
+ </div>
478
+
479
+ {isLoading ? (
480
+ <div className="space-y-3 p-4">
481
+ {Array.from({ length: 5 }).map((_, index) => (
482
+ <Skeleton key={index} className="h-14 w-full" />
483
+ ))}
484
+ </div>
485
+ ) : proposalPage.data.length === 0 ? (
486
+ <EmptyState
487
+ icon={<FileText className="h-12 w-12" />}
488
+ title={t('emptyTitle')}
489
+ description={t('emptyDescription')}
490
+ actionLabel={
491
+ statusFilter !== 'all' ? t('actions.showAll') : t('actions.refresh')
492
+ }
493
+ onAction={() => {
494
+ if (statusFilter !== 'all') {
495
+ setStatusFilter('all');
496
+ setPage(1);
497
+ return;
498
+ }
499
+
500
+ void refetch();
501
+ }}
502
+ />
503
+ ) : viewMode === 'table' ? (
504
+ <div className="overflow-x-auto rounded-md border">
505
+ <Table>
506
+ <TableHeader>
507
+ <TableRow>
508
+ <TableHead>{t('columns.proposal')}</TableHead>
509
+ <TableHead>{t('columns.customer')}</TableHead>
510
+ <TableHead>{t('columns.status')}</TableHead>
511
+ <TableHead className="text-right">
512
+ {t('columns.total')}
513
+ </TableHead>
514
+ <TableHead>{t('columns.validUntil')}</TableHead>
515
+ <TableHead>{t('columns.updatedAt')}</TableHead>
516
+ <TableHead className="text-right">
517
+ {t('columns.actions')}
518
+ </TableHead>
519
+ </TableRow>
520
+ </TableHeader>
521
+
522
+ <TableBody>
523
+ {proposalPage.data.map((proposal) => {
524
+ const customerName =
525
+ proposal.person?.trade_name ||
526
+ proposal.person?.name ||
527
+ `#${proposal.person_id}`;
528
+
529
+ return (
530
+ <TableRow key={proposal.id}>
531
+ <TableCell>
532
+ <div className="space-y-1">
533
+ <div className="font-medium text-foreground">
534
+ {proposal.title}
535
+ </div>
536
+ <div className="text-xs text-muted-foreground">
537
+ {proposal.code || `#${proposal.id}`}
538
+ {proposal.current_revision_number
539
+ ? ` · v${proposal.current_revision_number}`
540
+ : ''}
541
+ </div>
542
+ </div>
543
+ </TableCell>
544
+
545
+ <TableCell>
546
+ <div className="space-y-1">
547
+ <div className="font-medium text-foreground">
548
+ {customerName}
549
+ </div>
550
+ <div className="text-xs text-muted-foreground">
551
+ {proposal.person?.email ||
552
+ proposal.person?.phone ||
553
+ '—'}
554
+ </div>
555
+ </div>
556
+ </TableCell>
557
+
558
+ <TableCell>
559
+ <Badge
560
+ variant="outline"
561
+ className={cn(
562
+ 'font-medium',
563
+ getStatusBadgeClassName(proposal.status)
564
+ )}
565
+ >
566
+ {getStatusLabel(proposal.status)}
567
+ </Badge>
568
+ </TableCell>
569
+
570
+ <TableCell className="text-right font-medium">
571
+ {formatMoney(
572
+ proposal.total_amount_cents,
573
+ locale,
574
+ proposal.currency_code || 'BRL'
575
+ )}
576
+ </TableCell>
577
+
578
+ <TableCell>
579
+ {formatShortDate(proposal.valid_until, locale)}
580
+ </TableCell>
581
+ <TableCell>
582
+ {formatShortDate(proposal.updated_at, locale)}
583
+ </TableCell>
584
+
585
+ <TableCell>
586
+ <div className="flex flex-wrap justify-end gap-2">
587
+ {canSubmitProposal(proposal.status) ? (
588
+ <Button
589
+ size="sm"
590
+ variant="outline"
591
+ disabled={actionKey === `submit-${proposal.id}`}
592
+ onClick={() =>
593
+ void handleStatusAction(proposal, 'submit')
594
+ }
595
+ >
596
+ {proposalT('actions.submit')}
597
+ </Button>
598
+ ) : null}
599
+
600
+ {proposal.status === 'pending_approval' ? (
601
+ <>
602
+ <Button
603
+ size="sm"
604
+ disabled={actionKey === `approve-${proposal.id}`}
605
+ onClick={() =>
606
+ void handleStatusAction(proposal, 'approve')
607
+ }
608
+ >
609
+ {proposalT('actions.approve')}
610
+ </Button>
611
+ <Button
612
+ size="sm"
613
+ variant="destructive"
614
+ disabled={actionKey === `reject-${proposal.id}`}
615
+ onClick={() =>
616
+ void handleStatusAction(proposal, 'reject')
617
+ }
618
+ >
619
+ {proposalT('actions.reject')}
620
+ </Button>
621
+ </>
622
+ ) : null}
623
+ </div>
624
+ </TableCell>
625
+ </TableRow>
626
+ );
627
+ })}
628
+ </TableBody>
629
+ </Table>
630
+ </div>
631
+ ) : (
632
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
633
+ {proposalPage.data.map((proposal) => {
634
+ const customerName =
635
+ proposal.person?.trade_name ||
636
+ proposal.person?.name ||
637
+ `#${proposal.person_id}`;
638
+
639
+ return (
640
+ <Card
641
+ key={proposal.id}
642
+ className="h-full overflow-hidden border-border/70 py-0"
643
+ >
644
+ <CardContent className="flex h-full flex-col gap-3 p-4">
645
+ <div className="flex items-start justify-between gap-3">
646
+ <div className="min-w-0 space-y-1">
647
+ <p className="line-clamp-2 text-sm font-semibold text-foreground">
648
+ {proposal.title}
649
+ </p>
650
+ <p className="text-xs text-muted-foreground">
651
+ {proposal.code || `#${proposal.id}`}
652
+ {proposal.current_revision_number
653
+ ? ` · v${proposal.current_revision_number}`
654
+ : ''}
655
+ </p>
656
+ </div>
657
+ <Badge
658
+ variant="outline"
659
+ className={cn(
660
+ 'shrink-0 font-medium',
661
+ getStatusBadgeClassName(proposal.status)
662
+ )}
663
+ >
664
+ {getStatusLabel(proposal.status)}
665
+ </Badge>
666
+ </div>
667
+
668
+ <div className="grid gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-xs">
669
+ <div className="flex items-center justify-between gap-2">
670
+ <span className="text-muted-foreground">
671
+ {t('columns.customer')}
672
+ </span>
673
+ <span className="truncate font-medium text-foreground">
674
+ {customerName}
675
+ </span>
676
+ </div>
677
+ <div className="flex items-center justify-between gap-2">
678
+ <span className="text-muted-foreground">
679
+ {t('columns.total')}
680
+ </span>
681
+ <span className="font-medium text-foreground">
682
+ {formatMoney(
683
+ proposal.total_amount_cents,
684
+ locale,
685
+ proposal.currency_code || 'BRL'
686
+ )}
687
+ </span>
688
+ </div>
689
+ <div className="flex items-center justify-between gap-2">
690
+ <span className="text-muted-foreground">
691
+ {t('columns.validUntil')}
692
+ </span>
693
+ <span className="font-medium text-foreground">
694
+ {formatShortDate(proposal.valid_until, locale)}
695
+ </span>
696
+ </div>
697
+ <div className="flex items-center justify-between gap-2">
698
+ <span className="text-muted-foreground">
699
+ {t('columns.updatedAt')}
700
+ </span>
701
+ <span className="font-medium text-foreground">
702
+ {formatShortDate(proposal.updated_at, locale)}
703
+ </span>
704
+ </div>
705
+ </div>
706
+
707
+ <div className="mt-auto flex flex-wrap justify-end gap-2">
708
+ {canSubmitProposal(proposal.status) ? (
709
+ <Button
710
+ size="sm"
711
+ variant="outline"
712
+ disabled={actionKey === `submit-${proposal.id}`}
713
+ onClick={() =>
714
+ void handleStatusAction(proposal, 'submit')
715
+ }
716
+ >
717
+ {proposalT('actions.submit')}
718
+ </Button>
719
+ ) : null}
720
+
721
+ {proposal.status === 'pending_approval' ? (
722
+ <>
723
+ <Button
724
+ size="sm"
725
+ disabled={actionKey === `approve-${proposal.id}`}
726
+ onClick={() =>
727
+ void handleStatusAction(proposal, 'approve')
728
+ }
729
+ >
730
+ {proposalT('actions.approve')}
731
+ </Button>
732
+ <Button
733
+ size="sm"
734
+ variant="destructive"
735
+ disabled={actionKey === `reject-${proposal.id}`}
736
+ onClick={() =>
737
+ void handleStatusAction(proposal, 'reject')
738
+ }
739
+ >
740
+ {proposalT('actions.reject')}
741
+ </Button>
742
+ </>
743
+ ) : null}
744
+ </div>
745
+ </CardContent>
746
+ </Card>
747
+ );
748
+ })}
749
+ </div>
750
+ )}
751
+
752
+ <div className="border-t p-4">
753
+ <p className="mb-3 text-sm text-muted-foreground">
754
+ {t('visibleTotal', {
755
+ value: formatMoney(visibleTotalAmountCents, locale, 'BRL'),
756
+ })}
757
+ </p>
758
+
759
+ <PaginationFooter
760
+ currentPage={page}
761
+ pageSize={pageSize}
762
+ totalItems={proposalPage.total}
763
+ onPageChange={setPage}
764
+ onPageSizeChange={(nextPageSize) => {
765
+ setPageSize(nextPageSize);
766
+ setPage(1);
767
+ }}
768
+ pageSizeOptions={[12, 24, 36, 48]}
769
+ />
770
+ </div>
771
+ </Page>
772
+ );
773
+ }