@hed-hog/finance 0.0.350 → 0.0.353

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 (112) hide show
  1. package/dist/dto/create-financial-title.dto.d.ts +6 -0
  2. package/dist/dto/create-financial-title.dto.d.ts.map +1 -1
  3. package/dist/dto/create-financial-title.dto.js +27 -1
  4. package/dist/dto/create-financial-title.dto.js.map +1 -1
  5. package/dist/finance-data.controller.d.ts +12 -0
  6. package/dist/finance-data.controller.d.ts.map +1 -1
  7. package/dist/finance-installments.controller.d.ts +120 -0
  8. package/dist/finance-installments.controller.d.ts.map +1 -1
  9. package/dist/finance-realtime.controller.d.ts +7 -0
  10. package/dist/finance-realtime.controller.d.ts.map +1 -0
  11. package/dist/finance-realtime.controller.js +34 -0
  12. package/dist/finance-realtime.controller.js.map +1 -0
  13. package/dist/finance-realtime.service.d.ts +36 -0
  14. package/dist/finance-realtime.service.d.ts.map +1 -0
  15. package/dist/finance-realtime.service.js +59 -0
  16. package/dist/finance-realtime.service.js.map +1 -0
  17. package/dist/finance-statements.controller.d.ts +6 -0
  18. package/dist/finance-statements.controller.d.ts.map +1 -1
  19. package/dist/finance.module.d.ts.map +1 -1
  20. package/dist/finance.module.js +28 -0
  21. package/dist/finance.module.js.map +1 -1
  22. package/dist/finance.service.d.ts +142 -1
  23. package/dist/finance.service.d.ts.map +1 -1
  24. package/dist/finance.service.js +134 -19
  25. package/dist/finance.service.js.map +1 -1
  26. package/dist/mcp-tools/finance-audit-logs.mcp-tools.d.ts +8 -0
  27. package/dist/mcp-tools/finance-audit-logs.mcp-tools.d.ts.map +1 -0
  28. package/dist/mcp-tools/finance-audit-logs.mcp-tools.js +60 -0
  29. package/dist/mcp-tools/finance-audit-logs.mcp-tools.js.map +1 -0
  30. package/dist/mcp-tools/finance-bank-accounts.mcp-tools.d.ts +16 -0
  31. package/dist/mcp-tools/finance-bank-accounts.mcp-tools.d.ts.map +1 -0
  32. package/dist/mcp-tools/finance-bank-accounts.mcp-tools.js +121 -0
  33. package/dist/mcp-tools/finance-bank-accounts.mcp-tools.js.map +1 -0
  34. package/dist/mcp-tools/finance-categories.mcp-tools.d.ts +20 -0
  35. package/dist/mcp-tools/finance-categories.mcp-tools.d.ts.map +1 -0
  36. package/dist/mcp-tools/finance-categories.mcp-tools.js +135 -0
  37. package/dist/mcp-tools/finance-categories.mcp-tools.js.map +1 -0
  38. package/dist/mcp-tools/finance-collections.mcp-tools.d.ts +16 -0
  39. package/dist/mcp-tools/finance-collections.mcp-tools.d.ts.map +1 -0
  40. package/dist/mcp-tools/finance-collections.mcp-tools.js +91 -0
  41. package/dist/mcp-tools/finance-collections.mcp-tools.js.map +1 -0
  42. package/dist/mcp-tools/finance-cost-centers.mcp-tools.d.ts +16 -0
  43. package/dist/mcp-tools/finance-cost-centers.mcp-tools.d.ts.map +1 -0
  44. package/dist/mcp-tools/finance-cost-centers.mcp-tools.js +114 -0
  45. package/dist/mcp-tools/finance-cost-centers.mcp-tools.js.map +1 -0
  46. package/dist/mcp-tools/finance-currencies.mcp-tools.d.ts +16 -0
  47. package/dist/mcp-tools/finance-currencies.mcp-tools.d.ts.map +1 -0
  48. package/dist/mcp-tools/finance-currencies.mcp-tools.js +120 -0
  49. package/dist/mcp-tools/finance-currencies.mcp-tools.js.map +1 -0
  50. package/dist/mcp-tools/finance-data.mcp-tools.d.ts +15 -0
  51. package/dist/mcp-tools/finance-data.mcp-tools.d.ts.map +1 -0
  52. package/dist/mcp-tools/finance-data.mcp-tools.js +80 -0
  53. package/dist/mcp-tools/finance-data.mcp-tools.js.map +1 -0
  54. package/dist/mcp-tools/finance-installments.mcp-tools.d.ts +93 -0
  55. package/dist/mcp-tools/finance-installments.mcp-tools.d.ts.map +1 -0
  56. package/dist/mcp-tools/finance-installments.mcp-tools.js +646 -0
  57. package/dist/mcp-tools/finance-installments.mcp-tools.js.map +1 -0
  58. package/dist/mcp-tools/finance-period-close.mcp-tools.d.ts +9 -0
  59. package/dist/mcp-tools/finance-period-close.mcp-tools.d.ts.map +1 -0
  60. package/dist/mcp-tools/finance-period-close.mcp-tools.js +79 -0
  61. package/dist/mcp-tools/finance-period-close.mcp-tools.js.map +1 -0
  62. package/dist/mcp-tools/finance-reports.mcp-tools.d.ts +10 -0
  63. package/dist/mcp-tools/finance-reports.mcp-tools.d.ts.map +1 -0
  64. package/dist/mcp-tools/finance-reports.mcp-tools.js +89 -0
  65. package/dist/mcp-tools/finance-reports.mcp-tools.js.map +1 -0
  66. package/dist/mcp-tools/finance-statements.mcp-tools.d.ts +34 -0
  67. package/dist/mcp-tools/finance-statements.mcp-tools.d.ts.map +1 -0
  68. package/dist/mcp-tools/finance-statements.mcp-tools.js +253 -0
  69. package/dist/mcp-tools/finance-statements.mcp-tools.js.map +1 -0
  70. package/dist/mcp-tools/finance-transfers.mcp-tools.d.ts +9 -0
  71. package/dist/mcp-tools/finance-transfers.mcp-tools.d.ts.map +1 -0
  72. package/dist/mcp-tools/finance-transfers.mcp-tools.js +79 -0
  73. package/dist/mcp-tools/finance-transfers.mcp-tools.js.map +1 -0
  74. package/hedhog/data/route.yaml +659 -1
  75. package/hedhog/frontend/app/_components/finance-title-actions-menu.tsx.ejs +9 -3
  76. package/hedhog/frontend/app/_lib/http-error.ts.ejs +105 -0
  77. package/hedhog/frontend/app/_lib/use-finance-realtime-refresh.ts.ejs +62 -0
  78. package/hedhog/frontend/app/accounts-payable/approvals/page.tsx.ejs +3 -0
  79. package/hedhog/frontend/app/accounts-payable/installments/[id]/page.tsx.ejs +5 -5
  80. package/hedhog/frontend/app/accounts-payable/installments/page.tsx.ejs +776 -344
  81. package/hedhog/frontend/app/accounts-receivable/collections-default/page.tsx.ejs +7 -13
  82. package/hedhog/frontend/app/accounts-receivable/installments/[id]/page.tsx.ejs +5 -5
  83. package/hedhog/frontend/app/accounts-receivable/installments/page.tsx.ejs +802 -355
  84. package/hedhog/frontend/app/administration/categories/page.tsx.ejs +9 -3
  85. package/hedhog/frontend/app/administration/cost-centers/page.tsx.ejs +9 -3
  86. package/hedhog/frontend/app/administration/currencies/page.tsx.ejs +9 -3
  87. package/hedhog/frontend/app/administration/period-close/page.tsx.ejs +9 -3
  88. package/hedhog/frontend/app/cash-and-banks/bank-accounts/page.tsx.ejs +7 -3
  89. package/hedhog/frontend/app/cash-and-banks/bank-reconciliation/page.tsx.ejs +3 -3
  90. package/hedhog/frontend/app/cash-and-banks/statements/page.tsx.ejs +28 -7
  91. package/hedhog/frontend/app/cash-and-banks/transfers/page.tsx.ejs +12 -3
  92. package/hedhog/frontend/messages/en.json +63 -3
  93. package/hedhog/frontend/messages/pt.json +63 -3
  94. package/hedhog/table/financial_title.yaml +10 -0
  95. package/package.json +5 -5
  96. package/src/dto/create-financial-title.dto.ts +23 -0
  97. package/src/finance-realtime.controller.ts +12 -0
  98. package/src/finance-realtime.service.ts +106 -0
  99. package/src/finance.module.ts +28 -0
  100. package/src/finance.service.ts +184 -45
  101. package/src/mcp-tools/finance-audit-logs.mcp-tools.ts +38 -0
  102. package/src/mcp-tools/finance-bank-accounts.mcp-tools.ts +76 -0
  103. package/src/mcp-tools/finance-categories.mcp-tools.ts +86 -0
  104. package/src/mcp-tools/finance-collections.mcp-tools.ts +50 -0
  105. package/src/mcp-tools/finance-cost-centers.mcp-tools.ts +69 -0
  106. package/src/mcp-tools/finance-currencies.mcp-tools.ts +75 -0
  107. package/src/mcp-tools/finance-data.mcp-tools.ts +43 -0
  108. package/src/mcp-tools/finance-installments.mcp-tools.ts +560 -0
  109. package/src/mcp-tools/finance-period-close.mcp-tools.ts +53 -0
  110. package/src/mcp-tools/finance-reports.mcp-tools.ts +59 -0
  111. package/src/mcp-tools/finance-statements.mcp-tools.ts +202 -0
  112. package/src/mcp-tools/finance-transfers.mcp-tools.ts +53 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { PersonPickerField } from '@/app/(app)/(libraries)/contact/_components/person-picker';
3
+ import { PersonPickerField } from '@/app/(app)/(libraries)/crm/_components/person-picker';
4
4
  import { CategoryPickerField } from '@/app/(app)/(libraries)/finance/_components/category-picker-field';
5
5
  import { CostCenterPickerField } from '@/app/(app)/(libraries)/finance/_components/cost-center-picker-field';
6
6
  import {
@@ -40,6 +40,7 @@ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
40
40
  import { Label } from '@/components/ui/label';
41
41
  import { Money } from '@/components/ui/money';
42
42
  import { Progress } from '@/components/ui/progress';
43
+ import { ResizableSheetContent } from '@/components/ui/resizable-sheet-content';
43
44
  import {
44
45
  Select,
45
46
  SelectContent,
@@ -49,7 +50,6 @@ import {
49
50
  } from '@/components/ui/select';
50
51
  import {
51
52
  Sheet,
52
- SheetContent,
53
53
  SheetDescription,
54
54
  SheetHeader,
55
55
  SheetTitle,
@@ -98,7 +98,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
98
98
  import { useFieldArray, useForm, useWatch } from 'react-hook-form';
99
99
  import { z } from 'zod';
100
100
  import { formatarData } from '../../_lib/formatters';
101
+ import {
102
+ getApiErrorMessage,
103
+ getFirstFormErrorMessage,
104
+ } from '../../_lib/http-error';
101
105
  import { useFinanceData } from '../../_lib/use-finance-data';
106
+ import { useFinanceRealtimeRefresh } from '../../_lib/use-finance-realtime-refresh';
102
107
 
103
108
  const INSTALLMENT_REDISTRIBUTION_DEBOUNCE_MS = 300;
104
109
 
@@ -200,6 +205,7 @@ const redistributeRemainingInstallments = (
200
205
  const getNewTitleFormSchema = (t: ReturnType<typeof useTranslations>) =>
201
206
  z
202
207
  .object({
208
+ tipoTitulo: z.enum(['parcelado', 'recorrente']).default('parcelado'),
203
209
  documento: z.string().trim().min(1, t('validation.documentRequired')),
204
210
  clienteId: z.string().min(1, t('validation.clientRequired')),
205
211
  competencia: z.string().optional(),
@@ -222,12 +228,37 @@ const getNewTitleFormSchema = (t: ReturnType<typeof useTranslations>) =>
222
228
  })
223
229
  )
224
230
  .min(1, t('validation.installmentsRequired')),
231
+ recorrencia: z
232
+ .object({
233
+ frequencia: z.string().default('monthly'),
234
+ dataFim: z.string().optional(),
235
+ numOcorrencias: z.coerce
236
+ .number()
237
+ .int()
238
+ .min(1)
239
+ .max(600)
240
+ .optional()
241
+ .or(z.literal('').transform(() => undefined)),
242
+ })
243
+ .optional(),
225
244
  categoriaId: z.string().optional(),
226
245
  centroCustoId: z.string().optional(),
227
246
  canal: z.string().optional(),
228
247
  descricao: z.string().optional(),
229
248
  })
230
249
  .superRefine((values, ctx) => {
250
+ if (values.tipoTitulo === 'recorrente') {
251
+ const r = values.recorrencia;
252
+ if (!r?.dataFim && !r?.numOcorrencias) {
253
+ ctx.addIssue({
254
+ code: z.ZodIssueCode.custom,
255
+ path: ['recorrencia'],
256
+ message: t('validation.recurrenceEndOrCount'),
257
+ });
258
+ }
259
+ return;
260
+ }
261
+
231
262
  if (values.installments.length !== values.installmentsCount) {
232
263
  ctx.addIssue({
233
264
  code: z.ZodIssueCode.custom,
@@ -274,16 +305,22 @@ function NovoTituloSheet({
274
305
  t,
275
306
  onCreated,
276
307
  onOptionsUpdated,
308
+ open: controlledOpen,
309
+ onOpenChange,
277
310
  }: {
278
311
  categorias: any[];
279
312
  centrosCusto: any[];
280
313
  t: ReturnType<typeof useTranslations>;
281
314
  onCreated: () => Promise<any> | void;
282
315
  onOptionsUpdated?: () => Promise<any> | void;
316
+ open?: boolean;
317
+ onOpenChange?: (open: boolean) => void;
283
318
  }) {
284
319
  const { request, showToastHandler, currentLocaleCode, getSettingValue } =
285
320
  useApp();
286
- const [open, setOpen] = useState(false);
321
+ const [internalOpen, setInternalOpen] = useState(false);
322
+ const open = controlledOpen ?? internalOpen;
323
+ const setOpen = onOpenChange ?? setInternalOpen;
287
324
  const [uploadedFileId, setUploadedFileId] = useState<number | null>(null);
288
325
  const [uploadedFileName, setUploadedFileName] = useState('');
289
326
  const [isUploadingFile, setIsUploadingFile] = useState(false);
@@ -324,6 +361,7 @@ function NovoTituloSheet({
324
361
  const form = useForm<NewTitleFormValues>({
325
362
  resolver: zodResolver(newTitleFormSchema),
326
363
  defaultValues: {
364
+ tipoTitulo: 'parcelado',
327
365
  documento: '',
328
366
  clienteId: '',
329
367
  competencia: '',
@@ -331,6 +369,11 @@ function NovoTituloSheet({
331
369
  valor: 0,
332
370
  installmentsCount: 1,
333
371
  installments: [{ dueDate: '', amount: 0 }],
372
+ recorrencia: {
373
+ frequencia: 'monthly',
374
+ dataFim: '',
375
+ numOcorrencias: undefined,
376
+ },
334
377
  categoriaId: '',
335
378
  centroCustoId: '',
336
379
  canal: '',
@@ -348,6 +391,9 @@ function NovoTituloSheet({
348
391
  control: form.control,
349
392
  });
350
393
 
394
+ const tipoTitulo = form.watch('tipoTitulo');
395
+ const isRecorrente = tipoTitulo === 'recorrente';
396
+
351
397
  const {
352
398
  clearDraft,
353
399
  loadDraft,
@@ -359,6 +405,7 @@ function NovoTituloSheet({
359
405
  mode: 'create',
360
406
  titleId: null,
361
407
  values: {
408
+ tipoTitulo: watchedFormValues.tipoTitulo ?? 'parcelado',
362
409
  documento: watchedFormValues.documento ?? '',
363
410
  clienteId: watchedFormValues.clienteId ?? '',
364
411
  competencia: watchedFormValues.competencia ?? '',
@@ -369,6 +416,11 @@ function NovoTituloSheet({
369
416
  dueDate: installment?.dueDate ?? '',
370
417
  amount: Number(installment?.amount ?? 0),
371
418
  })) ?? [{ dueDate: '', amount: 0 }],
419
+ recorrencia: {
420
+ frequencia: watchedFormValues.recorrencia?.frequencia ?? 'monthly',
421
+ dataFim: watchedFormValues.recorrencia?.dataFim ?? '',
422
+ numOcorrencias: watchedFormValues.recorrencia?.numOcorrencias,
423
+ },
372
424
  categoriaId: watchedFormValues.categoriaId ?? '',
373
425
  centroCustoId: watchedFormValues.centroCustoId ?? '',
374
426
  canal: watchedFormValues.canal ?? '',
@@ -422,7 +474,9 @@ function NovoTituloSheet({
422
474
  currentLocaleCode
423
475
  );
424
476
 
425
- return t('draftStatus', { relativeLabel, absoluteLabel });
477
+ return t.has('draftStatus')
478
+ ? t('draftStatus', { relativeLabel, absoluteLabel })
479
+ : `${relativeLabel} - ${absoluteLabel}`;
426
480
  }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft, t]);
427
481
 
428
482
  const watchedInstallmentsCount = form.watch('installmentsCount');
@@ -454,6 +508,7 @@ function NovoTituloSheet({
454
508
  }
455
509
 
456
510
  form.reset({
511
+ tipoTitulo: 'parcelado',
457
512
  documento: '',
458
513
  clienteId: '',
459
514
  competencia: '',
@@ -461,6 +516,11 @@ function NovoTituloSheet({
461
516
  valor: 0,
462
517
  installmentsCount: 1,
463
518
  installments: [{ dueDate: '', amount: 0 }],
519
+ recorrencia: {
520
+ frequencia: 'monthly',
521
+ dataFim: '',
522
+ numOcorrencias: undefined,
523
+ },
464
524
  categoriaId: '',
465
525
  centroCustoId: '',
466
526
  canal: '',
@@ -562,6 +622,7 @@ function NovoTituloSheet({
562
622
 
563
623
  const handleSubmit = async (values: NewTitleFormValues) => {
564
624
  try {
625
+ const recorrente = values.tipoTitulo === 'recorrente';
565
626
  await request({
566
627
  url: '/finance/accounts-receivable/installments',
567
628
  method: 'POST',
@@ -581,11 +642,22 @@ function NovoTituloSheet({
581
642
  : undefined,
582
643
  payment_channel: values.canal || undefined,
583
644
  description: values.descricao?.trim() || undefined,
584
- installments: values.installments.map((installment, index) => ({
585
- installment_number: index + 1,
586
- due_date: installment.dueDate || values.vencimento,
587
- amount: installment.amount,
588
- })),
645
+ ...(recorrente
646
+ ? {
647
+ recurrence_rule: {
648
+ frequency: values.recorrencia?.frequencia ?? 'monthly',
649
+ end_date: values.recorrencia?.dataFim || undefined,
650
+ max_occurrences:
651
+ values.recorrencia?.numOcorrencias || undefined,
652
+ },
653
+ }
654
+ : {
655
+ installments: values.installments.map((installment, index) => ({
656
+ installment_number: index + 1,
657
+ due_date: installment.dueDate || values.vencimento,
658
+ amount: installment.amount,
659
+ })),
660
+ }),
589
661
  attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
590
662
  },
591
663
  });
@@ -601,8 +673,11 @@ function NovoTituloSheet({
601
673
  setAutoRedistributeInstallments(true);
602
674
  setOpen(false);
603
675
  showToastHandler?.('success', t('messages.createSuccess'));
604
- } catch {
605
- showToastHandler?.('error', t('messages.createError'));
676
+ } catch (error) {
677
+ showToastHandler?.(
678
+ 'error',
679
+ getApiErrorMessage(error, t('messages.createError'))
680
+ );
606
681
  }
607
682
  };
608
683
 
@@ -610,6 +685,13 @@ function NovoTituloSheet({
610
685
  setOpen(false);
611
686
  };
612
687
 
688
+ const handleInvalidSubmit = (errors: unknown) => {
689
+ showToastHandler?.(
690
+ 'error',
691
+ getFirstFormErrorMessage(errors, t('messages.createError'))
692
+ );
693
+ };
694
+
613
695
  const clearUploadedFile = () => {
614
696
  setUploadedFileId(null);
615
697
  setUploadedFileName('');
@@ -781,7 +863,11 @@ function NovoTituloSheet({
781
863
  {t('newTitle.action')}
782
864
  </Button>
783
865
  </SheetTrigger>
784
- <SheetContent className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-xl lg:max-w-4xl gap-0">
866
+ <ResizableSheetContent
867
+ sheetId="finance-receivable-create-title"
868
+ defaultWidth={896}
869
+ className="flex h-full w-full flex-col overflow-hidden p-0 gap-0"
870
+ >
785
871
  <SheetHeader className="border-b border-border/50 px-4 py-4 sm:px-6">
786
872
  <SheetTitle>{t('newTitle.title')}</SheetTitle>
787
873
  <SheetDescription>{t('newTitle.description')}</SheetDescription>
@@ -789,7 +875,7 @@ function NovoTituloSheet({
789
875
  <Form {...form}>
790
876
  <form
791
877
  className="flex h-full flex-col overflow-hidden"
792
- onSubmit={form.handleSubmit(handleSubmit)}
878
+ onSubmit={form.handleSubmit(handleSubmit, handleInvalidSubmit)}
793
879
  >
794
880
  <FinanceSheetBody className="pt-0">
795
881
  <FinanceSheetSection className="pt-0 mt-0">
@@ -947,6 +1033,39 @@ function NovoTituloSheet({
947
1033
  selectPlaceholder={t('common.select')}
948
1034
  />
949
1035
 
1036
+ {/* Tipo de título: Parcelado / Recorrente */}
1037
+ <FormField
1038
+ control={form.control}
1039
+ name="tipoTitulo"
1040
+ render={({ field }) => (
1041
+ <FormItem>
1042
+ <FormLabel>{t('titleType.label')}</FormLabel>
1043
+ <div className="flex gap-2">
1044
+ <Button
1045
+ type="button"
1046
+ variant={
1047
+ field.value === 'parcelado' ? 'default' : 'outline'
1048
+ }
1049
+ size="sm"
1050
+ onClick={() => field.onChange('parcelado')}
1051
+ >
1052
+ {t('titleType.installment')}
1053
+ </Button>
1054
+ <Button
1055
+ type="button"
1056
+ variant={
1057
+ field.value === 'recorrente' ? 'default' : 'outline'
1058
+ }
1059
+ size="sm"
1060
+ onClick={() => field.onChange('recorrente')}
1061
+ >
1062
+ {t('titleType.recurring')}
1063
+ </Button>
1064
+ </div>
1065
+ </FormItem>
1066
+ )}
1067
+ />
1068
+
950
1069
  <div className="grid gap-4 xl:grid-cols-2">
951
1070
  <div className="grid gap-4 md:grid-cols-2">
952
1071
  <FormField
@@ -972,7 +1091,11 @@ function NovoTituloSheet({
972
1091
  name="vencimento"
973
1092
  render={({ field }) => (
974
1093
  <FormItem>
975
- <FormLabel>{t('fields.dueDate')}</FormLabel>
1094
+ <FormLabel>
1095
+ {isRecorrente
1096
+ ? t('fields.dueDate')
1097
+ : t('fields.dueDate')}
1098
+ </FormLabel>
976
1099
  <FormControl>
977
1100
  <Input
978
1101
  type="date"
@@ -992,7 +1115,11 @@ function NovoTituloSheet({
992
1115
  name="valor"
993
1116
  render={({ field }) => (
994
1117
  <FormItem>
995
- <FormLabel>{t('fields.totalValue')}</FormLabel>
1118
+ <FormLabel>
1119
+ {isRecorrente
1120
+ ? t('fields.totalValueRecurring')
1121
+ : t('fields.totalValue')}
1122
+ </FormLabel>
996
1123
  <FormControl>
997
1124
  <InputMoney
998
1125
  ref={field.ref}
@@ -1010,179 +1137,292 @@ function NovoTituloSheet({
1010
1137
  )}
1011
1138
  />
1012
1139
 
1013
- <FormField
1014
- control={form.control}
1015
- name="installmentsCount"
1016
- render={({ field }) => (
1017
- <FormItem>
1018
- <FormLabel>
1019
- {t('installmentsEditor.countLabel')}
1020
- </FormLabel>
1021
- <FormControl>
1022
- <Input
1023
- type="number"
1024
- min={1}
1025
- max={120}
1026
- value={field.value}
1027
- onChange={(event) => {
1028
- const nextValue = Number(
1029
- event.target.value || 1
1030
- );
1031
- field.onChange(
1032
- Number.isNaN(nextValue) ? 1 : nextValue
1033
- );
1034
- }}
1035
- />
1036
- </FormControl>
1037
- <FormMessage />
1038
- </FormItem>
1039
- )}
1040
- />
1140
+ {!isRecorrente && (
1141
+ <FormField
1142
+ control={form.control}
1143
+ name="installmentsCount"
1144
+ render={({ field }) => (
1145
+ <FormItem>
1146
+ <FormLabel>
1147
+ {t('installmentsEditor.countLabel')}
1148
+ </FormLabel>
1149
+ <FormControl>
1150
+ <Input
1151
+ type="number"
1152
+ min={1}
1153
+ max={120}
1154
+ value={field.value}
1155
+ onChange={(event) => {
1156
+ const nextValue = Number(
1157
+ event.target.value || 1
1158
+ );
1159
+ field.onChange(
1160
+ Number.isNaN(nextValue) ? 1 : nextValue
1161
+ );
1162
+ }}
1163
+ />
1164
+ </FormControl>
1165
+ <FormMessage />
1166
+ </FormItem>
1167
+ )}
1168
+ />
1169
+ )}
1041
1170
  </div>
1042
1171
  </div>
1043
1172
 
1044
- <div className="space-y-3 rounded-md border p-3">
1045
- <div className="flex items-center justify-between gap-2">
1046
- <p className="text-sm font-medium">
1047
- {t('installmentsEditor.title')}
1048
- </p>
1049
- <Button
1050
- type="button"
1051
- variant="outline"
1052
- size="sm"
1053
- onClick={() => {
1054
- setIsInstallmentsEdited(false);
1055
- replaceInstallments(
1056
- buildEqualInstallments(
1057
- form.getValues('installmentsCount'),
1058
- form.getValues('valor'),
1059
- form.getValues('vencimento')
1060
- )
1061
- );
1062
- }}
1063
- >
1064
- {t('installmentsEditor.recalculate')}
1065
- </Button>
1066
- </div>
1067
-
1068
- <div className="flex items-center gap-2">
1069
- <Checkbox
1070
- id="auto-redistribute-installments-receivable"
1071
- checked={autoRedistributeInstallments}
1072
- onCheckedChange={(checked) =>
1073
- setAutoRedistributeInstallments(checked === true)
1074
- }
1075
- />
1076
- <Label
1077
- htmlFor="auto-redistribute-installments-receivable"
1078
- className="text-xs text-muted-foreground"
1079
- >
1080
- {t('installmentsEditor.autoRedistributeLabel')}
1081
- </Label>
1082
- </div>
1083
- {autoRedistributeInstallments && (
1084
- <p className="text-xs text-muted-foreground">
1085
- {t('installmentsEditor.autoRedistributeHint')}
1086
- </p>
1087
- )}
1173
+ {!isRecorrente && (
1174
+ <div className="space-y-3 rounded-md border p-3">
1175
+ <div className="flex items-center justify-between gap-2">
1176
+ <p className="text-sm font-medium">
1177
+ {t('installmentsEditor.title')}
1178
+ </p>
1179
+ <Button
1180
+ type="button"
1181
+ variant="outline"
1182
+ size="sm"
1183
+ onClick={() => {
1184
+ setIsInstallmentsEdited(false);
1185
+ replaceInstallments(
1186
+ buildEqualInstallments(
1187
+ form.getValues('installmentsCount'),
1188
+ form.getValues('valor'),
1189
+ form.getValues('vencimento')
1190
+ )
1191
+ );
1192
+ }}
1193
+ >
1194
+ {t('installmentsEditor.recalculate')}
1195
+ </Button>
1196
+ </div>
1088
1197
 
1089
- <div className="space-y-2">
1090
- {installmentFields.map((installment, index) => (
1091
- <div
1092
- key={installment.id}
1093
- className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
1198
+ <div className="flex items-center gap-2">
1199
+ <Checkbox
1200
+ id="auto-redistribute-installments-receivable"
1201
+ checked={autoRedistributeInstallments}
1202
+ onCheckedChange={(checked) =>
1203
+ setAutoRedistributeInstallments(checked === true)
1204
+ }
1205
+ />
1206
+ <Label
1207
+ htmlFor="auto-redistribute-installments-receivable"
1208
+ className="text-xs text-muted-foreground"
1094
1209
  >
1095
- <div className="flex items-center text-sm text-muted-foreground">
1096
- #{index + 1}
1210
+ {t('installmentsEditor.autoRedistributeLabel')}
1211
+ </Label>
1212
+ </div>
1213
+ {autoRedistributeInstallments && (
1214
+ <p className="text-xs text-muted-foreground">
1215
+ {t('installmentsEditor.autoRedistributeHint')}
1216
+ </p>
1217
+ )}
1218
+
1219
+ <div className="space-y-2">
1220
+ {installmentFields.map((installment, index) => (
1221
+ <div
1222
+ key={installment.id}
1223
+ className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
1224
+ >
1225
+ <div className="flex items-center text-sm text-muted-foreground">
1226
+ #{index + 1}
1227
+ </div>
1228
+
1229
+ <FormField
1230
+ control={form.control}
1231
+ name={`installments.${index}.dueDate` as const}
1232
+ render={({ field }) => (
1233
+ <FormItem>
1234
+ <FormLabel className="text-xs">
1235
+ {t('installmentsEditor.dueDateLabel')}
1236
+ </FormLabel>
1237
+ <FormControl>
1238
+ <Input
1239
+ type="date"
1240
+ {...field}
1241
+ value={field.value || ''}
1242
+ onChange={(event) => {
1243
+ setIsInstallmentsEdited(true);
1244
+ field.onChange(event);
1245
+ }}
1246
+ />
1247
+ </FormControl>
1248
+ <FormMessage />
1249
+ </FormItem>
1250
+ )}
1251
+ />
1252
+
1253
+ <FormField
1254
+ control={form.control}
1255
+ name={`installments.${index}.amount` as const}
1256
+ render={({ field }) => (
1257
+ <FormItem>
1258
+ <FormLabel className="text-xs">
1259
+ {t('installmentsEditor.amountLabel')}
1260
+ </FormLabel>
1261
+ <FormControl>
1262
+ <InputMoney
1263
+ ref={field.ref}
1264
+ name={field.name}
1265
+ value={field.value}
1266
+ onBlur={() => {
1267
+ field.onBlur();
1268
+
1269
+ if (!autoRedistributeInstallments) {
1270
+ return;
1271
+ }
1272
+
1273
+ clearScheduledRedistribution(index);
1274
+ runInstallmentRedistribution(index);
1275
+ }}
1276
+ onValueChange={(value) => {
1277
+ setIsInstallmentsEdited(true);
1278
+ field.onChange(value ?? 0);
1279
+
1280
+ if (!autoRedistributeInstallments) {
1281
+ return;
1282
+ }
1283
+
1284
+ scheduleInstallmentRedistribution(index);
1285
+ }}
1286
+ placeholder="0,00"
1287
+ />
1288
+ </FormControl>
1289
+ <FormMessage />
1290
+ </FormItem>
1291
+ )}
1292
+ />
1097
1293
  </div>
1294
+ ))}
1295
+ </div>
1098
1296
 
1099
- <FormField
1100
- control={form.control}
1101
- name={`installments.${index}.dueDate` as const}
1102
- render={({ field }) => (
1103
- <FormItem>
1104
- <FormLabel className="text-xs">
1105
- {t('installmentsEditor.dueDateLabel')}
1106
- </FormLabel>
1107
- <FormControl>
1108
- <Input
1109
- type="date"
1110
- {...field}
1111
- value={field.value || ''}
1112
- onChange={(event) => {
1113
- setIsInstallmentsEdited(true);
1114
- field.onChange(event);
1115
- }}
1116
- />
1117
- </FormControl>
1118
- <FormMessage />
1119
- </FormItem>
1120
- )}
1121
- />
1297
+ <p
1298
+ className={`text-xs ${
1299
+ installmentsDiffCents === 0
1300
+ ? 'text-muted-foreground'
1301
+ : 'text-destructive'
1302
+ }`}
1303
+ >
1304
+ {t('installmentsEditor.totalPrefix', {
1305
+ total: installmentsTotal.toFixed(2),
1306
+ })}
1307
+ {installmentsDiffCents > 0 &&
1308
+ ` ${t('installmentsEditor.adjustmentNeeded')}`}
1309
+ </p>
1310
+ {form.formState.errors.installments?.message && (
1311
+ <p className="text-xs text-destructive">
1312
+ {form.formState.errors.installments.message}
1313
+ </p>
1314
+ )}
1315
+ </div>
1316
+ )}
1122
1317
 
1123
- <FormField
1124
- control={form.control}
1125
- name={`installments.${index}.amount` as const}
1126
- render={({ field }) => (
1127
- <FormItem>
1128
- <FormLabel className="text-xs">
1129
- {t('installmentsEditor.amountLabel')}
1130
- </FormLabel>
1318
+ {isRecorrente && (
1319
+ <div className="space-y-3 rounded-md border p-3">
1320
+ <p className="text-sm font-medium">
1321
+ {t('recurrenceEditor.title')}
1322
+ </p>
1323
+ <div className="grid gap-4 md:grid-cols-3">
1324
+ <FormField
1325
+ control={form.control}
1326
+ name="recorrencia.frequencia"
1327
+ render={({ field }) => (
1328
+ <FormItem>
1329
+ <FormLabel>
1330
+ {t('recurrenceEditor.frequencyLabel')}
1331
+ </FormLabel>
1332
+ <Select
1333
+ value={field.value}
1334
+ onValueChange={field.onChange}
1335
+ >
1131
1336
  <FormControl>
1132
- <InputMoney
1133
- ref={field.ref}
1134
- name={field.name}
1135
- value={field.value}
1136
- onBlur={() => {
1137
- field.onBlur();
1138
-
1139
- if (!autoRedistributeInstallments) {
1140
- return;
1141
- }
1142
-
1143
- clearScheduledRedistribution(index);
1144
- runInstallmentRedistribution(index);
1145
- }}
1146
- onValueChange={(value) => {
1147
- setIsInstallmentsEdited(true);
1148
- field.onChange(value ?? 0);
1149
-
1150
- if (!autoRedistributeInstallments) {
1151
- return;
1152
- }
1153
-
1154
- scheduleInstallmentRedistribution(index);
1155
- }}
1156
- placeholder="0,00"
1157
- />
1337
+ <SelectTrigger className="w-full">
1338
+ <SelectValue />
1339
+ </SelectTrigger>
1158
1340
  </FormControl>
1159
- <FormMessage />
1160
- </FormItem>
1161
- )}
1162
- />
1163
- </div>
1164
- ))}
1341
+ <SelectContent>
1342
+ <SelectItem value="weekly">
1343
+ {t('recurrenceEditor.frequencies.weekly')}
1344
+ </SelectItem>
1345
+ <SelectItem value="biweekly">
1346
+ {t('recurrenceEditor.frequencies.biweekly')}
1347
+ </SelectItem>
1348
+ <SelectItem value="monthly">
1349
+ {t('recurrenceEditor.frequencies.monthly')}
1350
+ </SelectItem>
1351
+ <SelectItem value="bimonthly">
1352
+ {t('recurrenceEditor.frequencies.bimonthly')}
1353
+ </SelectItem>
1354
+ <SelectItem value="quarterly">
1355
+ {t('recurrenceEditor.frequencies.quarterly')}
1356
+ </SelectItem>
1357
+ <SelectItem value="semiannual">
1358
+ {t('recurrenceEditor.frequencies.semiannual')}
1359
+ </SelectItem>
1360
+ <SelectItem value="annual">
1361
+ {t('recurrenceEditor.frequencies.annual')}
1362
+ </SelectItem>
1363
+ </SelectContent>
1364
+ </Select>
1365
+ <FormMessage />
1366
+ </FormItem>
1367
+ )}
1368
+ />
1369
+
1370
+ <FormField
1371
+ control={form.control}
1372
+ name="recorrencia.dataFim"
1373
+ render={({ field }) => (
1374
+ <FormItem>
1375
+ <FormLabel>
1376
+ {t('recurrenceEditor.endDateLabel')}
1377
+ </FormLabel>
1378
+ <FormControl>
1379
+ <Input
1380
+ type="date"
1381
+ {...field}
1382
+ value={field.value || ''}
1383
+ />
1384
+ </FormControl>
1385
+ <FormMessage />
1386
+ </FormItem>
1387
+ )}
1388
+ />
1389
+
1390
+ <FormField
1391
+ control={form.control}
1392
+ name="recorrencia.numOcorrencias"
1393
+ render={({ field }) => (
1394
+ <FormItem>
1395
+ <FormLabel>
1396
+ {t('recurrenceEditor.maxOccurrencesLabel')}
1397
+ </FormLabel>
1398
+ <FormControl>
1399
+ <Input
1400
+ type="number"
1401
+ min={1}
1402
+ max={600}
1403
+ {...field}
1404
+ value={field.value ?? ''}
1405
+ onChange={(e) =>
1406
+ field.onChange(
1407
+ e.target.value === ''
1408
+ ? undefined
1409
+ : Number(e.target.value)
1410
+ )
1411
+ }
1412
+ />
1413
+ </FormControl>
1414
+ <FormMessage />
1415
+ </FormItem>
1416
+ )}
1417
+ />
1418
+ </div>
1419
+ {form.formState.errors.recorrencia?.message && (
1420
+ <p className="text-xs text-destructive">
1421
+ {form.formState.errors.recorrencia.message}
1422
+ </p>
1423
+ )}
1165
1424
  </div>
1166
-
1167
- <p
1168
- className={`text-xs ${
1169
- installmentsDiffCents === 0
1170
- ? 'text-muted-foreground'
1171
- : 'text-destructive'
1172
- }`}
1173
- >
1174
- {t('installmentsEditor.totalPrefix', {
1175
- total: installmentsTotal.toFixed(2),
1176
- })}
1177
- {installmentsDiffCents > 0 &&
1178
- ` ${t('installmentsEditor.adjustmentNeeded')}`}
1179
- </p>
1180
- {form.formState.errors.installments?.message && (
1181
- <p className="text-xs text-destructive">
1182
- {form.formState.errors.installments.message}
1183
- </p>
1184
- )}
1185
- </div>
1425
+ )}
1186
1426
  </FinanceSheetSection>
1187
1427
 
1188
1428
  <FinanceSheetSection
@@ -1305,7 +1545,7 @@ function NovoTituloSheet({
1305
1545
  </div>
1306
1546
  </form>
1307
1547
  </Form>
1308
- </SheetContent>
1548
+ </ResizableSheetContent>
1309
1549
  </Sheet>
1310
1550
  );
1311
1551
  }
@@ -1371,6 +1611,7 @@ function EditarTituloSheet({
1371
1611
  const form = useForm<NewTitleFormValues>({
1372
1612
  resolver: zodResolver(newTitleFormSchema),
1373
1613
  defaultValues: {
1614
+ tipoTitulo: 'parcelado',
1374
1615
  documento: '',
1375
1616
  clienteId: '',
1376
1617
  competencia: '',
@@ -1378,6 +1619,11 @@ function EditarTituloSheet({
1378
1619
  valor: 0,
1379
1620
  installmentsCount: 1,
1380
1621
  installments: [{ dueDate: '', amount: 0 }],
1622
+ recorrencia: {
1623
+ frequencia: 'monthly',
1624
+ dataFim: '',
1625
+ numOcorrencias: undefined,
1626
+ },
1381
1627
  categoriaId: '',
1382
1628
  centroCustoId: '',
1383
1629
  canal: '',
@@ -1395,6 +1641,9 @@ function EditarTituloSheet({
1395
1641
  control: form.control,
1396
1642
  });
1397
1643
 
1644
+ const tipoTituloEdit = form.watch('tipoTitulo');
1645
+ const isRecorrenteEdit = tipoTituloEdit === 'recorrente';
1646
+
1398
1647
  const {
1399
1648
  clearDraft,
1400
1649
  loadDraft,
@@ -1406,6 +1655,7 @@ function EditarTituloSheet({
1406
1655
  mode: 'edit',
1407
1656
  titleId: titulo?.id ? String(titulo.id) : null,
1408
1657
  values: {
1658
+ tipoTitulo: watchedFormValues.tipoTitulo ?? 'parcelado',
1409
1659
  documento: watchedFormValues.documento ?? '',
1410
1660
  clienteId: watchedFormValues.clienteId ?? '',
1411
1661
  competencia: watchedFormValues.competencia ?? '',
@@ -1416,6 +1666,11 @@ function EditarTituloSheet({
1416
1666
  dueDate: installment?.dueDate ?? '',
1417
1667
  amount: Number(installment?.amount ?? 0),
1418
1668
  })) ?? [{ dueDate: '', amount: 0 }],
1669
+ recorrencia: {
1670
+ frequencia: watchedFormValues.recorrencia?.frequencia ?? 'monthly',
1671
+ dataFim: watchedFormValues.recorrencia?.dataFim ?? '',
1672
+ numOcorrencias: watchedFormValues.recorrencia?.numOcorrencias,
1673
+ },
1419
1674
  categoriaId: watchedFormValues.categoriaId ?? '',
1420
1675
  centroCustoId: watchedFormValues.centroCustoId ?? '',
1421
1676
  canal: watchedFormValues.canal ?? '',
@@ -1469,7 +1724,9 @@ function EditarTituloSheet({
1469
1724
  currentLocaleCode
1470
1725
  );
1471
1726
 
1472
- return t('draftStatus', { relativeLabel, absoluteLabel });
1727
+ return t.has('draftStatus')
1728
+ ? t('draftStatus', { relativeLabel, absoluteLabel })
1729
+ : `${relativeLabel} - ${absoluteLabel}`;
1473
1730
  }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft, t]);
1474
1731
 
1475
1732
  const watchedInstallmentsCount = form.watch('installmentsCount');
@@ -1532,15 +1789,24 @@ function EditarTituloSheet({
1532
1789
  },
1533
1790
  ];
1534
1791
 
1792
+ const isRecurringTitle = Boolean(titulo.isRecurring);
1535
1793
  form.reset({
1794
+ tipoTitulo: isRecurringTitle ? 'recorrente' : 'parcelado',
1536
1795
  documento: titulo.documento || '',
1537
1796
  clienteId: titulo.clienteId || '',
1538
1797
  competencia: titulo.competencia || '',
1539
1798
  vencimento:
1540
1799
  normalizedInstallments[0]?.dueDate || toDateInput(titulo?.vencimento),
1541
- valor: Number(titulo.valorTotal || 0),
1800
+ valor: isRecurringTitle
1801
+ ? Number(normalizedInstallments[0]?.amount || 0)
1802
+ : Number(titulo.valorTotal || 0),
1542
1803
  installmentsCount: normalizedInstallments.length,
1543
1804
  installments: normalizedInstallments,
1805
+ recorrencia: {
1806
+ frequencia: titulo.recurrenceFrequency || 'monthly',
1807
+ dataFim: titulo.recurrenceEndDate || '',
1808
+ numOcorrencias: undefined,
1809
+ },
1544
1810
  categoriaId: titulo.categoriaId || '',
1545
1811
  centroCustoId: titulo.centroCustoId || '',
1546
1812
  canal: installments[0]?.canal || '',
@@ -1839,6 +2105,7 @@ function EditarTituloSheet({
1839
2105
  }
1840
2106
 
1841
2107
  try {
2108
+ const recorrente = values.tipoTitulo === 'recorrente';
1842
2109
  await request({
1843
2110
  url: `/finance/accounts-receivable/installments/${titulo.id}`,
1844
2111
  method: 'PATCH',
@@ -1858,11 +2125,22 @@ function EditarTituloSheet({
1858
2125
  : undefined,
1859
2126
  payment_channel: values.canal || undefined,
1860
2127
  description: values.descricao?.trim() || undefined,
1861
- installments: values.installments.map((installment, index) => ({
1862
- installment_number: index + 1,
1863
- due_date: installment.dueDate || values.vencimento,
1864
- amount: installment.amount,
1865
- })),
2128
+ ...(recorrente
2129
+ ? {
2130
+ recurrence_rule: {
2131
+ frequency: values.recorrencia?.frequencia ?? 'monthly',
2132
+ end_date: values.recorrencia?.dataFim || undefined,
2133
+ max_occurrences:
2134
+ values.recorrencia?.numOcorrencias || undefined,
2135
+ },
2136
+ }
2137
+ : {
2138
+ installments: values.installments.map((installment, index) => ({
2139
+ installment_number: index + 1,
2140
+ due_date: installment.dueDate || values.vencimento,
2141
+ amount: installment.amount,
2142
+ })),
2143
+ }),
1866
2144
  attachment_file_ids: uploadedFileId ? [uploadedFileId] : undefined,
1867
2145
  },
1868
2146
  });
@@ -1871,8 +2149,11 @@ function EditarTituloSheet({
1871
2149
  await onUpdated();
1872
2150
  showToastHandler?.('success', t('messages.updateSuccess'));
1873
2151
  onOpenChange(false);
1874
- } catch {
1875
- showToastHandler?.('error', t('messages.updateError'));
2152
+ } catch (error) {
2153
+ showToastHandler?.(
2154
+ 'error',
2155
+ getApiErrorMessage(error, t('messages.updateError'))
2156
+ );
1876
2157
  }
1877
2158
  };
1878
2159
 
@@ -1880,9 +2161,20 @@ function EditarTituloSheet({
1880
2161
  onOpenChange(false);
1881
2162
  };
1882
2163
 
2164
+ const handleInvalidSubmit = (errors: unknown) => {
2165
+ showToastHandler?.(
2166
+ 'error',
2167
+ getFirstFormErrorMessage(errors, t('messages.updateError'))
2168
+ );
2169
+ };
2170
+
1883
2171
  return (
1884
2172
  <Sheet open={open} onOpenChange={onOpenChange}>
1885
- <SheetContent className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-xl lg:max-w-4xl">
2173
+ <ResizableSheetContent
2174
+ sheetId="finance-receivable-edit-title"
2175
+ defaultWidth={896}
2176
+ className="flex h-full w-full flex-col overflow-hidden p-0"
2177
+ >
1886
2178
  <SheetHeader className="border-b border-border/50 px-4 py-4 sm:px-6">
1887
2179
  <SheetTitle>{t('table.actions.edit')}</SheetTitle>
1888
2180
  <SheetDescription>{t('editTitle.description')}</SheetDescription>
@@ -1890,7 +2182,7 @@ function EditarTituloSheet({
1890
2182
  <Form {...form}>
1891
2183
  <form
1892
2184
  className="flex h-full flex-col overflow-hidden"
1893
- onSubmit={form.handleSubmit(handleSubmit)}
2185
+ onSubmit={form.handleSubmit(handleSubmit, handleInvalidSubmit)}
1894
2186
  >
1895
2187
  <FinanceSheetBody>
1896
2188
  <FinanceSheetSection
@@ -2051,6 +2343,39 @@ function EditarTituloSheet({
2051
2343
  selectPlaceholder={t('common.select')}
2052
2344
  />
2053
2345
 
2346
+ {/* Tipo de título: Parcelado / Recorrente */}
2347
+ <FormField
2348
+ control={form.control}
2349
+ name="tipoTitulo"
2350
+ render={({ field }) => (
2351
+ <FormItem>
2352
+ <FormLabel>{t('titleType.label')}</FormLabel>
2353
+ <div className="flex gap-2">
2354
+ <Button
2355
+ type="button"
2356
+ variant={
2357
+ field.value === 'parcelado' ? 'default' : 'outline'
2358
+ }
2359
+ size="sm"
2360
+ onClick={() => field.onChange('parcelado')}
2361
+ >
2362
+ {t('titleType.installment')}
2363
+ </Button>
2364
+ <Button
2365
+ type="button"
2366
+ variant={
2367
+ field.value === 'recorrente' ? 'default' : 'outline'
2368
+ }
2369
+ size="sm"
2370
+ onClick={() => field.onChange('recorrente')}
2371
+ >
2372
+ {t('titleType.recurring')}
2373
+ </Button>
2374
+ </div>
2375
+ </FormItem>
2376
+ )}
2377
+ />
2378
+
2054
2379
  <div className="grid gap-4 xl:grid-cols-2">
2055
2380
  <div className="grid gap-4 md:grid-cols-2">
2056
2381
  <FormField
@@ -2096,7 +2421,11 @@ function EditarTituloSheet({
2096
2421
  name="valor"
2097
2422
  render={({ field }) => (
2098
2423
  <FormItem>
2099
- <FormLabel>{t('fields.totalValue')}</FormLabel>
2424
+ <FormLabel>
2425
+ {isRecorrenteEdit
2426
+ ? t('fields.totalValueRecurring')
2427
+ : t('fields.totalValue')}
2428
+ </FormLabel>
2100
2429
  <FormControl>
2101
2430
  <InputMoney
2102
2431
  ref={field.ref}
@@ -2114,181 +2443,294 @@ function EditarTituloSheet({
2114
2443
  )}
2115
2444
  />
2116
2445
 
2117
- <FormField
2118
- control={form.control}
2119
- name="installmentsCount"
2120
- render={({ field }) => (
2121
- <FormItem>
2122
- <FormLabel>
2123
- {t('installmentsEditor.countLabel')}
2124
- </FormLabel>
2125
- <FormControl>
2126
- <Input
2127
- type="number"
2128
- min={1}
2129
- max={120}
2130
- value={field.value}
2131
- onChange={(event) => {
2132
- const nextValue = Number(
2133
- event.target.value || 1
2134
- );
2135
- field.onChange(
2136
- Number.isNaN(nextValue) ? 1 : nextValue
2137
- );
2138
- setIsInstallmentsEdited(false);
2139
- }}
2140
- />
2141
- </FormControl>
2142
- <FormMessage />
2143
- </FormItem>
2144
- )}
2145
- />
2446
+ {!isRecorrenteEdit && (
2447
+ <FormField
2448
+ control={form.control}
2449
+ name="installmentsCount"
2450
+ render={({ field }) => (
2451
+ <FormItem>
2452
+ <FormLabel>
2453
+ {t('installmentsEditor.countLabel')}
2454
+ </FormLabel>
2455
+ <FormControl>
2456
+ <Input
2457
+ type="number"
2458
+ min={1}
2459
+ max={120}
2460
+ value={field.value}
2461
+ onChange={(event) => {
2462
+ const nextValue = Number(
2463
+ event.target.value || 1
2464
+ );
2465
+ field.onChange(
2466
+ Number.isNaN(nextValue) ? 1 : nextValue
2467
+ );
2468
+ setIsInstallmentsEdited(false);
2469
+ }}
2470
+ />
2471
+ </FormControl>
2472
+ <FormMessage />
2473
+ </FormItem>
2474
+ )}
2475
+ />
2476
+ )}
2146
2477
  </div>
2147
2478
  </div>
2148
2479
 
2149
- <div className="space-y-3 rounded-md border p-3">
2150
- <div className="flex items-center justify-between gap-2">
2151
- <p className="text-sm font-medium">
2152
- {t('installmentsEditor.title')}
2153
- </p>
2154
- <Button
2155
- type="button"
2156
- variant="outline"
2157
- size="sm"
2158
- onClick={() => {
2159
- setIsInstallmentsEdited(false);
2160
- replaceInstallments(
2161
- buildEqualInstallments(
2162
- form.getValues('installmentsCount'),
2163
- form.getValues('valor'),
2164
- form.getValues('vencimento')
2165
- )
2166
- );
2167
- }}
2168
- >
2169
- {t('installmentsEditor.recalculate')}
2170
- </Button>
2171
- </div>
2480
+ {!isRecorrenteEdit && (
2481
+ <div className="space-y-3 rounded-md border p-3">
2482
+ <div className="flex items-center justify-between gap-2">
2483
+ <p className="text-sm font-medium">
2484
+ {t('installmentsEditor.title')}
2485
+ </p>
2486
+ <Button
2487
+ type="button"
2488
+ variant="outline"
2489
+ size="sm"
2490
+ onClick={() => {
2491
+ setIsInstallmentsEdited(false);
2492
+ replaceInstallments(
2493
+ buildEqualInstallments(
2494
+ form.getValues('installmentsCount'),
2495
+ form.getValues('valor'),
2496
+ form.getValues('vencimento')
2497
+ )
2498
+ );
2499
+ }}
2500
+ >
2501
+ {t('installmentsEditor.recalculate')}
2502
+ </Button>
2503
+ </div>
2172
2504
 
2173
- <div className="flex items-center gap-2">
2174
- <Checkbox
2175
- id="auto-redistribute-installments-edit-receivable"
2176
- checked={autoRedistributeInstallments}
2177
- onCheckedChange={(checked) =>
2178
- setAutoRedistributeInstallments(checked === true)
2179
- }
2180
- />
2181
- <Label
2182
- htmlFor="auto-redistribute-installments-edit-receivable"
2183
- className="text-xs text-muted-foreground"
2184
- >
2185
- {t('installmentsEditor.autoRedistributeLabel')}
2186
- </Label>
2187
- </div>
2505
+ <div className="flex items-center gap-2">
2506
+ <Checkbox
2507
+ id="auto-redistribute-installments-edit-receivable"
2508
+ checked={autoRedistributeInstallments}
2509
+ onCheckedChange={(checked) =>
2510
+ setAutoRedistributeInstallments(checked === true)
2511
+ }
2512
+ />
2513
+ <Label
2514
+ htmlFor="auto-redistribute-installments-edit-receivable"
2515
+ className="text-xs text-muted-foreground"
2516
+ >
2517
+ {t('installmentsEditor.autoRedistributeLabel')}
2518
+ </Label>
2519
+ </div>
2188
2520
 
2189
- {autoRedistributeInstallments && (
2190
- <p className="text-xs text-muted-foreground">
2191
- {t('installmentsEditor.autoRedistributeHint')}
2192
- </p>
2193
- )}
2521
+ {autoRedistributeInstallments && (
2522
+ <p className="text-xs text-muted-foreground">
2523
+ {t('installmentsEditor.autoRedistributeHint')}
2524
+ </p>
2525
+ )}
2194
2526
 
2195
- <div className="space-y-2">
2196
- {installmentFields.map((installment, index) => (
2197
- <div
2198
- key={installment.id}
2199
- className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
2200
- >
2201
- <div className="flex items-center text-sm text-muted-foreground">
2202
- #{index + 1}
2527
+ <div className="space-y-2">
2528
+ {installmentFields.map((installment, index) => (
2529
+ <div
2530
+ key={installment.id}
2531
+ className="grid grid-cols-1 items-start gap-2 rounded-md border p-2 sm:grid-cols-[96px_1fr_160px]"
2532
+ >
2533
+ <div className="flex items-center text-sm text-muted-foreground">
2534
+ #{index + 1}
2535
+ </div>
2536
+
2537
+ <FormField
2538
+ control={form.control}
2539
+ name={`installments.${index}.dueDate` as const}
2540
+ render={({ field }) => (
2541
+ <FormItem>
2542
+ <FormLabel className="text-xs">
2543
+ {t('installmentsEditor.dueDateLabel')}
2544
+ </FormLabel>
2545
+ <FormControl>
2546
+ <Input
2547
+ type="date"
2548
+ {...field}
2549
+ value={field.value || ''}
2550
+ onChange={(event) => {
2551
+ setIsInstallmentsEdited(true);
2552
+ field.onChange(event);
2553
+ }}
2554
+ />
2555
+ </FormControl>
2556
+ <FormMessage />
2557
+ </FormItem>
2558
+ )}
2559
+ />
2560
+
2561
+ <FormField
2562
+ control={form.control}
2563
+ name={`installments.${index}.amount` as const}
2564
+ render={({ field }) => (
2565
+ <FormItem>
2566
+ <FormLabel className="text-xs">
2567
+ {t('installmentsEditor.amountLabel')}
2568
+ </FormLabel>
2569
+ <FormControl>
2570
+ <InputMoney
2571
+ ref={field.ref}
2572
+ name={field.name}
2573
+ value={field.value}
2574
+ onBlur={() => {
2575
+ field.onBlur();
2576
+
2577
+ if (!autoRedistributeInstallments) {
2578
+ return;
2579
+ }
2580
+
2581
+ clearScheduledRedistribution(index);
2582
+ runInstallmentRedistribution(index);
2583
+ }}
2584
+ onValueChange={(value) => {
2585
+ setIsInstallmentsEdited(true);
2586
+ field.onChange(value ?? 0);
2587
+
2588
+ if (!autoRedistributeInstallments) {
2589
+ return;
2590
+ }
2591
+
2592
+ scheduleInstallmentRedistribution(index);
2593
+ }}
2594
+ placeholder="0,00"
2595
+ />
2596
+ </FormControl>
2597
+ <FormMessage />
2598
+ </FormItem>
2599
+ )}
2600
+ />
2203
2601
  </div>
2602
+ ))}
2603
+ </div>
2204
2604
 
2205
- <FormField
2206
- control={form.control}
2207
- name={`installments.${index}.dueDate` as const}
2208
- render={({ field }) => (
2209
- <FormItem>
2210
- <FormLabel className="text-xs">
2211
- {t('installmentsEditor.dueDateLabel')}
2212
- </FormLabel>
2213
- <FormControl>
2214
- <Input
2215
- type="date"
2216
- {...field}
2217
- value={field.value || ''}
2218
- onChange={(event) => {
2219
- setIsInstallmentsEdited(true);
2220
- field.onChange(event);
2221
- }}
2222
- />
2223
- </FormControl>
2224
- <FormMessage />
2225
- </FormItem>
2226
- )}
2227
- />
2605
+ <p
2606
+ className={`text-xs ${
2607
+ installmentsDiffCents === 0
2608
+ ? 'text-muted-foreground'
2609
+ : 'text-destructive'
2610
+ }`}
2611
+ >
2612
+ {t('installmentsEditor.totalPrefix', {
2613
+ total: installmentsTotal.toFixed(2),
2614
+ })}
2615
+ {installmentsDiffCents > 0 &&
2616
+ ` ${t('installmentsEditor.adjustmentNeeded')}`}
2617
+ </p>
2618
+ {form.formState.errors.installments?.message && (
2619
+ <p className="text-xs text-destructive">
2620
+ {form.formState.errors.installments.message}
2621
+ </p>
2622
+ )}
2623
+ </div>
2624
+ )}
2228
2625
 
2229
- <FormField
2230
- control={form.control}
2231
- name={`installments.${index}.amount` as const}
2232
- render={({ field }) => (
2233
- <FormItem>
2234
- <FormLabel className="text-xs">
2235
- {t('installmentsEditor.amountLabel')}
2236
- </FormLabel>
2626
+ {isRecorrenteEdit && (
2627
+ <div className="space-y-3 rounded-md border p-3">
2628
+ <p className="text-sm font-medium">
2629
+ {t('recurrenceEditor.title')}
2630
+ </p>
2631
+ <div className="grid gap-4 md:grid-cols-3">
2632
+ <FormField
2633
+ control={form.control}
2634
+ name="recorrencia.frequencia"
2635
+ render={({ field }) => (
2636
+ <FormItem>
2637
+ <FormLabel>
2638
+ {t('recurrenceEditor.frequencyLabel')}
2639
+ </FormLabel>
2640
+ <Select
2641
+ value={field.value}
2642
+ onValueChange={field.onChange}
2643
+ >
2237
2644
  <FormControl>
2238
- <InputMoney
2239
- ref={field.ref}
2240
- name={field.name}
2241
- value={field.value}
2242
- onBlur={() => {
2243
- field.onBlur();
2244
-
2245
- if (!autoRedistributeInstallments) {
2246
- return;
2247
- }
2248
-
2249
- clearScheduledRedistribution(index);
2250
- runInstallmentRedistribution(index);
2251
- }}
2252
- onValueChange={(value) => {
2253
- setIsInstallmentsEdited(true);
2254
- field.onChange(value ?? 0);
2255
-
2256
- if (!autoRedistributeInstallments) {
2257
- return;
2258
- }
2259
-
2260
- scheduleInstallmentRedistribution(index);
2261
- }}
2262
- placeholder="0,00"
2263
- />
2645
+ <SelectTrigger className="w-full">
2646
+ <SelectValue />
2647
+ </SelectTrigger>
2264
2648
  </FormControl>
2265
- <FormMessage />
2266
- </FormItem>
2267
- )}
2268
- />
2269
- </div>
2270
- ))}
2649
+ <SelectContent>
2650
+ <SelectItem value="weekly">
2651
+ {t('recurrenceEditor.frequencies.weekly')}
2652
+ </SelectItem>
2653
+ <SelectItem value="biweekly">
2654
+ {t('recurrenceEditor.frequencies.biweekly')}
2655
+ </SelectItem>
2656
+ <SelectItem value="monthly">
2657
+ {t('recurrenceEditor.frequencies.monthly')}
2658
+ </SelectItem>
2659
+ <SelectItem value="bimonthly">
2660
+ {t('recurrenceEditor.frequencies.bimonthly')}
2661
+ </SelectItem>
2662
+ <SelectItem value="quarterly">
2663
+ {t('recurrenceEditor.frequencies.quarterly')}
2664
+ </SelectItem>
2665
+ <SelectItem value="semiannual">
2666
+ {t('recurrenceEditor.frequencies.semiannual')}
2667
+ </SelectItem>
2668
+ <SelectItem value="annual">
2669
+ {t('recurrenceEditor.frequencies.annual')}
2670
+ </SelectItem>
2671
+ </SelectContent>
2672
+ </Select>
2673
+ <FormMessage />
2674
+ </FormItem>
2675
+ )}
2676
+ />
2677
+
2678
+ <FormField
2679
+ control={form.control}
2680
+ name="recorrencia.dataFim"
2681
+ render={({ field }) => (
2682
+ <FormItem>
2683
+ <FormLabel>
2684
+ {t('recurrenceEditor.endDateLabel')}
2685
+ </FormLabel>
2686
+ <FormControl>
2687
+ <Input
2688
+ type="date"
2689
+ {...field}
2690
+ value={field.value || ''}
2691
+ />
2692
+ </FormControl>
2693
+ <FormMessage />
2694
+ </FormItem>
2695
+ )}
2696
+ />
2697
+
2698
+ <FormField
2699
+ control={form.control}
2700
+ name="recorrencia.numOcorrencias"
2701
+ render={({ field }) => (
2702
+ <FormItem>
2703
+ <FormLabel>
2704
+ {t('recurrenceEditor.maxOccurrencesLabel')}
2705
+ </FormLabel>
2706
+ <FormControl>
2707
+ <Input
2708
+ type="number"
2709
+ min={1}
2710
+ max={600}
2711
+ {...field}
2712
+ value={field.value ?? ''}
2713
+ onChange={(e) =>
2714
+ field.onChange(
2715
+ e.target.value === ''
2716
+ ? undefined
2717
+ : Number(e.target.value)
2718
+ )
2719
+ }
2720
+ />
2721
+ </FormControl>
2722
+ <FormMessage />
2723
+ </FormItem>
2724
+ )}
2725
+ />
2726
+ </div>
2727
+ {form.formState.errors.recorrencia?.message && (
2728
+ <p className="text-xs text-destructive">
2729
+ {form.formState.errors.recorrencia.message}
2730
+ </p>
2731
+ )}
2271
2732
  </div>
2272
-
2273
- <p
2274
- className={`text-xs ${
2275
- installmentsDiffCents === 0
2276
- ? 'text-muted-foreground'
2277
- : 'text-destructive'
2278
- }`}
2279
- >
2280
- {t('installmentsEditor.totalPrefix', {
2281
- total: installmentsTotal.toFixed(2),
2282
- })}
2283
- {installmentsDiffCents > 0 &&
2284
- ` ${t('installmentsEditor.adjustmentNeeded')}`}
2285
- </p>
2286
- {form.formState.errors.installments?.message && (
2287
- <p className="text-xs text-destructive">
2288
- {form.formState.errors.installments.message}
2289
- </p>
2290
- )}
2291
- </div>
2733
+ )}
2292
2734
  </FinanceSheetSection>
2293
2735
 
2294
2736
  <FinanceSheetSection
@@ -2411,7 +2853,7 @@ function EditarTituloSheet({
2411
2853
  </div>
2412
2854
  </form>
2413
2855
  </Form>
2414
- </SheetContent>
2856
+ </ResizableSheetContent>
2415
2857
  </Sheet>
2416
2858
  );
2417
2859
  }
@@ -2437,6 +2879,7 @@ export default function TitulosReceberPage() {
2437
2879
  const [fromDate, setFromDate] = useState('');
2438
2880
  const [toDate, setToDate] = useState('');
2439
2881
  const [page, setPage] = useState(1);
2882
+ const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
2440
2883
  const pageSize = 10;
2441
2884
 
2442
2885
  const normalizedStatusFilter =
@@ -2487,6 +2930,8 @@ export default function TitulosReceberPage() {
2487
2930
  placeholderData: (old) => old,
2488
2931
  });
2489
2932
 
2933
+ useFinanceRealtimeRefresh({ request, onRefresh: refetchTitles });
2934
+
2490
2935
  const titulosReceber = paginatedTitlesResponse?.data || [];
2491
2936
  const visibleTitlesTotal = useMemo(
2492
2937
  () =>
@@ -2673,6 +3118,8 @@ export default function TitulosReceberPage() {
2673
3118
  categorias={categorias}
2674
3119
  centrosCusto={centrosCusto}
2675
3120
  t={t}
3121
+ open={isCreateSheetOpen}
3122
+ onOpenChange={setIsCreateSheetOpen}
2676
3123
  onCreated={async () => {
2677
3124
  await Promise.all([refetchTitles(), refetchFinanceData()]);
2678
3125
  }}
@@ -2859,7 +3306,7 @@ export default function TitulosReceberPage() {
2859
3306
  title={t('empty.title')}
2860
3307
  description={t('empty.description')}
2861
3308
  actionLabel={t('newTitle.action')}
2862
- onAction={() => void refetchTitles()}
3309
+ onAction={() => setIsCreateSheetOpen(true)}
2863
3310
  />
2864
3311
  )}
2865
3312