@apptimate/ui 1.9.0 → 2.1.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}
|
|
@@ -135,7 +135,7 @@ export function DashboardLayout({
|
|
|
135
135
|
size: 22,
|
|
136
136
|
className: isActive ? "stroke-[2.5]" : "stroke-[2]"
|
|
137
137
|
})}
|
|
138
|
-
<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>
|
|
139
139
|
</div>
|
|
140
140
|
);
|
|
141
141
|
})}
|
|
@@ -9,8 +9,9 @@ import { CreditCard, Banknote, Building, Trash2, Loader2, Split, Check, AlertCir
|
|
|
9
9
|
export interface PaymentModeField {
|
|
10
10
|
key: string;
|
|
11
11
|
label: string;
|
|
12
|
-
type: "text" | "number" | "date" | "bank_account_select";
|
|
12
|
+
type: "text" | "number" | "date" | "bank_account_select" | "material_type_select";
|
|
13
13
|
required: boolean;
|
|
14
|
+
readonly?: boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface PaymentMode {
|
|
@@ -56,11 +57,15 @@ export interface PaymentSectionProps {
|
|
|
56
57
|
fetchPaymentModes: () => Promise<{ is_success: boolean; result?: any }>;
|
|
57
58
|
/** Fetcher for bank accounts */
|
|
58
59
|
fetchBankAccounts: () => Promise<{ is_success: boolean; result?: any }>;
|
|
60
|
+
/** Optional array of material types for the material_type_select field */
|
|
61
|
+
materialTypes?: any[];
|
|
62
|
+
/** Special mode codes that are allowed (e.g. 'credit-gold') */
|
|
63
|
+
allowedSpecialModes?: string[];
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/* ── Component ── */
|
|
62
67
|
|
|
63
|
-
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts }: PaymentSectionProps) {
|
|
68
|
+
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [] }: PaymentSectionProps) {
|
|
64
69
|
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
65
70
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
66
71
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -72,7 +77,9 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
72
77
|
setIsLoading(true);
|
|
73
78
|
try {
|
|
74
79
|
const [modesRes, bankRes] = await Promise.all([fetchPaymentModes(), fetchBankAccounts()]);
|
|
75
|
-
const
|
|
80
|
+
const modesRaw: PaymentMode[] = modesRes.is_success ? (modesRes.result || []) : [];
|
|
81
|
+
const SPECIAL_MODES = ["credit-gold"];
|
|
82
|
+
const modes = modesRaw.filter((m) => !SPECIAL_MODES.includes(m.code) || allowedSpecialModes.includes(m.code));
|
|
76
83
|
setPaymentModes(modes);
|
|
77
84
|
if (bankRes.is_success) setBankAccounts(bankRes.result || []);
|
|
78
85
|
|
|
@@ -92,16 +99,18 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
92
99
|
}, []);
|
|
93
100
|
|
|
94
101
|
// ── Calculations ──
|
|
95
|
-
const
|
|
102
|
+
const isCreditPayment = useCallback((payment: PaymentEntry) => payment.mode_code === "credit", []);
|
|
103
|
+
const totalPaid = useMemo(() => payments.reduce((s, p) => s + (isCreditPayment(p) ? 0 : p.amount), 0), [payments, isCreditPayment]);
|
|
104
|
+
const creditAmount = useMemo(() => payments.reduce((s, p) => s + (isCreditPayment(p) ? p.amount : 0), 0), [payments, isCreditPayment]);
|
|
96
105
|
const balance = useMemo(() => totalAmount - totalPaid, [totalAmount, totalPaid]);
|
|
97
106
|
const formatCurrency = (v: number) => v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
98
107
|
|
|
99
|
-
// ── Mode icon ──
|
|
100
108
|
const getModeIcon = useCallback((code: string, size = 14) => {
|
|
101
109
|
switch (code) {
|
|
102
110
|
case "cash": return <Banknote size={size} />;
|
|
103
111
|
case "card": return <CreditCard size={size} />;
|
|
104
112
|
case "bank_transfer": return <Building size={size} />;
|
|
113
|
+
case "credit-gold": return <AlertCircle size={size} />; // Fallback, better to use something else if available
|
|
105
114
|
default: return <CreditCard size={size} />;
|
|
106
115
|
}
|
|
107
116
|
}, []);
|
|
@@ -118,8 +127,34 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
118
127
|
const updatePayment = useCallback((index: number, updates: Partial<PaymentEntry>) => {
|
|
119
128
|
const next = [...payments];
|
|
120
129
|
next[index] = { ...next[index], ...updates };
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
selectionMode === "split" &&
|
|
133
|
+
Object.prototype.hasOwnProperty.call(updates, "amount") &&
|
|
134
|
+
!isCreditPayment(next[index]) &&
|
|
135
|
+
next.length > 1
|
|
136
|
+
) {
|
|
137
|
+
const targetIndex = next.findIndex((payment, paymentIndex) =>
|
|
138
|
+
paymentIndex !== index &&
|
|
139
|
+
!isCreditPayment(payment) &&
|
|
140
|
+
Number(payment.amount || 0) <= 0
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (targetIndex >= 0) {
|
|
144
|
+
const paidExceptTarget = next.reduce((sum, payment, paymentIndex) => {
|
|
145
|
+
if (paymentIndex === targetIndex || isCreditPayment(payment)) return sum;
|
|
146
|
+
return sum + Number(payment.amount || 0);
|
|
147
|
+
}, 0);
|
|
148
|
+
|
|
149
|
+
next[targetIndex] = {
|
|
150
|
+
...next[targetIndex],
|
|
151
|
+
amount: Math.max(0, Math.round((totalAmount - paidExceptTarget) * 100) / 100),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
onPaymentsChange(next);
|
|
122
|
-
}, [payments, onPaymentsChange]);
|
|
157
|
+
}, [payments, selectionMode, totalAmount, isCreditPayment, onPaymentsChange]);
|
|
123
158
|
|
|
124
159
|
// ── Auto-sync single mode amount when totalAmount changes ──
|
|
125
160
|
useEffect(() => {
|
|
@@ -191,7 +226,7 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
191
226
|
{/* ── Single Mode: Entry with fields (no delete) ── */}
|
|
192
227
|
{selectionMode === "single" && payments.length === 1 && (
|
|
193
228
|
<PaymentEntryRow entry={payments[0]} mode={paymentModes.find((m) => m.id === payments[0].mode_id)}
|
|
194
|
-
bankAccounts={bankAccounts} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} />
|
|
229
|
+
bankAccounts={bankAccounts} materialTypes={materialTypes} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} />
|
|
195
230
|
)}
|
|
196
231
|
|
|
197
232
|
{/* ── Split Mode: Multi-entry UI ── */}
|
|
@@ -200,14 +235,18 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
200
235
|
{payments.map((entry, idx) => {
|
|
201
236
|
const mode = paymentModes.find((m) => m.id === entry.mode_id);
|
|
202
237
|
return (
|
|
203
|
-
<PaymentEntryRow key={idx} entry={entry} mode={mode} bankAccounts={bankAccounts}
|
|
238
|
+
<PaymentEntryRow key={idx} entry={entry} mode={mode} bankAccounts={bankAccounts} materialTypes={materialTypes}
|
|
204
239
|
onUpdate={(updates) => updatePayment(idx, updates)} onRemove={() => removePayment(idx)} />
|
|
205
240
|
);
|
|
206
241
|
})}
|
|
207
242
|
{/* Add split mode buttons */}
|
|
208
243
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
209
244
|
{paymentModes
|
|
210
|
-
.filter((m) =>
|
|
245
|
+
.filter((m) => {
|
|
246
|
+
if (!m.is_active) return false;
|
|
247
|
+
const canBeMultiple = ["card", "bank_transfer", "cheque"].includes(m.code);
|
|
248
|
+
return canBeMultiple || !payments.some(p => p.mode_id === m.id);
|
|
249
|
+
})
|
|
211
250
|
.map((mode) => (
|
|
212
251
|
<button key={mode.id} onClick={() => addPayment(mode)}
|
|
213
252
|
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">
|
|
@@ -225,8 +264,11 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
225
264
|
<TotalRow label="Amount to Pay" value={formatCurrency(totalAmount)} bold />
|
|
226
265
|
<div className="border-t border-border-subtle my-2" />
|
|
227
266
|
<TotalRow label="Total Paid" value={formatCurrency(totalPaid)} />
|
|
267
|
+
{creditAmount > 0.01 && (
|
|
268
|
+
<TotalRow label="Credit Amount" value={formatCurrency(creditAmount)} className="text-amber-600" />
|
|
269
|
+
)}
|
|
228
270
|
<TotalRow
|
|
229
|
-
label={balance > 0.01 ? "Balance
|
|
271
|
+
label={balance > 0.01 ? "Outstanding Balance" : balance < -0.01 ? "Change Due" : "Settled"}
|
|
230
272
|
value={formatCurrency(Math.abs(balance))}
|
|
231
273
|
className={balance > 0.01 ? "text-danger-alt" : "text-green-600"}
|
|
232
274
|
bold
|
|
@@ -236,7 +278,7 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
236
278
|
<AlertCircle size={12} /><span>Change: {formatCurrency(Math.abs(balance))}</span>
|
|
237
279
|
</div>
|
|
238
280
|
)}
|
|
239
|
-
{Math.abs(balance) < 0.01 && (
|
|
281
|
+
{Math.abs(balance) < 0.01 && creditAmount <= 0.01 && (
|
|
240
282
|
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-green-600 font-medium">
|
|
241
283
|
<Check size={12} /><span>Fully paid</span>
|
|
242
284
|
</div>
|
|
@@ -264,12 +306,13 @@ interface PaymentEntryRowProps {
|
|
|
264
306
|
entry: PaymentEntry;
|
|
265
307
|
mode?: PaymentMode;
|
|
266
308
|
bankAccounts: BankAccount[];
|
|
309
|
+
materialTypes?: any[];
|
|
267
310
|
onUpdate: (updates: Partial<PaymentEntry>) => void;
|
|
268
311
|
onRemove: () => void;
|
|
269
312
|
showRemove?: boolean;
|
|
270
313
|
}
|
|
271
314
|
|
|
272
|
-
function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRemove = true }: PaymentEntryRowProps) {
|
|
315
|
+
function PaymentEntryRow({ entry, mode, bankAccounts, materialTypes = [], onUpdate, onRemove, showRemove = true }: PaymentEntryRowProps) {
|
|
273
316
|
const fields = mode?.field_config?.fields || [];
|
|
274
317
|
|
|
275
318
|
const getModeIcon = () => {
|
|
@@ -277,6 +320,7 @@ function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRe
|
|
|
277
320
|
case "cash": return <Banknote size={13} />;
|
|
278
321
|
case "card": return <CreditCard size={13} />;
|
|
279
322
|
case "bank_transfer": return <Building size={13} />;
|
|
323
|
+
case "credit-gold": return <AlertCircle size={13} />;
|
|
280
324
|
default: return <CreditCard size={13} />;
|
|
281
325
|
}
|
|
282
326
|
};
|
|
@@ -317,15 +361,26 @@ function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRe
|
|
|
317
361
|
{field.type === "bank_account_select" ? (
|
|
318
362
|
<select value={entry.metadata[field.key] || ""}
|
|
319
363
|
onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
|
|
320
|
-
|
|
364
|
+
disabled={field.readonly}
|
|
365
|
+
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 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
321
366
|
<option value="">Select...</option>
|
|
322
367
|
{bankAccounts.map((b) => (<option key={b.id} value={b.id}>{b.bank_name} - {b.account_number}</option>))}
|
|
323
368
|
</select>
|
|
369
|
+
) : field.type === "material_type_select" ? (
|
|
370
|
+
<select value={entry.metadata[field.key] || ""}
|
|
371
|
+
onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
|
|
372
|
+
disabled={field.readonly}
|
|
373
|
+
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 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
374
|
+
<option value="">Select Material...</option>
|
|
375
|
+
{materialTypes.map((m) => (<option key={m.id} value={m.id}>{m.name}</option>))}
|
|
376
|
+
</select>
|
|
324
377
|
) : (
|
|
325
378
|
<input type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
|
379
|
+
{...(field.type === "number" ? { step: "any" } : {})}
|
|
326
380
|
value={entry.metadata[field.key] || ""}
|
|
327
381
|
onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
|
|
328
|
-
|
|
382
|
+
readOnly={field.readonly}
|
|
383
|
+
className={cn("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", field.readonly && "opacity-60 bg-surface-0 cursor-not-allowed border-border")} />
|
|
329
384
|
)}
|
|
330
385
|
</div>
|
|
331
386
|
))}
|