@apptimate/ui 1.4.0 → 1.6.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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Modal } from './Modal';
|
|
5
|
+
|
|
6
|
+
export interface ActionModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
title?: string | React.ReactNode;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
onConfirm?: () => void;
|
|
12
|
+
confirmLabel?: string;
|
|
13
|
+
cancelLabel?: string;
|
|
14
|
+
isLoading?: boolean;
|
|
15
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
|
16
|
+
/** Variant controls the confirm button colour */
|
|
17
|
+
variant?: 'primary' | 'danger';
|
|
18
|
+
className?: string;
|
|
19
|
+
/** Override z-index layer (default 50). Increase for nested modals. */
|
|
20
|
+
zIndex?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ActionModal — a convenience wrapper around `Modal` that renders a
|
|
25
|
+
* standard Cancel / Confirm footer. Use this for quick confirm-style
|
|
26
|
+
* dialogs (deposits, withdrawals, delete confirmations, etc.).
|
|
27
|
+
*/
|
|
28
|
+
export const ActionModal = ({
|
|
29
|
+
isOpen,
|
|
30
|
+
onClose,
|
|
31
|
+
title,
|
|
32
|
+
children,
|
|
33
|
+
onConfirm,
|
|
34
|
+
confirmLabel = "Confirm",
|
|
35
|
+
cancelLabel = "Cancel",
|
|
36
|
+
isLoading = false,
|
|
37
|
+
size = 'sm',
|
|
38
|
+
variant = 'primary',
|
|
39
|
+
className,
|
|
40
|
+
zIndex,
|
|
41
|
+
}: ActionModalProps) => {
|
|
42
|
+
const confirmBtnClass =
|
|
43
|
+
variant === 'danger'
|
|
44
|
+
? "px-4 py-2 rounded-lg text-sm font-bold bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 transition-colors"
|
|
45
|
+
: "px-4 py-2 rounded-lg text-sm font-bold bg-[#2D3142] text-white hover:bg-[#3d4156] disabled:opacity-50 transition-colors";
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Modal
|
|
49
|
+
isOpen={isOpen}
|
|
50
|
+
onClose={onClose}
|
|
51
|
+
title={title}
|
|
52
|
+
size={size}
|
|
53
|
+
className={className}
|
|
54
|
+
zIndex={zIndex}
|
|
55
|
+
footer={
|
|
56
|
+
<>
|
|
57
|
+
<button
|
|
58
|
+
onClick={onClose}
|
|
59
|
+
className="px-4 py-2 rounded-lg text-sm font-bold text-gray-600 bg-gray-100 hover:bg-gray-200 transition-colors"
|
|
60
|
+
disabled={isLoading}
|
|
61
|
+
>
|
|
62
|
+
{cancelLabel}
|
|
63
|
+
</button>
|
|
64
|
+
{onConfirm && (
|
|
65
|
+
<button
|
|
66
|
+
onClick={onConfirm}
|
|
67
|
+
className={confirmBtnClass}
|
|
68
|
+
disabled={isLoading}
|
|
69
|
+
>
|
|
70
|
+
{isLoading ? (
|
|
71
|
+
<span className="inline-flex items-center gap-2">
|
|
72
|
+
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
73
|
+
{confirmLabel}
|
|
74
|
+
</span>
|
|
75
|
+
) : (
|
|
76
|
+
confirmLabel
|
|
77
|
+
)}
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
80
|
+
</>
|
|
81
|
+
}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</Modal>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { cn } from '@apptimate/core-lib';
|
|
5
|
+
import { Label } from './Label';
|
|
6
|
+
|
|
7
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export type AmountDisplayMode = 'label' | 'input';
|
|
10
|
+
|
|
11
|
+
export interface AmountComponentProps {
|
|
12
|
+
/** The numeric amount value */
|
|
13
|
+
value: number | string;
|
|
14
|
+
/** Display mode: 'label' (read-only text) or 'input' (editable field). Default: 'label' */
|
|
15
|
+
mode?: AmountDisplayMode;
|
|
16
|
+
/** Currency symbol. Default: 'Rs.' */
|
|
17
|
+
currency?: string;
|
|
18
|
+
/** Text color class (e.g. 'text-green-600', 'text-red-500'). Applied to the rendered amount. */
|
|
19
|
+
color?: string;
|
|
20
|
+
/** Label text shown above the amount (only when mode='label'). */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** If true, shows the required asterisk on the label */
|
|
23
|
+
isRequired?: boolean;
|
|
24
|
+
/** Font size class override. Default: 'text-sm' for label, 'text-[13.5px]' for input */
|
|
25
|
+
fontSize?: string;
|
|
26
|
+
/** Font weight class override. Default: 'font-semibold' */
|
|
27
|
+
fontWeight?: string;
|
|
28
|
+
/** Additional className for the root wrapper */
|
|
29
|
+
className?: string;
|
|
30
|
+
/** Number of decimal places. Default: 2 */
|
|
31
|
+
decimals?: number;
|
|
32
|
+
|
|
33
|
+
// ── Input mode props ──────────────────────────────────────────────────
|
|
34
|
+
/** Callback when value changes (input mode only) */
|
|
35
|
+
onChange?: (value: number) => void;
|
|
36
|
+
/** Placeholder text (input mode only). Default: '0.00' */
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
/** Disable the input (input mode only) */
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
/** Error message (input mode only) */
|
|
41
|
+
error?: string;
|
|
42
|
+
/** Min value (input mode only) */
|
|
43
|
+
min?: number;
|
|
44
|
+
/** Max value (input mode only) */
|
|
45
|
+
max?: number;
|
|
46
|
+
/** Step value (input mode only). Default: '0.01' */
|
|
47
|
+
step?: string;
|
|
48
|
+
/** Name attribute (input mode only) */
|
|
49
|
+
name?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function formatAmount(value: number | string, decimals: number): string {
|
|
55
|
+
const num = typeof value === 'string' ? parseFloat(value) : value;
|
|
56
|
+
if (isNaN(num)) return '0.' + '0'.repeat(decimals);
|
|
57
|
+
return num.toLocaleString('en-US', {
|
|
58
|
+
minimumFractionDigits: decimals,
|
|
59
|
+
maximumFractionDigits: decimals,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Component ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const AmountComponent = React.forwardRef<HTMLInputElement, AmountComponentProps>(
|
|
66
|
+
(
|
|
67
|
+
{
|
|
68
|
+
value,
|
|
69
|
+
mode = 'label',
|
|
70
|
+
currency = 'Rs.',
|
|
71
|
+
color,
|
|
72
|
+
label,
|
|
73
|
+
isRequired,
|
|
74
|
+
fontSize,
|
|
75
|
+
fontWeight = 'font-semibold',
|
|
76
|
+
className,
|
|
77
|
+
decimals = 2,
|
|
78
|
+
// Input mode props
|
|
79
|
+
onChange,
|
|
80
|
+
placeholder = '0.00',
|
|
81
|
+
disabled = false,
|
|
82
|
+
error,
|
|
83
|
+
min,
|
|
84
|
+
max,
|
|
85
|
+
step = '0.01',
|
|
86
|
+
name,
|
|
87
|
+
},
|
|
88
|
+
ref
|
|
89
|
+
) => {
|
|
90
|
+
// ── Label Mode ────────────────────────────────────────────────────────
|
|
91
|
+
if (mode === 'label') {
|
|
92
|
+
const resolvedFontSize = fontSize || 'text-sm';
|
|
93
|
+
const resolvedColor = color || 'text-foreground-1';
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className={cn('flex flex-col gap-1', className)}>
|
|
97
|
+
{label && (
|
|
98
|
+
<Label isRequired={isRequired}>{label}</Label>
|
|
99
|
+
)}
|
|
100
|
+
<span className={cn(resolvedFontSize, fontWeight, resolvedColor)}>
|
|
101
|
+
{currency} {formatAmount(value, decimals)}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Input Mode ────────────────────────────────────────────────────────
|
|
108
|
+
const resolvedFontSize = fontSize || 'text-[13.5px]';
|
|
109
|
+
const resolvedColor = color || 'text-foreground-1';
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={cn('flex flex-col gap-1.5 w-full', className)}>
|
|
113
|
+
{label && (
|
|
114
|
+
<Label isRequired={isRequired}>{label}</Label>
|
|
115
|
+
)}
|
|
116
|
+
<div className="relative group">
|
|
117
|
+
{/* Currency prefix */}
|
|
118
|
+
<span
|
|
119
|
+
className={cn(
|
|
120
|
+
'absolute left-3 top-1/2 -translate-y-1/2 text-[12px] font-medium text-gray-400 pointer-events-none select-none'
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{currency}
|
|
124
|
+
</span>
|
|
125
|
+
<input
|
|
126
|
+
ref={ref}
|
|
127
|
+
type="number"
|
|
128
|
+
name={name}
|
|
129
|
+
value={value}
|
|
130
|
+
onChange={(e) => onChange?.(parseFloat(e.target.value) || 0)}
|
|
131
|
+
disabled={disabled}
|
|
132
|
+
min={min}
|
|
133
|
+
max={max}
|
|
134
|
+
step={step}
|
|
135
|
+
placeholder={placeholder}
|
|
136
|
+
className={cn(
|
|
137
|
+
'w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] pl-10 pr-3.5 py-2.5 text-right outline-none transition-all',
|
|
138
|
+
'hover:border-gray-300 focus:border-gray-300 focus:bg-surface-1',
|
|
139
|
+
'placeholder:text-foreground-disabled',
|
|
140
|
+
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
|
|
141
|
+
resolvedFontSize,
|
|
142
|
+
fontWeight,
|
|
143
|
+
resolvedColor,
|
|
144
|
+
disabled && 'opacity-60 cursor-not-allowed',
|
|
145
|
+
error && 'border-danger-alt focus:border-danger-alt hover:border-danger-alt'
|
|
146
|
+
)}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
{error && (
|
|
150
|
+
<span className="text-[11px] text-danger-alt font-medium">{error}</span>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
AmountComponent.displayName = 'AmountComponent';
|
|
@@ -0,0 +1,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 totalPaid = useMemo(() => payments.reduce((s, p) => s + p.amount, 0), [payments]);
|
|
96
|
+
const balance = useMemo(() => totalAmount - totalPaid, [totalAmount, totalPaid]);
|
|
97
|
+
const formatCurrency = (v: number) => v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
98
|
+
|
|
99
|
+
// ── Mode icon ──
|
|
100
|
+
const getModeIcon = useCallback((code: string, size = 14) => {
|
|
101
|
+
switch (code) {
|
|
102
|
+
case "cash": return <Banknote size={size} />;
|
|
103
|
+
case "card": return <CreditCard size={size} />;
|
|
104
|
+
case "bank_transfer": return <Building size={size} />;
|
|
105
|
+
default: return <CreditCard size={size} />;
|
|
106
|
+
}
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
// ── Payment management ──
|
|
110
|
+
const selectSingleMode = useCallback((mode: PaymentMode) => {
|
|
111
|
+
setSelectionMode("single");
|
|
112
|
+
onPaymentsChange([{
|
|
113
|
+
mode_id: mode.id, mode_code: mode.code, mode_name: mode.name,
|
|
114
|
+
amount: totalAmount, metadata: {},
|
|
115
|
+
}]);
|
|
116
|
+
}, [totalAmount, onPaymentsChange]);
|
|
117
|
+
|
|
118
|
+
const updatePayment = useCallback((index: number, updates: Partial<PaymentEntry>) => {
|
|
119
|
+
const next = [...payments];
|
|
120
|
+
next[index] = { ...next[index], ...updates };
|
|
121
|
+
onPaymentsChange(next);
|
|
122
|
+
}, [payments, onPaymentsChange]);
|
|
123
|
+
|
|
124
|
+
// ── Auto-sync single mode amount ──
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (selectionMode === "single" && payments.length === 1 && payments[0].amount !== totalAmount) {
|
|
127
|
+
updatePayment(0, { amount: totalAmount });
|
|
128
|
+
}
|
|
129
|
+
}, [totalAmount, selectionMode, payments, updatePayment]);
|
|
130
|
+
|
|
131
|
+
const activateSplitMode = useCallback(() => {
|
|
132
|
+
setSelectionMode("split");
|
|
133
|
+
if (payments.length <= 1) onPaymentsChange([]);
|
|
134
|
+
}, [payments.length, onPaymentsChange]);
|
|
135
|
+
|
|
136
|
+
const addPayment = useCallback((mode: PaymentMode) => {
|
|
137
|
+
const entry: PaymentEntry = {
|
|
138
|
+
mode_id: mode.id, mode_code: mode.code, mode_name: mode.name,
|
|
139
|
+
amount: Math.max(0, balance), metadata: {},
|
|
140
|
+
};
|
|
141
|
+
onPaymentsChange([...payments, entry]);
|
|
142
|
+
}, [payments, balance, onPaymentsChange]);
|
|
143
|
+
|
|
144
|
+
const removePayment = useCallback((index: number) => {
|
|
145
|
+
onPaymentsChange(payments.filter((_, i) => i !== index));
|
|
146
|
+
}, [payments, onPaymentsChange]);
|
|
147
|
+
|
|
148
|
+
if (isLoading) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex items-center justify-center py-4">
|
|
151
|
+
<Loader2 size={18} className="animate-spin text-primary" />
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className="space-y-3">
|
|
158
|
+
{/* ── Mode Selection Buttons ── */}
|
|
159
|
+
<div className="flex flex-wrap gap-2">
|
|
160
|
+
{paymentModes
|
|
161
|
+
.filter((m) => m.is_active)
|
|
162
|
+
.map((mode) => {
|
|
163
|
+
const isActive = selectionMode === "single" && payments.length === 1 && payments[0].mode_id === mode.id;
|
|
164
|
+
return (
|
|
165
|
+
<button key={mode.id} onClick={() => selectSingleMode(mode)}
|
|
166
|
+
className={cn(
|
|
167
|
+
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
168
|
+
isActive
|
|
169
|
+
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
170
|
+
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
171
|
+
)}>
|
|
172
|
+
{getModeIcon(mode.code)}
|
|
173
|
+
{mode.name}
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
{/* Split Button */}
|
|
178
|
+
<button onClick={activateSplitMode}
|
|
179
|
+
className={cn(
|
|
180
|
+
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
181
|
+
selectionMode === "split"
|
|
182
|
+
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
183
|
+
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
184
|
+
)}>
|
|
185
|
+
<Split size={14} />
|
|
186
|
+
Multiple Methods
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* ── Single Mode: Entry with fields (no delete) ── */}
|
|
191
|
+
{selectionMode === "single" && payments.length === 1 && (
|
|
192
|
+
<PaymentEntryRow entry={payments[0]} mode={paymentModes.find((m) => m.id === payments[0].mode_id)}
|
|
193
|
+
bankAccounts={bankAccounts} onUpdate={(updates) => updatePayment(0, updates)} onRemove={() => {}} showRemove={false} />
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* ── Split Mode: Multi-entry UI ── */}
|
|
197
|
+
{selectionMode === "split" && (
|
|
198
|
+
<div className="space-y-2.5">
|
|
199
|
+
{payments.map((entry, idx) => {
|
|
200
|
+
const mode = paymentModes.find((m) => m.id === entry.mode_id);
|
|
201
|
+
return (
|
|
202
|
+
<PaymentEntryRow key={idx} entry={entry} mode={mode} bankAccounts={bankAccounts}
|
|
203
|
+
onUpdate={(updates) => updatePayment(idx, updates)} onRemove={() => removePayment(idx)} />
|
|
204
|
+
);
|
|
205
|
+
})}
|
|
206
|
+
{/* Add split mode buttons */}
|
|
207
|
+
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
208
|
+
{paymentModes
|
|
209
|
+
.filter((m) => m.is_active && !payments.some(p => p.mode_id === m.id))
|
|
210
|
+
.map((mode) => (
|
|
211
|
+
<button key={mode.id} onClick={() => addPayment(mode)}
|
|
212
|
+
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">
|
|
213
|
+
{getModeIcon(mode.code, 12)}
|
|
214
|
+
+ {mode.name}
|
|
215
|
+
</button>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* ── Totals ── */}
|
|
222
|
+
{payments.length > 0 && (
|
|
223
|
+
<div className="bg-surface-0 rounded-[12px] border border-border-subtle p-4 space-y-2">
|
|
224
|
+
<TotalRow label="Amount to Pay" value={formatCurrency(totalAmount)} bold />
|
|
225
|
+
<div className="border-t border-border-subtle my-2" />
|
|
226
|
+
<TotalRow label="Total Paid" value={formatCurrency(totalPaid)} />
|
|
227
|
+
<TotalRow
|
|
228
|
+
label={balance > 0.01 ? "Balance Due" : balance < -0.01 ? "Change Due" : "Settled"}
|
|
229
|
+
value={formatCurrency(Math.abs(balance))}
|
|
230
|
+
className={balance > 0.01 ? "text-danger-alt" : "text-green-600"}
|
|
231
|
+
bold
|
|
232
|
+
/>
|
|
233
|
+
{balance < -0.01 && (
|
|
234
|
+
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-amber-600 font-medium">
|
|
235
|
+
<AlertCircle size={12} /><span>Change: {formatCurrency(Math.abs(balance))}</span>
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
{Math.abs(balance) < 0.01 && (
|
|
239
|
+
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-green-600 font-medium">
|
|
240
|
+
<Check size={12} /><span>Fully paid</span>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ── TotalRow ── */
|
|
250
|
+
|
|
251
|
+
function TotalRow({ label, value, bold, className }: { label: string; value: string; bold?: boolean; className?: string }) {
|
|
252
|
+
return (
|
|
253
|
+
<div className="flex items-center justify-between">
|
|
254
|
+
<span className={cn("text-[12px]", bold ? "font-bold text-foreground-1" : "text-foreground-subtle")}>{label}</span>
|
|
255
|
+
<span className={cn("text-[12px]", bold ? "font-bold text-foreground-0 text-[14px]" : "text-foreground-1 font-medium", className)}>{value}</span>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ── PaymentEntryRow (mirrors sales POS exactly) ── */
|
|
261
|
+
|
|
262
|
+
interface PaymentEntryRowProps {
|
|
263
|
+
entry: PaymentEntry;
|
|
264
|
+
mode?: PaymentMode;
|
|
265
|
+
bankAccounts: BankAccount[];
|
|
266
|
+
onUpdate: (updates: Partial<PaymentEntry>) => void;
|
|
267
|
+
onRemove: () => void;
|
|
268
|
+
showRemove?: boolean;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function PaymentEntryRow({ entry, mode, bankAccounts, onUpdate, onRemove, showRemove = true }: PaymentEntryRowProps) {
|
|
272
|
+
const fields = mode?.field_config?.fields || [];
|
|
273
|
+
|
|
274
|
+
const getModeIcon = () => {
|
|
275
|
+
switch (entry.mode_code) {
|
|
276
|
+
case "cash": return <Banknote size={13} />;
|
|
277
|
+
case "card": return <CreditCard size={13} />;
|
|
278
|
+
case "bank_transfer": return <Building size={13} />;
|
|
279
|
+
default: return <CreditCard size={13} />;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div className="bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] p-4 space-y-3">
|
|
285
|
+
{/* Header */}
|
|
286
|
+
<div className="flex items-center justify-between">
|
|
287
|
+
<div className="flex items-center gap-1.5 text-[12px] font-bold text-foreground-1">
|
|
288
|
+
{getModeIcon()}
|
|
289
|
+
{entry.mode_name}
|
|
290
|
+
</div>
|
|
291
|
+
{showRemove && (
|
|
292
|
+
<button onClick={onRemove}
|
|
293
|
+
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">
|
|
294
|
+
<Trash2 size={13} />
|
|
295
|
+
</button>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* Amount */}
|
|
300
|
+
<div className="flex flex-col gap-1">
|
|
301
|
+
<label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider">Amount</label>
|
|
302
|
+
<input type="number" value={entry.amount || ""} onChange={(e) => onUpdate({ amount: Number(e.target.value) || 0 })}
|
|
303
|
+
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"
|
|
304
|
+
step="0.01" min={0} />
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
{/* Dynamic sub-fields from field_config */}
|
|
308
|
+
{fields.length > 0 && (
|
|
309
|
+
<div className="grid grid-cols-2 gap-2.5">
|
|
310
|
+
{fields.map((field: PaymentModeField) => (
|
|
311
|
+
<div key={field.key} className="flex flex-col gap-1">
|
|
312
|
+
<label className="text-[11px] text-foreground-subtle font-bold uppercase tracking-wider">
|
|
313
|
+
{field.label}
|
|
314
|
+
{field.required && <span className="text-danger-alt ml-0.5">*</span>}
|
|
315
|
+
</label>
|
|
316
|
+
{field.type === "bank_account_select" ? (
|
|
317
|
+
<select value={entry.metadata[field.key] || ""}
|
|
318
|
+
onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value ? Number(e.target.value) : "" } })}
|
|
319
|
+
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">
|
|
320
|
+
<option value="">Select...</option>
|
|
321
|
+
{bankAccounts.map((b) => (<option key={b.id} value={b.id}>{b.bank_name} - {b.account_number}</option>))}
|
|
322
|
+
</select>
|
|
323
|
+
) : (
|
|
324
|
+
<input type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
|
325
|
+
value={entry.metadata[field.key] || ""}
|
|
326
|
+
onChange={(e) => onUpdate({ metadata: { ...entry.metadata, [field.key]: e.target.value } })}
|
|
327
|
+
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" />
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
))}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
export * from './base-components/Accordion';
|
|
4
|
+
export * from './base-components/AmountComponent';
|
|
4
5
|
export * from './base-components/Badge';
|
|
5
6
|
export * from './base-components/Button';
|
|
6
7
|
export * from './base-components/ButtonGroup';
|
|
@@ -20,6 +21,7 @@ export * from './base-components/DesktopFilterPopover';
|
|
|
20
21
|
export * from './base-components/Input';
|
|
21
22
|
export * from './base-components/Label';
|
|
22
23
|
export * from './base-components/Modal';
|
|
24
|
+
export * from './base-components/ActionModal';
|
|
23
25
|
export * from './base-components/Pagination';
|
|
24
26
|
export * from './base-components/PopConfirm';
|
|
25
27
|
export * from './base-components/SearchableSelect';
|
|
@@ -30,3 +32,4 @@ export { default as ImageUploadComponent } from './components/shared/ImageUpload
|
|
|
30
32
|
export type { ImageUploadComponentProps, ItemImage } from './components/shared/ImageUploadComponent';
|
|
31
33
|
export * from './common-components/DashboardLayout';
|
|
32
34
|
export * from './common-components/charts';
|
|
35
|
+
export * from './common-components/PaymentSection';
|