el-contador 1.2.15 → 1.2.16

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.
@@ -41,6 +41,13 @@ services:
41
41
  INIT_ADMIN_PASSWORD: ${INIT_ADMIN_PASSWORD}
42
42
  GEMINI_API_KEY: ${GEMINI_API_KEY:-}
43
43
  GEMINI_MODEL: ${GEMINI_MODEL:-gemini-3.1-flash-lite-preview}
44
+ SMTP_HOST: ${SMTP_HOST:-}
45
+ SMTP_TLS_SERVERNAME: ${SMTP_TLS_SERVERNAME:-}
46
+ SMTP_PORT: ${SMTP_PORT:-587}
47
+ SMTP_SECURE: ${SMTP_SECURE:-false}
48
+ SMTP_USER: ${SMTP_USER:-}
49
+ SMTP_PASS: ${SMTP_PASS:-}
50
+ SMTP_FROM: ${SMTP_FROM:-}
44
51
  PORT: 3080
45
52
  ports:
46
53
  - "${ADMIN_PORT:-3080}:3080"
@@ -10,7 +10,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
10
10
  <CheckboxPrimitive.Root
11
11
  data-slot="checkbox"
12
12
  className={cn(
13
- "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
13
+ "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-[checked]:border-primary data-[checked]:bg-primary data-[checked]:text-primary-foreground dark:data-[checked]:bg-primary",
14
14
  className
15
15
  )}
16
16
  {...props}
@@ -14,14 +14,14 @@ function Switch({
14
14
  data-slot="switch"
15
15
  data-size={size}
16
16
  className={cn(
17
- "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
17
+ "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-[checked]:bg-primary data-[unchecked]:bg-input dark:data-[unchecked]:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
18
18
  className
19
19
  )}
20
20
  {...props}
21
21
  >
22
22
  <SwitchPrimitive.Thumb
23
23
  data-slot="switch-thumb"
24
- className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
24
+ className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-[checked]:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-[checked]:translate-x-[calc(100%-2px)] dark:data-[checked]:bg-primary-foreground group-data-[size=default]/switch:data-[unchecked]:translate-x-0 group-data-[size=sm]/switch:data-[unchecked]:translate-x-0 dark:data-[unchecked]:bg-foreground"
25
25
  />
26
26
  </SwitchPrimitive.Root>
27
27
  )
@@ -0,0 +1,74 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { api } from '../lib/api';
3
+
4
+ export type ContactLink = {
5
+ id: string;
6
+ supplierId: string;
7
+ supplierName: string;
8
+ customerId: string;
9
+ customerName: string;
10
+ notes: string;
11
+ createdAt: string;
12
+ };
13
+
14
+ export type InvoiceSettlement = {
15
+ id: string;
16
+ date: string;
17
+ description: string;
18
+ adjustmentAmount: number;
19
+ contactLinkId: string | null;
20
+ supplierId: string | null;
21
+ supplierName: string | null;
22
+ customerId: string | null;
23
+ customerName: string | null;
24
+ createdAt: string;
25
+ salesTotal: number;
26
+ expenseTotal: number;
27
+ sales: Array<{ id: string; invoiceNo: string; customer: string; total: number }>;
28
+ expenses: Array<{ id: string; vendor: string; invoiceNumber: string | null; total: number }>;
29
+ };
30
+
31
+ export function useContactLinks() {
32
+ return useQuery({
33
+ queryKey: ['contact-links'],
34
+ queryFn: async () => {
35
+ const { data } = await api.get<ContactLink[]>('/contact-links');
36
+ return data;
37
+ },
38
+ });
39
+ }
40
+
41
+ export function useCreateContactLink() {
42
+ const queryClient = useQueryClient();
43
+ return useMutation({
44
+ mutationFn: async (body: { supplierId: string; customerId: string; notes?: string }) => {
45
+ const { data } = await api.post<ContactLink>('/contact-links', body);
46
+ return data;
47
+ },
48
+ onSuccess: () => {
49
+ queryClient.invalidateQueries({ queryKey: ['contact-links'] });
50
+ },
51
+ });
52
+ }
53
+
54
+ export function useDeleteContactLink() {
55
+ const queryClient = useQueryClient();
56
+ return useMutation({
57
+ mutationFn: async (id: string) => {
58
+ await api.delete(`/contact-links/${id}`);
59
+ },
60
+ onSuccess: () => {
61
+ queryClient.invalidateQueries({ queryKey: ['contact-links'] });
62
+ },
63
+ });
64
+ }
65
+
66
+ export function useInvoiceSettlements() {
67
+ return useQuery({
68
+ queryKey: ['reconciliation', 'settlements'],
69
+ queryFn: async () => {
70
+ const { data } = await api.get<{ settlements: InvoiceSettlement[] }>('/reconciliation/settlements');
71
+ return data.settlements;
72
+ },
73
+ });
74
+ }
@@ -75,6 +75,20 @@ export function useDeleteCustomer() {
75
75
  });
76
76
  }
77
77
 
78
+ export function useMergeCustomers() {
79
+ const queryClient = useQueryClient();
80
+ return useMutation({
81
+ mutationFn: async (body: { keepCustomerId: string; mergeCustomerIds: string[] }) => {
82
+ const { data } = await api.post<{ ok: boolean; mergedCount: number }>('/customers/merge', body);
83
+ return data;
84
+ },
85
+ onSuccess: () => {
86
+ queryClient.invalidateQueries({ queryKey: ['customers'] });
87
+ queryClient.invalidateQueries({ queryKey: ['sales'] });
88
+ },
89
+ });
90
+ }
91
+
78
92
  // Suppliers
79
93
  export function useSuppliers() {
80
94
  return useQuery({
@@ -22,6 +22,8 @@ export type Expense = {
22
22
  reconciledAt: string | null;
23
23
  supplierId: string | null;
24
24
  bankTransactionId: string | null;
25
+ settlementId?: string | null;
26
+ paymentSource?: 'bank' | 'settlement' | null;
25
27
  createdAt: string;
26
28
  createdBy?: string | null;
27
29
  creatorEmail?: string | null;
@@ -50,7 +52,7 @@ export function useExpenseCategories() {
50
52
  });
51
53
  }
52
54
 
53
- /** Accounts for expenses: not asset/revenue (100-299) and Allow expenses enabled on the account */
55
+ /** Accounts for expenses: Allow expenses enabled on the account */
54
56
  export function useExpenseAccounts() {
55
57
  return useQuery({
56
58
  queryKey: ['accounts', 'expense'],
@@ -1,7 +1,8 @@
1
1
  import { useState, useEffect } from 'react';
2
- import { useCustomers, useCreateCustomer, useUpdateCustomer, useDeleteCustomer, type Customer } from '../hooks/useContacts';
2
+ import { useCustomers, useCreateCustomer, useUpdateCustomer, useDeleteCustomer, useMergeCustomers, type Customer } from '../hooks/useContacts';
3
3
  import { useSuppliers, useCreateSupplier, useUpdateSupplier, useDeleteSupplier, useMergeSuppliers, type Supplier } from '../hooks/useContacts';
4
4
  import { usePayees, useCreatePayee, useUpdatePayee, useDeletePayee, type Payee } from '../hooks/useContacts';
5
+ import { useContactLinks, useCreateContactLink, useDeleteContactLink } from '../hooks/useContactLinks';
5
6
  import { useExpenseCategories as useExpenseCategoriesHook, useExpenseAccounts } from '../hooks/useExpenses';
6
7
  import { Card, CardContent, CardHeader } from '@/components/ui/card';
7
8
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -40,6 +41,10 @@ export default function Contacts() {
40
41
  const createCustomer = useCreateCustomer();
41
42
  const updateCustomer = useUpdateCustomer();
42
43
  const deleteCustomer = useDeleteCustomer();
44
+ const mergeCustomers = useMergeCustomers();
45
+
46
+ const [clientMergeMode, setClientMergeMode] = useState(false);
47
+ const [clientIdsSelectedForMerge, setClientIdsSelectedForMerge] = useState<Set<string>>(new Set());
43
48
 
44
49
  const { data: suppliers, isLoading: loadingSuppliers } = useSuppliers();
45
50
  const { data: categories } = useExpenseCategoriesHook();
@@ -53,9 +58,18 @@ export default function Contacts() {
53
58
  const [supplierMergeMode, setSupplierMergeMode] = useState(false);
54
59
  const [supplierIdsSelectedForMerge, setSupplierIdsSelectedForMerge] = useState<Set<string>>(new Set());
55
60
  const [mergeWizardOpen, setMergeWizardOpen] = useState(false);
56
- const [mergeCandidates, setMergeCandidates] = useState<Supplier[]>([]);
61
+ const [mergeType, setMergeType] = useState<'client' | 'supplier' | null>(null);
62
+ const [mergeCandidates, setMergeCandidates] = useState<(Customer | Supplier)[]>([]);
57
63
  const [mergeKeepId, setMergeKeepId] = useState<string>('');
64
+ const [mergeConfirmOpen, setMergeConfirmOpen] = useState(false);
58
65
  const [supplierDuplicateMatches, setSupplierDuplicateMatches] = useState<Array<{ id: string; name: string }> | null>(null);
66
+ const [linkDialogOpen, setLinkDialogOpen] = useState(false);
67
+ const [linkSupplierId, setLinkSupplierId] = useState('');
68
+ const [linkCustomerId, setLinkCustomerId] = useState('');
69
+
70
+ const { data: contactLinks = [], isLoading: loadingContactLinks } = useContactLinks();
71
+ const createContactLink = useCreateContactLink();
72
+ const deleteContactLink = useDeleteContactLink();
59
73
 
60
74
  const { data: payees, isLoading: loadingPayees } = usePayees();
61
75
  const createPayee = useCreatePayee();
@@ -163,6 +177,63 @@ export default function Contacts() {
163
177
  <p className="text-muted-foreground">Manage clients, suppliers and payees. Add account numbers for bank or ledger reference.</p>
164
178
  </div>
165
179
 
180
+ <Card>
181
+ <CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 py-4">
182
+ <div>
183
+ <span className="text-sm font-medium">Supplier–client links</span>
184
+ <p className="text-xs text-muted-foreground mt-1">
185
+ Connect the same party on both sides (e.g. CP3 as supplier and client) for invoice offset reconciliation.
186
+ </p>
187
+ </div>
188
+ <Button type="button" size="sm" onClick={() => { setLinkSupplierId(''); setLinkCustomerId(''); setLinkDialogOpen(true); }}>
189
+ Add link
190
+ </Button>
191
+ </CardHeader>
192
+ <CardContent>
193
+ {loadingContactLinks ? (
194
+ <Skeleton className="h-16 w-full" />
195
+ ) : contactLinks.length === 0 ? (
196
+ <p className="text-sm text-muted-foreground">No links yet. Link a supplier to a client when they are the same organisation.</p>
197
+ ) : (
198
+ <Table>
199
+ <TableHeader>
200
+ <TableRow>
201
+ <TableHead>Supplier</TableHead>
202
+ <TableHead>Client</TableHead>
203
+ <TableHead className="w-[100px]">Actions</TableHead>
204
+ </TableRow>
205
+ </TableHeader>
206
+ <TableBody>
207
+ {contactLinks.map((link) => (
208
+ <TableRow key={link.id}>
209
+ <TableCell>{link.supplierName}</TableCell>
210
+ <TableCell>{link.customerName}</TableCell>
211
+ <TableCell>
212
+ <Button
213
+ variant="ghost"
214
+ size="icon"
215
+ className="h-8 w-8 text-destructive"
216
+ onClick={async () => {
217
+ if (!confirm(`Remove link between ${link.supplierName} and ${link.customerName}?`)) return;
218
+ try {
219
+ await deleteContactLink.mutateAsync(link.id);
220
+ } catch (err) {
221
+ console.error(err);
222
+ alert('Failed to remove link');
223
+ }
224
+ }}
225
+ >
226
+ <Trash2 className="h-4 w-4" />
227
+ </Button>
228
+ </TableCell>
229
+ </TableRow>
230
+ ))}
231
+ </TableBody>
232
+ </Table>
233
+ )}
234
+ </CardContent>
235
+ </Card>
236
+
166
237
  <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ContactType)} className="space-y-4">
167
238
  <TabsList>
168
239
  <TabsTrigger value="client">Clients</TabsTrigger>
@@ -172,9 +243,39 @@ export default function Contacts() {
172
243
 
173
244
  <TabsContent value="client" className="space-y-4">
174
245
  <Card>
175
- <CardHeader className="flex flex-row items-center justify-between py-4">
246
+ <CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 py-4">
176
247
  <span className="text-sm font-medium">Customers you invoice</span>
177
- <Button onClick={handleOpenCreate}>Add client</Button>
248
+ <div className="flex flex-wrap items-center gap-2">
249
+ <Button
250
+ type="button"
251
+ variant={clientMergeMode ? 'secondary' : 'outline'}
252
+ size="sm"
253
+ onClick={() => {
254
+ setClientMergeMode((m) => !m);
255
+ setClientIdsSelectedForMerge(new Set());
256
+ }}
257
+ >
258
+ {clientMergeMode ? 'Exit merge mode' : 'Merge clients'}
259
+ </Button>
260
+ {clientMergeMode && clientIdsSelectedForMerge.size >= 2 && (
261
+ <Button
262
+ type="button"
263
+ size="sm"
264
+ onClick={() => {
265
+ const ids = Array.from(clientIdsSelectedForMerge);
266
+ const candidates = (customers ?? []).filter((c) => ids.includes(c.id));
267
+ if (candidates.length < 2) return;
268
+ setMergeType('client');
269
+ setMergeCandidates(candidates);
270
+ setMergeKeepId(candidates[0].id);
271
+ setMergeWizardOpen(true);
272
+ }}
273
+ >
274
+ Merge selected ({clientIdsSelectedForMerge.size})
275
+ </Button>
276
+ )}
277
+ <Button onClick={handleOpenCreate}>Add client</Button>
278
+ </div>
178
279
  </CardHeader>
179
280
  <CardContent>
180
281
  {loadingCustomers ? (
@@ -183,6 +284,7 @@ export default function Contacts() {
183
284
  <Table>
184
285
  <TableHeader>
185
286
  <TableRow>
287
+ {clientMergeMode && <TableHead className="w-10" />}
186
288
  <TableHead>Name</TableHead>
187
289
  <TableHead>Account number</TableHead>
188
290
  <TableHead>Email</TableHead>
@@ -193,23 +295,51 @@ export default function Contacts() {
193
295
  <TableBody>
194
296
  {(customers ?? []).length === 0 ? (
195
297
  <TableRow>
196
- <TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
298
+ <TableCell colSpan={clientMergeMode ? 6 : 5} className="text-center py-8 text-muted-foreground">
197
299
  No clients yet. Add one to get started.
198
300
  </TableCell>
199
301
  </TableRow>
200
302
  ) : (
201
303
  (customers ?? []).map((row) => (
202
304
  <TableRow key={row.id}>
305
+ {clientMergeMode && (
306
+ <TableCell className="w-10">
307
+ <Checkbox
308
+ checked={clientIdsSelectedForMerge.has(row.id)}
309
+ onCheckedChange={(checked) => {
310
+ setClientIdsSelectedForMerge((prev) => {
311
+ const next = new Set(prev);
312
+ if (checked === true) next.add(row.id);
313
+ else next.delete(row.id);
314
+ return next;
315
+ });
316
+ }}
317
+ aria-label={`Select ${row.name} for merge`}
318
+ />
319
+ </TableCell>
320
+ )}
203
321
  <TableCell className="font-medium">{row.name}</TableCell>
204
322
  <TableCell>{row.accountNumber || '-'}</TableCell>
205
323
  <TableCell>{row.email || '-'}</TableCell>
206
324
  <TableCell>{row.phone || '-'}</TableCell>
207
325
  <TableCell>
208
326
  <div className="flex items-center gap-1">
209
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleOpenEdit(row)}>
327
+ <Button
328
+ variant="ghost"
329
+ size="icon"
330
+ className="h-8 w-8"
331
+ disabled={clientMergeMode}
332
+ onClick={() => handleOpenEdit(row)}
333
+ >
210
334
  <Pencil className="h-4 w-4" />
211
335
  </Button>
212
- <Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDelete('client', row.id)}>
336
+ <Button
337
+ variant="ghost"
338
+ size="icon"
339
+ className="h-8 w-8 text-destructive"
340
+ disabled={clientMergeMode}
341
+ onClick={() => handleDelete('client', row.id)}
342
+ >
213
343
  <Trash2 className="h-4 w-4" />
214
344
  </Button>
215
345
  </div>
@@ -248,6 +378,7 @@ export default function Contacts() {
248
378
  const ids = Array.from(supplierIdsSelectedForMerge);
249
379
  const candidates = (suppliers ?? []).filter((s) => ids.includes(s.id));
250
380
  if (candidates.length < 2) return;
381
+ setMergeType('supplier');
251
382
  setMergeCandidates(candidates);
252
383
  setMergeKeepId(candidates[0].id);
253
384
  setMergeWizardOpen(true);
@@ -397,15 +528,18 @@ export default function Contacts() {
397
528
  open={mergeWizardOpen}
398
529
  onOpenChange={(open) => {
399
530
  setMergeWizardOpen(open);
400
- if (!open) setMergeCandidates([]);
531
+ if (!open) {
532
+ setMergeCandidates([]);
533
+ setMergeType(null);
534
+ }
401
535
  }}
402
536
  >
403
537
  <DialogContent className="sm:max-w-[480px]">
404
538
  <DialogHeader>
405
- <DialogTitle>Merge suppliers</DialogTitle>
539
+ <DialogTitle>Merge {mergeType === 'client' ? 'clients' : 'suppliers'}</DialogTitle>
406
540
  </DialogHeader>
407
541
  <p className="text-sm text-muted-foreground">
408
- Choose the supplier to keep. The others will be removed and any expenses linked to them will point to the kept supplier. Contact details on the kept row are filled from merged rows when empty.
542
+ Choose the {mergeType === 'client' ? 'client' : 'supplier'} to keep. Contact details on the kept row are filled from merged rows when empty.
409
543
  </p>
410
544
  <div className="space-y-2 max-h-[40vh] overflow-y-auto">
411
545
  {mergeCandidates.map((s) => (
@@ -427,22 +561,101 @@ export default function Contacts() {
427
561
  </Button>
428
562
  <Button
429
563
  type="button"
430
- disabled={mergeSuppliers.isPending || !mergeKeepId || mergeCandidates.length < 2}
564
+ disabled={!mergeKeepId || mergeCandidates.length < 2}
565
+ onClick={() => {
566
+ setMergeWizardOpen(false);
567
+ setMergeConfirmOpen(true);
568
+ }}
569
+ >
570
+ Continue
571
+ </Button>
572
+ </div>
573
+ </DialogContent>
574
+ </Dialog>
575
+
576
+ <Dialog
577
+ open={mergeConfirmOpen}
578
+ onOpenChange={(open) => {
579
+ setMergeConfirmOpen(open);
580
+ if (!open) {
581
+ setMergeCandidates([]);
582
+ setMergeType(null);
583
+ setMergeKeepId('');
584
+ }
585
+ }}
586
+ >
587
+ <DialogContent className="sm:max-w-[480px]">
588
+ <DialogHeader>
589
+ <DialogTitle>Confirm merge</DialogTitle>
590
+ </DialogHeader>
591
+ <div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm space-y-2">
592
+ <p className="font-medium text-destructive">This action cannot be undone.</p>
593
+ <p className="text-muted-foreground">
594
+ {mergeCandidates.filter((c) => c.id !== mergeKeepId).length}{' '}
595
+ {mergeType === 'client' ? 'client' : 'supplier'}
596
+ {mergeCandidates.filter((c) => c.id !== mergeKeepId).length === 1 ? '' : 's'} will be permanently removed:
597
+ </p>
598
+ <ul className="list-disc list-inside text-muted-foreground">
599
+ {mergeCandidates
600
+ .filter((c) => c.id !== mergeKeepId)
601
+ .map((c) => (
602
+ <li key={c.id}>{c.name}</li>
603
+ ))}
604
+ </ul>
605
+ <p className="text-muted-foreground">
606
+ {mergeType === 'client'
607
+ ? 'Any sales linked to the removed clients will point to '
608
+ : 'Any expenses linked to the removed suppliers will point to '}
609
+ <span className="font-medium text-foreground">
610
+ {mergeCandidates.find((c) => c.id === mergeKeepId)?.name}
611
+ </span>
612
+ .
613
+ </p>
614
+ </div>
615
+ <div className="flex justify-end gap-2 pt-2">
616
+ <Button
617
+ type="button"
618
+ variant="outline"
619
+ onClick={() => {
620
+ setMergeConfirmOpen(false);
621
+ setMergeWizardOpen(true);
622
+ }}
623
+ >
624
+ Go back
625
+ </Button>
626
+ <Button
627
+ type="button"
628
+ variant="destructive"
629
+ disabled={
630
+ (mergeType === 'client' ? mergeCustomers.isPending : mergeSuppliers.isPending) ||
631
+ !mergeKeepId ||
632
+ mergeCandidates.length < 2
633
+ }
431
634
  onClick={async () => {
432
- const mergeSupplierIds = mergeCandidates.filter((s) => s.id !== mergeKeepId).map((s) => s.id);
635
+ const mergeIds = mergeCandidates.filter((c) => c.id !== mergeKeepId).map((c) => c.id);
433
636
  try {
434
- await mergeSuppliers.mutateAsync({ keepSupplierId: mergeKeepId, mergeSupplierIds });
435
- setMergeWizardOpen(false);
637
+ if (mergeType === 'client') {
638
+ await mergeCustomers.mutateAsync({ keepCustomerId: mergeKeepId, mergeCustomerIds: mergeIds });
639
+ setClientIdsSelectedForMerge(new Set());
640
+ setClientMergeMode(false);
641
+ } else {
642
+ await mergeSuppliers.mutateAsync({ keepSupplierId: mergeKeepId, mergeSupplierIds: mergeIds });
643
+ setSupplierIdsSelectedForMerge(new Set());
644
+ setSupplierMergeMode(false);
645
+ }
646
+ setMergeConfirmOpen(false);
436
647
  setMergeCandidates([]);
437
- setSupplierIdsSelectedForMerge(new Set());
438
- setSupplierMergeMode(false);
648
+ setMergeType(null);
649
+ setMergeKeepId('');
439
650
  } catch (e) {
440
651
  console.error(e);
441
652
  alert('Merge failed. Try again or check the server log.');
442
653
  }
443
654
  }}
444
655
  >
445
- {mergeSuppliers.isPending ? 'Merging…' : 'Confirm merge'}
656
+ {(mergeType === 'client' ? mergeCustomers.isPending : mergeSuppliers.isPending)
657
+ ? 'Merging…'
658
+ : 'Yes, merge permanently'}
446
659
  </Button>
447
660
  </div>
448
661
  </DialogContent>
@@ -567,6 +780,82 @@ export default function Contacts() {
567
780
  )}
568
781
  </div>
569
782
  )}
783
+ {activeTab === 'supplier' && editing?.id && (
784
+ <div className="space-y-2 rounded-md border p-3 bg-muted/30">
785
+ <label className="text-sm font-medium">Linked clients</label>
786
+ {(contactLinks.filter((l) => l.supplierId === editing.id)).length === 0 ? (
787
+ <p className="text-xs text-muted-foreground">No linked clients. Use &quot;Add link&quot; above or pick a client below.</p>
788
+ ) : (
789
+ <ul className="text-sm space-y-1">
790
+ {contactLinks.filter((l) => l.supplierId === editing.id).map((l) => (
791
+ <li key={l.id}>{l.customerName}</li>
792
+ ))}
793
+ </ul>
794
+ )}
795
+ <Select
796
+ value={linkCustomerId}
797
+ onValueChange={async (customerId) => {
798
+ if (!customerId || !editing?.id) return;
799
+ try {
800
+ await createContactLink.mutateAsync({ supplierId: editing.id, customerId });
801
+ setLinkCustomerId('');
802
+ } catch (err) {
803
+ console.error(err);
804
+ alert('Failed to create link');
805
+ }
806
+ }}
807
+ >
808
+ <SelectTrigger>
809
+ <SelectValue placeholder="Link to client…" />
810
+ </SelectTrigger>
811
+ <SelectContent>
812
+ {(customers ?? [])
813
+ .filter((c) => !contactLinks.some((l) => l.supplierId === editing.id && l.customerId === c.id))
814
+ .map((c) => (
815
+ <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
816
+ ))}
817
+ </SelectContent>
818
+ </Select>
819
+ </div>
820
+ )}
821
+ {activeTab === 'client' && editing?.id && (
822
+ <div className="space-y-2 rounded-md border p-3 bg-muted/30">
823
+ <label className="text-sm font-medium">Linked suppliers</label>
824
+ {(contactLinks.filter((l) => l.customerId === editing.id)).length === 0 ? (
825
+ <p className="text-xs text-muted-foreground">No linked suppliers. Use &quot;Add link&quot; above or pick a supplier below.</p>
826
+ ) : (
827
+ <ul className="text-sm space-y-1">
828
+ {contactLinks.filter((l) => l.customerId === editing.id).map((l) => (
829
+ <li key={l.id}>{l.supplierName}</li>
830
+ ))}
831
+ </ul>
832
+ )}
833
+ <Select
834
+ value={linkSupplierId}
835
+ onValueChange={async (supplierId) => {
836
+ if (!supplierId || !editing?.id) return;
837
+ try {
838
+ await createContactLink.mutateAsync({ supplierId, customerId: editing.id });
839
+ setLinkSupplierId('');
840
+ } catch (err) {
841
+ console.error(err);
842
+ alert('Failed to create link');
843
+ }
844
+ }}
845
+ >
846
+ <SelectTrigger>
847
+ <SelectValue placeholder="Link to supplier…" />
848
+ </SelectTrigger>
849
+ <SelectContent>
850
+ {(suppliers ?? [])
851
+ .filter((s) => !contactLinks.some((l) => l.customerId === editing.id && l.supplierId === s.id))
852
+ .map((s) => (
853
+ <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
854
+ ))}
855
+ </SelectContent>
856
+ </Select>
857
+ </div>
858
+ )}
570
859
  <div className="space-y-2">
571
860
  <label className="text-sm font-medium">Address</label>
572
861
  <Input value={form.address} onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))} />
@@ -586,6 +875,65 @@ export default function Contacts() {
586
875
  </form>
587
876
  </DialogContent>
588
877
  </Dialog>
878
+
879
+ <Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
880
+ <DialogContent className="sm:max-w-[480px]">
881
+ <DialogHeader>
882
+ <DialogTitle>Link supplier to client</DialogTitle>
883
+ </DialogHeader>
884
+ <div className="space-y-4">
885
+ <div className="space-y-2">
886
+ <label className="text-sm font-medium">Supplier</label>
887
+ <Select value={linkSupplierId} onValueChange={(v) => setLinkSupplierId(v || '')}>
888
+ <SelectTrigger>
889
+ <SelectValue placeholder="Select supplier" />
890
+ </SelectTrigger>
891
+ <SelectContent>
892
+ {(suppliers ?? []).map((s) => (
893
+ <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
894
+ ))}
895
+ </SelectContent>
896
+ </Select>
897
+ </div>
898
+ <div className="space-y-2">
899
+ <label className="text-sm font-medium">Client</label>
900
+ <Select value={linkCustomerId} onValueChange={(v) => setLinkCustomerId(v || '')}>
901
+ <SelectTrigger>
902
+ <SelectValue placeholder="Select client" />
903
+ </SelectTrigger>
904
+ <SelectContent>
905
+ {(customers ?? []).map((c) => (
906
+ <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
907
+ ))}
908
+ </SelectContent>
909
+ </Select>
910
+ </div>
911
+ <div className="flex justify-end gap-2 pt-2">
912
+ <Button type="button" variant="outline" onClick={() => setLinkDialogOpen(false)}>Cancel</Button>
913
+ <Button
914
+ type="button"
915
+ disabled={!linkSupplierId || !linkCustomerId || createContactLink.isPending}
916
+ onClick={async () => {
917
+ try {
918
+ await createContactLink.mutateAsync({
919
+ supplierId: linkSupplierId,
920
+ customerId: linkCustomerId,
921
+ });
922
+ setLinkDialogOpen(false);
923
+ setLinkSupplierId('');
924
+ setLinkCustomerId('');
925
+ } catch (err) {
926
+ console.error(err);
927
+ alert('Failed to create link');
928
+ }
929
+ }}
930
+ >
931
+ {createContactLink.isPending ? 'Linking…' : 'Create link'}
932
+ </Button>
933
+ </div>
934
+ </div>
935
+ </DialogContent>
936
+ </Dialog>
589
937
  </div>
590
938
  );
591
939
  }