@accounter/client 0.0.8-alpha-20251021150615-800574fc6d416cd319de216c97b431643d8958a2 → 0.0.8-alpha-20251021163440-2ab1a9ffaec95fd99fac5495c3a392b97429ce77

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 (56) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/dist/assets/index-B2UYAO1O.css +1 -0
  3. package/dist/assets/index-BexxGuN6.js +1224 -0
  4. package/dist/assets/{index.es-DHwHzag1.js → index.es-CWwhWGxX.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +6 -5
  7. package/src/app.tsx +35 -25
  8. package/src/components/business/business-header.tsx +68 -0
  9. package/src/components/business/charges-section.tsx +82 -0
  10. package/src/components/business/charts-section.tsx +115 -0
  11. package/src/components/business/configurations-section.tsx +885 -0
  12. package/src/components/business/contact-info-section.tsx +536 -0
  13. package/src/components/business/contracts-section.tsx +196 -0
  14. package/src/components/business/documents-section.tsx +26 -0
  15. package/src/components/business/index.tsx +171 -0
  16. package/src/components/business/integrations-section.tsx +477 -0
  17. package/src/components/business/transactions-section.tsx +26 -0
  18. package/src/components/business-transactions/business-extended-info.tsx +11 -15
  19. package/src/components/business-transactions/business-transactions-single.tsx +1 -1
  20. package/src/components/business-transactions/index.tsx +1 -1
  21. package/src/components/charges/charge-extended-info-menu.tsx +27 -21
  22. package/src/components/charges/charges-row.tsx +12 -10
  23. package/src/components/charges/charges-table.tsx +15 -9
  24. package/src/components/clients/contracts/modify-contract-dialog.tsx +464 -0
  25. package/src/components/clients/modify-client-dialog.tsx +276 -0
  26. package/src/components/common/documents/issue-document/index.tsx +3 -3
  27. package/src/components/common/documents/issue-document/{recent-client-docs.tsx → recent-business-docs.tsx} +19 -13
  28. package/src/components/common/forms/business-card.tsx +1 -0
  29. package/src/components/common/forms/modify-business-fields.tsx +2 -19
  30. package/src/components/common/inputs/combo-box.tsx +1 -1
  31. package/src/components/layout/sidelinks.tsx +3 -3
  32. package/src/components/reports/trial-balance-report/trial-balance-report-group.tsx +4 -6
  33. package/src/components/reports/trial-balance-report/trial-balance-report-sort-code.tsx +8 -11
  34. package/src/components/screens/businesses/business.tsx +44 -0
  35. package/src/components/screens/documents/issue-documents/edit-issue-document-modal.tsx +4 -4
  36. package/src/components/ui/progress.tsx +25 -0
  37. package/src/components/ui/skeleton.tsx +12 -0
  38. package/src/gql/gql.ts +93 -9
  39. package/src/gql/graphql.ts +289 -9
  40. package/src/helpers/contracts.ts +22 -0
  41. package/src/helpers/currency.ts +5 -0
  42. package/src/helpers/index.ts +2 -0
  43. package/src/helpers/pcn874.ts +17 -0
  44. package/src/hooks/use-add-sort-code.ts +1 -1
  45. package/src/hooks/use-add-tag.ts +1 -1
  46. package/src/hooks/use-create-contract.ts +62 -0
  47. package/src/hooks/use-delete-contract.ts +64 -0
  48. package/src/hooks/use-delete-tag.ts +1 -1
  49. package/src/hooks/use-get-all-contracts.ts +0 -1
  50. package/src/hooks/use-insert-client.ts +80 -0
  51. package/src/hooks/use-merge-businesses.ts +1 -1
  52. package/src/hooks/use-merge-charges.ts +1 -1
  53. package/src/hooks/use-update-client.ts +75 -0
  54. package/src/hooks/use-update-contract.ts +69 -0
  55. package/dist/assets/index-0eCf1BcD.css +0 -1
  56. package/dist/assets/index-DHTbHvtz.js +0 -1188
@@ -0,0 +1,885 @@
1
+ import { useCallback, useContext, useEffect, useState } from 'react';
2
+ import { Plus, Save, X } from 'lucide-react';
3
+ import { useForm, type UseFormReturn } from 'react-hook-form';
4
+ import { Badge } from '@/components/ui/badge.js';
5
+ import { Button } from '@/components/ui/button.js';
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardFooter,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from '@/components/ui/card.js';
14
+ import {
15
+ Form,
16
+ FormControl,
17
+ FormDescription,
18
+ FormField,
19
+ FormItem,
20
+ FormLabel,
21
+ FormMessage,
22
+ } from '@/components/ui/form.js';
23
+ import { Input } from '@/components/ui/input.js';
24
+ import {
25
+ Select,
26
+ SelectContent,
27
+ SelectItem,
28
+ SelectTrigger,
29
+ SelectValue,
30
+ } from '@/components/ui/select.js';
31
+ import { Separator } from '@/components/ui/separator.js';
32
+ import { Switch } from '@/components/ui/switch.js';
33
+ import {
34
+ BusinessConfigurationSectionFragmentDoc,
35
+ EmailAttachmentType,
36
+ Pcn874RecordType,
37
+ type BusinessConfigurationSectionFragment,
38
+ type UpdateBusinessInput,
39
+ } from '@/gql/graphql.js';
40
+ import { getFragmentData, type FragmentType } from '@/gql/index.js';
41
+ import {
42
+ dirtyFieldMarker,
43
+ pcn874RecordEnum,
44
+ relevantDataPicker,
45
+ type MakeBoolean,
46
+ } from '@/helpers/index.js';
47
+ import { useGetSortCodes } from '@/hooks/use-get-sort-codes.js';
48
+ import { useGetTags } from '@/hooks/use-get-tags.js';
49
+ import { useGetTaxCategories } from '@/hooks/use-get-tax-categories.js';
50
+ import { useUpdateBusiness } from '@/hooks/use-update-business.js';
51
+ import { UserContext } from '@/providers/user-provider.js';
52
+ import { ModifyClientDialog } from '../clients/modify-client-dialog.js';
53
+ import {
54
+ ComboBox,
55
+ MultiSelect,
56
+ NumberInput,
57
+ SimilarChargesByBusinessModal,
58
+ } from '../common/index.js';
59
+
60
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen
61
+ /* GraphQL */ `
62
+ fragment BusinessConfigurationSection on Business {
63
+ __typename
64
+ id
65
+ pcn874RecordType
66
+ irsCode
67
+ isActive
68
+ ... on LtdFinancialEntity {
69
+ optionalVAT
70
+ exemptDealer
71
+ isReceiptEnough
72
+ isDocumentsOptional
73
+ sortCode {
74
+ id
75
+ key
76
+ defaultIrsCode
77
+ }
78
+ taxCategory {
79
+ id
80
+ }
81
+ suggestions {
82
+ phrases
83
+ emails
84
+ tags {
85
+ id
86
+ }
87
+ description
88
+ emailListener {
89
+ internalEmailLinks
90
+ emailBody
91
+ attachments
92
+ }
93
+ }
94
+ clientInfo {
95
+ id
96
+ }
97
+ }
98
+ }
99
+ `;
100
+
101
+ interface ConfigurationFormValues {
102
+ isClient: boolean;
103
+ isActive: boolean;
104
+ isReceiptEnough: boolean;
105
+ isDocumentsOptional: boolean;
106
+ isVatOptional: boolean;
107
+ isExemptDealer: boolean;
108
+ sortCode: string;
109
+ taxCategory: string;
110
+ pcn874RecordType: Pcn874RecordType;
111
+ irsCode: number | null;
112
+ description: string;
113
+ tags: string[];
114
+ phrases: string[];
115
+ emails: string[];
116
+ internalLinks: string[];
117
+ attachmentTypes: EmailAttachmentType[];
118
+ useMessageBody: boolean;
119
+ }
120
+
121
+ const availableAttachmentTypes = Object.values(EmailAttachmentType);
122
+
123
+ function ConfigurationsSectionFragmentToFormValues(
124
+ business?: BusinessConfigurationSectionFragment,
125
+ ): Partial<ConfigurationFormValues> {
126
+ if (!business || business.__typename !== 'LtdFinancialEntity') {
127
+ return {} as ConfigurationFormValues;
128
+ }
129
+
130
+ return {
131
+ isClient: !!business.clientInfo?.id,
132
+ isActive: business.isActive,
133
+ isReceiptEnough: business.isReceiptEnough ?? undefined,
134
+ isDocumentsOptional: business.isDocumentsOptional ?? undefined,
135
+ isVatOptional: business.optionalVAT ?? undefined,
136
+ isExemptDealer: business.exemptDealer ?? undefined,
137
+ sortCode: business.sortCode?.key?.toString() ?? undefined,
138
+ taxCategory: business.taxCategory?.id ?? undefined,
139
+ pcn874RecordType: business?.pcn874RecordType ?? undefined,
140
+ irsCode: business?.irsCode ?? undefined,
141
+ description: business.suggestions?.description ?? undefined,
142
+ tags: business.suggestions?.tags?.map(tag => tag.id) ?? [],
143
+ phrases: business.suggestions?.phrases ?? [],
144
+ emails: business.suggestions?.emails ?? [],
145
+ internalLinks: business.suggestions?.emailListener?.internalEmailLinks ?? [],
146
+ attachmentTypes: business.suggestions?.emailListener?.attachments ?? [],
147
+ useMessageBody: business.suggestions?.emailListener?.emailBody ?? undefined,
148
+ };
149
+ }
150
+
151
+ function convertFormDataToUpdateBusinessInput(
152
+ formData: Partial<ConfigurationFormValues>,
153
+ ): UpdateBusinessInput {
154
+ const emailListenerDataExists =
155
+ formData.internalLinks || formData.useMessageBody || formData.attachmentTypes;
156
+ const emailListener:
157
+ | NonNullable<UpdateBusinessInput['suggestions']>['emailListener']
158
+ | undefined = emailListenerDataExists
159
+ ? {
160
+ internalEmailLinks: formData.internalLinks,
161
+ emailBody: formData.useMessageBody,
162
+ attachments: formData.attachmentTypes,
163
+ }
164
+ : undefined;
165
+
166
+ const suggestionsDataExists =
167
+ formData.description || formData.tags || formData.phrases || formData.emails || emailListener;
168
+ const suggestions: UpdateBusinessInput['suggestions'] | undefined = suggestionsDataExists
169
+ ? {
170
+ description: formData.description,
171
+ tags: formData.tags?.map(id => ({ id })),
172
+ phrases: formData.phrases,
173
+ emails: formData.emails,
174
+ emailListener,
175
+ }
176
+ : undefined;
177
+
178
+ return {
179
+ optionalVAT: formData.isVatOptional,
180
+ exemptDealer: formData.isExemptDealer,
181
+ sortCode: formData.sortCode ? parseInt(formData.sortCode) : undefined,
182
+ taxCategory: formData.taxCategory,
183
+ pcn874RecordType: formData.pcn874RecordType,
184
+ irsCode: formData.irsCode,
185
+ isActive: formData.isActive,
186
+ isReceiptEnough: formData.isReceiptEnough,
187
+ isDocumentsOptional: formData.isDocumentsOptional,
188
+ suggestions,
189
+ };
190
+ }
191
+
192
+ interface Props {
193
+ data?: FragmentType<typeof BusinessConfigurationSectionFragmentDoc>;
194
+ refetchBusiness?: () => Promise<void>;
195
+ }
196
+
197
+ export function ConfigurationsSection({ data, refetchBusiness }: Props) {
198
+ const business = getFragmentData(BusinessConfigurationSectionFragmentDoc, data);
199
+ const [defaultFormValues, setDefaultFormValues] = useState(
200
+ ConfigurationsSectionFragmentToFormValues(business),
201
+ );
202
+ const { userContext } = useContext(UserContext);
203
+
204
+ const { updateBusiness: updateDbBusiness, fetching: isBusinessUpdating } = useUpdateBusiness();
205
+
206
+ const [similarChargesOpen, setSimilarChargesOpen] = useState(false);
207
+ const [similarChargesData, setSimilarChargesData] = useState<
208
+ | {
209
+ tagIds?: { id: string }[];
210
+ description?: string;
211
+ }
212
+ | undefined
213
+ >(undefined);
214
+
215
+ const form = useForm<ConfigurationFormValues>({
216
+ defaultValues: defaultFormValues,
217
+ });
218
+
219
+ const onSubmit = async (values: Partial<ConfigurationFormValues>) => {
220
+ if (!business || !userContext?.context.adminBusinessId) {
221
+ return;
222
+ }
223
+
224
+ const dataToUpdate = relevantDataPicker(
225
+ values,
226
+ form.formState.dirtyFields as MakeBoolean<typeof values>,
227
+ );
228
+
229
+ if (!dataToUpdate) return;
230
+
231
+ const updateBusinessInput = convertFormDataToUpdateBusinessInput(dataToUpdate);
232
+
233
+ await updateDbBusiness({
234
+ businessId: business.id,
235
+ ownerId: userContext.context.adminBusinessId,
236
+ fields: updateBusinessInput,
237
+ });
238
+
239
+ if (dataToUpdate.tags?.length || dataToUpdate.description) {
240
+ // Show similar charges modal if tags or description were updated
241
+ setSimilarChargesData({
242
+ tagIds: dataToUpdate.tags?.map(id => ({ id })),
243
+ description: dataToUpdate.description,
244
+ });
245
+ setSimilarChargesOpen(true);
246
+ } else {
247
+ // Otherwise, just refetch business data
248
+ refetchBusiness?.();
249
+ }
250
+ };
251
+
252
+ useEffect(() => {
253
+ if (business) {
254
+ const formValues = ConfigurationsSectionFragmentToFormValues(business);
255
+ setDefaultFormValues(formValues);
256
+ form.reset(formValues);
257
+ }
258
+ }, [business, form]);
259
+
260
+ if (!business) {
261
+ return <div />;
262
+ }
263
+
264
+ const isClient = form.watch('isClient');
265
+
266
+ return (
267
+ <Card>
268
+ <CardHeader>
269
+ <div className="flex items-center justify-between">
270
+ <div>
271
+ <CardTitle>Configurations</CardTitle>
272
+ <CardDescription>
273
+ Business status, tax settings, automation rules, and integration preferences
274
+ </CardDescription>
275
+ </div>
276
+ {!isClient && <ModifyClientDialog businessId={business.id} onDone={refetchBusiness} />}
277
+ </div>
278
+ </CardHeader>
279
+ <Form {...form}>
280
+ <form onSubmit={form.handleSubmit(onSubmit)}>
281
+ <CardContent className="space-y-6">
282
+ <BusinessBehaviorSubSection form={form} />
283
+
284
+ <Separator />
285
+
286
+ <DefaultSettingsSubSection form={form} />
287
+
288
+ <Separator />
289
+
290
+ <AutoMatchingConfigurationSubSection form={form} />
291
+
292
+ <Separator />
293
+
294
+ <GmailConfigurationSubSection form={form} />
295
+ </CardContent>
296
+ <CardFooter className="flex justify-end border-t pt-6">
297
+ <Button
298
+ type="submit"
299
+ disabled={isBusinessUpdating || Object.keys(form.formState.dirtyFields).length === 0}
300
+ >
301
+ <Save className="h-4 w-4 mr-2" />
302
+ Save Changes
303
+ </Button>
304
+ </CardFooter>
305
+ </form>
306
+ </Form>
307
+
308
+ <SimilarChargesByBusinessModal
309
+ businessId={business.id}
310
+ tagIds={similarChargesData?.tagIds}
311
+ description={similarChargesData?.description}
312
+ open={similarChargesOpen}
313
+ onOpenChange={setSimilarChargesOpen}
314
+ onClose={refetchBusiness}
315
+ />
316
+ </Card>
317
+ );
318
+ }
319
+
320
+ interface SubSectionProps {
321
+ form: UseFormReturn<ConfigurationFormValues, unknown, ConfigurationFormValues>;
322
+ }
323
+
324
+ function BusinessBehaviorSubSection({ form }: SubSectionProps) {
325
+ return (
326
+ <div className="space-y-4">
327
+ <h3 className="text-sm font-semibold text-foreground">Business Status & Behavior</h3>
328
+ <div className="space-y-4">
329
+ <FormField
330
+ control={form.control}
331
+ name="isClient"
332
+ render={({ field, fieldState }) => (
333
+ <FormItem className="flex items-center justify-between">
334
+ <div className="space-y-0.5">
335
+ <FormLabel>Is Client</FormLabel>
336
+ <FormDescription>Mark this business as a client</FormDescription>
337
+ </div>
338
+ <FormControl>
339
+ <Switch
340
+ disabled
341
+ checked={field.value}
342
+ onCheckedChange={field.onChange}
343
+ className={dirtyFieldMarker(fieldState)}
344
+ />
345
+ </FormControl>
346
+ </FormItem>
347
+ )}
348
+ />
349
+
350
+ <FormField
351
+ control={form.control}
352
+ name="isActive"
353
+ render={({ field, fieldState }) => (
354
+ <FormItem className="flex items-center justify-between">
355
+ <div className="space-y-0.5">
356
+ <FormLabel>Is Active</FormLabel>
357
+ <FormDescription>Business is currently active</FormDescription>
358
+ </div>
359
+ <FormControl>
360
+ <Switch
361
+ checked={field.value}
362
+ onCheckedChange={field.onChange}
363
+ className={dirtyFieldMarker(fieldState)}
364
+ />
365
+ </FormControl>
366
+ </FormItem>
367
+ )}
368
+ />
369
+
370
+ <FormField
371
+ control={form.control}
372
+ name="isReceiptEnough"
373
+ render={({ field, fieldState }) => (
374
+ <FormItem className="flex items-center justify-between">
375
+ <div className="space-y-0.5">
376
+ <FormLabel>Is Receipt Enough</FormLabel>
377
+ <FormDescription>
378
+ Generate ledger for receipt documents if no invoice available
379
+ </FormDescription>
380
+ </div>
381
+ <FormControl>
382
+ <Switch
383
+ checked={field.value}
384
+ onCheckedChange={field.onChange}
385
+ className={dirtyFieldMarker(fieldState)}
386
+ />
387
+ </FormControl>
388
+ </FormItem>
389
+ )}
390
+ />
391
+
392
+ <FormField
393
+ control={form.control}
394
+ name="isDocumentsOptional"
395
+ render={({ field, fieldState }) => (
396
+ <FormItem className="flex items-center justify-between">
397
+ <div className="space-y-0.5">
398
+ <FormLabel>No Docs Required</FormLabel>
399
+ <FormDescription>Skip document validation for common charges</FormDescription>
400
+ </div>
401
+ <FormControl>
402
+ <Switch
403
+ checked={field.value}
404
+ onCheckedChange={field.onChange}
405
+ className={dirtyFieldMarker(fieldState)}
406
+ />
407
+ </FormControl>
408
+ </FormItem>
409
+ )}
410
+ />
411
+
412
+ <FormField
413
+ control={form.control}
414
+ name="isVatOptional"
415
+ render={({ field, fieldState }) => (
416
+ <FormItem className="flex items-center justify-between">
417
+ <div className="space-y-0.5">
418
+ <FormLabel>Is VAT Optional</FormLabel>
419
+ <FormDescription>Mute missing VAT indicator</FormDescription>
420
+ </div>
421
+ <FormControl>
422
+ <Switch
423
+ checked={field.value}
424
+ onCheckedChange={field.onChange}
425
+ className={dirtyFieldMarker(fieldState)}
426
+ />
427
+ </FormControl>
428
+ </FormItem>
429
+ )}
430
+ />
431
+
432
+ <FormField
433
+ control={form.control}
434
+ name="isExemptDealer"
435
+ render={({ field, fieldState }) => (
436
+ <FormItem className="flex items-center justify-between">
437
+ <div className="space-y-0.5">
438
+ <FormLabel>Is Exempt Dealer</FormLabel>
439
+ <FormDescription>Business is exempt from VAT requirements</FormDescription>
440
+ </div>
441
+ <FormControl>
442
+ <Switch
443
+ checked={field.value}
444
+ onCheckedChange={field.onChange}
445
+ className={dirtyFieldMarker(fieldState)}
446
+ />
447
+ </FormControl>
448
+ </FormItem>
449
+ )}
450
+ />
451
+ </div>
452
+ </div>
453
+ );
454
+ }
455
+
456
+ function DefaultSettingsSubSection({ form }: SubSectionProps) {
457
+ const { selectableTaxCategories, fetching: fetchingTaxCategories } = useGetTaxCategories();
458
+ const { selectableSortCodes, fetching: fetchingSortCodes, sortCodes } = useGetSortCodes();
459
+ const { selectableTags, fetching: fetchingTags } = useGetTags();
460
+
461
+ // When sort code changes, update IRS code if sort code has a default IRS code
462
+ const onSortCodeChangeUpdateIrsCode = useCallback(
463
+ (sortCode: string | null) => {
464
+ if (sortCode) {
465
+ const sortCodeObj = sortCodes.find(sc => Number(sc.key) === Number(sortCode));
466
+
467
+ if (sortCodeObj) {
468
+ if (sortCodeObj.defaultIrsCode) {
469
+ form.setValue('irsCode', sortCodeObj.defaultIrsCode, { shouldDirty: true });
470
+ } else {
471
+ form.setValue('irsCode', null, { shouldDirty: true });
472
+ }
473
+ }
474
+ }
475
+ },
476
+ [form, sortCodes],
477
+ );
478
+
479
+ return (
480
+ <div className="space-y-4">
481
+ <h3 className="text-sm font-semibold text-foreground">Default Settings</h3>
482
+ <div className="grid gap-4 md:grid-cols-2">
483
+ <FormField
484
+ control={form.control}
485
+ name="sortCode"
486
+ render={({ field, fieldState }) => (
487
+ <FormItem>
488
+ <FormLabel>Sort Code</FormLabel>
489
+ <FormControl>
490
+ <ComboBox
491
+ onChange={sortCode => {
492
+ onSortCodeChangeUpdateIrsCode(sortCode);
493
+ field.onChange(sortCode);
494
+ }}
495
+ data={selectableSortCodes}
496
+ value={field.value}
497
+ disabled={fetchingSortCodes}
498
+ placeholder="Scroll to see all options"
499
+ formPart
500
+ triggerProps={{
501
+ className: dirtyFieldMarker(fieldState),
502
+ }}
503
+ />
504
+ </FormControl>
505
+ <FormMessage />
506
+ </FormItem>
507
+ )}
508
+ />
509
+
510
+ <FormField
511
+ control={form.control}
512
+ name="taxCategory"
513
+ render={({ field, fieldState }) => (
514
+ <FormItem>
515
+ <FormLabel>Tax Category</FormLabel>
516
+ <FormControl>
517
+ <ComboBox
518
+ data={selectableTaxCategories}
519
+ disabled={fetchingTaxCategories}
520
+ value={field.value}
521
+ onChange={field.onChange}
522
+ placeholder="Scroll to see all options"
523
+ formPart
524
+ triggerProps={{
525
+ className: dirtyFieldMarker(fieldState),
526
+ }}
527
+ />
528
+ </FormControl>
529
+ <FormMessage />
530
+ </FormItem>
531
+ )}
532
+ />
533
+
534
+ <FormField
535
+ control={form.control}
536
+ name="pcn874RecordType"
537
+ render={({ field, fieldState }) => (
538
+ <FormItem>
539
+ <FormLabel>PCN874 Record Type</FormLabel>
540
+ <Select onValueChange={field.onChange} value={field.value}>
541
+ <FormControl>
542
+ <SelectTrigger className={dirtyFieldMarker(fieldState)}>
543
+ <SelectValue />
544
+ </SelectTrigger>
545
+ </FormControl>
546
+ <SelectContent>
547
+ {Object.entries(pcn874RecordEnum).map(([value, label]) => (
548
+ <SelectItem key={value} value={value}>
549
+ {`${label} (${value})`}
550
+ </SelectItem>
551
+ ))}
552
+ </SelectContent>
553
+ </Select>
554
+ <FormMessage />
555
+ </FormItem>
556
+ )}
557
+ />
558
+
559
+ <FormField
560
+ control={form.control}
561
+ name="irsCode"
562
+ render={({ field, fieldState }) => (
563
+ <FormItem>
564
+ <FormLabel>IRS Code</FormLabel>
565
+ <FormControl>
566
+ <NumberInput
567
+ value={field.value ?? undefined}
568
+ onValueChange={value => field.onChange(value ?? null)}
569
+ hideControls
570
+ decimalScale={0}
571
+ className={dirtyFieldMarker(fieldState)}
572
+ />
573
+ </FormControl>
574
+ <FormMessage />
575
+ </FormItem>
576
+ )}
577
+ />
578
+
579
+ <FormField
580
+ control={form.control}
581
+ name="description"
582
+ render={({ field, fieldState }) => (
583
+ <FormItem>
584
+ <FormLabel>Charge Description</FormLabel>
585
+ <FormControl>
586
+ <Input
587
+ placeholder="Enter default charge description"
588
+ {...field}
589
+ className={dirtyFieldMarker(fieldState)}
590
+ />
591
+ </FormControl>
592
+ <FormMessage />
593
+ </FormItem>
594
+ )}
595
+ />
596
+
597
+ <FormField
598
+ control={form.control}
599
+ name="tags"
600
+ render={({ field, fieldState }) => (
601
+ <FormItem>
602
+ <FormLabel>Tags</FormLabel>
603
+ <FormControl>
604
+ <MultiSelect
605
+ options={Object.values(selectableTags).map(({ value, label }) => ({
606
+ label,
607
+ value,
608
+ }))}
609
+ onValueChange={field.onChange}
610
+ defaultValue={field.value}
611
+ value={field.value}
612
+ placeholder="Select Default Tags"
613
+ variant="default"
614
+ disabled={fetchingTags}
615
+ className={dirtyFieldMarker(fieldState)}
616
+ />
617
+ </FormControl>
618
+ <FormMessage />
619
+ </FormItem>
620
+ )}
621
+ />
622
+ </div>
623
+ </div>
624
+ );
625
+ }
626
+
627
+ function AutoMatchingConfigurationSubSection({ form }: SubSectionProps) {
628
+ const [newPhrase, setNewPhrase] = useState('');
629
+ const [newEmail, setNewEmail] = useState('');
630
+
631
+ const addPhrase = () => {
632
+ if (newPhrase.trim()) {
633
+ const currentPhrases = form.getValues('phrases');
634
+ form.setValue('phrases', [...currentPhrases, newPhrase.trim()], { shouldDirty: true });
635
+ setNewPhrase('');
636
+ }
637
+ };
638
+
639
+ const addEmail = () => {
640
+ if (newEmail.trim()) {
641
+ const currentEmails = form.getValues('emails');
642
+ form.setValue('emails', [...currentEmails, newEmail.trim()], { shouldDirty: true });
643
+ setNewEmail('');
644
+ }
645
+ };
646
+
647
+ const removePhrase = (index: number) => {
648
+ const currentPhrases = form.getValues('phrases');
649
+ form.setValue(
650
+ 'phrases',
651
+ currentPhrases.filter((_, i) => i !== index),
652
+ { shouldDirty: true },
653
+ );
654
+ };
655
+
656
+ const removeEmail = (index: number) => {
657
+ const currentEmails = form.getValues('emails');
658
+ form.setValue(
659
+ 'emails',
660
+ currentEmails.filter((_, i) => i !== index),
661
+ { shouldDirty: true },
662
+ );
663
+ };
664
+
665
+ return (
666
+ <div className="space-y-4">
667
+ <h3 className="text-sm font-semibold text-foreground">Auto-matching Configuration</h3>
668
+ <p className="text-sm text-muted-foreground">
669
+ Configure patterns for automatic matching of bank transactions and documents
670
+ </p>
671
+
672
+ <FormField
673
+ control={form.control}
674
+ name="phrases"
675
+ render={({ field, fieldState }) => (
676
+ <FormItem>
677
+ <FormLabel>Phrases</FormLabel>
678
+ <div className="flex gap-2">
679
+ <Input
680
+ placeholder="Add phrase..."
681
+ value={newPhrase}
682
+ onChange={e => setNewPhrase(e.target.value)}
683
+ onKeyDown={e => {
684
+ if (e.key === 'Enter') {
685
+ e.preventDefault();
686
+ addPhrase();
687
+ }
688
+ }}
689
+ className={dirtyFieldMarker(fieldState)}
690
+ />
691
+ <Button type="button" size="sm" onClick={addPhrase}>
692
+ <Plus className="h-4 w-4" />
693
+ </Button>
694
+ </div>
695
+ <div className="flex flex-wrap gap-2">
696
+ {field.value?.map((phrase, index) => (
697
+ <Badge key={index} variant="secondary" className="gap-1">
698
+ {phrase}
699
+ <Button
700
+ variant="ghost"
701
+ size="icon"
702
+ className="p-0 size-3"
703
+ onClick={() => removePhrase(index)}
704
+ >
705
+ <X className="size-3 cursor-pointer" onClick={() => removePhrase(index)} />
706
+ </Button>
707
+ </Badge>
708
+ ))}
709
+ </div>
710
+ <FormMessage />
711
+ </FormItem>
712
+ )}
713
+ />
714
+
715
+ <FormField
716
+ control={form.control}
717
+ name="emails"
718
+ render={({ field, fieldState }) => (
719
+ <FormItem>
720
+ <FormLabel>Email Addresses</FormLabel>
721
+ <div className="flex gap-2">
722
+ <Input
723
+ type="email"
724
+ placeholder="Add email..."
725
+ value={newEmail}
726
+ onChange={e => setNewEmail(e.target.value)}
727
+ onKeyDown={e => {
728
+ if (e.key === 'Enter') {
729
+ e.preventDefault();
730
+ addEmail();
731
+ }
732
+ }}
733
+ className={dirtyFieldMarker(fieldState)}
734
+ />
735
+ <Button type="button" size="sm" onClick={addEmail}>
736
+ <Plus className="h-4 w-4" />
737
+ </Button>
738
+ </div>
739
+ <div className="flex flex-wrap gap-2">
740
+ {field.value?.map((email, index) => (
741
+ <Badge key={index} variant="secondary" className="gap-1">
742
+ {email}
743
+ <Button
744
+ variant="ghost"
745
+ size="icon"
746
+ className="p-0 size-3"
747
+ onClick={() => removeEmail(index)}
748
+ >
749
+ <X className="size-3 cursor-pointer" />
750
+ </Button>
751
+ </Badge>
752
+ ))}
753
+ </div>
754
+ <FormMessage />
755
+ </FormItem>
756
+ )}
757
+ />
758
+ </div>
759
+ );
760
+ }
761
+
762
+ function GmailConfigurationSubSection({ form }: SubSectionProps) {
763
+ const [newLink, setNewLink] = useState('');
764
+
765
+ const addLink = () => {
766
+ if (newLink.trim()) {
767
+ const currentLinks = form.getValues('internalLinks');
768
+ form.setValue('internalLinks', [...currentLinks, newLink.trim()], { shouldDirty: true });
769
+ setNewLink('');
770
+ }
771
+ };
772
+
773
+ const toggleAttachmentType = (type: EmailAttachmentType) => {
774
+ const currentTypes = form.getValues('attachmentTypes');
775
+ form.setValue(
776
+ 'attachmentTypes',
777
+ currentTypes?.includes(type) ? currentTypes.filter(t => t !== type) : [...currentTypes, type],
778
+ { shouldDirty: true },
779
+ );
780
+ };
781
+
782
+ const removeLink = (index: number) => {
783
+ const currentLinks = form.getValues('internalLinks');
784
+ form.setValue(
785
+ 'internalLinks',
786
+ currentLinks.filter((_, i) => i !== index),
787
+ { shouldDirty: true },
788
+ );
789
+ };
790
+
791
+ return (
792
+ <div className="space-y-4">
793
+ <h3 className="text-sm font-semibold text-foreground">Gmail Feature Configuration</h3>
794
+ <p className="text-sm text-muted-foreground">
795
+ Configure Gmail integration settings for document processing
796
+ </p>
797
+
798
+ <FormField
799
+ control={form.control}
800
+ name="attachmentTypes"
801
+ render={({ field, fieldState }) => (
802
+ <FormItem>
803
+ <FormLabel>Attachment Types</FormLabel>
804
+ <div className={dirtyFieldMarker(fieldState) + ' rounded-md flex flex-wrap gap-2'}>
805
+ {availableAttachmentTypes.map(type => (
806
+ <Badge
807
+ key={type}
808
+ variant={field.value?.includes(type) ? 'default' : 'outline'}
809
+ className="cursor-pointer"
810
+ onClick={() => toggleAttachmentType(type)}
811
+ >
812
+ {type}
813
+ </Badge>
814
+ ))}
815
+ </div>
816
+ <FormMessage />
817
+ </FormItem>
818
+ )}
819
+ />
820
+
821
+ <FormField
822
+ control={form.control}
823
+ name="internalLinks"
824
+ render={({ field, fieldState }) => (
825
+ <FormItem>
826
+ <FormLabel>Internal Links</FormLabel>
827
+ <div className="flex gap-2">
828
+ <Input
829
+ type="url"
830
+ placeholder="Add internal link..."
831
+ value={newLink}
832
+ onChange={e => setNewLink(e.target.value)}
833
+ onKeyDown={e => {
834
+ if (e.key === 'Enter') {
835
+ e.preventDefault();
836
+ addLink();
837
+ }
838
+ }}
839
+ />
840
+ <Button type="button" size="sm" onClick={addLink}>
841
+ <Plus className="h-4 w-4" />
842
+ </Button>
843
+ </div>
844
+ <div className={dirtyFieldMarker(fieldState) + ' flex flex-wrap gap-2 rounded-md'}>
845
+ {field.value?.map((link, index) => (
846
+ <Badge key={index} variant="secondary" className="gap-1 max-w-xs truncate">
847
+ {link}
848
+ <Button
849
+ variant="ghost"
850
+ size="icon"
851
+ className="p-0 size-3"
852
+ onClick={() => removeLink(index)}
853
+ >
854
+ <X className="size-3 cursor-pointer flex-shrink-0" />
855
+ </Button>
856
+ </Badge>
857
+ ))}
858
+ </div>
859
+ <FormMessage />
860
+ </FormItem>
861
+ )}
862
+ />
863
+
864
+ <FormField
865
+ control={form.control}
866
+ name="useMessageBody"
867
+ render={({ field, fieldState }) => (
868
+ <FormItem className="flex items-center justify-between">
869
+ <div className="space-y-0.5">
870
+ <FormLabel>Should Use Message Body</FormLabel>
871
+ <FormDescription>Extract information from email message body</FormDescription>
872
+ </div>
873
+ <FormControl>
874
+ <Switch
875
+ checked={field.value}
876
+ onCheckedChange={field.onChange}
877
+ className={dirtyFieldMarker(fieldState)}
878
+ />
879
+ </FormControl>
880
+ </FormItem>
881
+ )}
882
+ />
883
+ </div>
884
+ );
885
+ }