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.
Files changed (41) 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 +2 -2
  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/layouts/ProtectedLayout.tsx +2 -1
  11. package/frontend/src/lib/api-errors.ts +16 -0
  12. package/frontend/src/lib/selectDisplay.ts +13 -0
  13. package/frontend/src/pages/AccountLedger.tsx +9 -5
  14. package/frontend/src/pages/Bank.tsx +23 -5
  15. package/frontend/src/pages/Contacts.tsx +191 -10
  16. package/frontend/src/pages/Expenses.tsx +873 -131
  17. package/frontend/src/pages/Projects.tsx +474 -0
  18. package/frontend/src/pages/Reconciliation.tsx +236 -17
  19. package/frontend/src/pages/Sales.tsx +81 -12
  20. package/frontend/src/pages/Settings.tsx +75 -10
  21. package/package.json +1 -1
  22. package/server/Dockerfile +2 -0
  23. package/server/README.md +4 -0
  24. package/server/db/init.js +28 -7
  25. package/server/db/migrations/optional_suppliers_name_unique.sql +7 -0
  26. package/server/db/schema.sql +69 -5
  27. package/server/index.js +22 -0
  28. package/server/lib/supplier-name.js +83 -0
  29. package/server/package-lock.json +2 -2
  30. package/server/package.json +1 -1
  31. package/server/routes/accounts.js +56 -13
  32. package/server/routes/expenses.js +837 -327
  33. package/server/routes/project-reports.js +174 -0
  34. package/server/routes/projects.js +168 -0
  35. package/server/routes/reconciliation.js +52 -3
  36. package/server/routes/sales.js +71 -14
  37. package/server/routes/suppliers.js +199 -1
  38. package/server/scripts/batch-import-expenses.js +6 -1
  39. package/server/services/invoice-extraction.js +79 -2
  40. package/server/services/journal-posting.js +97 -1
  41. 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}
@@ -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,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-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: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 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
+ }
@@ -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}` : null;
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
- {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>