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 +2 -0
- package/frontend/src/App.tsx +2 -0
- package/frontend/src/hooks/useSettings.ts +1 -1
- package/frontend/src/index.css +4 -4
- package/frontend/src/layouts/ProtectedLayout.tsx +10 -10
- package/frontend/src/pages/AccountLedger.tsx +483 -0
- package/frontend/src/pages/Expenses.tsx +12 -4
- package/frontend/src/pages/Reconciliation.tsx +360 -61
- package/frontend/src/pages/Settings.tsx +141 -29
- package/package.json +1 -1
- package/server/README.md +10 -4
- package/server/db/init.js +15 -1
- package/server/db/schema.sql +20 -0
- package/server/index.js +4 -7
- package/server/package-lock.json +3 -31
- package/server/package.json +2 -4
- package/server/routes/accounts.js +97 -0
- package/server/routes/expenses.js +1 -1
- package/server/routes/integrations.js +19 -32
- package/server/routes/invoice-config.js +15 -0
- package/server/routes/journal.js +183 -0
- package/server/routes/public.js +25 -15
- package/server/routes/reconciliation.js +285 -8
- package/server/routes/vat-reports.js +19 -19
- package/server/services/journal-posting.js +59 -0
- package/server/routes/webhooks.js +0 -168
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;
|
package/frontend/src/App.tsx
CHANGED
|
@@ -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>
|
package/frontend/src/index.css
CHANGED
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
--chart-revenue: #42d4a6;
|
|
38
38
|
--chart-expenses: #f13f66;
|
|
39
39
|
/* Reconciliation match block */
|
|
40
|
-
--reconciliation-match-bg: #
|
|
41
|
-
--reconciliation-match-border:
|
|
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: #
|
|
44
|
-
--reconciliation-selected-hover: #
|
|
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
|
-
|
|
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>
|