@hed-hog/finance 0.0.256 → 0.0.260

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 (64) hide show
  1. package/dist/dto/create-bank-statement-adjustment.dto.d.ts +8 -0
  2. package/dist/dto/create-bank-statement-adjustment.dto.d.ts.map +1 -0
  3. package/dist/dto/create-bank-statement-adjustment.dto.js +50 -0
  4. package/dist/dto/create-bank-statement-adjustment.dto.js.map +1 -0
  5. package/dist/dto/create-transfer.dto.d.ts +8 -0
  6. package/dist/dto/create-transfer.dto.d.ts.map +1 -0
  7. package/dist/dto/create-transfer.dto.js +52 -0
  8. package/dist/dto/create-transfer.dto.js.map +1 -0
  9. package/dist/dto/register-collection-agreement.dto.d.ts +7 -0
  10. package/dist/dto/register-collection-agreement.dto.d.ts.map +1 -0
  11. package/dist/dto/register-collection-agreement.dto.js +37 -0
  12. package/dist/dto/register-collection-agreement.dto.js.map +1 -0
  13. package/dist/dto/send-collection.dto.d.ts +5 -0
  14. package/dist/dto/send-collection.dto.d.ts.map +1 -0
  15. package/dist/dto/send-collection.dto.js +29 -0
  16. package/dist/dto/send-collection.dto.js.map +1 -0
  17. package/dist/dto/settle-installment.dto.d.ts +1 -0
  18. package/dist/dto/settle-installment.dto.d.ts.map +1 -1
  19. package/dist/dto/settle-installment.dto.js +6 -0
  20. package/dist/dto/settle-installment.dto.js.map +1 -1
  21. package/dist/finance-collections.controller.d.ts +35 -0
  22. package/dist/finance-collections.controller.d.ts.map +1 -0
  23. package/dist/finance-collections.controller.js +65 -0
  24. package/dist/finance-collections.controller.js.map +1 -0
  25. package/dist/finance-data.controller.d.ts +4 -0
  26. package/dist/finance-data.controller.d.ts.map +1 -1
  27. package/dist/finance-installments.controller.d.ts +44 -0
  28. package/dist/finance-installments.controller.d.ts.map +1 -1
  29. package/dist/finance-statements.controller.d.ts +16 -2
  30. package/dist/finance-statements.controller.d.ts.map +1 -1
  31. package/dist/finance-statements.controller.js +34 -6
  32. package/dist/finance-statements.controller.js.map +1 -1
  33. package/dist/finance-transfers.controller.d.ts +23 -0
  34. package/dist/finance-transfers.controller.d.ts.map +1 -0
  35. package/dist/finance-transfers.controller.js +56 -0
  36. package/dist/finance-transfers.controller.js.map +1 -0
  37. package/dist/finance.module.d.ts.map +1 -1
  38. package/dist/finance.module.js +4 -0
  39. package/dist/finance.module.js.map +1 -1
  40. package/dist/finance.service.d.ts +115 -2
  41. package/dist/finance.service.d.ts.map +1 -1
  42. package/dist/finance.service.js +632 -8
  43. package/dist/finance.service.js.map +1 -1
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/hedhog/data/route.yaml +63 -0
  49. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +643 -440
  50. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +825 -477
  51. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +367 -43
  52. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +315 -75
  53. package/package.json +6 -6
  54. package/src/dto/create-bank-statement-adjustment.dto.ts +38 -0
  55. package/src/dto/create-transfer.dto.ts +46 -0
  56. package/src/dto/register-collection-agreement.dto.ts +27 -0
  57. package/src/dto/send-collection.dto.ts +14 -0
  58. package/src/dto/settle-installment.dto.ts +5 -0
  59. package/src/finance-collections.controller.ts +34 -0
  60. package/src/finance-statements.controller.ts +29 -1
  61. package/src/finance-transfers.controller.ts +26 -0
  62. package/src/finance.module.ts +4 -0
  63. package/src/finance.service.ts +775 -5
  64. package/src/index.ts +2 -0
@@ -1,440 +1,643 @@
1
- 'use client';
2
-
3
- import { Page, PageHeader } from '@/components/entity-list';
4
- import { Button } from '@/components/ui/button';
5
- import {
6
- Card,
7
- CardContent,
8
- CardDescription,
9
- CardHeader,
10
- CardTitle,
11
- } from '@/components/ui/card';
12
- import {
13
- Dialog,
14
- DialogContent,
15
- DialogDescription,
16
- DialogFooter,
17
- DialogHeader,
18
- DialogTitle,
19
- DialogTrigger,
20
- } from '@/components/ui/dialog';
21
- import { Input } from '@/components/ui/input';
22
- import { InputMoney } from '@/components/ui/input-money';
23
- import { Label } from '@/components/ui/label';
24
- import { Money } from '@/components/ui/money';
25
- import {
26
- Sheet,
27
- SheetContent,
28
- SheetDescription,
29
- SheetHeader,
30
- SheetTitle,
31
- SheetTrigger,
32
- } from '@/components/ui/sheet';
33
- import {
34
- Table,
35
- TableBody,
36
- TableCell,
37
- TableHead,
38
- TableHeader,
39
- TableRow,
40
- } from '@/components/ui/table';
41
- import { Textarea } from '@/components/ui/textarea';
42
- import { AlertTriangle, FileText, Send } from 'lucide-react';
43
- import { useTranslations } from 'next-intl';
44
- import {
45
- Bar,
46
- BarChart,
47
- CartesianGrid,
48
- Cell,
49
- ResponsiveContainer,
50
- Tooltip,
51
- XAxis,
52
- YAxis,
53
- } from 'recharts';
54
- import { formatarMoeda } from '../../_lib/formatters';
55
- import { useFinanceData } from '../../_lib/use-finance-data';
56
-
57
- function HistoricoContatosSheet({
58
- cliente,
59
- historicoContatos,
60
- t,
61
- }: {
62
- cliente: string;
63
- historicoContatos: any[];
64
- t: ReturnType<typeof useTranslations>;
65
- }) {
66
- return (
67
- <Sheet>
68
- <SheetTrigger asChild>
69
- <Button variant="outline" size="sm">
70
- <FileText className="mr-2 h-4 w-4" />
71
- {t('actions.history')}
72
- </Button>
73
- </SheetTrigger>
74
- <SheetContent>
75
- <SheetHeader>
76
- <SheetTitle>{t('history.title')}</SheetTitle>
77
- <SheetDescription>{cliente}</SheetDescription>
78
- </SheetHeader>
79
- <div className="mt-6 space-y-4">
80
- {historicoContatos.map((contato, i) => (
81
- <div key={i} className="rounded-lg border p-4">
82
- <div className="flex items-center justify-between">
83
- <span className="font-medium">{contato.tipo}</span>
84
- <span className="text-sm text-muted-foreground">
85
- {contato.data}
86
- </span>
87
- </div>
88
- <p className="mt-2 text-sm">{contato.descricao}</p>
89
- <p className="mt-1 text-xs text-muted-foreground">
90
- {t('history.by')} {contato.responsavel}
91
- </p>
92
- </div>
93
- ))}
94
- </div>
95
- </SheetContent>
96
- </Sheet>
97
- );
98
- }
99
-
100
- function EnviarCobrancaDialog({
101
- cliente,
102
- t,
103
- }: {
104
- cliente: string;
105
- t: ReturnType<typeof useTranslations>;
106
- }) {
107
- return (
108
- <Dialog>
109
- <DialogTrigger asChild>
110
- <Button size="sm">
111
- <Send className="mr-2 h-4 w-4" />
112
- {t('actions.collect')}
113
- </Button>
114
- </DialogTrigger>
115
- <DialogContent>
116
- <DialogHeader>
117
- <DialogTitle>{t('send.title')}</DialogTitle>
118
- <DialogDescription>
119
- {t('send.description', { cliente })}
120
- </DialogDescription>
121
- </DialogHeader>
122
- <div className="space-y-4">
123
- <div className="space-y-2">
124
- <Label>{t('send.contactType')}</Label>
125
- <div className="flex gap-2">
126
- <Button
127
- type="button"
128
- variant="outline"
129
- className="flex-1 bg-transparent"
130
- >
131
- {t('send.channels.email')}
132
- </Button>
133
- <Button
134
- type="button"
135
- variant="outline"
136
- className="flex-1 bg-transparent"
137
- >
138
- WhatsApp
139
- </Button>
140
- <Button
141
- type="button"
142
- variant="outline"
143
- className="flex-1 bg-transparent"
144
- >
145
- SMS
146
- </Button>
147
- </div>
148
- </div>
149
- <div className="space-y-2">
150
- <Label htmlFor="mensagem">{t('send.message')}</Label>
151
- <Textarea
152
- id="mensagem"
153
- placeholder={t('send.messagePlaceholder')}
154
- defaultValue={t('send.defaultMessage')}
155
- />
156
- </div>
157
- </div>
158
- <DialogFooter>
159
- <Button type="button" variant="outline">
160
- {t('common.cancel')}
161
- </Button>
162
- <Button type="button">{t('send.submit')}</Button>
163
- </DialogFooter>
164
- </DialogContent>
165
- </Dialog>
166
- );
167
- }
168
-
169
- function RegistrarAcordoDialog({
170
- cliente,
171
- t,
172
- }: {
173
- cliente: string;
174
- t: ReturnType<typeof useTranslations>;
175
- }) {
176
- return (
177
- <Dialog>
178
- <DialogTrigger asChild>
179
- <Button variant="outline" size="sm">
180
- {t('actions.registerAgreement')}
181
- </Button>
182
- </DialogTrigger>
183
- <DialogContent>
184
- <DialogHeader>
185
- <DialogTitle>{t('agreement.title')}</DialogTitle>
186
- <DialogDescription>
187
- {t('agreement.description', { cliente })}
188
- </DialogDescription>
189
- </DialogHeader>
190
- <div className="space-y-4">
191
- <div className="grid grid-cols-2 gap-4">
192
- <div className="space-y-2">
193
- <Label htmlFor="valorAcordo">{t('agreement.value')}</Label>
194
- <InputMoney id="valorAcordo" placeholder="0,00" />
195
- </div>
196
- <div className="space-y-2">
197
- <Label htmlFor="parcelas">{t('agreement.installments')}</Label>
198
- <Input id="parcelas" type="number" placeholder="1" />
199
- </div>
200
- </div>
201
- <div className="space-y-2">
202
- <Label htmlFor="dataVencimento">
203
- {t('agreement.firstDueDate')}
204
- </Label>
205
- <Input id="dataVencimento" type="date" />
206
- </div>
207
- <div className="space-y-2">
208
- <Label htmlFor="observacoes">{t('agreement.notes')}</Label>
209
- <Textarea
210
- id="observacoes"
211
- placeholder={t('agreement.notesPlaceholder')}
212
- />
213
- </div>
214
- </div>
215
- <DialogFooter>
216
- <Button type="button" variant="outline">
217
- {t('common.cancel')}
218
- </Button>
219
- <Button type="button">{t('agreement.submit')}</Button>
220
- </DialogFooter>
221
- </DialogContent>
222
- </Dialog>
223
- );
224
- }
225
-
226
- export default function CobrancaPage() {
227
- const t = useTranslations('finance.CollectionsDefaultPage');
228
- const { data } = useFinanceData();
229
- const { agingInadimplencia, historicoContatos } = data;
230
-
231
- const chartData = [
232
- {
233
- name: t('chart.range0to30'),
234
- valor: agingInadimplencia.reduce(
235
- (acc, c) => acc + (c.bucket0_30 || 0),
236
- 0
237
- ),
238
- fill: 'hsl(var(--chart-2))',
239
- },
240
- {
241
- name: t('chart.range31to60'),
242
- valor: agingInadimplencia.reduce(
243
- (acc, c) => acc + (c.bucket31_60 || 0),
244
- 0
245
- ),
246
- fill: 'hsl(var(--chart-4))',
247
- },
248
- {
249
- name: t('chart.range61to90'),
250
- valor: agingInadimplencia.reduce(
251
- (acc, c) => acc + (c.bucket61_90 || 0),
252
- 0
253
- ),
254
- fill: 'hsl(var(--chart-5))',
255
- },
256
- {
257
- name: t('chart.range90plus'),
258
- valor: agingInadimplencia.reduce(
259
- (acc, c) => acc + (c.bucket90plus || 0),
260
- 0
261
- ),
262
- fill: 'hsl(var(--destructive))',
263
- },
264
- ];
265
-
266
- const totalInadimplencia = agingInadimplencia.reduce(
267
- (acc, c) => acc + c.total,
268
- 0
269
- );
270
- const totalClientes = agingInadimplencia.length;
271
-
272
- return (
273
- <Page>
274
- <PageHeader
275
- title={t('header.title')}
276
- description={t('header.description')}
277
- breadcrumbs={[
278
- { label: t('breadcrumbs.home'), href: '/' },
279
- { label: t('breadcrumbs.finance'), href: '/finance' },
280
- { label: t('breadcrumbs.current') },
281
- ]}
282
- />
283
-
284
- <div className="grid gap-4 md:grid-cols-3">
285
- <Card>
286
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
287
- <CardTitle className="text-sm font-medium">
288
- {t('cards.totalDefault')}
289
- </CardTitle>
290
- <AlertTriangle className="h-4 w-4 text-destructive" />
291
- </CardHeader>
292
- <CardContent>
293
- <div className="text-2xl font-bold">
294
- <Money value={totalInadimplencia} />
295
- </div>
296
- </CardContent>
297
- </Card>
298
- <Card>
299
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
300
- <CardTitle className="text-sm font-medium">
301
- {t('cards.lateClients')}
302
- </CardTitle>
303
- </CardHeader>
304
- <CardContent>
305
- <div className="text-2xl font-bold">{totalClientes}</div>
306
- </CardContent>
307
- </Card>
308
- <Card>
309
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
310
- <CardTitle className="text-sm font-medium">
311
- {t('cards.maxDelay')}
312
- </CardTitle>
313
- </CardHeader>
314
- <CardContent>
315
- <div className="text-2xl font-bold text-destructive">
316
- {t('cards.maxDelayValue')}
317
- </div>
318
- </CardContent>
319
- </Card>
320
- </div>
321
-
322
- <Card>
323
- <CardHeader>
324
- <CardTitle>{t('aging.title')}</CardTitle>
325
- <CardDescription>{t('aging.description')}</CardDescription>
326
- </CardHeader>
327
- <CardContent>
328
- <ResponsiveContainer width="100%" height={250}>
329
- <BarChart data={chartData}>
330
- <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
331
- <XAxis dataKey="name" tick={{ fontSize: 12 }} />
332
- <YAxis
333
- tick={{ fontSize: 12 }}
334
- tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
335
- />
336
- <Tooltip
337
- formatter={(value: number) => formatarMoeda(value)}
338
- contentStyle={{
339
- backgroundColor: 'hsl(var(--background))',
340
- border: '1px solid hsl(var(--border))',
341
- borderRadius: '8px',
342
- }}
343
- />
344
- <Bar dataKey="valor" radius={[4, 4, 0, 0]}>
345
- {chartData.map((entry, index) => (
346
- <Cell key={`cell-${index}`} fill={entry.fill} />
347
- ))}
348
- </Bar>
349
- </BarChart>
350
- </ResponsiveContainer>
351
- </CardContent>
352
- </Card>
353
-
354
- <Card>
355
- <CardHeader>
356
- <CardTitle>{t('table.title')}</CardTitle>
357
- <CardDescription>{t('table.description')}</CardDescription>
358
- </CardHeader>
359
- <CardContent>
360
- <Table>
361
- <TableHeader>
362
- <TableRow>
363
- <TableHead>{t('table.headers.client')}</TableHead>
364
- <TableHead className="text-right">
365
- {t('table.headers.range0to30')}
366
- </TableHead>
367
- <TableHead className="text-right">
368
- {t('table.headers.range31to60')}
369
- </TableHead>
370
- <TableHead className="text-right">
371
- {t('table.headers.range61to90')}
372
- </TableHead>
373
- <TableHead className="text-right">
374
- {t('table.headers.range90plus')}
375
- </TableHead>
376
- <TableHead className="text-right">
377
- {t('table.headers.total')}
378
- </TableHead>
379
- <TableHead className="text-right">
380
- {t('table.headers.actions')}
381
- </TableHead>
382
- </TableRow>
383
- </TableHeader>
384
- <TableBody>
385
- {agingInadimplencia.map((item) => (
386
- <TableRow key={item.clienteId}>
387
- <TableCell className="font-medium">{item.cliente}</TableCell>
388
- <TableCell className="text-right">
389
- {item.bucket0_30 > 0 ? (
390
- <Money value={item.bucket0_30} />
391
- ) : (
392
- '-'
393
- )}
394
- </TableCell>
395
- <TableCell className="text-right">
396
- {item.bucket31_60 > 0 ? (
397
- <Money value={item.bucket31_60} />
398
- ) : (
399
- '-'
400
- )}
401
- </TableCell>
402
- <TableCell className="text-right">
403
- {item.bucket61_90 > 0 ? (
404
- <Money value={item.bucket61_90} />
405
- ) : (
406
- '-'
407
- )}
408
- </TableCell>
409
- <TableCell className="text-right">
410
- {item.bucket90plus > 0 ? (
411
- <span className="text-destructive font-medium">
412
- <Money value={item.bucket90plus} />
413
- </span>
414
- ) : (
415
- '-'
416
- )}
417
- </TableCell>
418
- <TableCell className="text-right font-semibold">
419
- <Money value={item.total} />
420
- </TableCell>
421
- <TableCell>
422
- <div className="flex justify-end gap-2">
423
- <HistoricoContatosSheet
424
- cliente={item.cliente}
425
- historicoContatos={historicoContatos}
426
- t={t}
427
- />
428
- <EnviarCobrancaDialog cliente={item.cliente} t={t} />
429
- <RegistrarAcordoDialog cliente={item.cliente} t={t} />
430
- </div>
431
- </TableCell>
432
- </TableRow>
433
- ))}
434
- </TableBody>
435
- </Table>
436
- </CardContent>
437
- </Card>
438
- </Page>
439
- );
440
- }
1
+ 'use client';
2
+
3
+ import { Page, PageHeader } from '@/components/entity-list';
4
+ import { RichTextEditor } from '@/components/rich-text-editor';
5
+ import { Button } from '@/components/ui/button';
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from '@/components/ui/card';
13
+ import {
14
+ Form,
15
+ FormControl,
16
+ FormField,
17
+ FormItem,
18
+ FormLabel,
19
+ FormMessage,
20
+ } from '@/components/ui/form';
21
+ import { Input } from '@/components/ui/input';
22
+ import { InputMoney } from '@/components/ui/input-money';
23
+ import { Money } from '@/components/ui/money';
24
+ import {
25
+ Sheet,
26
+ SheetContent,
27
+ SheetDescription,
28
+ SheetHeader,
29
+ SheetTitle,
30
+ SheetTrigger,
31
+ } from '@/components/ui/sheet';
32
+ import {
33
+ Table,
34
+ TableBody,
35
+ TableCell,
36
+ TableHead,
37
+ TableHeader,
38
+ TableRow,
39
+ } from '@/components/ui/table';
40
+ import { Textarea } from '@/components/ui/textarea';
41
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
42
+ import { zodResolver } from '@hookform/resolvers/zod';
43
+ import { AlertTriangle, FileText, Send } from 'lucide-react';
44
+ import { useTranslations } from 'next-intl';
45
+ import { useState } from 'react';
46
+ import { useForm } from 'react-hook-form';
47
+ import {
48
+ Bar,
49
+ BarChart,
50
+ CartesianGrid,
51
+ ResponsiveContainer,
52
+ Tooltip,
53
+ XAxis,
54
+ YAxis,
55
+ } from 'recharts';
56
+ import { z } from 'zod';
57
+ import { formatarMoeda } from '../../_lib/formatters';
58
+
59
+ type AgingItem = {
60
+ clienteId: string;
61
+ cliente: string;
62
+ bucket0_30: number;
63
+ bucket31_60: number;
64
+ bucket61_90: number;
65
+ bucket90plus: number;
66
+ total: number;
67
+ };
68
+
69
+ type HistoricoContato = {
70
+ clienteId: string;
71
+ tipo: string;
72
+ data: string;
73
+ descricao: string;
74
+ responsavel: string;
75
+ };
76
+
77
+ type CollectionsDefaultData = {
78
+ agingInadimplencia: AgingItem[];
79
+ historicoContatos: HistoricoContato[];
80
+ };
81
+
82
+ const EMPTY_DATA: CollectionsDefaultData = {
83
+ agingInadimplencia: [],
84
+ historicoContatos: [],
85
+ };
86
+
87
+ const sendCollectionSchema = z.object({
88
+ message: z.string().min(5),
89
+ });
90
+
91
+ const registerAgreementSchema = z.object({
92
+ amount: z.number().min(0.01),
93
+ installments: z.coerce.number().int().min(1).max(120),
94
+ first_due_date: z.string().min(1),
95
+ notes: z.string().optional(),
96
+ });
97
+
98
+ function HistoricoContatosSheet({
99
+ cliente,
100
+ historicoContatos,
101
+ t,
102
+ }: {
103
+ cliente: string;
104
+ historicoContatos: HistoricoContato[];
105
+ t: ReturnType<typeof useTranslations>;
106
+ }) {
107
+ return (
108
+ <Sheet>
109
+ <SheetTrigger asChild>
110
+ <Button variant="outline" size="sm">
111
+ <FileText className="mr-2 h-4 w-4" />
112
+ {t('actions.history')}
113
+ </Button>
114
+ </SheetTrigger>
115
+ <SheetContent>
116
+ <SheetHeader>
117
+ <SheetTitle>{t('history.title')}</SheetTitle>
118
+ <SheetDescription>{cliente}</SheetDescription>
119
+ </SheetHeader>
120
+ <div className="mt-6 space-y-4 px-4">
121
+ {historicoContatos.length === 0 ? (
122
+ <div className="rounded-lg border p-4 text-sm text-muted-foreground">
123
+ -
124
+ </div>
125
+ ) : (
126
+ historicoContatos.map((contato, i) => (
127
+ <div
128
+ key={`${contato.data}-${i}`}
129
+ className="rounded-lg border p-4"
130
+ >
131
+ <div className="flex items-center justify-between">
132
+ <span className="font-medium">{contato.tipo}</span>
133
+ <span className="text-sm text-muted-foreground">
134
+ {new Date(contato.data).toLocaleString()}
135
+ </span>
136
+ </div>
137
+ <p className="mt-2 text-sm">{contato.descricao}</p>
138
+ <p className="mt-1 text-xs text-muted-foreground">
139
+ {t('history.by')} {contato.responsavel}
140
+ </p>
141
+ </div>
142
+ ))
143
+ )}
144
+ </div>
145
+ </SheetContent>
146
+ </Sheet>
147
+ );
148
+ }
149
+
150
+ function EnviarCobrancaDialog({
151
+ cliente,
152
+ clienteId,
153
+ onSuccess,
154
+ t,
155
+ }: {
156
+ cliente: string;
157
+ clienteId: string;
158
+ onSuccess: () => Promise<unknown>;
159
+ t: ReturnType<typeof useTranslations>;
160
+ }) {
161
+ const { request, showToastHandler } = useApp();
162
+ const [open, setOpen] = useState(false);
163
+
164
+ const form = useForm<z.infer<typeof sendCollectionSchema>>({
165
+ resolver: zodResolver(sendCollectionSchema),
166
+ defaultValues: {
167
+ message: t('send.defaultMessage'),
168
+ },
169
+ });
170
+
171
+ const onSubmit = async (values: z.infer<typeof sendCollectionSchema>) => {
172
+ try {
173
+ await request({
174
+ url: `/finance/accounts-receivable/collections-default/${clienteId}/send`,
175
+ method: 'POST',
176
+ data: {
177
+ message: values.message,
178
+ },
179
+ });
180
+
181
+ await onSuccess();
182
+ showToastHandler?.('success', t('send.submit'));
183
+ setOpen(false);
184
+ form.reset({
185
+ message: t('send.defaultMessage'),
186
+ });
187
+ } catch {
188
+ showToastHandler?.('error', t('send.submit'));
189
+ }
190
+ };
191
+
192
+ return (
193
+ <Sheet open={open} onOpenChange={setOpen}>
194
+ <SheetTrigger asChild>
195
+ <Button size="sm">
196
+ <Send className="mr-2 h-4 w-4" />
197
+ {t('actions.collect')}
198
+ </Button>
199
+ </SheetTrigger>
200
+ <SheetContent side="right" className="w-[90vw] sm:max-w-xl">
201
+ <SheetHeader>
202
+ <SheetTitle>{t('send.title')}</SheetTitle>
203
+ <SheetDescription>
204
+ {t('send.description', { cliente })}
205
+ </SheetDescription>
206
+ </SheetHeader>
207
+
208
+ <Form {...form}>
209
+ <form
210
+ className="space-y-4 px-4"
211
+ onSubmit={form.handleSubmit(onSubmit)}
212
+ >
213
+ <FormField
214
+ control={form.control}
215
+ name="message"
216
+ render={({ field }) => (
217
+ <FormItem className="space-y-2">
218
+ <FormLabel>{t('send.message')}</FormLabel>
219
+ <FormControl>
220
+ <RichTextEditor
221
+ className="w-full max-w-full min-w-0 overflow-hidden [&_.ProseMirror]:max-w-full [&_.ProseMirror]:mx-0"
222
+ value={field.value ?? ''}
223
+ onChange={field.onChange}
224
+ />
225
+ </FormControl>
226
+ <FormMessage />
227
+ </FormItem>
228
+ )}
229
+ />
230
+
231
+ <div className="flex flex-col gap-2">
232
+ <Button
233
+ type="submit"
234
+ className="w-full"
235
+ disabled={form.formState.isSubmitting}
236
+ >
237
+ {t('send.submit')}
238
+ </Button>
239
+ </div>
240
+ </form>
241
+ </Form>
242
+ </SheetContent>
243
+ </Sheet>
244
+ );
245
+ }
246
+
247
+ function RegistrarAcordoDialog({
248
+ cliente,
249
+ clienteId,
250
+ onSuccess,
251
+ t,
252
+ }: {
253
+ cliente: string;
254
+ clienteId: string;
255
+ onSuccess: () => Promise<unknown>;
256
+ t: ReturnType<typeof useTranslations>;
257
+ }) {
258
+ const { request, showToastHandler } = useApp();
259
+ const [open, setOpen] = useState(false);
260
+
261
+ const form = useForm<z.infer<typeof registerAgreementSchema>>({
262
+ resolver: zodResolver(registerAgreementSchema),
263
+ defaultValues: {
264
+ amount: 0,
265
+ installments: 1,
266
+ first_due_date: '',
267
+ notes: '',
268
+ },
269
+ });
270
+
271
+ const onSubmit = async (values: z.infer<typeof registerAgreementSchema>) => {
272
+ try {
273
+ await request({
274
+ url: `/finance/accounts-receivable/collections-default/${clienteId}/agreements`,
275
+ method: 'POST',
276
+ data: values,
277
+ });
278
+
279
+ await onSuccess();
280
+ showToastHandler?.('success', t('agreement.submit'));
281
+ setOpen(false);
282
+ form.reset({
283
+ amount: 0,
284
+ installments: 1,
285
+ first_due_date: '',
286
+ notes: '',
287
+ });
288
+ } catch {
289
+ showToastHandler?.('error', t('agreement.submit'));
290
+ }
291
+ };
292
+
293
+ return (
294
+ <Sheet open={open} onOpenChange={setOpen}>
295
+ <SheetTrigger asChild>
296
+ <Button variant="outline" size="sm">
297
+ {t('actions.registerAgreement')}
298
+ </Button>
299
+ </SheetTrigger>
300
+ <SheetContent side="right" className="w-[90vw] sm:max-w-xl">
301
+ <SheetHeader>
302
+ <SheetTitle>{t('agreement.title')}</SheetTitle>
303
+ <SheetDescription>
304
+ {t('agreement.description', { cliente })}
305
+ </SheetDescription>
306
+ </SheetHeader>
307
+
308
+ <Form {...form}>
309
+ <form
310
+ className="space-y-4 px-4"
311
+ onSubmit={form.handleSubmit(onSubmit)}
312
+ >
313
+ <div className="grid grid-cols-2 gap-4">
314
+ <FormField
315
+ control={form.control}
316
+ name="amount"
317
+ render={({ field }) => (
318
+ <FormItem className="space-y-2">
319
+ <FormLabel>{t('agreement.value')}</FormLabel>
320
+ <FormControl>
321
+ <InputMoney
322
+ placeholder="0,00"
323
+ value={field.value ?? 0}
324
+ onValueChange={(value) => field.onChange(value ?? 0)}
325
+ />
326
+ </FormControl>
327
+ <FormMessage />
328
+ </FormItem>
329
+ )}
330
+ />
331
+
332
+ <FormField
333
+ control={form.control}
334
+ name="installments"
335
+ render={({ field }) => (
336
+ <FormItem className="space-y-2">
337
+ <FormLabel>{t('agreement.installments')}</FormLabel>
338
+ <FormControl>
339
+ <Input
340
+ type="number"
341
+ min={1}
342
+ max={120}
343
+ value={field.value}
344
+ onChange={field.onChange}
345
+ />
346
+ </FormControl>
347
+ <FormMessage />
348
+ </FormItem>
349
+ )}
350
+ />
351
+ </div>
352
+
353
+ <FormField
354
+ control={form.control}
355
+ name="first_due_date"
356
+ render={({ field }) => (
357
+ <FormItem className="space-y-2">
358
+ <FormLabel>{t('agreement.firstDueDate')}</FormLabel>
359
+ <FormControl>
360
+ <Input type="date" {...field} />
361
+ </FormControl>
362
+ <FormMessage />
363
+ </FormItem>
364
+ )}
365
+ />
366
+
367
+ <FormField
368
+ control={form.control}
369
+ name="notes"
370
+ render={({ field }) => (
371
+ <FormItem className="space-y-2">
372
+ <FormLabel>{t('agreement.notes')}</FormLabel>
373
+ <FormControl>
374
+ <Textarea
375
+ placeholder={t('agreement.notesPlaceholder')}
376
+ {...field}
377
+ />
378
+ </FormControl>
379
+ <FormMessage />
380
+ </FormItem>
381
+ )}
382
+ />
383
+
384
+ <div className="flex flex-col gap-2">
385
+ <Button
386
+ type="submit"
387
+ className="w-full"
388
+ disabled={form.formState.isSubmitting}
389
+ >
390
+ {t('agreement.submit')}
391
+ </Button>
392
+ </div>
393
+ </form>
394
+ </Form>
395
+ </SheetContent>
396
+ </Sheet>
397
+ );
398
+ }
399
+
400
+ export default function CobrancaPage() {
401
+ const t = useTranslations('finance.CollectionsDefaultPage');
402
+ const { request } = useApp();
403
+
404
+ const { data = EMPTY_DATA, refetch } = useQuery<CollectionsDefaultData>({
405
+ queryKey: ['finance-collections-default'],
406
+ queryFn: async () => {
407
+ const response = await request({
408
+ url: '/finance/accounts-receivable/collections-default',
409
+ method: 'GET',
410
+ });
411
+
412
+ return {
413
+ ...EMPTY_DATA,
414
+ ...(response?.data || {}),
415
+ };
416
+ },
417
+ staleTime: 0,
418
+ refetchOnMount: 'always',
419
+ });
420
+
421
+ const { agingInadimplencia, historicoContatos } = data;
422
+
423
+ const chartData = [
424
+ {
425
+ name: t('chart.range0to30'),
426
+ valor: agingInadimplencia.reduce(
427
+ (acc, c) => acc + (c.bucket0_30 || 0),
428
+ 0
429
+ ),
430
+ },
431
+ {
432
+ name: t('chart.range31to60'),
433
+ valor: agingInadimplencia.reduce(
434
+ (acc, c) => acc + (c.bucket31_60 || 0),
435
+ 0
436
+ ),
437
+ },
438
+ {
439
+ name: t('chart.range61to90'),
440
+ valor: agingInadimplencia.reduce(
441
+ (acc, c) => acc + (c.bucket61_90 || 0),
442
+ 0
443
+ ),
444
+ },
445
+ {
446
+ name: t('chart.range90plus'),
447
+ valor: agingInadimplencia.reduce(
448
+ (acc, c) => acc + (c.bucket90plus || 0),
449
+ 0
450
+ ),
451
+ },
452
+ ];
453
+
454
+ const totalInadimplencia = agingInadimplencia.reduce(
455
+ (acc, c) => acc + c.total,
456
+ 0
457
+ );
458
+ const totalClientes = agingInadimplencia.length;
459
+
460
+ return (
461
+ <Page>
462
+ <PageHeader
463
+ title={t('header.title')}
464
+ description={t('header.description')}
465
+ breadcrumbs={[
466
+ { label: t('breadcrumbs.home'), href: '/' },
467
+ { label: t('breadcrumbs.finance'), href: '/finance' },
468
+ { label: t('breadcrumbs.current') },
469
+ ]}
470
+ />
471
+
472
+ <div className="grid gap-4 md:grid-cols-3">
473
+ <Card>
474
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
475
+ <CardTitle className="text-sm font-medium">
476
+ {t('cards.totalDefault')}
477
+ </CardTitle>
478
+ <AlertTriangle className="h-4 w-4 text-destructive" />
479
+ </CardHeader>
480
+ <CardContent>
481
+ <div className="text-2xl font-bold">
482
+ <Money value={totalInadimplencia} />
483
+ </div>
484
+ </CardContent>
485
+ </Card>
486
+ <Card>
487
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
488
+ <CardTitle className="text-sm font-medium">
489
+ {t('cards.lateClients')}
490
+ </CardTitle>
491
+ </CardHeader>
492
+ <CardContent>
493
+ <div className="text-2xl font-bold">{totalClientes}</div>
494
+ </CardContent>
495
+ </Card>
496
+ <Card>
497
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
498
+ <CardTitle className="text-sm font-medium">
499
+ {t('cards.maxDelay')}
500
+ </CardTitle>
501
+ </CardHeader>
502
+ <CardContent>
503
+ <div className="text-2xl font-bold text-destructive">
504
+ {t('cards.maxDelayValue')}
505
+ </div>
506
+ </CardContent>
507
+ </Card>
508
+ </div>
509
+
510
+ <Card>
511
+ <CardHeader>
512
+ <CardTitle>{t('aging.title')}</CardTitle>
513
+ <CardDescription>{t('aging.description')}</CardDescription>
514
+ </CardHeader>
515
+ <CardContent>
516
+ <ResponsiveContainer width="100%" height={250}>
517
+ <BarChart data={chartData}>
518
+ <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
519
+ <XAxis dataKey="name" tick={{ fontSize: 12 }} />
520
+ <YAxis
521
+ tick={{ fontSize: 12 }}
522
+ tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
523
+ />
524
+ <Tooltip
525
+ formatter={(value: number) => formatarMoeda(value)}
526
+ contentStyle={{
527
+ backgroundColor: 'var(--background)',
528
+ border: '1px solid var(--border)',
529
+ borderRadius: '8px',
530
+ color: 'var(--foreground)',
531
+ }}
532
+ labelStyle={{ color: 'var(--foreground)' }}
533
+ itemStyle={{ color: 'var(--foreground)' }}
534
+ />
535
+ <Bar
536
+ dataKey="valor"
537
+ radius={[4, 4, 0, 0]}
538
+ fill="var(--primary)"
539
+ />
540
+ </BarChart>
541
+ </ResponsiveContainer>
542
+ </CardContent>
543
+ </Card>
544
+
545
+ <Card>
546
+ <CardHeader>
547
+ <CardTitle>{t('table.title')}</CardTitle>
548
+ <CardDescription>{t('table.description')}</CardDescription>
549
+ </CardHeader>
550
+ <CardContent>
551
+ <Table>
552
+ <TableHeader>
553
+ <TableRow>
554
+ <TableHead>{t('table.headers.client')}</TableHead>
555
+ <TableHead className="text-right">
556
+ {t('table.headers.range0to30')}
557
+ </TableHead>
558
+ <TableHead className="text-right">
559
+ {t('table.headers.range31to60')}
560
+ </TableHead>
561
+ <TableHead className="text-right">
562
+ {t('table.headers.range61to90')}
563
+ </TableHead>
564
+ <TableHead className="text-right">
565
+ {t('table.headers.range90plus')}
566
+ </TableHead>
567
+ <TableHead className="text-right">
568
+ {t('table.headers.total')}
569
+ </TableHead>
570
+ <TableHead className="text-right">
571
+ {t('table.headers.actions')}
572
+ </TableHead>
573
+ </TableRow>
574
+ </TableHeader>
575
+ <TableBody>
576
+ {agingInadimplencia.map((item) => (
577
+ <TableRow key={item.clienteId}>
578
+ <TableCell className="font-medium">{item.cliente}</TableCell>
579
+ <TableCell className="text-right">
580
+ {item.bucket0_30 > 0 ? (
581
+ <Money value={item.bucket0_30} />
582
+ ) : (
583
+ '-'
584
+ )}
585
+ </TableCell>
586
+ <TableCell className="text-right">
587
+ {item.bucket31_60 > 0 ? (
588
+ <Money value={item.bucket31_60} />
589
+ ) : (
590
+ '-'
591
+ )}
592
+ </TableCell>
593
+ <TableCell className="text-right">
594
+ {item.bucket61_90 > 0 ? (
595
+ <Money value={item.bucket61_90} />
596
+ ) : (
597
+ '-'
598
+ )}
599
+ </TableCell>
600
+ <TableCell className="text-right">
601
+ {item.bucket90plus > 0 ? (
602
+ <span className="font-medium text-destructive">
603
+ <Money value={item.bucket90plus} />
604
+ </span>
605
+ ) : (
606
+ '-'
607
+ )}
608
+ </TableCell>
609
+ <TableCell className="text-right font-semibold">
610
+ <Money value={item.total} />
611
+ </TableCell>
612
+ <TableCell>
613
+ <div className="flex justify-end gap-2">
614
+ <HistoricoContatosSheet
615
+ cliente={item.cliente}
616
+ historicoContatos={historicoContatos.filter(
617
+ (contato) => contato.clienteId === item.clienteId
618
+ )}
619
+ t={t}
620
+ />
621
+ <EnviarCobrancaDialog
622
+ cliente={item.cliente}
623
+ clienteId={item.clienteId}
624
+ onSuccess={refetch}
625
+ t={t}
626
+ />
627
+ <RegistrarAcordoDialog
628
+ cliente={item.cliente}
629
+ clienteId={item.clienteId}
630
+ onSuccess={refetch}
631
+ t={t}
632
+ />
633
+ </div>
634
+ </TableCell>
635
+ </TableRow>
636
+ ))}
637
+ </TableBody>
638
+ </Table>
639
+ </CardContent>
640
+ </Card>
641
+ </Page>
642
+ );
643
+ }