el-contador 1.2.13 → 1.2.15
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/README.md +4 -0
- package/docker-compose.yml +2 -0
- package/frontend/src/App.tsx +2 -0
- package/frontend/src/components/ui/dropdown-menu.tsx +3 -3
- package/frontend/src/components/ui/popover.tsx +2 -2
- package/frontend/src/components/ui/select.tsx +4 -4
- package/frontend/src/hooks/useContacts.ts +15 -1
- package/frontend/src/hooks/useExpenses.ts +38 -5
- package/frontend/src/hooks/useProjects.ts +148 -0
- package/frontend/src/hooks/useSettings.ts +25 -0
- package/frontend/src/index.css +5 -1
- package/frontend/src/layouts/ProtectedLayout.tsx +2 -1
- package/frontend/src/lib/api-errors.ts +16 -0
- package/frontend/src/lib/selectDisplay.ts +13 -0
- package/frontend/src/pages/AccountLedger.tsx +4 -2
- package/frontend/src/pages/Bank.tsx +23 -5
- package/frontend/src/pages/Contacts.tsx +191 -10
- package/frontend/src/pages/Dashboard.tsx +86 -23
- package/frontend/src/pages/Expenses.tsx +873 -131
- package/frontend/src/pages/Projects.tsx +474 -0
- package/frontend/src/pages/Reconciliation.tsx +236 -17
- package/frontend/src/pages/Sales.tsx +86 -13
- package/frontend/src/pages/Settings.tsx +260 -36
- package/frontend/tailwind.config.js +20 -19
- package/package.json +1 -1
- package/server/Dockerfile +2 -0
- package/server/README.md +4 -0
- package/server/db/init.js +28 -7
- package/server/db/migrations/optional_suppliers_name_unique.sql +7 -0
- package/server/db/schema.sql +69 -5
- package/server/index.js +27 -0
- package/server/lib/supplier-name.js +83 -0
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/routes/accounts.js +56 -13
- package/server/routes/expenses.js +837 -327
- package/server/routes/integrations.js +140 -6
- package/server/routes/project-reports.js +174 -0
- package/server/routes/projects.js +168 -0
- package/server/routes/reconciliation.js +52 -3
- package/server/routes/sales.js +74 -15
- package/server/routes/suppliers.js +199 -1
- package/server/routes/webhooks-payments.js +148 -0
- package/server/scripts/batch-import-expenses.js +6 -1
- package/server/services/invoice-extraction.js +79 -2
- package/server/services/invoice-pdf.js +122 -44
- package/server/services/journal-posting.js +97 -1
- package/server/services/payment-sales-import.js +260 -0
- package/server/services/pdf-compress.js +113 -0
package/README.md
CHANGED
|
@@ -75,6 +75,10 @@ The app is built to run behind a reverse proxy (`trust proxy` is enabled). To se
|
|
|
75
75
|
proxy_set_header X-Real-IP $remote_addr;
|
|
76
76
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
77
77
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
78
|
+
# Batch expense upload calls Gemini once per file; default 60s proxy timeout often returns 504
|
|
79
|
+
proxy_connect_timeout 300s;
|
|
80
|
+
proxy_send_timeout 300s;
|
|
81
|
+
proxy_read_timeout 300s;
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
```
|
package/docker-compose.yml
CHANGED
|
@@ -5,6 +5,7 @@ services:
|
|
|
5
5
|
postgres:
|
|
6
6
|
container_name: el-contador-postgres
|
|
7
7
|
image: postgres:16-alpine
|
|
8
|
+
restart: unless-stopped
|
|
8
9
|
environment:
|
|
9
10
|
POSTGRES_USER: ${DB_USER:-el_contador}
|
|
10
11
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
|
|
@@ -25,6 +26,7 @@ services:
|
|
|
25
26
|
build:
|
|
26
27
|
context: .
|
|
27
28
|
dockerfile: server/Dockerfile
|
|
29
|
+
restart: unless-stopped
|
|
28
30
|
depends_on:
|
|
29
31
|
postgres:
|
|
30
32
|
condition: service_healthy
|
package/frontend/src/App.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import Bank from './pages/Bank';
|
|
|
10
10
|
import Reconciliation from './pages/Reconciliation';
|
|
11
11
|
import AccountLedger from './pages/AccountLedger';
|
|
12
12
|
import Contacts from './pages/Contacts';
|
|
13
|
+
import Projects from './pages/Projects';
|
|
13
14
|
import Settings from './pages/Settings';
|
|
14
15
|
|
|
15
16
|
const queryClient = new QueryClient({
|
|
@@ -36,6 +37,7 @@ function App() {
|
|
|
36
37
|
<Route path="reconciliation" element={<Reconciliation />} />
|
|
37
38
|
<Route path="accounts/:id" element={<AccountLedger />} />
|
|
38
39
|
<Route path="contacts" element={<Contacts />} />
|
|
40
|
+
<Route path="projects" element={<Projects />} />
|
|
39
41
|
<Route path="settings" element={<Settings />} />
|
|
40
42
|
</Route>
|
|
41
43
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
@@ -22,7 +22,7 @@ function DropdownMenuContent({
|
|
|
22
22
|
align = "start",
|
|
23
23
|
alignOffset = 0,
|
|
24
24
|
side = "bottom",
|
|
25
|
-
sideOffset =
|
|
25
|
+
sideOffset = 10,
|
|
26
26
|
className,
|
|
27
27
|
...props
|
|
28
28
|
}: MenuPrimitive.Popup.Props &
|
|
@@ -41,7 +41,7 @@ function DropdownMenuContent({
|
|
|
41
41
|
>
|
|
42
42
|
<MenuPrimitive.Popup
|
|
43
43
|
data-slot="dropdown-menu-content"
|
|
44
|
-
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md
|
|
44
|
+
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md duration-100 outline-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:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
|
45
45
|
{...props}
|
|
46
46
|
/>
|
|
47
47
|
</MenuPrimitive.Positioner>
|
|
@@ -136,7 +136,7 @@ function DropdownMenuSubContent({
|
|
|
136
136
|
<DropdownMenuContent
|
|
137
137
|
data-slot="dropdown-menu-sub-content"
|
|
138
138
|
className={cn(
|
|
139
|
-
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg
|
|
139
|
+
"w-auto min-w-[96px] rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-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:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
140
140
|
className
|
|
141
141
|
)}
|
|
142
142
|
align={align}
|
|
@@ -16,7 +16,7 @@ function PopoverContent({
|
|
|
16
16
|
align = "center",
|
|
17
17
|
alignOffset = 0,
|
|
18
18
|
side = "bottom",
|
|
19
|
-
sideOffset =
|
|
19
|
+
sideOffset = 10,
|
|
20
20
|
...props
|
|
21
21
|
}: PopoverPrimitive.Popup.Props &
|
|
22
22
|
Pick<
|
|
@@ -35,7 +35,7 @@ function PopoverContent({
|
|
|
35
35
|
<PopoverPrimitive.Popup
|
|
36
36
|
data-slot="popover-content"
|
|
37
37
|
className={cn(
|
|
38
|
-
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md
|
|
38
|
+
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg border border-border bg-popover p-2.5 text-sm text-popover-foreground shadow-md outline-hidden duration-100 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:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
39
39
|
className
|
|
40
40
|
)}
|
|
41
41
|
{...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}
|
|
@@ -58,7 +58,7 @@ function SelectContent({
|
|
|
58
58
|
className,
|
|
59
59
|
children,
|
|
60
60
|
side = "bottom",
|
|
61
|
-
sideOffset =
|
|
61
|
+
sideOffset = 10,
|
|
62
62
|
align = "center",
|
|
63
63
|
alignOffset = 0,
|
|
64
64
|
alignItemWithTrigger = true,
|
|
@@ -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 bg-
|
|
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>
|
|
@@ -89,7 +89,7 @@ export function useSuppliers() {
|
|
|
89
89
|
export function useCreateSupplier() {
|
|
90
90
|
const queryClient = useQueryClient();
|
|
91
91
|
return useMutation({
|
|
92
|
-
mutationFn: async (body: Partial<Supplier>) => {
|
|
92
|
+
mutationFn: async (body: Partial<Supplier> & { force?: boolean }) => {
|
|
93
93
|
const { data } = await api.post<Supplier>('/suppliers', body);
|
|
94
94
|
return data;
|
|
95
95
|
},
|
|
@@ -124,6 +124,20 @@ export function useDeleteSupplier() {
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
export function useMergeSuppliers() {
|
|
128
|
+
const queryClient = useQueryClient();
|
|
129
|
+
return useMutation({
|
|
130
|
+
mutationFn: async (body: { keepSupplierId: string; mergeSupplierIds: string[] }) => {
|
|
131
|
+
const { data } = await api.post<{ ok: boolean; mergedCount: number }>('/suppliers/merge', body);
|
|
132
|
+
return data;
|
|
133
|
+
},
|
|
134
|
+
onSuccess: () => {
|
|
135
|
+
queryClient.invalidateQueries({ queryKey: ['suppliers'] });
|
|
136
|
+
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
// Payees
|
|
128
142
|
export function usePayees() {
|
|
129
143
|
return useQuery({
|
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { api } from '../lib/api';
|
|
3
|
+
import type { ProjectAllocation } from './useProjects';
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
+
export type Expense = {
|
|
6
|
+
id: string;
|
|
7
|
+
date: string;
|
|
8
|
+
vendor: string;
|
|
9
|
+
category: string | null;
|
|
10
|
+
categoryId: string | null;
|
|
11
|
+
categoryName: string | null;
|
|
12
|
+
accountId: string | null;
|
|
13
|
+
accountCode: number | null;
|
|
14
|
+
accountName: string | null;
|
|
15
|
+
amount: number;
|
|
16
|
+
vat: number;
|
|
17
|
+
vatRate: number | null;
|
|
18
|
+
notes: string;
|
|
19
|
+
fileName: string | null;
|
|
20
|
+
invoiceNumber: string | null;
|
|
21
|
+
reconciled: boolean;
|
|
22
|
+
reconciledAt: string | null;
|
|
23
|
+
supplierId: string | null;
|
|
24
|
+
bankTransactionId: string | null;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
createdBy?: string | null;
|
|
27
|
+
creatorEmail?: string | null;
|
|
28
|
+
approvalStatus?: string;
|
|
29
|
+
projectAllocations: ProjectAllocation[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function useExpenses(projectId?: string | null) {
|
|
5
33
|
return useQuery({
|
|
6
|
-
queryKey: ['expenses'],
|
|
34
|
+
queryKey: ['expenses', projectId || 'all'],
|
|
7
35
|
queryFn: async () => {
|
|
8
|
-
const {
|
|
36
|
+
const params = projectId ? { projectId } : undefined;
|
|
37
|
+
const { data } = await api.get<Expense[]>('/expenses', { params });
|
|
9
38
|
return data;
|
|
10
39
|
},
|
|
11
40
|
});
|
|
@@ -21,7 +50,7 @@ export function useExpenseCategories() {
|
|
|
21
50
|
});
|
|
22
51
|
}
|
|
23
52
|
|
|
24
|
-
/**
|
|
53
|
+
/** Accounts for expenses: not asset/revenue (100-299) and Allow expenses enabled on the account */
|
|
25
54
|
export function useExpenseAccounts() {
|
|
26
55
|
return useQuery({
|
|
27
56
|
queryKey: ['accounts', 'expense'],
|
|
@@ -44,6 +73,8 @@ export function useCreateExpense() {
|
|
|
44
73
|
onSuccess: () => {
|
|
45
74
|
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
|
46
75
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
76
|
+
queryClient.invalidateQueries({ queryKey: ['project-reports'] });
|
|
77
|
+
queryClient.invalidateQueries({ queryKey: ['project-report-detail'] });
|
|
47
78
|
},
|
|
48
79
|
});
|
|
49
80
|
}
|
|
@@ -51,12 +82,14 @@ export function useCreateExpense() {
|
|
|
51
82
|
export function useDeleteExpense() {
|
|
52
83
|
const queryClient = useQueryClient();
|
|
53
84
|
return useMutation({
|
|
54
|
-
mutationFn: async (id:
|
|
85
|
+
mutationFn: async (id: string) => {
|
|
55
86
|
await api.delete(`/expenses/${id}`);
|
|
56
87
|
},
|
|
57
88
|
onSuccess: () => {
|
|
58
89
|
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
|
59
90
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
|
91
|
+
queryClient.invalidateQueries({ queryKey: ['project-reports'] });
|
|
92
|
+
queryClient.invalidateQueries({ queryKey: ['project-report-detail'] });
|
|
60
93
|
},
|
|
61
94
|
});
|
|
62
95
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { api } from '../lib/api';
|
|
3
|
+
|
|
4
|
+
export type Project = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
code: string;
|
|
8
|
+
description: string;
|
|
9
|
+
responsible: string;
|
|
10
|
+
projectAccountNum: string;
|
|
11
|
+
active: boolean;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
updatedAt?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ProjectAllocation = {
|
|
17
|
+
projectId: string;
|
|
18
|
+
projectName: string;
|
|
19
|
+
projectCode?: string;
|
|
20
|
+
responsible?: string;
|
|
21
|
+
projectAccountNum?: string;
|
|
22
|
+
allocationPercentage: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ProjectReportSummaryRow = {
|
|
26
|
+
projectId: string;
|
|
27
|
+
projectName: string;
|
|
28
|
+
projectCode: string;
|
|
29
|
+
responsible: string;
|
|
30
|
+
projectAccountNum: string;
|
|
31
|
+
active: boolean;
|
|
32
|
+
expenseCount: number;
|
|
33
|
+
netAmount: number;
|
|
34
|
+
vatAmount: number;
|
|
35
|
+
totalAmount: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type ProjectReportDetail = {
|
|
39
|
+
from: string;
|
|
40
|
+
to: string;
|
|
41
|
+
project: Project;
|
|
42
|
+
totals: {
|
|
43
|
+
expenseCount: number;
|
|
44
|
+
netAmount: number;
|
|
45
|
+
vatAmount: number;
|
|
46
|
+
totalAmount: number;
|
|
47
|
+
};
|
|
48
|
+
breakdown: Array<{
|
|
49
|
+
category: string;
|
|
50
|
+
expenseCount: number;
|
|
51
|
+
netAmount: number;
|
|
52
|
+
vatAmount: number;
|
|
53
|
+
totalAmount: number;
|
|
54
|
+
}>;
|
|
55
|
+
expenses: Array<{
|
|
56
|
+
expenseId: string;
|
|
57
|
+
date: string;
|
|
58
|
+
vendor: string;
|
|
59
|
+
invoiceNumber: string;
|
|
60
|
+
notes: string;
|
|
61
|
+
accountId: string | null;
|
|
62
|
+
accountCode: number | null;
|
|
63
|
+
accountName: string | null;
|
|
64
|
+
categoryName: string | null;
|
|
65
|
+
allocationPercentage: number;
|
|
66
|
+
netAmount: number;
|
|
67
|
+
vatAmount: number;
|
|
68
|
+
totalAmount: number;
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function useProjects(active?: boolean) {
|
|
73
|
+
return useQuery({
|
|
74
|
+
queryKey: ['projects', active ?? 'all'],
|
|
75
|
+
queryFn: async () => {
|
|
76
|
+
const params = active === undefined ? undefined : { active: String(active) };
|
|
77
|
+
const { data } = await api.get<Project[]>('/projects', { params });
|
|
78
|
+
return data;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useCreateProject() {
|
|
84
|
+
const queryClient = useQueryClient();
|
|
85
|
+
return useMutation({
|
|
86
|
+
mutationFn: async (body: Partial<Project>) => {
|
|
87
|
+
const { data } = await api.post<Project>('/projects', body);
|
|
88
|
+
return data;
|
|
89
|
+
},
|
|
90
|
+
onSuccess: () => {
|
|
91
|
+
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
92
|
+
queryClient.invalidateQueries({ queryKey: ['project-reports'] });
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useUpdateProject() {
|
|
98
|
+
const queryClient = useQueryClient();
|
|
99
|
+
return useMutation({
|
|
100
|
+
mutationFn: async ({ id, ...body }: Partial<Project> & { id: string }) => {
|
|
101
|
+
const { data } = await api.put<Project>(`/projects/${id}`, body);
|
|
102
|
+
return data;
|
|
103
|
+
},
|
|
104
|
+
onSuccess: (_, variables) => {
|
|
105
|
+
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
106
|
+
queryClient.invalidateQueries({ queryKey: ['project-reports'] });
|
|
107
|
+
queryClient.invalidateQueries({ queryKey: ['project-report-detail', variables.id] });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function useDeleteProject() {
|
|
113
|
+
const queryClient = useQueryClient();
|
|
114
|
+
return useMutation({
|
|
115
|
+
mutationFn: async (id: string) => {
|
|
116
|
+
await api.delete(`/projects/${id}`);
|
|
117
|
+
},
|
|
118
|
+
onSuccess: () => {
|
|
119
|
+
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
120
|
+
queryClient.invalidateQueries({ queryKey: ['project-reports'] });
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function useProjectReportSummary(from: string, to: string) {
|
|
126
|
+
return useQuery({
|
|
127
|
+
queryKey: ['project-reports', 'summary', from, to],
|
|
128
|
+
queryFn: async () => {
|
|
129
|
+
const { data } = await api.get<{ from: string; to: string; projects: ProjectReportSummaryRow[] }>('/reports/projects/summary', {
|
|
130
|
+
params: { from, to },
|
|
131
|
+
});
|
|
132
|
+
return data;
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function useProjectReportDetail(projectId: string | null, from: string, to: string) {
|
|
138
|
+
return useQuery({
|
|
139
|
+
queryKey: ['project-report-detail', projectId, from, to],
|
|
140
|
+
enabled: Boolean(projectId),
|
|
141
|
+
queryFn: async () => {
|
|
142
|
+
const { data } = await api.get<ProjectReportDetail>(`/reports/projects/${projectId}`, {
|
|
143
|
+
params: { from, to },
|
|
144
|
+
});
|
|
145
|
+
return data;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -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;
|
|
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|
|
2
2
|
import { Navigate, Outlet } from 'react-router-dom';
|
|
3
3
|
import { useAuth } from '../contexts/AuthContext';
|
|
4
4
|
import { useInvoiceConfig } from '../hooks/useSettings';
|
|
5
|
-
import { LogOut, Settings, LayoutDashboard, FileText, Landmark, Users, Contact, Menu } from 'lucide-react';
|
|
5
|
+
import { LogOut, Settings, LayoutDashboard, FileText, Landmark, Users, Contact, Menu, BriefcaseBusiness } from 'lucide-react';
|
|
6
6
|
import { Link, useLocation } from 'react-router-dom';
|
|
7
7
|
import { Button } from '@/components/ui/button';
|
|
8
8
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
|
@@ -37,6 +37,7 @@ export default function ProtectedLayout() {
|
|
|
37
37
|
{ name: 'Bank', path: '/bank', icon: Landmark },
|
|
38
38
|
{ name: 'Reconciliation', path: '/reconciliation', icon: FileText },
|
|
39
39
|
{ name: 'Contacts', path: '/contacts', icon: Contact },
|
|
40
|
+
{ name: 'Projects', path: '/projects', icon: BriefcaseBusiness },
|
|
40
41
|
{ name: 'Settings', path: '/settings', icon: Settings },
|
|
41
42
|
];
|
|
42
43
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function parseApiError(error: unknown, fallback = 'Request failed. Please try again.'): string {
|
|
2
|
+
const res =
|
|
3
|
+
error && typeof error === 'object' && 'response' in error
|
|
4
|
+
? (error as { response?: { status?: number; data?: unknown } }).response
|
|
5
|
+
: undefined;
|
|
6
|
+
const data = res?.data;
|
|
7
|
+
if (data && typeof data === 'object') {
|
|
8
|
+
const d = data as Record<string, unknown>;
|
|
9
|
+
if (typeof d.message === 'string' && d.message.trim()) return d.message.trim();
|
|
10
|
+
if (typeof d.error === 'string' && d.error.trim()) return d.error.trim();
|
|
11
|
+
}
|
|
12
|
+
if (res?.status === 413) {
|
|
13
|
+
return 'File too large. Use a smaller file or ask an administrator to raise the upload limit.';
|
|
14
|
+
}
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base UI Select shows the raw `value` (e.g. UUID) in the trigger when SelectValue
|
|
3
|
+
* has no children. Use this to resolve a human-readable label from a list by id.
|
|
4
|
+
*/
|
|
5
|
+
export function labelForId<T extends { id: string }>(
|
|
6
|
+
items: T[] | null | undefined,
|
|
7
|
+
id: string | null | undefined,
|
|
8
|
+
getLabel: (row: T) => string
|
|
9
|
+
): string | undefined {
|
|
10
|
+
if (id == null || id === '') return undefined;
|
|
11
|
+
const row = items?.find((x) => String(x.id) === String(id));
|
|
12
|
+
return row ? getLabel(row) : undefined;
|
|
13
|
+
}
|
|
@@ -356,7 +356,9 @@ export default function AccountLedger() {
|
|
|
356
356
|
<Label>Direction</Label>
|
|
357
357
|
<Select value={newTxDirection} onValueChange={(v) => v && setNewTxDirection(v as 'pay_to' | 'pay_from')}>
|
|
358
358
|
<SelectTrigger>
|
|
359
|
-
<SelectValue
|
|
359
|
+
<SelectValue>
|
|
360
|
+
{newTxDirection === 'pay_to' ? 'Pay to this account' : newTxDirection === 'pay_from' ? 'Pay from this account' : undefined}
|
|
361
|
+
</SelectValue>
|
|
360
362
|
</SelectTrigger>
|
|
361
363
|
<SelectContent>
|
|
362
364
|
<SelectItem value="pay_to">Pay to this account</SelectItem>
|
|
@@ -372,7 +374,7 @@ export default function AccountLedger() {
|
|
|
372
374
|
{newTxCounterAccountId
|
|
373
375
|
? (() => {
|
|
374
376
|
const sel = otherAccounts.find((a: any) => String(a.id) === String(newTxCounterAccountId));
|
|
375
|
-
return sel ? `${sel.code} ${sel.name}` :
|
|
377
|
+
return sel ? `${sel.code} ${sel.name}` : 'Unknown account';
|
|
376
378
|
})()
|
|
377
379
|
: null}
|
|
378
380
|
</SelectValue>
|
|
@@ -265,7 +265,19 @@ export default function Bank() {
|
|
|
265
265
|
/>
|
|
266
266
|
<Select value={filterType} onValueChange={(val) => setFilterType(val || 'all')}>
|
|
267
267
|
<SelectTrigger className="w-[180px]">
|
|
268
|
-
<SelectValue placeholder="Filter..."
|
|
268
|
+
<SelectValue placeholder="Filter...">
|
|
269
|
+
{filterType === 'all'
|
|
270
|
+
? 'All Transactions'
|
|
271
|
+
: filterType === 'unreconciled'
|
|
272
|
+
? 'Unreconciled'
|
|
273
|
+
: filterType === 'reconciled'
|
|
274
|
+
? 'Reconciled'
|
|
275
|
+
: filterType === 'in'
|
|
276
|
+
? 'Money In'
|
|
277
|
+
: filterType === 'out'
|
|
278
|
+
? 'Money Out'
|
|
279
|
+
: undefined}
|
|
280
|
+
</SelectValue>
|
|
269
281
|
</SelectTrigger>
|
|
270
282
|
<SelectContent>
|
|
271
283
|
<SelectItem value="all">All Transactions</SelectItem>
|
|
@@ -416,7 +428,7 @@ export default function Bank() {
|
|
|
416
428
|
<Label>Type</Label>
|
|
417
429
|
<Select value={editForm.type} onValueChange={(v) => v && setEditForm((f) => ({ ...f, type: v as 'in' | 'out' }))}>
|
|
418
430
|
<SelectTrigger className="col-span-3">
|
|
419
|
-
<SelectValue
|
|
431
|
+
<SelectValue>{editForm.type === 'in' ? 'Money In' : editForm.type === 'out' ? 'Money Out' : undefined}</SelectValue>
|
|
420
432
|
</SelectTrigger>
|
|
421
433
|
<SelectContent>
|
|
422
434
|
<SelectItem value="in">Money In</SelectItem>
|
|
@@ -582,7 +594,7 @@ export default function Bank() {
|
|
|
582
594
|
<TableCell>
|
|
583
595
|
<Select value={row.type} onValueChange={(v) => v && setReviewRowField(i, 'type', v as 'in' | 'out')}>
|
|
584
596
|
<SelectTrigger className="h-8 text-sm">
|
|
585
|
-
<SelectValue
|
|
597
|
+
<SelectValue>{row.type === 'in' ? 'In' : row.type === 'out' ? 'Out' : undefined}</SelectValue>
|
|
586
598
|
</SelectTrigger>
|
|
587
599
|
<SelectContent>
|
|
588
600
|
<SelectItem value="in">In</SelectItem>
|
|
@@ -621,12 +633,18 @@ export default function Bank() {
|
|
|
621
633
|
{importError && (
|
|
622
634
|
<p className="text-sm text-destructive mt-2">{importError}</p>
|
|
623
635
|
)}
|
|
624
|
-
<DialogFooter showCloseButton>
|
|
636
|
+
<DialogFooter showCloseButton className="sm:justify-between">
|
|
637
|
+
<p className="text-sm text-muted-foreground">
|
|
638
|
+
Ready to import {selectedCount} {selectedCount === 1 ? 'transaction' : 'transactions'}.
|
|
639
|
+
</p>
|
|
625
640
|
<Button
|
|
641
|
+
size="lg"
|
|
642
|
+
className="min-w-[220px] font-semibold shadow-sm"
|
|
626
643
|
onClick={handleImportSelected}
|
|
627
644
|
disabled={confirmImportMutation.isPending || selectedCount === 0}
|
|
628
645
|
>
|
|
629
|
-
|
|
646
|
+
<Upload className="w-4 h-4 mr-2" />
|
|
647
|
+
{confirmImportMutation.isPending ? 'Importing...' : `Import ${selectedCount} ${selectedCount === 1 ? 'transaction' : 'transactions'}`}
|
|
630
648
|
</Button>
|
|
631
649
|
</DialogFooter>
|
|
632
650
|
</DialogContent>
|