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.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/docker-compose.yml +2 -0
  3. package/frontend/src/App.tsx +2 -0
  4. package/frontend/src/components/ui/dropdown-menu.tsx +3 -3
  5. package/frontend/src/components/ui/popover.tsx +2 -2
  6. package/frontend/src/components/ui/select.tsx +4 -4
  7. package/frontend/src/hooks/useContacts.ts +15 -1
  8. package/frontend/src/hooks/useExpenses.ts +38 -5
  9. package/frontend/src/hooks/useProjects.ts +148 -0
  10. package/frontend/src/hooks/useSettings.ts +25 -0
  11. package/frontend/src/index.css +5 -1
  12. package/frontend/src/layouts/ProtectedLayout.tsx +2 -1
  13. package/frontend/src/lib/api-errors.ts +16 -0
  14. package/frontend/src/lib/selectDisplay.ts +13 -0
  15. package/frontend/src/pages/AccountLedger.tsx +4 -2
  16. package/frontend/src/pages/Bank.tsx +23 -5
  17. package/frontend/src/pages/Contacts.tsx +191 -10
  18. package/frontend/src/pages/Dashboard.tsx +86 -23
  19. package/frontend/src/pages/Expenses.tsx +873 -131
  20. package/frontend/src/pages/Projects.tsx +474 -0
  21. package/frontend/src/pages/Reconciliation.tsx +236 -17
  22. package/frontend/src/pages/Sales.tsx +86 -13
  23. package/frontend/src/pages/Settings.tsx +260 -36
  24. package/frontend/tailwind.config.js +20 -19
  25. package/package.json +1 -1
  26. package/server/Dockerfile +2 -0
  27. package/server/README.md +4 -0
  28. package/server/db/init.js +28 -7
  29. package/server/db/migrations/optional_suppliers_name_unique.sql +7 -0
  30. package/server/db/schema.sql +69 -5
  31. package/server/index.js +27 -0
  32. package/server/lib/supplier-name.js +83 -0
  33. package/server/package-lock.json +2 -2
  34. package/server/package.json +1 -1
  35. package/server/routes/accounts.js +56 -13
  36. package/server/routes/expenses.js +837 -327
  37. package/server/routes/integrations.js +140 -6
  38. package/server/routes/project-reports.js +174 -0
  39. package/server/routes/projects.js +168 -0
  40. package/server/routes/reconciliation.js +52 -3
  41. package/server/routes/sales.js +74 -15
  42. package/server/routes/suppliers.js +199 -1
  43. package/server/routes/webhooks-payments.js +148 -0
  44. package/server/scripts/batch-import-expenses.js +6 -1
  45. package/server/services/invoice-extraction.js +79 -2
  46. package/server/services/invoice-pdf.js +122 -44
  47. package/server/services/journal-posting.js +97 -1
  48. package/server/services/payment-sales-import.js +260 -0
  49. 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
  ```
@@ -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
@@ -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 = 4,
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 ring-1 ring-foreground/10 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 )}
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 ring-1 ring-foreground/10 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",
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 = 4,
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 ring-1 ring-foreground/10 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",
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-transparent 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",
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 = 4,
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-white pb-[15px] text-popover-foreground shadow-md ring-1 ring-foreground/10 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 )}
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 function useExpenses() {
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 { data } = await api.get('/expenses');
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
- /** Expense accounts (chart of accounts 400-799) for expense dropdowns */
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: number) => {
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({
@@ -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 outline-ring/50;
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}` : null;
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
- {confirmImportMutation.isPending ? 'Importing...' : `Import selected (${selectedCount})`}
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>