@hed-hog/finance 0.0.235 → 0.0.237

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 (75) hide show
  1. package/dist/dto/create-cost-center.dto.d.ts +4 -0
  2. package/dist/dto/create-cost-center.dto.d.ts.map +1 -0
  3. package/dist/dto/create-cost-center.dto.js +24 -0
  4. package/dist/dto/create-cost-center.dto.js.map +1 -0
  5. package/dist/dto/create-finance-category.dto.d.ts +6 -0
  6. package/dist/dto/create-finance-category.dto.d.ts.map +1 -0
  7. package/dist/dto/create-finance-category.dto.js +37 -0
  8. package/dist/dto/create-finance-category.dto.js.map +1 -0
  9. package/dist/dto/create-period-close.dto.d.ts +7 -0
  10. package/dist/dto/create-period-close.dto.d.ts.map +1 -0
  11. package/dist/dto/create-period-close.dto.js +44 -0
  12. package/dist/dto/create-period-close.dto.js.map +1 -0
  13. package/dist/dto/move-finance-category.dto.d.ts +5 -0
  14. package/dist/dto/move-finance-category.dto.d.ts.map +1 -0
  15. package/dist/dto/move-finance-category.dto.js +32 -0
  16. package/dist/dto/move-finance-category.dto.js.map +1 -0
  17. package/dist/dto/update-cost-center.dto.d.ts +5 -0
  18. package/dist/dto/update-cost-center.dto.d.ts.map +1 -0
  19. package/dist/dto/update-cost-center.dto.js +32 -0
  20. package/dist/dto/update-cost-center.dto.js.map +1 -0
  21. package/dist/dto/update-finance-category.dto.d.ts +7 -0
  22. package/dist/dto/update-finance-category.dto.d.ts.map +1 -0
  23. package/dist/dto/update-finance-category.dto.js +46 -0
  24. package/dist/dto/update-finance-category.dto.js.map +1 -0
  25. package/dist/finance-audit-logs.controller.d.ts +13 -0
  26. package/dist/finance-audit-logs.controller.d.ts.map +1 -0
  27. package/dist/finance-audit-logs.controller.js +54 -0
  28. package/dist/finance-audit-logs.controller.js.map +1 -0
  29. package/dist/finance-categories.controller.d.ts +42 -0
  30. package/dist/finance-categories.controller.d.ts.map +1 -0
  31. package/dist/finance-categories.controller.js +84 -0
  32. package/dist/finance-categories.controller.js.map +1 -0
  33. package/dist/finance-cost-centers.controller.d.ts +32 -0
  34. package/dist/finance-cost-centers.controller.d.ts.map +1 -0
  35. package/dist/finance-cost-centers.controller.js +72 -0
  36. package/dist/finance-cost-centers.controller.js.map +1 -0
  37. package/dist/finance-period-close.controller.d.ts +27 -0
  38. package/dist/finance-period-close.controller.d.ts.map +1 -0
  39. package/dist/finance-period-close.controller.js +64 -0
  40. package/dist/finance-period-close.controller.js.map +1 -0
  41. package/dist/finance.module.d.ts.map +1 -1
  42. package/dist/finance.module.js +8 -0
  43. package/dist/finance.module.js.map +1 -1
  44. package/dist/finance.service.d.ts +111 -0
  45. package/dist/finance.service.d.ts.map +1 -1
  46. package/dist/finance.service.js +446 -17
  47. package/dist/finance.service.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/hedhog/data/route.yaml +108 -0
  53. package/hedhog/frontend/app/_components/person-field-with-create.tsx.ejs +627 -0
  54. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +865 -883
  55. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +838 -861
  56. package/hedhog/frontend/app/administration/audit-logs/page.tsx.ejs +309 -0
  57. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +725 -0
  58. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +378 -0
  59. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +502 -0
  60. package/hedhog/frontend/messages/en.json +225 -0
  61. package/hedhog/frontend/messages/pt.json +225 -0
  62. package/package.json +5 -5
  63. package/src/dto/create-cost-center.dto.ts +9 -0
  64. package/src/dto/create-finance-category.dto.ts +21 -0
  65. package/src/dto/create-period-close.dto.ts +34 -0
  66. package/src/dto/move-finance-category.dto.ts +18 -0
  67. package/src/dto/update-cost-center.dto.ts +17 -0
  68. package/src/dto/update-finance-category.dto.ts +30 -0
  69. package/src/finance-audit-logs.controller.ts +30 -0
  70. package/src/finance-categories.controller.ts +52 -0
  71. package/src/finance-cost-centers.controller.ts +43 -0
  72. package/src/finance-period-close.controller.ts +34 -0
  73. package/src/finance.module.ts +8 -0
  74. package/src/finance.service.ts +578 -9
  75. package/src/index.ts +4 -0
@@ -1,861 +1,838 @@
1
- 'use client';
2
-
3
- import { Page, PageHeader } from '@/components/entity-list';
4
- import { Badge } from '@/components/ui/badge';
5
- import { Button } from '@/components/ui/button';
6
- import {
7
- DropdownMenu,
8
- DropdownMenuContent,
9
- DropdownMenuItem,
10
- DropdownMenuSeparator,
11
- DropdownMenuTrigger,
12
- } from '@/components/ui/dropdown-menu';
13
- import { FilterBar } from '@/components/ui/filter-bar';
14
- import {
15
- Form,
16
- FormControl,
17
- FormField,
18
- FormItem,
19
- FormLabel,
20
- FormMessage,
21
- } from '@/components/ui/form';
22
- import { Input } from '@/components/ui/input';
23
- import { InputMoney } from '@/components/ui/input-money';
24
- import { Money } from '@/components/ui/money';
25
- import { Progress } from '@/components/ui/progress';
26
- import {
27
- Select,
28
- SelectContent,
29
- SelectItem,
30
- SelectTrigger,
31
- SelectValue,
32
- } from '@/components/ui/select';
33
- import {
34
- Sheet,
35
- SheetContent,
36
- SheetDescription,
37
- SheetHeader,
38
- SheetTitle,
39
- SheetTrigger,
40
- } from '@/components/ui/sheet';
41
- import { StatusBadge } from '@/components/ui/status-badge';
42
- import {
43
- Table,
44
- TableBody,
45
- TableCell,
46
- TableHead,
47
- TableHeader,
48
- TableRow,
49
- } from '@/components/ui/table';
50
- import { Textarea } from '@/components/ui/textarea';
51
- import { useApp } from '@hed-hog/next-app-provider';
52
- import { zodResolver } from '@hookform/resolvers/zod';
53
- import {
54
- Download,
55
- Edit,
56
- Eye,
57
- Loader2,
58
- MoreHorizontal,
59
- Paperclip,
60
- Plus,
61
- Send,
62
- } from 'lucide-react';
63
- import { useTranslations } from 'next-intl';
64
- import Link from 'next/link';
65
- import { useState } from 'react';
66
- import { useForm } from 'react-hook-form';
67
- import { z } from 'zod';
68
- import { formatarData } from '../../_lib/formatters';
69
- import { useFinanceData } from '../../_lib/use-finance-data';
70
-
71
- const newTitleFormSchema = z.object({
72
- documento: z.string().trim().min(1, 'Documento é obrigatório'),
73
- clienteId: z.string().min(1, 'Cliente é obrigatório'),
74
- competencia: z.string().optional(),
75
- vencimento: z.string().min(1, 'Vencimento é obrigatório'),
76
- valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
77
- categoriaId: z.string().optional(),
78
- centroCustoId: z.string().optional(),
79
- canal: z.string().optional(),
80
- descricao: z.string().optional(),
81
- });
82
-
83
- type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
84
-
85
- function NovoTituloSheet({
86
- pessoas,
87
- categorias,
88
- centrosCusto,
89
- t,
90
- onCreated,
91
- }: {
92
- pessoas: any[];
93
- categorias: any[];
94
- centrosCusto: any[];
95
- t: ReturnType<typeof useTranslations>;
96
- onCreated: () => Promise<any> | void;
97
- }) {
98
- const { request, showToastHandler } = useApp();
99
- const [open, setOpen] = useState(false);
100
- const [uploadedFileId, setUploadedFileId] = useState<number | null>(null);
101
- const [uploadedFileName, setUploadedFileName] = useState('');
102
- const [isUploadingFile, setIsUploadingFile] = useState(false);
103
- const [isExtractingFileData, setIsExtractingFileData] = useState(false);
104
- const [extractionConfidence, setExtractionConfidence] = useState<
105
- number | null
106
- >(null);
107
- const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
108
- const [uploadProgress, setUploadProgress] = useState(0);
109
-
110
- const normalizeFilenameForDisplay = (filename: string) => {
111
- if (!filename) {
112
- return filename;
113
- }
114
-
115
- if (!/Ã.|Â.|â[\u0080-\u00BF]/.test(filename)) {
116
- return filename;
117
- }
118
-
119
- try {
120
- const bytes = Uint8Array.from(filename, (char) => char.charCodeAt(0));
121
- const decoded = new TextDecoder('utf-8').decode(bytes);
122
- return /Ã.|Â.|â[\u0080-\u00BF]/.test(decoded) ? filename : decoded;
123
- } catch {
124
- return filename;
125
- }
126
- };
127
-
128
- const form = useForm<NewTitleFormValues>({
129
- resolver: zodResolver(newTitleFormSchema),
130
- defaultValues: {
131
- documento: '',
132
- clienteId: '',
133
- competencia: '',
134
- vencimento: '',
135
- valor: 0,
136
- categoriaId: '',
137
- centroCustoId: '',
138
- canal: '',
139
- descricao: '',
140
- },
141
- });
142
-
143
- const handleSubmit = async (values: NewTitleFormValues) => {
144
- try {
145
- await request({
146
- url: '/finance/accounts-receivable/installments',
147
- method: 'POST',
148
- data: {
149
- document_number: values.documento,
150
- person_id: Number(values.clienteId),
151
- competence_date: values.competencia
152
- ? `${values.competencia}-01`
153
- : undefined,
154
- due_date: values.vencimento,
155
- total_amount: values.valor,
156
- finance_category_id: values.categoriaId
157
- ? Number(values.categoriaId)
158
- : undefined,
159
- cost_center_id: values.centroCustoId
160
- ? Number(values.centroCustoId)
161
- : undefined,
162
- payment_channel: values.canal || undefined,
163
- description: values.descricao?.trim() || undefined,
164
- attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
165
- },
166
- });
167
-
168
- await onCreated();
169
- form.reset();
170
- setUploadedFileId(null);
171
- setUploadedFileName('');
172
- setExtractionConfidence(null);
173
- setExtractionWarnings([]);
174
- setOpen(false);
175
- showToastHandler?.('success', 'Título criado com sucesso');
176
- } catch {
177
- showToastHandler?.('error', 'Erro ao criar título');
178
- }
179
- };
180
-
181
- const handleCancel = () => {
182
- form.reset();
183
- setUploadedFileId(null);
184
- setUploadedFileName('');
185
- setExtractionConfidence(null);
186
- setExtractionWarnings([]);
187
- setUploadProgress(0);
188
- setOpen(false);
189
- };
190
-
191
- const uploadRelatedFile = async (file: File) => {
192
- setIsUploadingFile(true);
193
- setUploadProgress(0);
194
-
195
- try {
196
- const formData = new FormData();
197
- formData.append('file', file);
198
- formData.append('destination', 'finance/titles');
199
-
200
- const { data } = await request<{ id: number; filename: string }>({
201
- url: '/file',
202
- method: 'POST',
203
- data: formData,
204
- headers: {
205
- 'Content-Type': 'multipart/form-data',
206
- },
207
- onUploadProgress: (event) => {
208
- if (!event.total) {
209
- return;
210
- }
211
-
212
- const progress = Math.round((event.loaded * 100) / event.total);
213
- setUploadProgress(progress);
214
- },
215
- });
216
-
217
- if (!data?.id) {
218
- throw new Error('Arquivo inválido');
219
- }
220
-
221
- setUploadedFileId(data.id);
222
- setUploadedFileName(
223
- normalizeFilenameForDisplay(data.filename || file.name)
224
- );
225
- setUploadProgress(100);
226
- showToastHandler?.('success', 'Arquivo relacionado com sucesso');
227
-
228
- setIsExtractingFileData(true);
229
- try {
230
- const extraction = await request<{
231
- documento?: string | null;
232
- clienteId?: string;
233
- competencia?: string;
234
- vencimento?: string;
235
- valor?: number | null;
236
- categoriaId?: string;
237
- centroCustoId?: string;
238
- canal?: string;
239
- descricao?: string | null;
240
- confidence?: number | null;
241
- confidenceLevel?: 'low' | 'high' | null;
242
- warnings?: string[];
243
- }>({
244
- url: '/finance/accounts-receivable/installments/extract-from-file',
245
- method: 'POST',
246
- data: {
247
- file_id: data.id,
248
- },
249
- });
250
-
251
- const extracted = extraction.data || {};
252
- setExtractionConfidence(
253
- typeof extracted.confidence === 'number' ? extracted.confidence : null
254
- );
255
- setExtractionWarnings(
256
- Array.isArray(extracted.warnings)
257
- ? extracted.warnings.filter(Boolean)
258
- : []
259
- );
260
-
261
- if (extracted.documento) {
262
- form.setValue('documento', extracted.documento, {
263
- shouldValidate: true,
264
- });
265
- }
266
-
267
- if (extracted.clienteId) {
268
- form.setValue('clienteId', extracted.clienteId, {
269
- shouldValidate: true,
270
- });
271
- }
272
-
273
- if (extracted.competencia) {
274
- form.setValue('competencia', extracted.competencia, {
275
- shouldValidate: true,
276
- });
277
- }
278
-
279
- if (extracted.vencimento) {
280
- form.setValue('vencimento', extracted.vencimento, {
281
- shouldValidate: true,
282
- });
283
- }
284
-
285
- if (typeof extracted.valor === 'number' && extracted.valor > 0) {
286
- form.setValue('valor', extracted.valor, {
287
- shouldValidate: true,
288
- });
289
- }
290
-
291
- if (extracted.categoriaId) {
292
- form.setValue('categoriaId', extracted.categoriaId, {
293
- shouldValidate: true,
294
- });
295
- }
296
-
297
- if (extracted.centroCustoId) {
298
- form.setValue('centroCustoId', extracted.centroCustoId, {
299
- shouldValidate: true,
300
- });
301
- }
302
-
303
- if (extracted.canal) {
304
- form.setValue('canal', extracted.canal, {
305
- shouldValidate: true,
306
- });
307
- }
308
-
309
- if (extracted.descricao) {
310
- form.setValue('descricao', extracted.descricao, {
311
- shouldValidate: true,
312
- });
313
- }
314
-
315
- showToastHandler?.(
316
- 'success',
317
- 'Dados da fatura extraídos e preenchidos automaticamente'
318
- );
319
- } catch {
320
- setExtractionConfidence(null);
321
- setExtractionWarnings([]);
322
- showToastHandler?.(
323
- 'error',
324
- 'Não foi possível extrair os dados automaticamente'
325
- );
326
- } finally {
327
- setIsExtractingFileData(false);
328
- }
329
- } catch {
330
- setUploadedFileId(null);
331
- setUploadedFileName('');
332
- setExtractionConfidence(null);
333
- setExtractionWarnings([]);
334
- setUploadProgress(0);
335
- showToastHandler?.('error', 'Não foi possível enviar o arquivo');
336
- } finally {
337
- setIsUploadingFile(false);
338
- }
339
- };
340
-
341
- return (
342
- <Sheet open={open} onOpenChange={setOpen}>
343
- <SheetTrigger asChild>
344
- <Button>
345
- <Plus className="mr-2 h-4 w-4" />
346
- {t('newTitle.action')}
347
- </Button>
348
- </SheetTrigger>
349
- <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
350
- <SheetHeader>
351
- <SheetTitle>{t('newTitle.title')}</SheetTitle>
352
- <SheetDescription>{t('newTitle.description')}</SheetDescription>
353
- </SheetHeader>
354
- <Form {...form}>
355
- <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
356
- <div className="grid gap-4">
357
- <div className="grid gap-2">
358
- <FormLabel>Arquivo da fatura (opcional)</FormLabel>
359
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
360
- <Input
361
- type="file"
362
- accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
363
- onChange={(event) => {
364
- const file = event.target.files?.[0];
365
- if (!file) {
366
- return;
367
- }
368
-
369
- setUploadedFileId(null);
370
- setUploadedFileName('');
371
- setExtractionConfidence(null);
372
- setExtractionWarnings([]);
373
- setUploadProgress(0);
374
- void uploadRelatedFile(file);
375
- }}
376
- disabled={
377
- isUploadingFile ||
378
- isExtractingFileData ||
379
- form.formState.isSubmitting
380
- }
381
- />
382
- </div>
383
- {isUploadingFile && (
384
- <div className="space-y-1">
385
- <Progress value={uploadProgress} className="h-2" />
386
- <p className="text-xs text-muted-foreground">
387
- Upload em andamento: {uploadProgress}%
388
- </p>
389
- </div>
390
- )}
391
- {uploadedFileId && (
392
- <p className="text-xs text-muted-foreground">
393
- Arquivo relacionado: {uploadedFileName}
394
- </p>
395
- )}
396
- {isExtractingFileData && (
397
- <div className="rounded-md border border-primary/30 bg-primary/5 p-3">
398
- <div className="flex items-center gap-2 text-sm font-medium text-primary">
399
- <Loader2 className="h-4 w-4 animate-spin" />
400
- Analisando documento com IA
401
- </div>
402
- <p className="mt-1 text-xs text-muted-foreground">
403
- Os campos serão preenchidos automaticamente em instantes.
404
- Revise os dados antes de salvar.
405
- </p>
406
- </div>
407
- )}
408
- {!isExtractingFileData &&
409
- extractionConfidence !== null &&
410
- extractionConfidence < 70 && (
411
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
412
- <div className="text-sm font-medium text-destructive">
413
- Confiança da extração:{' '}
414
- {Math.round(extractionConfidence)}%
415
- </div>
416
- <p className="mt-1 text-xs text-muted-foreground">
417
- Revise principalmente valor e vencimento antes de
418
- salvar.
419
- </p>
420
- {extractionWarnings.length > 0 && (
421
- <p className="mt-1 text-xs text-muted-foreground">
422
- {extractionWarnings[0]}
423
- </p>
424
- )}
425
- </div>
426
- )}
427
- </div>
428
-
429
- <FormField
430
- control={form.control}
431
- name="documento"
432
- render={({ field }) => (
433
- <FormItem>
434
- <FormLabel>{t('fields.document')}</FormLabel>
435
- <FormControl>
436
- <Input placeholder="FAT-00000" {...field} />
437
- </FormControl>
438
- <FormMessage />
439
- </FormItem>
440
- )}
441
- />
442
-
443
- <FormField
444
- control={form.control}
445
- name="clienteId"
446
- render={({ field }) => (
447
- <FormItem>
448
- <FormLabel>{t('fields.client')}</FormLabel>
449
- <Select value={field.value} onValueChange={field.onChange}>
450
- <FormControl>
451
- <SelectTrigger>
452
- <SelectValue placeholder={t('common.select')} />
453
- </SelectTrigger>
454
- </FormControl>
455
- <SelectContent>
456
- {pessoas
457
- .filter(
458
- (p) => p.tipo === 'cliente' || p.tipo === 'ambos'
459
- )
460
- .map((p) => (
461
- <SelectItem key={p.id} value={String(p.id)}>
462
- {p.nome}
463
- </SelectItem>
464
- ))}
465
- </SelectContent>
466
- </Select>
467
- <FormMessage />
468
- </FormItem>
469
- )}
470
- />
471
-
472
- <div className="grid grid-cols-2 gap-4">
473
- <FormField
474
- control={form.control}
475
- name="competencia"
476
- render={({ field }) => (
477
- <FormItem>
478
- <FormLabel>{t('fields.competency')}</FormLabel>
479
- <FormControl>
480
- <Input
481
- type="month"
482
- {...field}
483
- value={field.value || ''}
484
- />
485
- </FormControl>
486
- <FormMessage />
487
- </FormItem>
488
- )}
489
- />
490
-
491
- <FormField
492
- control={form.control}
493
- name="vencimento"
494
- render={({ field }) => (
495
- <FormItem>
496
- <FormLabel>{t('fields.dueDate')}</FormLabel>
497
- <FormControl>
498
- <Input
499
- type="date"
500
- {...field}
501
- value={field.value || ''}
502
- />
503
- </FormControl>
504
- <FormMessage />
505
- </FormItem>
506
- )}
507
- />
508
- </div>
509
-
510
- <FormField
511
- control={form.control}
512
- name="valor"
513
- render={({ field }) => (
514
- <FormItem>
515
- <FormLabel>{t('fields.totalValue')}</FormLabel>
516
- <FormControl>
517
- <InputMoney
518
- ref={field.ref}
519
- name={field.name}
520
- value={field.value}
521
- onBlur={field.onBlur}
522
- onValueChange={(value) => field.onChange(value ?? 0)}
523
- placeholder="0,00"
524
- />
525
- </FormControl>
526
- <FormMessage />
527
- </FormItem>
528
- )}
529
- />
530
-
531
- <FormField
532
- control={form.control}
533
- name="categoriaId"
534
- render={({ field }) => (
535
- <FormItem>
536
- <FormLabel>{t('fields.category')}</FormLabel>
537
- <Select value={field.value} onValueChange={field.onChange}>
538
- <FormControl>
539
- <SelectTrigger>
540
- <SelectValue placeholder={t('common.select')} />
541
- </SelectTrigger>
542
- </FormControl>
543
- <SelectContent>
544
- {categorias
545
- .filter((c) => c.natureza === 'receita')
546
- .map((c) => (
547
- <SelectItem key={c.id} value={String(c.id)}>
548
- {c.codigo} - {c.nome}
549
- </SelectItem>
550
- ))}
551
- </SelectContent>
552
- </Select>
553
- <FormMessage />
554
- </FormItem>
555
- )}
556
- />
557
-
558
- <FormField
559
- control={form.control}
560
- name="centroCustoId"
561
- render={({ field }) => (
562
- <FormItem>
563
- <FormLabel>{t('fields.costCenter')}</FormLabel>
564
- <Select value={field.value} onValueChange={field.onChange}>
565
- <FormControl>
566
- <SelectTrigger>
567
- <SelectValue placeholder={t('common.select')} />
568
- </SelectTrigger>
569
- </FormControl>
570
- <SelectContent>
571
- {centrosCusto.map((c) => (
572
- <SelectItem key={c.id} value={String(c.id)}>
573
- {c.codigo} - {c.nome}
574
- </SelectItem>
575
- ))}
576
- </SelectContent>
577
- </Select>
578
- <FormMessage />
579
- </FormItem>
580
- )}
581
- />
582
-
583
- <FormField
584
- control={form.control}
585
- name="canal"
586
- render={({ field }) => (
587
- <FormItem>
588
- <FormLabel>{t('fields.channel')}</FormLabel>
589
- <Select value={field.value} onValueChange={field.onChange}>
590
- <FormControl>
591
- <SelectTrigger>
592
- <SelectValue placeholder={t('common.select')} />
593
- </SelectTrigger>
594
- </FormControl>
595
- <SelectContent>
596
- <SelectItem value="boleto">
597
- {t('channels.boleto')}
598
- </SelectItem>
599
- <SelectItem value="pix">PIX</SelectItem>
600
- <SelectItem value="cartao">
601
- {t('channels.card')}
602
- </SelectItem>
603
- <SelectItem value="transferencia">
604
- {t('channels.transfer')}
605
- </SelectItem>
606
- </SelectContent>
607
- </Select>
608
- <FormMessage />
609
- </FormItem>
610
- )}
611
- />
612
-
613
- <FormField
614
- control={form.control}
615
- name="descricao"
616
- render={({ field }) => (
617
- <FormItem>
618
- <FormLabel>{t('fields.description')}</FormLabel>
619
- <FormControl>
620
- <Textarea
621
- placeholder={t('newTitle.descriptionPlaceholder')}
622
- {...field}
623
- value={field.value || ''}
624
- />
625
- </FormControl>
626
- <FormMessage />
627
- </FormItem>
628
- )}
629
- />
630
- </div>
631
-
632
- <div className="flex justify-end gap-2 pt-4">
633
- <Button type="button" variant="outline" onClick={handleCancel}>
634
- {t('common.cancel')}
635
- </Button>
636
- <Button
637
- type="submit"
638
- disabled={
639
- form.formState.isSubmitting ||
640
- isUploadingFile ||
641
- isExtractingFileData
642
- }
643
- >
644
- {(isUploadingFile || isExtractingFileData) && (
645
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
646
- )}
647
- {isExtractingFileData
648
- ? 'Preenchendo com IA...'
649
- : isUploadingFile
650
- ? 'Enviando arquivo...'
651
- : t('common.save')}
652
- </Button>
653
- </div>
654
- </form>
655
- </Form>
656
- </SheetContent>
657
- </Sheet>
658
- );
659
- }
660
-
661
- export default function TitulosReceberPage() {
662
- const t = useTranslations('finance.ReceivableInstallmentsPage');
663
- const { data, refetch } = useFinanceData();
664
- const { titulosReceber, pessoas, categorias, centrosCusto } = data;
665
-
666
- const getPessoaById = (id?: string) => pessoas.find((p) => p.id === id);
667
-
668
- const [search, setSearch] = useState('');
669
- const [statusFilter, setStatusFilter] = useState<string>('');
670
-
671
- const canalBadge = {
672
- boleto: {
673
- label: t('channels.boleto'),
674
- className: 'bg-blue-100 text-blue-700',
675
- },
676
- pix: { label: 'PIX', className: 'bg-green-100 text-green-700' },
677
- cartao: {
678
- label: t('channels.card'),
679
- className: 'bg-purple-100 text-purple-700',
680
- },
681
- transferencia: {
682
- label: t('channels.transfer'),
683
- className: 'bg-orange-100 text-orange-700',
684
- },
685
- };
686
-
687
- const filteredTitulos = titulosReceber.filter((titulo) => {
688
- const matchesSearch =
689
- titulo.documento.toLowerCase().includes(search.toLowerCase()) ||
690
- getPessoaById(titulo.clienteId)
691
- ?.nome.toLowerCase()
692
- .includes(search.toLowerCase());
693
-
694
- const matchesStatus = !statusFilter || titulo.status === statusFilter;
695
-
696
- return matchesSearch && matchesStatus;
697
- });
698
-
699
- return (
700
- <Page>
701
- <PageHeader
702
- title={t('header.title')}
703
- description={t('header.description')}
704
- breadcrumbs={[
705
- { label: t('breadcrumbs.home'), href: '/' },
706
- { label: t('breadcrumbs.finance'), href: '/finance' },
707
- { label: t('breadcrumbs.current') },
708
- ]}
709
- actions={
710
- <NovoTituloSheet
711
- pessoas={pessoas}
712
- categorias={categorias}
713
- centrosCusto={centrosCusto}
714
- t={t}
715
- onCreated={refetch}
716
- />
717
- }
718
- />
719
-
720
- <FilterBar
721
- searchPlaceholder={t('filters.searchPlaceholder')}
722
- searchValue={search}
723
- onSearchChange={setSearch}
724
- filters={[
725
- {
726
- id: 'status',
727
- label: t('filters.status'),
728
- value: statusFilter,
729
- onChange: setStatusFilter,
730
- options: [
731
- { value: 'all', label: t('statuses.all') },
732
- { value: 'aberto', label: t('statuses.aberto') },
733
- { value: 'parcial', label: t('statuses.parcial') },
734
- { value: 'liquidado', label: t('statuses.liquidado') },
735
- { value: 'vencido', label: t('statuses.vencido') },
736
- { value: 'cancelado', label: t('statuses.cancelado') },
737
- ],
738
- },
739
- ]}
740
- activeFilters={statusFilter && statusFilter !== 'all' ? 1 : 0}
741
- onClearFilters={() => setStatusFilter('')}
742
- />
743
-
744
- <div className="rounded-md border">
745
- <Table>
746
- <TableHeader>
747
- <TableRow>
748
- <TableHead>{t('table.headers.document')}</TableHead>
749
- <TableHead>{t('table.headers.client')}</TableHead>
750
- <TableHead>{t('table.headers.competency')}</TableHead>
751
- <TableHead>{t('table.headers.dueDate')}</TableHead>
752
- <TableHead className="text-right">
753
- {t('table.headers.value')}
754
- </TableHead>
755
- <TableHead>{t('table.headers.channel')}</TableHead>
756
- <TableHead>{t('table.headers.status')}</TableHead>
757
- <TableHead className="w-[50px]" />
758
- </TableRow>
759
- </TableHeader>
760
- <TableBody>
761
- {filteredTitulos.map((titulo) => {
762
- const cliente = getPessoaById(titulo.clienteId);
763
- const canal =
764
- canalBadge[titulo.canal as keyof typeof canalBadge] ||
765
- canalBadge.transferencia;
766
- const proximaParcela = titulo.parcelas.find(
767
- (p: any) => p.status === 'aberto' || p.status === 'vencido'
768
- );
769
-
770
- return (
771
- <TableRow key={titulo.id}>
772
- <TableCell className="font-medium">
773
- <Link
774
- href={`/finance/accounts-receivable/installments/${titulo.id}`}
775
- className="hover:underline"
776
- >
777
- {titulo.documento}
778
- </Link>
779
- {titulo.anexos.length > 0 && (
780
- <Paperclip className="ml-1 inline h-3 w-3 text-muted-foreground" />
781
- )}
782
- </TableCell>
783
- <TableCell>{cliente?.nome}</TableCell>
784
- <TableCell>{titulo.competencia}</TableCell>
785
- <TableCell>
786
- {proximaParcela
787
- ? formatarData(proximaParcela.vencimento)
788
- : '-'}
789
- </TableCell>
790
- <TableCell className="text-right">
791
- <Money value={titulo.valorTotal} />
792
- </TableCell>
793
- <TableCell>
794
- <Badge className={canal.className} variant="outline">
795
- {canal.label}
796
- </Badge>
797
- </TableCell>
798
- <TableCell>
799
- <StatusBadge status={titulo.status} />
800
- </TableCell>
801
- <TableCell>
802
- <DropdownMenu>
803
- <DropdownMenuTrigger asChild>
804
- <Button variant="ghost" size="icon">
805
- <MoreHorizontal className="h-4 w-4" />
806
- <span className="sr-only">
807
- {t('table.actions.srActions')}
808
- </span>
809
- </Button>
810
- </DropdownMenuTrigger>
811
- <DropdownMenuContent align="end">
812
- <DropdownMenuItem asChild>
813
- <Link
814
- href={`/finance/accounts-receivable/installments/${titulo.id}`}
815
- >
816
- <Eye className="mr-2 h-4 w-4" />
817
- {t('table.actions.viewDetails')}
818
- </Link>
819
- </DropdownMenuItem>
820
- <DropdownMenuItem>
821
- <Edit className="mr-2 h-4 w-4" />
822
- {t('table.actions.edit')}
823
- </DropdownMenuItem>
824
- <DropdownMenuSeparator />
825
- <DropdownMenuItem>
826
- <Download className="mr-2 h-4 w-4" />
827
- {t('table.actions.registerReceipt')}
828
- </DropdownMenuItem>
829
- <DropdownMenuItem>
830
- <Send className="mr-2 h-4 w-4" />
831
- {t('table.actions.sendCollection')}
832
- </DropdownMenuItem>
833
- </DropdownMenuContent>
834
- </DropdownMenu>
835
- </TableCell>
836
- </TableRow>
837
- );
838
- })}
839
- </TableBody>
840
- </Table>
841
- </div>
842
-
843
- <div className="flex items-center justify-between">
844
- <p className="text-sm text-muted-foreground">
845
- {t('footer.showing', {
846
- filtered: filteredTitulos.length,
847
- total: titulosReceber.length,
848
- })}
849
- </p>
850
- <div className="flex items-center gap-2">
851
- <Button variant="outline" size="sm" disabled>
852
- {t('footer.previous')}
853
- </Button>
854
- <Button variant="outline" size="sm" disabled>
855
- {t('footer.next')}
856
- </Button>
857
- </div>
858
- </div>
859
- </Page>
860
- );
861
- }
1
+ 'use client';
2
+
3
+ import { PersonFieldWithCreate } from '@/app/(app)/(libraries)/finance/_components/person-field-with-create';
4
+ import { Page, PageHeader } from '@/components/entity-list';
5
+ import { Badge } from '@/components/ui/badge';
6
+ import { Button } from '@/components/ui/button';
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ } from '@/components/ui/dropdown-menu';
14
+ import { FilterBar } from '@/components/ui/filter-bar';
15
+ import {
16
+ Form,
17
+ FormControl,
18
+ FormField,
19
+ FormItem,
20
+ FormLabel,
21
+ FormMessage,
22
+ } from '@/components/ui/form';
23
+ import { Input } from '@/components/ui/input';
24
+ import { InputMoney } from '@/components/ui/input-money';
25
+ import { Money } from '@/components/ui/money';
26
+ import { Progress } from '@/components/ui/progress';
27
+ import {
28
+ Select,
29
+ SelectContent,
30
+ SelectItem,
31
+ SelectTrigger,
32
+ SelectValue,
33
+ } from '@/components/ui/select';
34
+ import {
35
+ Sheet,
36
+ SheetContent,
37
+ SheetDescription,
38
+ SheetHeader,
39
+ SheetTitle,
40
+ SheetTrigger,
41
+ } from '@/components/ui/sheet';
42
+ import { StatusBadge } from '@/components/ui/status-badge';
43
+ import {
44
+ Table,
45
+ TableBody,
46
+ TableCell,
47
+ TableHead,
48
+ TableHeader,
49
+ TableRow,
50
+ } from '@/components/ui/table';
51
+ import { Textarea } from '@/components/ui/textarea';
52
+ import { useApp } from '@hed-hog/next-app-provider';
53
+ import { zodResolver } from '@hookform/resolvers/zod';
54
+ import {
55
+ Download,
56
+ Edit,
57
+ Eye,
58
+ Loader2,
59
+ MoreHorizontal,
60
+ Paperclip,
61
+ Plus,
62
+ Send,
63
+ } from 'lucide-react';
64
+ import { useTranslations } from 'next-intl';
65
+ import Link from 'next/link';
66
+ import { useState } from 'react';
67
+ import { useForm } from 'react-hook-form';
68
+ import { z } from 'zod';
69
+ import { formatarData } from '../../_lib/formatters';
70
+ import { useFinanceData } from '../../_lib/use-finance-data';
71
+
72
+ const newTitleFormSchema = z.object({
73
+ documento: z.string().trim().min(1, 'Documento é obrigatório'),
74
+ clienteId: z.string().min(1, 'Cliente é obrigatório'),
75
+ competencia: z.string().optional(),
76
+ vencimento: z.string().min(1, 'Vencimento é obrigatório'),
77
+ valor: z.number().min(0.01, 'Valor deve ser maior que zero'),
78
+ categoriaId: z.string().optional(),
79
+ centroCustoId: z.string().optional(),
80
+ canal: z.string().optional(),
81
+ descricao: z.string().optional(),
82
+ });
83
+
84
+ type NewTitleFormValues = z.infer<typeof newTitleFormSchema>;
85
+
86
+ function NovoTituloSheet({
87
+ categorias,
88
+ centrosCusto,
89
+ t,
90
+ onCreated,
91
+ }: {
92
+ categorias: any[];
93
+ centrosCusto: any[];
94
+ t: ReturnType<typeof useTranslations>;
95
+ onCreated: () => Promise<any> | void;
96
+ }) {
97
+ const { request, showToastHandler } = useApp();
98
+ const [open, setOpen] = useState(false);
99
+ const [uploadedFileId, setUploadedFileId] = useState<number | null>(null);
100
+ const [uploadedFileName, setUploadedFileName] = useState('');
101
+ const [isUploadingFile, setIsUploadingFile] = useState(false);
102
+ const [isExtractingFileData, setIsExtractingFileData] = useState(false);
103
+ const [extractionConfidence, setExtractionConfidence] = useState<
104
+ number | null
105
+ >(null);
106
+ const [extractionWarnings, setExtractionWarnings] = useState<string[]>([]);
107
+ const [uploadProgress, setUploadProgress] = useState(0);
108
+
109
+ const normalizeFilenameForDisplay = (filename: string) => {
110
+ if (!filename) {
111
+ return filename;
112
+ }
113
+
114
+ if (!/Ã.|Â.|â[\u0080-\u00BF]/.test(filename)) {
115
+ return filename;
116
+ }
117
+
118
+ try {
119
+ const bytes = Uint8Array.from(filename, (char) => char.charCodeAt(0));
120
+ const decoded = new TextDecoder('utf-8').decode(bytes);
121
+ return /Ã.|Â.|â[\u0080-\u00BF]/.test(decoded) ? filename : decoded;
122
+ } catch {
123
+ return filename;
124
+ }
125
+ };
126
+
127
+ const form = useForm<NewTitleFormValues>({
128
+ resolver: zodResolver(newTitleFormSchema),
129
+ defaultValues: {
130
+ documento: '',
131
+ clienteId: '',
132
+ competencia: '',
133
+ vencimento: '',
134
+ valor: 0,
135
+ categoriaId: '',
136
+ centroCustoId: '',
137
+ canal: '',
138
+ descricao: '',
139
+ },
140
+ });
141
+
142
+ const handleSubmit = async (values: NewTitleFormValues) => {
143
+ try {
144
+ await request({
145
+ url: '/finance/accounts-receivable/installments',
146
+ method: 'POST',
147
+ data: {
148
+ document_number: values.documento,
149
+ person_id: Number(values.clienteId),
150
+ competence_date: values.competencia
151
+ ? `${values.competencia}-01`
152
+ : undefined,
153
+ due_date: values.vencimento,
154
+ total_amount: values.valor,
155
+ finance_category_id: values.categoriaId
156
+ ? Number(values.categoriaId)
157
+ : undefined,
158
+ cost_center_id: values.centroCustoId
159
+ ? Number(values.centroCustoId)
160
+ : undefined,
161
+ payment_channel: values.canal || undefined,
162
+ description: values.descricao?.trim() || undefined,
163
+ attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
164
+ },
165
+ });
166
+
167
+ await onCreated();
168
+ form.reset();
169
+ setUploadedFileId(null);
170
+ setUploadedFileName('');
171
+ setExtractionConfidence(null);
172
+ setExtractionWarnings([]);
173
+ setOpen(false);
174
+ showToastHandler?.('success', 'Título criado com sucesso');
175
+ } catch {
176
+ showToastHandler?.('error', 'Erro ao criar título');
177
+ }
178
+ };
179
+
180
+ const handleCancel = () => {
181
+ form.reset();
182
+ setUploadedFileId(null);
183
+ setUploadedFileName('');
184
+ setExtractionConfidence(null);
185
+ setExtractionWarnings([]);
186
+ setUploadProgress(0);
187
+ setOpen(false);
188
+ };
189
+
190
+ const uploadRelatedFile = async (file: File) => {
191
+ setIsUploadingFile(true);
192
+ setUploadProgress(0);
193
+
194
+ try {
195
+ const formData = new FormData();
196
+ formData.append('file', file);
197
+ formData.append('destination', 'finance/titles');
198
+
199
+ const { data } = await request<{ id: number; filename: string }>({
200
+ url: '/file',
201
+ method: 'POST',
202
+ data: formData,
203
+ headers: {
204
+ 'Content-Type': 'multipart/form-data',
205
+ },
206
+ onUploadProgress: (event) => {
207
+ if (!event.total) {
208
+ return;
209
+ }
210
+
211
+ const progress = Math.round((event.loaded * 100) / event.total);
212
+ setUploadProgress(progress);
213
+ },
214
+ });
215
+
216
+ if (!data?.id) {
217
+ throw new Error('Arquivo inválido');
218
+ }
219
+
220
+ setUploadedFileId(data.id);
221
+ setUploadedFileName(
222
+ normalizeFilenameForDisplay(data.filename || file.name)
223
+ );
224
+ setUploadProgress(100);
225
+ showToastHandler?.('success', 'Arquivo relacionado com sucesso');
226
+
227
+ setIsExtractingFileData(true);
228
+ try {
229
+ const extraction = await request<{
230
+ documento?: string | null;
231
+ clienteId?: string;
232
+ competencia?: string;
233
+ vencimento?: string;
234
+ valor?: number | null;
235
+ categoriaId?: string;
236
+ centroCustoId?: string;
237
+ canal?: string;
238
+ descricao?: string | null;
239
+ confidence?: number | null;
240
+ confidenceLevel?: 'low' | 'high' | null;
241
+ warnings?: string[];
242
+ }>({
243
+ url: '/finance/accounts-receivable/installments/extract-from-file',
244
+ method: 'POST',
245
+ data: {
246
+ file_id: data.id,
247
+ },
248
+ });
249
+
250
+ const extracted = extraction.data || {};
251
+ setExtractionConfidence(
252
+ typeof extracted.confidence === 'number' ? extracted.confidence : null
253
+ );
254
+ setExtractionWarnings(
255
+ Array.isArray(extracted.warnings)
256
+ ? extracted.warnings.filter(Boolean)
257
+ : []
258
+ );
259
+
260
+ if (extracted.documento) {
261
+ form.setValue('documento', extracted.documento, {
262
+ shouldValidate: true,
263
+ });
264
+ }
265
+
266
+ if (extracted.clienteId) {
267
+ form.setValue('clienteId', extracted.clienteId, {
268
+ shouldValidate: true,
269
+ });
270
+ }
271
+
272
+ if (extracted.competencia) {
273
+ form.setValue('competencia', extracted.competencia, {
274
+ shouldValidate: true,
275
+ });
276
+ }
277
+
278
+ if (extracted.vencimento) {
279
+ form.setValue('vencimento', extracted.vencimento, {
280
+ shouldValidate: true,
281
+ });
282
+ }
283
+
284
+ if (typeof extracted.valor === 'number' && extracted.valor > 0) {
285
+ form.setValue('valor', extracted.valor, {
286
+ shouldValidate: true,
287
+ });
288
+ }
289
+
290
+ if (extracted.categoriaId) {
291
+ form.setValue('categoriaId', extracted.categoriaId, {
292
+ shouldValidate: true,
293
+ });
294
+ }
295
+
296
+ if (extracted.centroCustoId) {
297
+ form.setValue('centroCustoId', extracted.centroCustoId, {
298
+ shouldValidate: true,
299
+ });
300
+ }
301
+
302
+ if (extracted.canal) {
303
+ form.setValue('canal', extracted.canal, {
304
+ shouldValidate: true,
305
+ });
306
+ }
307
+
308
+ if (extracted.descricao) {
309
+ form.setValue('descricao', extracted.descricao, {
310
+ shouldValidate: true,
311
+ });
312
+ }
313
+
314
+ showToastHandler?.(
315
+ 'success',
316
+ 'Dados da fatura extraídos e preenchidos automaticamente'
317
+ );
318
+ } catch {
319
+ setExtractionConfidence(null);
320
+ setExtractionWarnings([]);
321
+ showToastHandler?.(
322
+ 'error',
323
+ 'Não foi possível extrair os dados automaticamente'
324
+ );
325
+ } finally {
326
+ setIsExtractingFileData(false);
327
+ }
328
+ } catch {
329
+ setUploadedFileId(null);
330
+ setUploadedFileName('');
331
+ setExtractionConfidence(null);
332
+ setExtractionWarnings([]);
333
+ setUploadProgress(0);
334
+ showToastHandler?.('error', 'Não foi possível enviar o arquivo');
335
+ } finally {
336
+ setIsUploadingFile(false);
337
+ }
338
+ };
339
+
340
+ return (
341
+ <Sheet open={open} onOpenChange={setOpen}>
342
+ <SheetTrigger asChild>
343
+ <Button>
344
+ <Plus className="mr-2 h-4 w-4" />
345
+ {t('newTitle.action')}
346
+ </Button>
347
+ </SheetTrigger>
348
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto">
349
+ <SheetHeader>
350
+ <SheetTitle>{t('newTitle.title')}</SheetTitle>
351
+ <SheetDescription>{t('newTitle.description')}</SheetDescription>
352
+ </SheetHeader>
353
+ <Form {...form}>
354
+ <form className="px-4" onSubmit={form.handleSubmit(handleSubmit)}>
355
+ <div className="grid gap-4">
356
+ <div className="grid gap-2">
357
+ <FormLabel>Arquivo da fatura (opcional)</FormLabel>
358
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
359
+ <Input
360
+ type="file"
361
+ accept=".pdf,.png,.jpg,.jpeg,.xml,.txt"
362
+ onChange={(event) => {
363
+ const file = event.target.files?.[0];
364
+ if (!file) {
365
+ return;
366
+ }
367
+
368
+ setUploadedFileId(null);
369
+ setUploadedFileName('');
370
+ setExtractionConfidence(null);
371
+ setExtractionWarnings([]);
372
+ setUploadProgress(0);
373
+ void uploadRelatedFile(file);
374
+ }}
375
+ disabled={
376
+ isUploadingFile ||
377
+ isExtractingFileData ||
378
+ form.formState.isSubmitting
379
+ }
380
+ />
381
+ </div>
382
+ {isUploadingFile && (
383
+ <div className="space-y-1">
384
+ <Progress value={uploadProgress} className="h-2" />
385
+ <p className="text-xs text-muted-foreground">
386
+ Upload em andamento: {uploadProgress}%
387
+ </p>
388
+ </div>
389
+ )}
390
+ {uploadedFileId && (
391
+ <p className="text-xs text-muted-foreground">
392
+ Arquivo relacionado: {uploadedFileName}
393
+ </p>
394
+ )}
395
+ {isExtractingFileData && (
396
+ <div className="rounded-md border border-primary/30 bg-primary/5 p-3">
397
+ <div className="flex items-center gap-2 text-sm font-medium text-primary">
398
+ <Loader2 className="h-4 w-4 animate-spin" />
399
+ Analisando documento com IA
400
+ </div>
401
+ <p className="mt-1 text-xs text-muted-foreground">
402
+ Os campos serão preenchidos automaticamente em instantes.
403
+ Revise os dados antes de salvar.
404
+ </p>
405
+ </div>
406
+ )}
407
+ {!isExtractingFileData &&
408
+ extractionConfidence !== null &&
409
+ extractionConfidence < 70 && (
410
+ <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3">
411
+ <div className="text-sm font-medium text-destructive">
412
+ Confiança da extração:{' '}
413
+ {Math.round(extractionConfidence)}%
414
+ </div>
415
+ <p className="mt-1 text-xs text-muted-foreground">
416
+ Revise principalmente valor e vencimento antes de
417
+ salvar.
418
+ </p>
419
+ {extractionWarnings.length > 0 && (
420
+ <p className="mt-1 text-xs text-muted-foreground">
421
+ {extractionWarnings[0]}
422
+ </p>
423
+ )}
424
+ </div>
425
+ )}
426
+ </div>
427
+
428
+ <FormField
429
+ control={form.control}
430
+ name="documento"
431
+ render={({ field }) => (
432
+ <FormItem>
433
+ <FormLabel>{t('fields.document')}</FormLabel>
434
+ <FormControl>
435
+ <Input placeholder="FAT-00000" {...field} />
436
+ </FormControl>
437
+ <FormMessage />
438
+ </FormItem>
439
+ )}
440
+ />
441
+
442
+ <PersonFieldWithCreate
443
+ form={form}
444
+ name="clienteId"
445
+ label={t('fields.client')}
446
+ entityLabel="cliente"
447
+ selectPlaceholder={t('common.select')}
448
+ />
449
+
450
+ <div className="grid grid-cols-2 gap-4">
451
+ <FormField
452
+ control={form.control}
453
+ name="competencia"
454
+ render={({ field }) => (
455
+ <FormItem>
456
+ <FormLabel>{t('fields.competency')}</FormLabel>
457
+ <FormControl>
458
+ <Input
459
+ type="month"
460
+ {...field}
461
+ value={field.value || ''}
462
+ />
463
+ </FormControl>
464
+ <FormMessage />
465
+ </FormItem>
466
+ )}
467
+ />
468
+
469
+ <FormField
470
+ control={form.control}
471
+ name="vencimento"
472
+ render={({ field }) => (
473
+ <FormItem>
474
+ <FormLabel>{t('fields.dueDate')}</FormLabel>
475
+ <FormControl>
476
+ <Input
477
+ type="date"
478
+ {...field}
479
+ value={field.value || ''}
480
+ />
481
+ </FormControl>
482
+ <FormMessage />
483
+ </FormItem>
484
+ )}
485
+ />
486
+ </div>
487
+
488
+ <FormField
489
+ control={form.control}
490
+ name="valor"
491
+ render={({ field }) => (
492
+ <FormItem>
493
+ <FormLabel>{t('fields.totalValue')}</FormLabel>
494
+ <FormControl>
495
+ <InputMoney
496
+ ref={field.ref}
497
+ name={field.name}
498
+ value={field.value}
499
+ onBlur={field.onBlur}
500
+ onValueChange={(value) => field.onChange(value ?? 0)}
501
+ placeholder="0,00"
502
+ />
503
+ </FormControl>
504
+ <FormMessage />
505
+ </FormItem>
506
+ )}
507
+ />
508
+
509
+ <FormField
510
+ control={form.control}
511
+ name="categoriaId"
512
+ render={({ field }) => (
513
+ <FormItem>
514
+ <FormLabel>{t('fields.category')}</FormLabel>
515
+ <Select value={field.value} onValueChange={field.onChange}>
516
+ <FormControl>
517
+ <SelectTrigger>
518
+ <SelectValue placeholder={t('common.select')} />
519
+ </SelectTrigger>
520
+ </FormControl>
521
+ <SelectContent>
522
+ {categorias
523
+ .filter((c) => c.natureza === 'receita')
524
+ .map((c) => (
525
+ <SelectItem key={c.id} value={String(c.id)}>
526
+ {c.codigo} - {c.nome}
527
+ </SelectItem>
528
+ ))}
529
+ </SelectContent>
530
+ </Select>
531
+ <FormMessage />
532
+ </FormItem>
533
+ )}
534
+ />
535
+
536
+ <FormField
537
+ control={form.control}
538
+ name="centroCustoId"
539
+ render={({ field }) => (
540
+ <FormItem>
541
+ <FormLabel>{t('fields.costCenter')}</FormLabel>
542
+ <Select value={field.value} onValueChange={field.onChange}>
543
+ <FormControl>
544
+ <SelectTrigger>
545
+ <SelectValue placeholder={t('common.select')} />
546
+ </SelectTrigger>
547
+ </FormControl>
548
+ <SelectContent>
549
+ {centrosCusto.map((c) => (
550
+ <SelectItem key={c.id} value={String(c.id)}>
551
+ {c.codigo} - {c.nome}
552
+ </SelectItem>
553
+ ))}
554
+ </SelectContent>
555
+ </Select>
556
+ <FormMessage />
557
+ </FormItem>
558
+ )}
559
+ />
560
+
561
+ <FormField
562
+ control={form.control}
563
+ name="canal"
564
+ render={({ field }) => (
565
+ <FormItem>
566
+ <FormLabel>{t('fields.channel')}</FormLabel>
567
+ <Select value={field.value} onValueChange={field.onChange}>
568
+ <FormControl>
569
+ <SelectTrigger>
570
+ <SelectValue placeholder={t('common.select')} />
571
+ </SelectTrigger>
572
+ </FormControl>
573
+ <SelectContent>
574
+ <SelectItem value="boleto">
575
+ {t('channels.boleto')}
576
+ </SelectItem>
577
+ <SelectItem value="pix">PIX</SelectItem>
578
+ <SelectItem value="cartao">
579
+ {t('channels.card')}
580
+ </SelectItem>
581
+ <SelectItem value="transferencia">
582
+ {t('channels.transfer')}
583
+ </SelectItem>
584
+ </SelectContent>
585
+ </Select>
586
+ <FormMessage />
587
+ </FormItem>
588
+ )}
589
+ />
590
+
591
+ <FormField
592
+ control={form.control}
593
+ name="descricao"
594
+ render={({ field }) => (
595
+ <FormItem>
596
+ <FormLabel>{t('fields.description')}</FormLabel>
597
+ <FormControl>
598
+ <Textarea
599
+ placeholder={t('newTitle.descriptionPlaceholder')}
600
+ {...field}
601
+ value={field.value || ''}
602
+ />
603
+ </FormControl>
604
+ <FormMessage />
605
+ </FormItem>
606
+ )}
607
+ />
608
+ </div>
609
+
610
+ <div className="flex justify-end gap-2 pt-4">
611
+ <Button type="button" variant="outline" onClick={handleCancel}>
612
+ {t('common.cancel')}
613
+ </Button>
614
+ <Button
615
+ type="submit"
616
+ disabled={
617
+ form.formState.isSubmitting ||
618
+ isUploadingFile ||
619
+ isExtractingFileData
620
+ }
621
+ >
622
+ {(isUploadingFile || isExtractingFileData) && (
623
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
624
+ )}
625
+ {isExtractingFileData
626
+ ? 'Preenchendo com IA...'
627
+ : isUploadingFile
628
+ ? 'Enviando arquivo...'
629
+ : t('common.save')}
630
+ </Button>
631
+ </div>
632
+ </form>
633
+ </Form>
634
+ </SheetContent>
635
+ </Sheet>
636
+ );
637
+ }
638
+
639
+ export default function TitulosReceberPage() {
640
+ const t = useTranslations('finance.ReceivableInstallmentsPage');
641
+ const { data, refetch } = useFinanceData();
642
+ const { titulosReceber, pessoas, categorias, centrosCusto } = data;
643
+
644
+ const getPessoaById = (id?: string) => pessoas.find((p) => p.id === id);
645
+
646
+ const [search, setSearch] = useState('');
647
+ const [statusFilter, setStatusFilter] = useState<string>('');
648
+
649
+ const canalBadge = {
650
+ boleto: {
651
+ label: t('channels.boleto'),
652
+ className: 'bg-blue-100 text-blue-700',
653
+ },
654
+ pix: { label: 'PIX', className: 'bg-green-100 text-green-700' },
655
+ cartao: {
656
+ label: t('channels.card'),
657
+ className: 'bg-purple-100 text-purple-700',
658
+ },
659
+ transferencia: {
660
+ label: t('channels.transfer'),
661
+ className: 'bg-orange-100 text-orange-700',
662
+ },
663
+ };
664
+
665
+ const filteredTitulos = titulosReceber.filter((titulo) => {
666
+ const matchesSearch =
667
+ titulo.documento.toLowerCase().includes(search.toLowerCase()) ||
668
+ getPessoaById(titulo.clienteId)
669
+ ?.nome.toLowerCase()
670
+ .includes(search.toLowerCase());
671
+
672
+ const matchesStatus = !statusFilter || titulo.status === statusFilter;
673
+
674
+ return matchesSearch && matchesStatus;
675
+ });
676
+
677
+ return (
678
+ <Page>
679
+ <PageHeader
680
+ title={t('header.title')}
681
+ description={t('header.description')}
682
+ breadcrumbs={[
683
+ { label: t('breadcrumbs.home'), href: '/' },
684
+ { label: t('breadcrumbs.finance'), href: '/finance' },
685
+ { label: t('breadcrumbs.current') },
686
+ ]}
687
+ actions={
688
+ <NovoTituloSheet
689
+ categorias={categorias}
690
+ centrosCusto={centrosCusto}
691
+ t={t}
692
+ onCreated={refetch}
693
+ />
694
+ }
695
+ />
696
+
697
+ <FilterBar
698
+ searchPlaceholder={t('filters.searchPlaceholder')}
699
+ searchValue={search}
700
+ onSearchChange={setSearch}
701
+ filters={[
702
+ {
703
+ id: 'status',
704
+ label: t('filters.status'),
705
+ value: statusFilter,
706
+ onChange: setStatusFilter,
707
+ options: [
708
+ { value: 'all', label: t('statuses.all') },
709
+ { value: 'aberto', label: t('statuses.aberto') },
710
+ { value: 'parcial', label: t('statuses.parcial') },
711
+ { value: 'liquidado', label: t('statuses.liquidado') },
712
+ { value: 'vencido', label: t('statuses.vencido') },
713
+ { value: 'cancelado', label: t('statuses.cancelado') },
714
+ ],
715
+ },
716
+ ]}
717
+ activeFilters={statusFilter && statusFilter !== 'all' ? 1 : 0}
718
+ onClearFilters={() => setStatusFilter('')}
719
+ />
720
+
721
+ <div className="rounded-md border">
722
+ <Table>
723
+ <TableHeader>
724
+ <TableRow>
725
+ <TableHead>{t('table.headers.document')}</TableHead>
726
+ <TableHead>{t('table.headers.client')}</TableHead>
727
+ <TableHead>{t('table.headers.competency')}</TableHead>
728
+ <TableHead>{t('table.headers.dueDate')}</TableHead>
729
+ <TableHead className="text-right">
730
+ {t('table.headers.value')}
731
+ </TableHead>
732
+ <TableHead>{t('table.headers.channel')}</TableHead>
733
+ <TableHead>{t('table.headers.status')}</TableHead>
734
+ <TableHead className="w-[50px]" />
735
+ </TableRow>
736
+ </TableHeader>
737
+ <TableBody>
738
+ {filteredTitulos.map((titulo) => {
739
+ const cliente = getPessoaById(titulo.clienteId);
740
+ const canal =
741
+ canalBadge[titulo.canal as keyof typeof canalBadge] ||
742
+ canalBadge.transferencia;
743
+ const proximaParcela = titulo.parcelas.find(
744
+ (p: any) => p.status === 'aberto' || p.status === 'vencido'
745
+ );
746
+
747
+ return (
748
+ <TableRow key={titulo.id}>
749
+ <TableCell className="font-medium">
750
+ <Link
751
+ href={`/finance/accounts-receivable/installments/${titulo.id}`}
752
+ className="hover:underline"
753
+ >
754
+ {titulo.documento}
755
+ </Link>
756
+ {titulo.anexos.length > 0 && (
757
+ <Paperclip className="ml-1 inline h-3 w-3 text-muted-foreground" />
758
+ )}
759
+ </TableCell>
760
+ <TableCell>{cliente?.nome}</TableCell>
761
+ <TableCell>{titulo.competencia}</TableCell>
762
+ <TableCell>
763
+ {proximaParcela
764
+ ? formatarData(proximaParcela.vencimento)
765
+ : '-'}
766
+ </TableCell>
767
+ <TableCell className="text-right">
768
+ <Money value={titulo.valorTotal} />
769
+ </TableCell>
770
+ <TableCell>
771
+ <Badge className={canal.className} variant="outline">
772
+ {canal.label}
773
+ </Badge>
774
+ </TableCell>
775
+ <TableCell>
776
+ <StatusBadge status={titulo.status} />
777
+ </TableCell>
778
+ <TableCell>
779
+ <DropdownMenu>
780
+ <DropdownMenuTrigger asChild>
781
+ <Button variant="ghost" size="icon">
782
+ <MoreHorizontal className="h-4 w-4" />
783
+ <span className="sr-only">
784
+ {t('table.actions.srActions')}
785
+ </span>
786
+ </Button>
787
+ </DropdownMenuTrigger>
788
+ <DropdownMenuContent align="end">
789
+ <DropdownMenuItem asChild>
790
+ <Link
791
+ href={`/finance/accounts-receivable/installments/${titulo.id}`}
792
+ >
793
+ <Eye className="mr-2 h-4 w-4" />
794
+ {t('table.actions.viewDetails')}
795
+ </Link>
796
+ </DropdownMenuItem>
797
+ <DropdownMenuItem>
798
+ <Edit className="mr-2 h-4 w-4" />
799
+ {t('table.actions.edit')}
800
+ </DropdownMenuItem>
801
+ <DropdownMenuSeparator />
802
+ <DropdownMenuItem>
803
+ <Download className="mr-2 h-4 w-4" />
804
+ {t('table.actions.registerReceipt')}
805
+ </DropdownMenuItem>
806
+ <DropdownMenuItem>
807
+ <Send className="mr-2 h-4 w-4" />
808
+ {t('table.actions.sendCollection')}
809
+ </DropdownMenuItem>
810
+ </DropdownMenuContent>
811
+ </DropdownMenu>
812
+ </TableCell>
813
+ </TableRow>
814
+ );
815
+ })}
816
+ </TableBody>
817
+ </Table>
818
+ </div>
819
+
820
+ <div className="flex items-center justify-between">
821
+ <p className="text-sm text-muted-foreground">
822
+ {t('footer.showing', {
823
+ filtered: filteredTitulos.length,
824
+ total: titulosReceber.length,
825
+ })}
826
+ </p>
827
+ <div className="flex items-center gap-2">
828
+ <Button variant="outline" size="sm" disabled>
829
+ {t('footer.previous')}
830
+ </Button>
831
+ <Button variant="outline" size="sm" disabled>
832
+ {t('footer.next')}
833
+ </Button>
834
+ </div>
835
+ </div>
836
+ </Page>
837
+ );
838
+ }