@apptimate/ui 1.8.0 → 2.0.0
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/package.json
CHANGED
|
@@ -8,12 +8,39 @@ export interface DateLabelProps {
|
|
|
8
8
|
export function DateLabel({ date, className = '' }: DateLabelProps) {
|
|
9
9
|
if (!date) return <span className="text-gray-300">-</span>;
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
let isDateOnly = false;
|
|
12
|
+
let parsedDate: string | Date = date;
|
|
13
|
+
|
|
14
|
+
if (typeof date === 'string') {
|
|
15
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
16
|
+
isDateOnly = true;
|
|
17
|
+
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(date)) {
|
|
18
|
+
parsedDate = date.replace(' ', 'T') + 'Z';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let d: Date;
|
|
23
|
+
if (isDateOnly && typeof date === 'string') {
|
|
24
|
+
const [y, m, day] = date.split('-');
|
|
25
|
+
d = new Date(parseInt(y, 10), parseInt(m, 10) - 1, parseInt(day, 10));
|
|
26
|
+
} else {
|
|
27
|
+
d = new Date(parsedDate);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isNaN(d.getTime())) return <span className="text-gray-300">-</span>;
|
|
31
|
+
|
|
14
32
|
const year = d.getFullYear();
|
|
15
33
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
16
34
|
const day = String(d.getDate()).padStart(2, '0');
|
|
35
|
+
|
|
36
|
+
if (isDateOnly) {
|
|
37
|
+
return (
|
|
38
|
+
<div className={`flex flex-col ${className}`}>
|
|
39
|
+
<span className="text-gray-600 font-medium whitespace-nowrap">{year}-{month}-{day}</span>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
17
44
|
let hours = d.getHours();
|
|
18
45
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
19
46
|
const ampm = hours >= 12 ? 'PM' : 'AM';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Search } from 'lucide-react';
|
|
2
|
+
import { Search, X } from 'lucide-react';
|
|
3
3
|
import { Dropdown } from './Dropdown';
|
|
4
4
|
import { Input } from './Input';
|
|
5
5
|
import { Button } from './Button';
|
|
@@ -26,16 +26,33 @@ export const DesktopSearchPopover = ({
|
|
|
26
26
|
align="right"
|
|
27
27
|
contentClassName="w-[300px] p-2"
|
|
28
28
|
trigger={
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
searchValue ? (
|
|
30
|
+
<div className="flex items-center gap-1.5 bg-primary-50 hover:bg-primary-100 transition-colors pl-3 pr-1.5 py-1.5 rounded-full cursor-pointer border border-primary-100">
|
|
31
|
+
<Search size={14} className="text-primary-500" />
|
|
32
|
+
<span className="text-xs font-bold text-primary-700 max-w-[120px] truncate">{searchValue}</span>
|
|
33
|
+
<div
|
|
34
|
+
className="p-1 hover:bg-primary-200 rounded-full text-primary-600 transition-colors"
|
|
35
|
+
onClick={(e) => {
|
|
36
|
+
e.stopPropagation();
|
|
37
|
+
onSearchChange("");
|
|
38
|
+
}}
|
|
39
|
+
title="Clear search"
|
|
40
|
+
>
|
|
41
|
+
<X size={14} />
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
) : (
|
|
45
|
+
<Button
|
|
46
|
+
variant="soft"
|
|
47
|
+
color="default"
|
|
48
|
+
className={cn("!p-0 text-gray-500", triggerClassName)}
|
|
49
|
+
icon={<Search size={18} strokeWidth={2} />}
|
|
50
|
+
ariaLabel="Search"
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
36
53
|
}
|
|
37
54
|
>
|
|
38
|
-
<div className="p-1">
|
|
55
|
+
<div className="p-1" onClick={(e) => e.stopPropagation()}>
|
|
39
56
|
<Input
|
|
40
57
|
autoFocus
|
|
41
58
|
placeholder={placeholder}
|
|
@@ -65,16 +65,31 @@ export function DashboardLayout({
|
|
|
65
65
|
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
|
66
66
|
const pathname = usePathname() || "";
|
|
67
67
|
|
|
68
|
-
// Find which main menu should be active based on current path
|
|
68
|
+
// Find which main menu should be active based on current path.
|
|
69
|
+
// When basePath is set, prefer the menu whose items are mostly within basePath.
|
|
69
70
|
const fullPath = basePath + (pathname === "/" && basePath ? "" : pathname);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
const currentMainMenu = (() => {
|
|
72
|
+
let bestMenu = menus[0];
|
|
73
|
+
let bestScore = -1;
|
|
74
|
+
let bestInternalCount = -1;
|
|
75
|
+
for (const menu of menus) {
|
|
76
|
+
for (const group of menu.groups) {
|
|
77
|
+
for (const item of group.items) {
|
|
78
|
+
if (fullPath === item.path || fullPath.startsWith(item.path + "/")) {
|
|
79
|
+
const internalCount = basePath
|
|
80
|
+
? menu.groups.reduce((sum, g) => sum + g.items.filter(i => i.path.startsWith(basePath)).length, 0)
|
|
81
|
+
: 0;
|
|
82
|
+
if (item.path.length > bestScore || (item.path.length === bestScore && internalCount > bestInternalCount)) {
|
|
83
|
+
bestScore = item.path.length;
|
|
84
|
+
bestInternalCount = internalCount;
|
|
85
|
+
bestMenu = menu;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return bestMenu;
|
|
92
|
+
})();
|
|
78
93
|
|
|
79
94
|
const [activeMenuId, setActiveMenuId] = useState(currentMainMenu?.id || "");
|
|
80
95
|
|
|
@@ -120,7 +135,7 @@ export function DashboardLayout({
|
|
|
120
135
|
size: 22,
|
|
121
136
|
className: isActive ? "stroke-[2.5]" : "stroke-[2]"
|
|
122
137
|
})}
|
|
123
|
-
<span className={`text-[11px] ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
|
|
138
|
+
<span className={`text-[11px] text-center leading-tight px-1 ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
|
|
124
139
|
</div>
|
|
125
140
|
);
|
|
126
141
|
})}
|
|
@@ -158,7 +173,7 @@ export function DashboardLayout({
|
|
|
158
173
|
|
|
159
174
|
{/* Desktop Secondary Sidebar */}
|
|
160
175
|
{activeMenu && activeMenu.groups.length > 0 && (
|
|
161
|
-
<aside className="hidden md:flex w-[
|
|
176
|
+
<aside className="hidden md:flex w-[210px] bg-white border-gray-200 flex-col py-8 shrink-0 z-10">
|
|
162
177
|
<h2 className="px-6 text-xl font-bold text-[#2D3142]/80 mb-6">{activeMenu.label}</h2>
|
|
163
178
|
<div className="flex-1 overflow-y-auto px-4 space-y-6">
|
|
164
179
|
{activeMenu.groups.map((group) => (
|
|
@@ -1,335 +1,371 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
4
|
-
import { cn } from "@apptimate/core-lib";
|
|
5
|
-
import { CreditCard, Banknote, Building, Trash2, Loader2, Split, Check, AlertCircle } from "lucide-react";
|
|
6
|
-
|
|
7
|
-
/* ── Types (mirroring sales POS types) ── */
|
|
8
|
-
|
|
9
|
-
export interface PaymentModeField {
|
|
10
|
-
key: string;
|
|
11
|
-
label: string;
|
|
12
|
-
type: "text" | "number" | "date" | "bank_account_select";
|
|
13
|
-
required: boolean;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface PaymentMode {
|
|
17
|
-
id: number;
|
|
18
|
-
code: string;
|
|
19
|
-
name: string;
|
|
20
|
-
is_active: boolean;
|
|
21
|
-
requires_reference: boolean;
|
|
22
|
-
field_config: { fields: PaymentModeField[] };
|
|
23
|
-
sort_order: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface PaymentEntry {
|
|
27
|
-
mode_id: number;
|
|
28
|
-
mode_code: string;
|
|
29
|
-
mode_name: string;
|
|
30
|
-
amount: number;
|
|
31
|
-
metadata: Record<string, string | number>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface BankAccount {
|
|
35
|
-
id: number;
|
|
36
|
-
account_name: string;
|
|
37
|
-
bank_name: string;
|
|
38
|
-
branch?: string;
|
|
39
|
-
account_number: string;
|
|
40
|
-
account_type: string;
|
|
41
|
-
currency: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/* ── Props ── */
|
|
45
|
-
|
|
46
|
-
export interface PaymentSectionProps {
|
|
47
|
-
/** The total amount to collect */
|
|
48
|
-
totalAmount: number;
|
|
49
|
-
/** Current payment entries */
|
|
50
|
-
payments: PaymentEntry[];
|
|
51
|
-
/** Callback when payments change */
|
|
52
|
-
onPaymentsChange: (payments: PaymentEntry[]) => void;
|
|
53
|
-
/** Optional: show compact variant */
|
|
54
|
-
compact?: boolean;
|
|
55
|
-
/** Fetcher for payment modes */
|
|
56
|
-
fetchPaymentModes: () => Promise<{ is_success: boolean; result?: any }>;
|
|
57
|
-
/** Fetcher for bank accounts */
|
|
58
|
-
fetchBankAccounts: () => Promise<{ is_success: boolean; result?: any }>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/* ── Component ── */
|
|
62
|
-
|
|
63
|
-
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts }: PaymentSectionProps) {
|
|
64
|
-
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
65
|
-
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
66
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
67
|
-
const [selectionMode, setSelectionMode] = useState<"single" | "split">("single");
|
|
68
|
-
|
|
69
|
-
// ── Load payment modes on mount ──
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
const load = async () => {
|
|
72
|
-
setIsLoading(true);
|
|
73
|
-
try {
|
|
74
|
-
const [modesRes, bankRes] = await Promise.all([fetchPaymentModes(), fetchBankAccounts()]);
|
|
75
|
-
const modes: PaymentMode[] = modesRes.is_success ? (modesRes.result || []) : [];
|
|
76
|
-
setPaymentModes(modes);
|
|
77
|
-
if (bankRes.is_success) setBankAccounts(bankRes.result || []);
|
|
78
|
-
|
|
79
|
-
// Auto-select cash if no payments yet
|
|
80
|
-
if (payments.length === 0 && modes.length > 0) {
|
|
81
|
-
const cashMode = modes.find((m) => m.code === "cash" && m.is_active);
|
|
82
|
-
if (cashMode) {
|
|
83
|
-
onPaymentsChange([{
|
|
84
|
-
mode_id: cashMode.id, mode_code: cashMode.code, mode_name: cashMode.name,
|
|
85
|
-
amount: totalAmount, metadata: {},
|
|
86
|
-
}]);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
} finally { setIsLoading(false); }
|
|
90
|
-
};
|
|
91
|
-
load();
|
|
92
|
-
}, []);
|
|
93
|
-
|
|
94
|
-
// ── Calculations ──
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
case "
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
);
|
|
205
|
-
})}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
)}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
4
|
+
import { cn } from "@apptimate/core-lib";
|
|
5
|
+
import { CreditCard, Banknote, Building, Trash2, Loader2, Split, Check, AlertCircle } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
/* ── Types (mirroring sales POS types) ── */
|
|
8
|
+
|
|
9
|
+
export interface PaymentModeField {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
type: "text" | "number" | "date" | "bank_account_select";
|
|
13
|
+
required: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PaymentMode {
|
|
17
|
+
id: number;
|
|
18
|
+
code: string;
|
|
19
|
+
name: string;
|
|
20
|
+
is_active: boolean;
|
|
21
|
+
requires_reference: boolean;
|
|
22
|
+
field_config: { fields: PaymentModeField[] };
|
|
23
|
+
sort_order: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PaymentEntry {
|
|
27
|
+
mode_id: number;
|
|
28
|
+
mode_code: string;
|
|
29
|
+
mode_name: string;
|
|
30
|
+
amount: number;
|
|
31
|
+
metadata: Record<string, string | number>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BankAccount {
|
|
35
|
+
id: number;
|
|
36
|
+
account_name: string;
|
|
37
|
+
bank_name: string;
|
|
38
|
+
branch?: string;
|
|
39
|
+
account_number: string;
|
|
40
|
+
account_type: string;
|
|
41
|
+
currency: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ── Props ── */
|
|
45
|
+
|
|
46
|
+
export interface PaymentSectionProps {
|
|
47
|
+
/** The total amount to collect */
|
|
48
|
+
totalAmount: number;
|
|
49
|
+
/** Current payment entries */
|
|
50
|
+
payments: PaymentEntry[];
|
|
51
|
+
/** Callback when payments change */
|
|
52
|
+
onPaymentsChange: (payments: PaymentEntry[]) => void;
|
|
53
|
+
/** Optional: show compact variant */
|
|
54
|
+
compact?: boolean;
|
|
55
|
+
/** Fetcher for payment modes */
|
|
56
|
+
fetchPaymentModes: () => Promise<{ is_success: boolean; result?: any }>;
|
|
57
|
+
/** Fetcher for bank accounts */
|
|
58
|
+
fetchBankAccounts: () => Promise<{ is_success: boolean; result?: any }>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* ── Component ── */
|
|
62
|
+
|
|
63
|
+
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts }: PaymentSectionProps) {
|
|
64
|
+
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
65
|
+
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
66
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
67
|
+
const [selectionMode, setSelectionMode] = useState<"single" | "split">("single");
|
|
68
|
+
|
|
69
|
+
// ── Load payment modes on mount ──
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const load = async () => {
|
|
72
|
+
setIsLoading(true);
|
|
73
|
+
try {
|
|
74
|
+
const [modesRes, bankRes] = await Promise.all([fetchPaymentModes(), fetchBankAccounts()]);
|
|
75
|
+
const modes: PaymentMode[] = modesRes.is_success ? (modesRes.result || []) : [];
|
|
76
|
+
setPaymentModes(modes);
|
|
77
|
+
if (bankRes.is_success) setBankAccounts(bankRes.result || []);
|
|
78
|
+
|
|
79
|
+
// Auto-select cash if no payments yet
|
|
80
|
+
if (payments.length === 0 && modes.length > 0) {
|
|
81
|
+
const cashMode = modes.find((m) => m.code === "cash" && m.is_active);
|
|
82
|
+
if (cashMode) {
|
|
83
|
+
onPaymentsChange([{
|
|
84
|
+
mode_id: cashMode.id, mode_code: cashMode.code, mode_name: cashMode.name,
|
|
85
|
+
amount: totalAmount, metadata: {},
|
|
86
|
+
}]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} finally { setIsLoading(false); }
|
|
90
|
+
};
|
|
91
|
+
load();
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// ── Calculations ──
|
|
95
|
+
const isCreditPayment = useCallback((payment: PaymentEntry) => payment.mode_code === "credit", []);
|
|
96
|
+
const totalPaid = useMemo(() => payments.reduce((s, p) => s + (isCreditPayment(p) ? 0 : p.amount), 0), [payments, isCreditPayment]);
|
|
97
|
+
const creditAmount = useMemo(() => payments.reduce((s, p) => s + (isCreditPayment(p) ? p.amount : 0), 0), [payments, isCreditPayment]);
|
|
98
|
+
const balance = useMemo(() => totalAmount - totalPaid, [totalAmount, totalPaid]);
|
|
99
|
+
const formatCurrency = (v: number) => v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
100
|
+
|
|
101
|
+
// ── Mode icon ──
|
|
102
|
+
const getModeIcon = useCallback((code: string, size = 14) => {
|
|
103
|
+
switch (code) {
|
|
104
|
+
case "cash": return <Banknote size={size} />;
|
|
105
|
+
case "card": return <CreditCard size={size} />;
|
|
106
|
+
case "bank_transfer": return <Building size={size} />;
|
|
107
|
+
default: return <CreditCard size={size} />;
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// ── Payment management ──
|
|
112
|
+
const selectSingleMode = useCallback((mode: PaymentMode) => {
|
|
113
|
+
setSelectionMode("single");
|
|
114
|
+
onPaymentsChange([{
|
|
115
|
+
mode_id: mode.id, mode_code: mode.code, mode_name: mode.name,
|
|
116
|
+
amount: totalAmount, metadata: {},
|
|
117
|
+
}]);
|
|
118
|
+
}, [totalAmount, onPaymentsChange]);
|
|
119
|
+
|
|
120
|
+
const updatePayment = useCallback((index: number, updates: Partial<PaymentEntry>) => {
|
|
121
|
+
const next = [...payments];
|
|
122
|
+
next[index] = { ...next[index], ...updates };
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
selectionMode === "split" &&
|
|
126
|
+
Object.prototype.hasOwnProperty.call(updates, "amount") &&
|
|
127
|
+
!isCreditPayment(next[index]) &&
|
|
128
|
+
next.length > 1
|
|
129
|
+
) {
|
|
130
|
+
const targetIndex = next.findIndex((payment, paymentIndex) =>
|
|
131
|
+
paymentIndex !== index &&
|
|
132
|
+
!isCreditPayment(payment) &&
|
|
133
|
+
Number(payment.amount || 0) <= 0
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (targetIndex >= 0) {
|
|
137
|
+
const paidExceptTarget = next.reduce((sum, payment, paymentIndex) => {
|
|
138
|
+
if (paymentIndex === targetIndex || isCreditPayment(payment)) return sum;
|
|
139
|
+
return sum + Number(payment.amount || 0);
|
|
140
|
+
}, 0);
|
|
141
|
+
|
|
142
|
+
next[targetIndex] = {
|
|
143
|
+
...next[targetIndex],
|
|
144
|
+
amount: Math.max(0, Math.round((totalAmount - paidExceptTarget) * 100) / 100),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onPaymentsChange(next);
|
|
150
|
+
}, [payments, selectionMode, totalAmount, isCreditPayment, onPaymentsChange]);
|
|
151
|
+
|
|
152
|
+
// ── Auto-sync single mode amount when totalAmount changes ──
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (selectionMode === "single" && payments.length === 1) {
|
|
155
|
+
updatePayment(0, { amount: totalAmount });
|
|
156
|
+
}
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [totalAmount, selectionMode]); // intentionally omitting `payments` to allow manual partial payments
|
|
159
|
+
|
|
160
|
+
const activateSplitMode = useCallback(() => {
|
|
161
|
+
setSelectionMode("split");
|
|
162
|
+
if (payments.length <= 1) onPaymentsChange([]);
|
|
163
|
+
}, [payments.length, onPaymentsChange]);
|
|
164
|
+
|
|
165
|
+
const addPayment = useCallback((mode: PaymentMode) => {
|
|
166
|
+
const entry: PaymentEntry = {
|
|
167
|
+
mode_id: mode.id, mode_code: mode.code, mode_name: mode.name,
|
|
168
|
+
amount: Math.max(0, balance), metadata: {},
|
|
169
|
+
};
|
|
170
|
+
onPaymentsChange([...payments, entry]);
|
|
171
|
+
}, [payments, balance, onPaymentsChange]);
|
|
172
|
+
|
|
173
|
+
const removePayment = useCallback((index: number) => {
|
|
174
|
+
onPaymentsChange(payments.filter((_, i) => i !== index));
|
|
175
|
+
}, [payments, onPaymentsChange]);
|
|
176
|
+
|
|
177
|
+
if (isLoading) {
|
|
178
|
+
return (
|
|
179
|
+
<div className="flex items-center justify-center py-4">
|
|
180
|
+
<Loader2 size={18} className="animate-spin text-primary" />
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="space-y-3">
|
|
187
|
+
{/* ── Mode Selection Buttons ── */}
|
|
188
|
+
<div className="flex flex-wrap gap-2">
|
|
189
|
+
{paymentModes
|
|
190
|
+
.filter((m) => m.is_active)
|
|
191
|
+
.map((mode) => {
|
|
192
|
+
const isActive = selectionMode === "single" && payments.length === 1 && payments[0].mode_id === mode.id;
|
|
193
|
+
return (
|
|
194
|
+
<button key={mode.id} onClick={() => selectSingleMode(mode)}
|
|
195
|
+
className={cn(
|
|
196
|
+
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
197
|
+
isActive
|
|
198
|
+
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
199
|
+
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
200
|
+
)}>
|
|
201
|
+
{getModeIcon(mode.code)}
|
|
202
|
+
{mode.name}
|
|
203
|
+
</button>
|
|
204
|
+
);
|
|
205
|
+
})}
|
|
206
|
+
{/* Split Button */}
|
|
207
|
+
<button onClick={activateSplitMode}
|
|
208
|
+
className={cn(
|
|
209
|
+
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
210
|
+
selectionMode === "split"
|
|
211
|
+
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
212
|
+
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
213
|
+
)}>
|
|
214
|
+
<Split size={14} />
|
|
215
|
+
Multiple Methods
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* ── Single Mode: Entry with fields (no delete) ── */}
|
|
220
|
+
{selectionMode === "single" && payments.length === 1 && (
|
|
221
|
+
<PaymentEntryRow entry={payments[0]} mode={paymentModes.find((m) => m.id === payments[0].mode_id)}
|
|
222
|
+
bankAccounts={bankAccounts} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} />
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{/* ── Split Mode: Multi-entry UI ── */}
|
|
226
|
+
{selectionMode === "split" && (
|
|
227
|
+
<div className="space-y-2.5">
|
|
228
|
+
{payments.map((entry, idx) => {
|
|
229
|
+
const mode = paymentModes.find((m) => m.id === entry.mode_id);
|
|
230
|
+
return (
|
|
231
|
+
<PaymentEntryRow key={idx} entry={entry} mode={mode} bankAccounts={bankAccounts}
|
|
232
|
+
onUpdate={(updates) => updatePayment(idx, updates)} onRemove={() => removePayment(idx)} />
|
|
233
|
+
);
|
|
234
|
+
})}
|
|
235
|
+
{/* Add split mode buttons */}
|
|
236
|
+
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
237
|
+
{paymentModes
|
|
238
|
+
.filter((m) => {
|
|
239
|
+
if (!m.is_active) return false;
|
|
240
|
+
const canBeMultiple = ["card", "bank_transfer", "cheque"].includes(m.code);
|
|
241
|
+
return canBeMultiple || !payments.some(p => p.mode_id === m.id);
|
|
242
|
+
})
|
|
243
|
+
.map((mode) => (
|
|
244
|
+
<button key={mode.id} onClick={() => addPayment(mode)}
|
|
245
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold rounded-[8px] bg-surface-0 border border-border-subtle text-foreground-subtle hover:border-primary/40 hover:text-primary transition-all">
|
|
246
|
+
{getModeIcon(mode.code, 12)}
|
|
247
|
+
+ {mode.name}
|
|
248
|
+
</button>
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{/* ── Totals ── */}
|
|
255
|
+
{payments.length > 0 && (
|
|
256
|
+
<div className="bg-surface-0 rounded-[12px] border border-border-subtle p-4 space-y-2">
|
|
257
|
+
<TotalRow label="Amount to Pay" value={formatCurrency(totalAmount)} bold />
|
|
258
|
+
<div className="border-t border-border-subtle my-2" />
|
|
259
|
+
<TotalRow label="Total Paid" value={formatCurrency(totalPaid)} />
|
|
260
|
+
{creditAmount > 0.01 && (
|
|
261
|
+
<TotalRow label="Credit Amount" value={formatCurrency(creditAmount)} className="text-amber-600" />
|
|
262
|
+
)}
|
|
263
|
+
<TotalRow
|
|
264
|
+
label={balance > 0.01 ? "Outstanding Balance" : balance < -0.01 ? "Change Due" : "Settled"}
|
|
265
|
+
value={formatCurrency(Math.abs(balance))}
|
|
266
|
+
className={balance > 0.01 ? "text-danger-alt" : "text-green-600"}
|
|
267
|
+
bold
|
|
268
|
+
/>
|
|
269
|
+
{balance < -0.01 && (
|
|
270
|
+
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-amber-600 font-medium">
|
|
271
|
+
<AlertCircle size={12} /><span>Change: {formatCurrency(Math.abs(balance))}</span>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
{Math.abs(balance) < 0.01 && creditAmount <= 0.01 && (
|
|
275
|
+
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-green-600 font-medium">
|
|
276
|
+
<Check size={12} /><span>Fully paid</span>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* ── TotalRow ── */
|
|
286
|
+
|
|
287
|
+
function TotalRow({ label, value, bold, className }: { label: string; value: string; bold?: boolean; className?: string }) {
|
|
288
|
+
return (
|
|
289
|
+
<div className="flex items-center justify-between">
|
|
290
|
+
<span className={cn("text-[12px]", bold ? "font-bold text-foreground-1" : "text-foreground-subtle")}>{label}</span>
|
|
291
|
+
<span className={cn("text-[12px]", bold ? "font-bold text-foreground-0 text-[14px]" : "text-foreground-1 font-medium", className)}>{value}</span>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ── PaymentEntryRow (mirrors sales POS exactly) ── */
|
|
297
|
+
|
|
298
|
+
interface PaymentEntryRowProps {
|
|
299
|
+
entry: PaymentEntry;
|
|
300
|
+
mode?: PaymentMode;
|
|
301
|
+
bankAccounts: BankAccount[];
|
|
302
|
+
onUpdate: (updates: Partial<PaymentEntry>) => void;
|
|
303
|
+
onRemove: () => void;
|
|
304
|
+
showRemove?: boolean;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRemove = true }: PaymentEntryRowProps) {
|
|
308
|
+
const fields = mode?.field_config?.fields || [];
|
|
309
|
+
|
|
310
|
+
const getModeIcon = () => {
|
|
311
|
+
switch (entry.mode_code) {
|
|
312
|
+
case "cash": return <Banknote size={13} />;
|
|
313
|
+
case "card": return <CreditCard size={13} />;
|
|
314
|
+
case "bank_transfer": return <Building size={13} />;
|
|
315
|
+
default: return <CreditCard size={13} />;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div className="bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] p-4 space-y-3">
|
|
321
|
+
{/* Header */}
|
|
322
|
+
<div className="flex items-center justify-between">
|
|
323
|
+
<div className="flex items-center gap-1.5 text-[12px] font-bold text-foreground-1">
|
|
324
|
+
{getModeIcon()}
|
|
325
|
+
{entry.mode_name}
|
|
326
|
+
</div>
|
|
327
|
+
{showRemove && (
|
|
328
|
+
<button onClick={onRemove}
|
|
329
|
+
className="w-7 h-7 rounded-[6px] flex items-center justify-center text-foreground-disabled hover:text-danger-alt hover:bg-danger-alt/10 transition-all">
|
|
330
|
+
<Trash2 size={13} />
|
|
331
|
+
</button>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{/* Amount */}
|
|
336
|
+
<div className="flex flex-col gap-1">
|
|
337
|
+
<label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider">Amount</label>
|
|
338
|
+
<input type="number" value={entry.amount || ""} onChange={(e) => onUpdate({ amount: Number(e.target.value) || 0 })}
|
|
339
|
+
className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-3 py-2.5 text-[13px] font-medium text-foreground-1 outline-none focus:border-primary/40 transition-all [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
340
|
+
step="0.01" min={0} />
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{/* Dynamic sub-fields from field_config */}
|
|
344
|
+
{fields.length > 0 && (
|
|
345
|
+
<div className="grid grid-cols-2 gap-2.5">
|
|
346
|
+
{fields.map((field: PaymentModeField) => (
|
|
347
|
+
<div key={field.key} className="flex flex-col gap-1">
|
|
348
|
+
<label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider">
|
|
349
|
+
{field.label}
|
|
350
|
+
{field.required && <span className="text-danger-alt ml-0.5">*</span>}
|
|
351
|
+
</label>
|
|
352
|
+
{field.type === "bank_account_select" ? (
|
|
353
|
+
<select value={entry.metadata[field.key] || ""}
|
|
354
|
+
onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
|
|
355
|
+
className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all">
|
|
356
|
+
<option value="">Select...</option>
|
|
357
|
+
{bankAccounts.map((b) => (<option key={b.id} value={b.id}>{b.bank_name} - {b.account_number}</option>))}
|
|
358
|
+
</select>
|
|
359
|
+
) : (
|
|
360
|
+
<input type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
|
361
|
+
value={entry.metadata[field.key] || ""}
|
|
362
|
+
onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
|
|
363
|
+
className="w-full bg-surface-1 border-[1.5px] border-border-subtle rounded-[8px] px-2.5 py-2.5 text-[12px] text-foreground-1 outline-none focus:border-primary/40 transition-all [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|