el-contador 1.2.13 → 1.2.14
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 +2 -2
- 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/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 +9 -5
- package/frontend/src/pages/Bank.tsx +23 -5
- package/frontend/src/pages/Contacts.tsx +191 -10
- 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 +81 -12
- package/frontend/src/pages/Settings.tsx +75 -10
- 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 +22 -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/project-reports.js +174 -0
- package/server/routes/projects.js +168 -0
- package/server/routes/reconciliation.js +52 -3
- package/server/routes/sales.js +71 -14
- package/server/routes/suppliers.js +199 -1
- package/server/scripts/batch-import-expenses.js +6 -1
- package/server/services/invoice-extraction.js +79 -2
- package/server/services/journal-posting.js +97 -1
- 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}
|
|
@@ -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,7 +81,7 @@ 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:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
|
85
85
|
{...props}
|
|
86
86
|
>
|
|
87
87
|
<SelectScrollUpButton />
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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">{year === 'all' ? 'All (historic)' : year}</SelectValue>
|
|
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">{quarter === 'all' ? 'All' : `Q${quarter}`}</SelectValue>
|
|
244
244
|
</SelectTrigger>
|
|
245
245
|
<SelectContent>
|
|
246
246
|
<SelectItem value="all">All</SelectItem>
|
|
@@ -251,7 +251,9 @@ 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"
|
|
254
|
+
<SelectValue placeholder="Month">
|
|
255
|
+
{month === 'all' ? 'All' : new Date(2000, Number(month) - 1, 1).toLocaleString('default', { month: 'short' })}
|
|
256
|
+
</SelectValue>
|
|
255
257
|
</SelectTrigger>
|
|
256
258
|
<SelectContent>
|
|
257
259
|
<SelectItem value="all">All</SelectItem>
|
|
@@ -356,7 +358,9 @@ export default function AccountLedger() {
|
|
|
356
358
|
<Label>Direction</Label>
|
|
357
359
|
<Select value={newTxDirection} onValueChange={(v) => v && setNewTxDirection(v as 'pay_to' | 'pay_from')}>
|
|
358
360
|
<SelectTrigger>
|
|
359
|
-
<SelectValue
|
|
361
|
+
<SelectValue>
|
|
362
|
+
{newTxDirection === 'pay_to' ? 'Pay to this account' : newTxDirection === 'pay_from' ? 'Pay from this account' : undefined}
|
|
363
|
+
</SelectValue>
|
|
360
364
|
</SelectTrigger>
|
|
361
365
|
<SelectContent>
|
|
362
366
|
<SelectItem value="pay_to">Pay to this account</SelectItem>
|
|
@@ -372,7 +376,7 @@ export default function AccountLedger() {
|
|
|
372
376
|
{newTxCounterAccountId
|
|
373
377
|
? (() => {
|
|
374
378
|
const sel = otherAccounts.find((a: any) => String(a.id) === String(newTxCounterAccountId));
|
|
375
|
-
return sel ? `${sel.code} ${sel.name}` :
|
|
379
|
+
return sel ? `${sel.code} ${sel.name}` : 'Unknown account';
|
|
376
380
|
})()
|
|
377
381
|
: null}
|
|
378
382
|
</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>
|