@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.
- package/dist/person/person.service.d.ts +2 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +111 -127
- package/dist/person/person.service.js.map +1 -1
- package/dist/person/person.service.spec.d.ts +2 -0
- package/dist/person/person.service.spec.d.ts.map +1 -0
- package/dist/person/person.service.spec.js +106 -0
- package/dist/person/person.service.spec.js.map +1 -0
- package/dist/proposal/proposal.service.d.ts +5 -0
- package/dist/proposal/proposal.service.d.ts.map +1 -1
- package/dist/proposal/proposal.service.js +242 -19
- package/dist/proposal/proposal.service.js.map +1 -1
- package/dist/proposal/proposal.service.spec.js +153 -165
- package/dist/proposal/proposal.service.spec.js.map +1 -1
- package/hedhog/data/menu.yaml +35 -18
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +517 -346
- package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +42 -17
- package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +1 -1
- package/hedhog/frontend/app/activities/page.tsx.ejs +315 -101
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +172 -22
- package/hedhog/frontend/app/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +1 -1
- package/hedhog/frontend/app/pipeline/_components/lead-detail-sheet.tsx.ejs +1 -1
- package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +509 -441
- package/hedhog/frontend/app/pipeline/page.tsx.ejs +30 -4
- package/hedhog/frontend/app/proposals/_components/proposals-management-page.tsx.ejs +773 -0
- package/hedhog/frontend/app/proposals/approvals/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/proposals/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/reports/page.tsx.ejs +431 -375
- package/hedhog/frontend/messages/en.json +100 -1
- package/hedhog/frontend/messages/pt.json +100 -1
- package/package.json +6 -6
- package/src/person/person.service.spec.ts +143 -0
- package/src/person/person.service.ts +147 -158
- package/src/proposal/proposal.service.spec.ts +196 -0
- 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
|
+
}
|