el-contador 1.2.14 → 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/select.tsx +3 -3
- 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/hooks/useSettings.ts +25 -0
- package/frontend/src/index.css +5 -1
- package/frontend/src/pages/AccountLedger.tsx +3 -5
- package/frontend/src/pages/Contacts.tsx +365 -17
- package/frontend/src/pages/Dashboard.tsx +86 -23
- package/frontend/src/pages/Expenses.tsx +9 -5
- package/frontend/src/pages/Reconciliation.tsx +266 -18
- package/frontend/src/pages/Sales.tsx +11 -3
- package/frontend/src/pages/Settings.tsx +243 -68
- package/frontend/tailwind.config.js +20 -19
- package/package.json +1 -1
- package/server/db/schema.sql +50 -0
- package/server/index.js +10 -1
- package/server/package-lock.json +2 -2
- 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/integrations.js +140 -6
- package/server/routes/reconciliation.js +222 -2
- package/server/routes/sales.js +14 -1
- package/server/routes/suppliers.js +0 -12
- package/server/routes/webhooks-payments.js +148 -0
- package/server/scripts/test-offset-e2e.js +96 -0
- package/server/services/email.js +9 -2
- package/server/services/invoice-pdf.js +122 -44
- package/server/services/journal-posting.js +66 -0
- package/server/services/payment-sales-import.js +260 -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}
|
|
@@ -39,7 +39,7 @@ function SelectTrigger({
|
|
|
39
39
|
data-slot="select-trigger"
|
|
40
40
|
data-size={size}
|
|
41
41
|
className={cn(
|
|
42
|
-
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-
|
|
42
|
+
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-background py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none 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 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
43
43
|
className
|
|
44
44
|
)}
|
|
45
45
|
{...props}
|
|
@@ -81,11 +81,11 @@ function SelectContent({
|
|
|
81
81
|
<SelectPrimitive.Popup
|
|
82
82
|
data-slot="select-content"
|
|
83
83
|
data-align-trigger={alignItemWithTrigger}
|
|
84
|
-
className={cn("relative isolate z-[200] max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover pb-[15px] text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:
|
|
84
|
+
className={cn("relative isolate z-[200] max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover pb-[15px] text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:zoom-in-95 data-closed:animate-out data-closed:zoom-out-95", className )}
|
|
85
85
|
{...props}
|
|
86
86
|
>
|
|
87
87
|
<SelectScrollUpButton />
|
|
88
|
-
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
|
88
|
+
<SelectPrimitive.List className="bg-popover">{children}</SelectPrimitive.List>
|
|
89
89
|
<SelectScrollDownButton />
|
|
90
90
|
</SelectPrimitive.Popup>
|
|
91
91
|
</SelectPrimitive.Positioner>
|
|
@@ -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'],
|
|
@@ -60,6 +60,31 @@ export function useSaveIntegrationsSettings() {
|
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export type IntegrationTestResult = { ok: boolean; error?: string; detail?: string };
|
|
64
|
+
|
|
65
|
+
export function useTestIntegrationConnection() {
|
|
66
|
+
return useMutation({
|
|
67
|
+
mutationFn: async (payload: { provider: 'stripe' | 'paddle'; stripeSecretKey?: string; paddleApiKey?: string }) => {
|
|
68
|
+
const { data } = await api.post<{ testResults: { stripe?: IntegrationTestResult; paddle?: IntegrationTestResult } }>(
|
|
69
|
+
'/integrations/test',
|
|
70
|
+
payload
|
|
71
|
+
);
|
|
72
|
+
return data;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type PaymentSyncResult = { imported: number; skipped: number; errors: { id?: string; error: string }[] };
|
|
78
|
+
|
|
79
|
+
export function useSyncPaymentIntegrations() {
|
|
80
|
+
return useMutation({
|
|
81
|
+
mutationFn: async (payload: { provider: 'stripe' | 'paddle'; limit?: number }) => {
|
|
82
|
+
const { data } = await api.post<PaymentSyncResult>('/integrations/sync', payload);
|
|
83
|
+
return data;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
63
88
|
// Users
|
|
64
89
|
export function useUsers() {
|
|
65
90
|
return useQuery({
|
package/frontend/src/index.css
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
--accent: oklch(0.97 0 0);
|
|
23
23
|
--accent-foreground: oklch(0.205 0 0);
|
|
24
24
|
--destructive: oklch(0.58 0.22 27);
|
|
25
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
25
26
|
--border: oklch(0.922 0 0);
|
|
26
27
|
--input: oklch(0.922 0 0);
|
|
27
28
|
--ring: oklch(0.708 0 0);
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
--accent: oklch(0.371 0 0);
|
|
69
70
|
--accent-foreground: oklch(0.985 0 0);
|
|
70
71
|
--destructive: oklch(0.704 0.191 22.216);
|
|
72
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
71
73
|
--border: oklch(1 0 0 / 10%);
|
|
72
74
|
--input: oklch(1 0 0 / 15%);
|
|
73
75
|
--ring: oklch(0.556 0 0);
|
|
@@ -99,7 +101,9 @@
|
|
|
99
101
|
--font-sans: 'Geist Variable', sans-serif
|
|
100
102
|
}
|
|
101
103
|
* {
|
|
102
|
-
@apply border-border
|
|
104
|
+
@apply border-border;
|
|
105
|
+
/* outline-ring/50 is not generated for theme colors defined as full var(--token) */
|
|
106
|
+
outline-color: color-mix(in oklch, var(--ring) 50%, transparent);
|
|
103
107
|
}
|
|
104
108
|
body {
|
|
105
109
|
@apply bg-background text-foreground;
|
|
@@ -216,7 +216,7 @@ export default function AccountLedger() {
|
|
|
216
216
|
<div className="flex flex-wrap items-center gap-2">
|
|
217
217
|
<Select value={year} onValueChange={(v) => setYear(v ?? 'all')}>
|
|
218
218
|
<SelectTrigger className="w-24">
|
|
219
|
-
<SelectValue placeholder="Year"
|
|
219
|
+
<SelectValue placeholder="Year" />
|
|
220
220
|
</SelectTrigger>
|
|
221
221
|
<SelectContent>
|
|
222
222
|
<SelectItem value="all">All (historic)</SelectItem>
|
|
@@ -240,7 +240,7 @@ export default function AccountLedger() {
|
|
|
240
240
|
<>
|
|
241
241
|
<Select value={quarter} onValueChange={(v) => setQuarter(v ?? 'all')}>
|
|
242
242
|
<SelectTrigger className="w-28">
|
|
243
|
-
<SelectValue placeholder="Quarter"
|
|
243
|
+
<SelectValue placeholder="Quarter" />
|
|
244
244
|
</SelectTrigger>
|
|
245
245
|
<SelectContent>
|
|
246
246
|
<SelectItem value="all">All</SelectItem>
|
|
@@ -251,9 +251,7 @@ export default function AccountLedger() {
|
|
|
251
251
|
</Select>
|
|
252
252
|
<Select value={month} onValueChange={(v) => setMonth(v ?? 'all')}>
|
|
253
253
|
<SelectTrigger className="w-28">
|
|
254
|
-
<SelectValue placeholder="Month"
|
|
255
|
-
{month === 'all' ? 'All' : new Date(2000, Number(month) - 1, 1).toLocaleString('default', { month: 'short' })}
|
|
256
|
-
</SelectValue>
|
|
254
|
+
<SelectValue placeholder="Month" />
|
|
257
255
|
</SelectTrigger>
|
|
258
256
|
<SelectContent>
|
|
259
257
|
<SelectItem value="all">All</SelectItem>
|