el-contador 1.2.7 → 1.2.8

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 CHANGED
@@ -65,6 +65,8 @@ The app is built to run behind a reverse proxy (`trust proxy` is enabled). To se
65
65
  server {
66
66
  listen 80;
67
67
  server_name contador.example.com;
68
+ # Allow batch expense import (e.g. 30 files × 1MB)
69
+ client_max_body_size 35M;
68
70
 
69
71
  location / {
70
72
  proxy_pass http://127.0.0.1:3080;
@@ -8,6 +8,7 @@ import Expenses from './pages/Expenses';
8
8
  import Sales from './pages/Sales';
9
9
  import Bank from './pages/Bank';
10
10
  import Reconciliation from './pages/Reconciliation';
11
+ import AccountLedger from './pages/AccountLedger';
11
12
  import Contacts from './pages/Contacts';
12
13
  import Settings from './pages/Settings';
13
14
 
@@ -33,6 +34,7 @@ function App() {
33
34
  <Route path="sales" element={<Sales />} />
34
35
  <Route path="bank" element={<Bank />} />
35
36
  <Route path="reconciliation" element={<Reconciliation />} />
37
+ <Route path="accounts/:id" element={<AccountLedger />} />
36
38
  <Route path="contacts" element={<Contacts />} />
37
39
  <Route path="settings" element={<Settings />} />
38
40
  </Route>
@@ -38,7 +38,7 @@ export function useUploadLogo() {
38
38
  });
39
39
  }
40
40
 
41
- // Integrations
41
+ // Integrations (payments)
42
42
  export function useIntegrationsSettings() {
43
43
  return useQuery({
44
44
  queryKey: ['integrations'],
@@ -37,11 +37,11 @@
37
37
  --chart-revenue: #42d4a6;
38
38
  --chart-expenses: #f13f66;
39
39
  /* Reconciliation match block */
40
- --reconciliation-match-bg: #eff6ff;
41
- --reconciliation-match-border: lch(88.74% 26.13 221.7);
40
+ --reconciliation-match-bg: #edeebb;
41
+ --reconciliation-match-border: #dbdfae;
42
42
  --reconciliation-match-muted-bg: rgba(239, 246, 255, 0.6);
43
- --reconciliation-selected-bg: #eff6ff;
44
- --reconciliation-selected-hover: #dbeafe;
43
+ --reconciliation-selected-bg: #96c4b1;
44
+ --reconciliation-selected-hover: #c2f5db;
45
45
  --radius: 0.625rem;
46
46
  --sidebar: oklch(0.985 0 0);
47
47
  --sidebar-foreground: oklch(0.145 0 0);
@@ -62,33 +62,33 @@ export default function ProtectedLayout() {
62
62
  );
63
63
 
64
64
  const sidebarContent = (
65
- <>
66
- <div className="p-4 flex items-center min-h-[7rem]">
65
+ <div className="flex flex-col h-full min-h-0">
66
+ <div className="p-4 flex items-center min-h-[7rem] shrink-0">
67
67
  {logoUrl ? (
68
68
  <div className="rounded-lg bg-white p-2 w-full h-20 flex items-center justify-center">
69
69
  <img src={logoUrl} alt="" className="max-w-full max-h-full w-auto h-auto object-contain" />
70
70
  </div>
71
71
  ) : null}
72
72
  </div>
73
- <nav className="flex-1 px-2 py-4 space-y-1">
73
+ <nav className="flex-1 px-2 py-4 space-y-1 overflow-y-auto min-h-0">
74
74
  {navLinks}
75
75
  </nav>
76
- <div className="p-4 border-t border-slate-800">
76
+ <div className="p-4 border-t border-slate-800 shrink-0 mt-auto">
77
77
  <div className="flex items-center gap-3 mb-4">
78
78
  <div className="w-8 h-8 rounded-full bg-slate-700 flex items-center justify-center">
79
79
  <Users size={16} />
80
80
  </div>
81
- <div className="text-sm">
82
- <div className="font-medium text-white">{user.name || 'User'}</div>
81
+ <div className="text-sm min-w-0">
82
+ <div className="font-medium text-white truncate">{user.name || 'User'}</div>
83
83
  <div className="text-xs text-slate-400 capitalize">{user.role}</div>
84
84
  </div>
85
85
  </div>
86
86
  <Button variant="outline" className="w-full justify-start text-slate-800 bg-white" onClick={() => { setMobileNavOpen(false); logout(); }}>
87
- <LogOut className="mr-2 h-4 w-4" />
87
+ <LogOut className="mr-2 h-4 w-4 shrink-0" />
88
88
  Log out
89
89
  </Button>
90
90
  </div>
91
- </>
91
+ </div>
92
92
  );
93
93
 
94
94
  return (
@@ -113,8 +113,8 @@ export default function ProtectedLayout() {
113
113
  ) : null}
114
114
  </header>
115
115
 
116
- {/* Desktop: sidebar */}
117
- <aside className="hidden md:flex w-64 bg-slate-900 text-slate-300 flex-col shrink-0">
116
+ {/* Desktop: sidebar - user and logout pinned to bottom */}
117
+ <aside className="hidden md:flex w-64 bg-slate-900 text-slate-300 flex-col shrink-0 h-screen sticky top-0">
118
118
  {sidebarContent}
119
119
  </aside>
120
120
 
@@ -0,0 +1,483 @@
1
+ import { useState } from 'react';
2
+ import { useParams, Link } from 'react-router-dom';
3
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
+ import { Input } from '@/components/ui/input';
9
+ import { Label } from '@/components/ui/label';
10
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
11
+ import { Skeleton } from '@/components/ui/skeleton';
12
+ import { api } from '@/lib/api';
13
+ import { formatYyMmDd } from '@/lib/date';
14
+ import { ArrowLeft, Plus, Pencil, Trash2 } from 'lucide-react';
15
+ import { toast } from 'sonner';
16
+
17
+ const currentYear = new Date().getFullYear();
18
+ const YEARS = Array.from({ length: 10 }, (_, i) => currentYear - 5 + i);
19
+
20
+ export default function AccountLedger() {
21
+ const { id } = useParams<{ id: string }>();
22
+ const queryClient = useQueryClient();
23
+ const [year, setYear] = useState<string>('all');
24
+ const [quarter, setQuarter] = useState<string>('all');
25
+ const [month, setMonth] = useState<string>('all');
26
+ const [ytd, setYtd] = useState(false);
27
+ const [newTxOpen, setNewTxOpen] = useState(false);
28
+ const [newTxDirection, setNewTxDirection] = useState<'pay_to' | 'pay_from'>('pay_to');
29
+ const [newTxCounterAccountId, setNewTxCounterAccountId] = useState('');
30
+ const [newTxAmount, setNewTxAmount] = useState('');
31
+ const [newTxDate, setNewTxDate] = useState(() => new Date().toISOString().slice(0, 10));
32
+ const [newTxDescription, setNewTxDescription] = useState('');
33
+ const [editEntryId, setEditEntryId] = useState<string | null>(null);
34
+ const [editDate, setEditDate] = useState('');
35
+ const [editDescription, setEditDescription] = useState('');
36
+ const [deleteEntryId, setDeleteEntryId] = useState<string | null>(null);
37
+
38
+ const { data: account, isLoading: accountLoading } = useQuery({
39
+ queryKey: ['account', id],
40
+ queryFn: async () => {
41
+ const { data } = await api.get(`/accounts/${id}`);
42
+ return data;
43
+ },
44
+ enabled: !!id,
45
+ });
46
+
47
+ const ledgerParams = new URLSearchParams();
48
+ if (year && year !== 'all') ledgerParams.set('year', year);
49
+ if (quarter && quarter !== 'all') ledgerParams.set('quarter', quarter);
50
+ if (month && month !== 'all') ledgerParams.set('month', month);
51
+ if (ytd && year === String(currentYear)) ledgerParams.set('ytd', '1');
52
+
53
+ const { data: ledgerData, isLoading: ledgerLoading } = useQuery({
54
+ queryKey: ['account-ledger', id, year, quarter, month, ytd],
55
+ queryFn: async () => {
56
+ const { data } = await api.get(`/accounts/${id}/ledger?${ledgerParams.toString()}`);
57
+ return data;
58
+ },
59
+ enabled: !!id,
60
+ });
61
+
62
+ const { data: accounts } = useQuery({
63
+ queryKey: ['accounts', 'all'],
64
+ queryFn: async () => {
65
+ const { data } = await api.get('/accounts/all');
66
+ return data;
67
+ },
68
+ });
69
+
70
+ const createManualMutation = useMutation({
71
+ mutationFn: async (body: {
72
+ accountId: string;
73
+ counterAccountId: string;
74
+ amount: number;
75
+ date: string;
76
+ description?: string;
77
+ direction: 'pay_to' | 'pay_from';
78
+ }) => {
79
+ const { data } = await api.post('/journal/manual', body);
80
+ return data;
81
+ },
82
+ onSuccess: () => {
83
+ queryClient.invalidateQueries({ queryKey: ['account-ledger', id] });
84
+ setNewTxOpen(false);
85
+ setNewTxAmount('');
86
+ setNewTxDescription('');
87
+ toast.success('Transaction created');
88
+ },
89
+ onError: (err: any) => {
90
+ toast.error(err.response?.data?.error || 'Failed to create transaction');
91
+ },
92
+ });
93
+
94
+ const updateEntryMutation = useMutation({
95
+ mutationFn: async ({ entryId, date, description }: { entryId: string; date: string; description: string }) => {
96
+ await api.patch(`/journal/entries/${entryId}`, { date, description });
97
+ },
98
+ onSuccess: () => {
99
+ queryClient.invalidateQueries({ queryKey: ['account-ledger', id] });
100
+ setEditEntryId(null);
101
+ toast.success('Transaction updated');
102
+ },
103
+ onError: (err: any) => {
104
+ toast.error(err.response?.data?.error || 'Failed to update transaction');
105
+ },
106
+ });
107
+
108
+ const deleteEntryMutation = useMutation({
109
+ mutationFn: async (entryId: string) => {
110
+ await api.delete(`/journal/entries/${entryId}`);
111
+ },
112
+ onSuccess: () => {
113
+ queryClient.invalidateQueries({ queryKey: ['account-ledger', id] });
114
+ setDeleteEntryId(null);
115
+ toast.success('Transaction deleted');
116
+ },
117
+ onError: (err: any) => {
118
+ toast.error(err.response?.data?.error || 'Failed to delete transaction');
119
+ },
120
+ });
121
+
122
+ const lines = ledgerData?.lines || [];
123
+ const periodSum = Number(ledgerData?.periodSum ?? 0);
124
+ let runningBalance = 0;
125
+ const rows = lines.map((line: any) => {
126
+ const debit = line.debitAmount || 0;
127
+ const credit = line.creditAmount || 0;
128
+ runningBalance += debit - credit;
129
+ return { ...line, runningBalance };
130
+ });
131
+
132
+ const otherAccounts = (accounts || []).filter((a: any) => String(a.id) !== String(id));
133
+
134
+ const handleCreateTransaction = (e: React.FormEvent) => {
135
+ e.preventDefault();
136
+ const amt = parseFloat(newTxAmount);
137
+ if (!id || !newTxCounterAccountId || Number.isNaN(amt) || amt <= 0 || !newTxDate) {
138
+ toast.error('Fill in amount, date and counter account');
139
+ return;
140
+ }
141
+ createManualMutation.mutate({
142
+ accountId: id,
143
+ counterAccountId: newTxCounterAccountId,
144
+ amount: amt,
145
+ date: newTxDate,
146
+ description: newTxDescription.trim() || undefined,
147
+ direction: newTxDirection,
148
+ });
149
+ };
150
+
151
+ const handleOpenEdit = (row: { journalEntryId: string; date: string; description?: string | null }) => {
152
+ setEditEntryId(row.journalEntryId);
153
+ setEditDate(String(row.date).slice(0, 10));
154
+ setEditDescription(row.description || '');
155
+ };
156
+
157
+ const handleSaveEdit = (e: React.FormEvent) => {
158
+ e.preventDefault();
159
+ if (!editEntryId || !editDate) return;
160
+ updateEntryMutation.mutate({
161
+ entryId: editEntryId,
162
+ date: editDate,
163
+ description: editDescription.trim(),
164
+ });
165
+ };
166
+
167
+ const handleDeleteConfirm = () => {
168
+ if (deleteEntryId) deleteEntryMutation.mutate(deleteEntryId);
169
+ };
170
+
171
+ if (accountLoading || !id) {
172
+ return (
173
+ <div className="space-y-4">
174
+ <Skeleton className="h-8 w-48" />
175
+ <Skeleton className="h-64 w-full" />
176
+ </div>
177
+ );
178
+ }
179
+
180
+ if (!account) {
181
+ return (
182
+ <div>
183
+ <p className="text-muted-foreground">Account not found.</p>
184
+ <Link to="/settings"><Button variant="link" type="button">Back to Settings</Button></Link>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ return (
190
+ <div className="space-y-6">
191
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
192
+ <div className="flex items-center gap-3">
193
+ <Link to="/settings">
194
+ <Button variant="ghost" size="icon" type="button">
195
+ <ArrowLeft className="w-4 h-4" />
196
+ </Button>
197
+ </Link>
198
+ <div>
199
+ <h2 className="text-2xl font-bold tracking-tight">
200
+ {account.name} ({account.code})
201
+ </h2>
202
+ <p className="text-sm text-muted-foreground">{account.accountGroupName}</p>
203
+ </div>
204
+ </div>
205
+ <Button onClick={() => setNewTxOpen(true)}>
206
+ <Plus className="w-4 h-4 mr-2" /> New transaction
207
+ </Button>
208
+ </div>
209
+
210
+ <Card>
211
+ <CardHeader className="flex flex-row flex-wrap items-center justify-between gap-4">
212
+ <div className="space-y-1">
213
+ <CardTitle>Ledger</CardTitle>
214
+ <p className="text-sm text-muted-foreground">Fiscal periods: year from 1 Jan; use Year to date for current year (1 Jan to today).</p>
215
+ </div>
216
+ <div className="flex flex-wrap items-center gap-2">
217
+ <Select value={year} onValueChange={(v) => setYear(v ?? 'all')}>
218
+ <SelectTrigger className="w-24">
219
+ <SelectValue placeholder="Year" />
220
+ </SelectTrigger>
221
+ <SelectContent>
222
+ <SelectItem value="all">All (historic)</SelectItem>
223
+ {YEARS.map((y) => (
224
+ <SelectItem key={y} value={String(y)}>{y}</SelectItem>
225
+ ))}
226
+ </SelectContent>
227
+ </Select>
228
+ {year && year !== 'all' && year === String(currentYear) && (
229
+ <label className="flex items-center gap-2 text-sm whitespace-nowrap">
230
+ <input
231
+ type="checkbox"
232
+ checked={ytd}
233
+ onChange={(e) => setYtd(e.target.checked)}
234
+ className="rounded border-input"
235
+ />
236
+ Year to date
237
+ </label>
238
+ )}
239
+ {year && year !== 'all' && (
240
+ <>
241
+ <Select value={quarter} onValueChange={(v) => setQuarter(v ?? 'all')}>
242
+ <SelectTrigger className="w-28">
243
+ <SelectValue placeholder="Quarter" />
244
+ </SelectTrigger>
245
+ <SelectContent>
246
+ <SelectItem value="all">All</SelectItem>
247
+ {[1, 2, 3, 4].map((q) => (
248
+ <SelectItem key={q} value={String(q)}>Q{q}</SelectItem>
249
+ ))}
250
+ </SelectContent>
251
+ </Select>
252
+ <Select value={month} onValueChange={(v) => setMonth(v ?? 'all')}>
253
+ <SelectTrigger className="w-28">
254
+ <SelectValue placeholder="Month" />
255
+ </SelectTrigger>
256
+ <SelectContent>
257
+ <SelectItem value="all">All</SelectItem>
258
+ {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
259
+ <SelectItem key={m} value={String(m)}>{new Date(2000, m - 1, 1).toLocaleString('default', { month: 'short' })}</SelectItem>
260
+ ))}
261
+ </SelectContent>
262
+ </Select>
263
+ </>
264
+ )}
265
+ </div>
266
+ </CardHeader>
267
+ <CardContent>
268
+ {ledgerLoading ? (
269
+ <Skeleton className="h-48 w-full" />
270
+ ) : (
271
+ <>
272
+ {rows.length > 0 && (
273
+ <p className="text-sm font-medium mb-2">
274
+ Period total: €{periodSum.toFixed(2)}
275
+ </p>
276
+ )}
277
+ <Table>
278
+ <TableHeader>
279
+ <TableRow>
280
+ <TableHead className="w-28">Date</TableHead>
281
+ <TableHead>Description</TableHead>
282
+ <TableHead className="w-28 text-right">Debit</TableHead>
283
+ <TableHead className="w-28 text-right">Credit</TableHead>
284
+ <TableHead className="w-28 text-right">Balance</TableHead>
285
+ <TableHead className="w-24">Actions</TableHead>
286
+ </TableRow>
287
+ </TableHeader>
288
+ <TableBody>
289
+ {rows.length === 0 ? (
290
+ <TableRow>
291
+ <TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
292
+ No transactions in this period.
293
+ </TableCell>
294
+ </TableRow>
295
+ ) : (
296
+ rows.map((row: any) => (
297
+ <TableRow key={row.lineId}>
298
+ <TableCell className="whitespace-nowrap">{formatYyMmDd(row.date)}</TableCell>
299
+ <TableCell className="min-w-0 max-w-xs truncate" title={row.description || undefined}>
300
+ {row.description || '\u2014'}
301
+ </TableCell>
302
+ <TableCell className="text-right font-mono">
303
+ {row.debitAmount > 0 ? '€' + row.debitAmount.toFixed(2) : '\u2014'}
304
+ </TableCell>
305
+ <TableCell className="text-right font-mono">
306
+ {row.creditAmount > 0 ? '€' + row.creditAmount.toFixed(2) : '\u2014'}
307
+ </TableCell>
308
+ <TableCell className="text-right font-mono">
309
+ €{row.runningBalance.toFixed(2)}
310
+ </TableCell>
311
+ <TableCell className="w-24">
312
+ {row.sourceRefType === 'manual' ? (
313
+ <div className="flex items-center gap-1">
314
+ <Button
315
+ type="button"
316
+ variant="ghost"
317
+ size="icon"
318
+ className="h-8 w-8"
319
+ onClick={() => handleOpenEdit(row)}
320
+ title="Edit"
321
+ >
322
+ <Pencil className="h-4 w-4" />
323
+ </Button>
324
+ <Button
325
+ type="button"
326
+ variant="ghost"
327
+ size="icon"
328
+ className="h-8 w-8 text-destructive hover:text-destructive"
329
+ onClick={() => setDeleteEntryId(row.journalEntryId)}
330
+ title="Delete"
331
+ >
332
+ <Trash2 className="h-4 w-4" />
333
+ </Button>
334
+ </div>
335
+ ) : (
336
+ '\u2014'
337
+ )}
338
+ </TableCell>
339
+ </TableRow>
340
+ ))
341
+ )}
342
+ </TableBody>
343
+ </Table>
344
+ </>
345
+ )}
346
+ </CardContent>
347
+ </Card>
348
+
349
+ <Dialog open={newTxOpen} onOpenChange={setNewTxOpen}>
350
+ <DialogContent>
351
+ <DialogHeader>
352
+ <DialogTitle>New transaction</DialogTitle>
353
+ </DialogHeader>
354
+ <form onSubmit={handleCreateTransaction} className="space-y-4">
355
+ <div>
356
+ <Label>Direction</Label>
357
+ <Select value={newTxDirection} onValueChange={(v) => v && setNewTxDirection(v as 'pay_to' | 'pay_from')}>
358
+ <SelectTrigger>
359
+ <SelectValue />
360
+ </SelectTrigger>
361
+ <SelectContent>
362
+ <SelectItem value="pay_to">Pay to this account</SelectItem>
363
+ <SelectItem value="pay_from">Pay from this account</SelectItem>
364
+ </SelectContent>
365
+ </Select>
366
+ </div>
367
+ <div>
368
+ <Label>Counter account *</Label>
369
+ <Select value={newTxCounterAccountId} onValueChange={(v) => setNewTxCounterAccountId(v ?? '')} required>
370
+ <SelectTrigger>
371
+ <SelectValue placeholder="Select account">
372
+ {newTxCounterAccountId
373
+ ? (() => {
374
+ const sel = otherAccounts.find((a: any) => String(a.id) === String(newTxCounterAccountId));
375
+ return sel ? `${sel.code} ${sel.name}` : null;
376
+ })()
377
+ : null}
378
+ </SelectValue>
379
+ </SelectTrigger>
380
+ <SelectContent>
381
+ {otherAccounts.map((a: any) => (
382
+ <SelectItem key={a.id} value={a.id}>
383
+ {a.code} {a.name}
384
+ </SelectItem>
385
+ ))}
386
+ </SelectContent>
387
+ </Select>
388
+ </div>
389
+ <div>
390
+ <Label>Amount *</Label>
391
+ <Input
392
+ type="number"
393
+ step="0.01"
394
+ min="0.01"
395
+ value={newTxAmount}
396
+ onChange={(e) => setNewTxAmount(e.target.value)}
397
+ required
398
+ />
399
+ </div>
400
+ <div>
401
+ <Label>Date *</Label>
402
+ <Input type="date" value={newTxDate} onChange={(e) => setNewTxDate(e.target.value)} required />
403
+ </div>
404
+ <div>
405
+ <Label>Description</Label>
406
+ <Input
407
+ value={newTxDescription}
408
+ onChange={(e) => setNewTxDescription(e.target.value)}
409
+ placeholder="Optional"
410
+ />
411
+ </div>
412
+ <div className="flex justify-end gap-2">
413
+ <Button type="button" variant="outline" onClick={() => setNewTxOpen(false)}>
414
+ Cancel
415
+ </Button>
416
+ <Button type="submit" disabled={createManualMutation.isPending}>
417
+ Create
418
+ </Button>
419
+ </div>
420
+ </form>
421
+ </DialogContent>
422
+ </Dialog>
423
+
424
+ <Dialog open={!!editEntryId} onOpenChange={(open) => !open && setEditEntryId(null)}>
425
+ <DialogContent>
426
+ <DialogHeader>
427
+ <DialogTitle>Edit transaction</DialogTitle>
428
+ </DialogHeader>
429
+ <form onSubmit={handleSaveEdit} className="space-y-4">
430
+ <div>
431
+ <Label>Date *</Label>
432
+ <Input
433
+ type="date"
434
+ value={editDate}
435
+ onChange={(e) => setEditDate(e.target.value)}
436
+ required
437
+ />
438
+ </div>
439
+ <div>
440
+ <Label>Description</Label>
441
+ <Input
442
+ value={editDescription}
443
+ onChange={(e) => setEditDescription(e.target.value)}
444
+ placeholder="Optional"
445
+ />
446
+ </div>
447
+ <div className="flex justify-end gap-2">
448
+ <Button type="button" variant="outline" onClick={() => setEditEntryId(null)}>
449
+ Cancel
450
+ </Button>
451
+ <Button type="submit" disabled={updateEntryMutation.isPending}>
452
+ Save
453
+ </Button>
454
+ </div>
455
+ </form>
456
+ </DialogContent>
457
+ </Dialog>
458
+
459
+ <Dialog open={!!deleteEntryId} onOpenChange={(open: boolean) => !open && setDeleteEntryId(null)}>
460
+ <DialogContent>
461
+ <DialogHeader>
462
+ <DialogTitle>Delete transaction</DialogTitle>
463
+ <p className="text-sm text-muted-foreground">
464
+ This will permanently delete this manual journal entry. This action cannot be undone.
465
+ </p>
466
+ </DialogHeader>
467
+ <div className="flex justify-end gap-2 pt-2">
468
+ <Button type="button" variant="outline" onClick={() => setDeleteEntryId(null)}>
469
+ Cancel
470
+ </Button>
471
+ <Button
472
+ type="button"
473
+ variant="destructive"
474
+ onClick={handleDeleteConfirm}
475
+ >
476
+ Delete
477
+ </Button>
478
+ </div>
479
+ </DialogContent>
480
+ </Dialog>
481
+ </div>
482
+ );
483
+ }
@@ -695,7 +695,10 @@ export default function Expenses() {
695
695
  {batchStep === 1 && (
696
696
  <div className="space-y-4 py-2">
697
697
  <p className="text-sm text-muted-foreground">
698
- Select invoice files from your computer (PDF, JPG, PNG, WebP, GIF). They will be copied temporarily and processed with AI extraction.
698
+ Select invoice files from your computer (PDF, JPG, PNG, WebP, GIF). Max 1MB per file. They will be copied temporarily and processed with AI extraction.
699
+ </p>
700
+ <p className="text-xs text-muted-foreground">
701
+ If upload fails with many files (413), the web server in front of this app often has a 1MB total request limit. An administrator must raise it to at least 35M for this domain (nginx: client_max_body_size 35M; Apache: LimitRequestBody 36700160).
699
702
  </p>
700
703
  <div className="flex flex-col gap-2">
701
704
  <Input
@@ -724,7 +727,12 @@ export default function Expenses() {
724
727
  setSupplierMappings(initial);
725
728
  setBatchStep(2);
726
729
  } catch (err: any) {
727
- setBatchUploadError(err.response?.data?.error || err.message || 'Upload failed');
730
+ const status = err.response?.status;
731
+ if (status === 413) {
732
+ setBatchUploadError('Request too large. The web server (proxy) in front of this app is rejecting the request—it often has a 1MB total limit, so many small files can trigger this. An administrator must raise the limit for this domain: nginx use client_max_body_size 35M; Apache use LimitRequestBody 36700160. Then try again.');
733
+ } else {
734
+ setBatchUploadError(err.response?.data?.error || err.message || 'Upload failed');
735
+ }
728
736
  } finally {
729
737
  setBatchUploading(false);
730
738
  }
@@ -748,7 +756,7 @@ export default function Expenses() {
748
756
  <div key={vendor} className="flex flex-wrap items-end gap-2 p-2 border rounded">
749
757
  <span className="font-medium w-full text-sm text-muted-foreground">{vendor}</span>
750
758
  <Select
751
- value={supplierMappings[vendor]?.type === 'existing' ? (supplierMappings[vendor] as { type: 'existing'; supplierId: string }).supplierId : 'new'}
759
+ value={supplierMappings[vendor]?.type === 'existing' ? String((supplierMappings[vendor] as { type: 'existing'; supplierId: string }).supplierId) : 'new'}
752
760
  onValueChange={(val) => {
753
761
  if (val === 'new' || !val) {
754
762
  setSupplierMappings((m) => ({ ...m, [vendor]: { type: 'new', name: vendor, categoryId: null } }));
@@ -763,7 +771,7 @@ export default function Expenses() {
763
771
  <SelectContent>
764
772
  <SelectItem value="new">Create new supplier</SelectItem>
765
773
  {(suppliers || []).map((s) => (
766
- <SelectItem key={s.id} value={s.id}>
774
+ <SelectItem key={String(s.id)} value={String(s.id)}>
767
775
  {s.name}
768
776
  {s.categoryName ? ` (${s.categoryName})` : ''}
769
777
  </SelectItem>