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.
- package/docker-compose.yml +7 -0
- package/frontend/src/components/ui/checkbox.tsx +1 -1
- package/frontend/src/components/ui/switch.tsx +2 -2
- package/frontend/src/hooks/useContactLinks.ts +74 -0
- package/frontend/src/hooks/useContacts.ts +14 -0
- package/frontend/src/hooks/useExpenses.ts +3 -1
- package/frontend/src/pages/Contacts.tsx +365 -17
- package/frontend/src/pages/Expenses.tsx +9 -5
- package/frontend/src/pages/Reconciliation.tsx +266 -18
- package/frontend/src/pages/Sales.tsx +6 -2
- package/frontend/src/pages/Settings.tsx +47 -31
- package/package.json +1 -1
- package/server/db/schema.sql +50 -0
- package/server/index.js +5 -1
- package/server/package.json +1 -1
- package/server/routes/accounts.js +3 -11
- package/server/routes/contact-links.js +82 -0
- package/server/routes/customers.js +85 -0
- package/server/routes/expenses.js +7 -16
- package/server/routes/reconciliation.js +222 -2
- package/server/routes/sales.js +12 -1
- package/server/routes/suppliers.js +0 -12
- package/server/scripts/test-offset-e2e.js +96 -0
- package/server/services/email.js +9 -2
- package/server/services/journal-posting.js +66 -0
package/docker-compose.yml
CHANGED
|
@@ -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:
|
|
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 [
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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={
|
|
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
|
|
635
|
+
const mergeIds = mergeCandidates.filter((c) => c.id !== mergeKeepId).map((c) => c.id);
|
|
433
636
|
try {
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
{
|
|
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 "Add link" 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 "Add link" 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
|
}
|