@apptimate/ui 1.1.0 → 1.3.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 +4 -3
- package/src/base-components/DateLabel.tsx +68 -0
- package/src/base-components/DateRangePicker.tsx +392 -0
- package/src/base-components/ImageDropzone.tsx +80 -0
- package/src/base-components/Modal.tsx +19 -7
- package/src/base-components/PopConfirm.tsx +15 -4
- package/src/common-components/DashboardLayout.tsx +16 -5
- package/src/common-components/charts/RAreaChart.tsx +139 -0
- package/src/common-components/charts/RBarChart.tsx +171 -0
- package/src/common-components/charts/RLineChart.tsx +156 -0
- package/src/common-components/charts/RPieChart.tsx +215 -0
- package/src/common-components/charts/RStatCard.tsx +138 -0
- package/src/common-components/charts/index.ts +7 -0
- package/src/common-components/charts/palette.ts +84 -0
- package/src/components/shared/CustomerSelectorComponent.tsx +163 -0
- package/src/components/shared/ImageUploadComponent.tsx +228 -0
- package/src/components/shared/PaymentModeComponent.tsx +421 -0
- package/src/components/shared/ProcurementManagerSelector.tsx +5 -0
- package/src/components/shared/ProductSelectorComponent.tsx +166 -0
- package/src/components/shared/SupplierSelectorComponent.tsx +5 -0
- package/src/index.tsx +6 -1
- /package/src/common-components/{Charts.tsx → charts/ApexCharts.tsx} +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useState, useRef, useCallback } from "react";
|
|
3
|
+
import { Button, Modal, ModalFooter } from "@apptimate/ui";
|
|
4
|
+
import { Upload, X, Star, GripVertical, ImagePlus, Trash2 } from "lucide-react";
|
|
5
|
+
import toast from "react-hot-toast";
|
|
6
|
+
import { sendRequest } from "@apptimate/core-lib";
|
|
7
|
+
|
|
8
|
+
export interface ItemImage {
|
|
9
|
+
id: number;
|
|
10
|
+
file_path: string;
|
|
11
|
+
file_url?: string;
|
|
12
|
+
is_primary: boolean;
|
|
13
|
+
sort_order: number;
|
|
14
|
+
original_name?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ImageUploadComponentProps {
|
|
18
|
+
/** API base URL — defaults to process.env.NEXT_PUBLIC_API_URL */
|
|
19
|
+
apiBase?: string;
|
|
20
|
+
/** Item/entity ID for the image upload endpoint */
|
|
21
|
+
entityId: number;
|
|
22
|
+
/** API path template — {id} will be replaced. Default: /api/inventory/items/{id}/images */
|
|
23
|
+
apiPath?: string;
|
|
24
|
+
/** API path for single image delete. {imageId} will be replaced. Default: /api/inventory/images/{imageId} */
|
|
25
|
+
deletePath?: string;
|
|
26
|
+
/** API path for set primary. {imageId} will be replaced. Default: /api/inventory/images/{imageId}/primary */
|
|
27
|
+
primaryPath?: string;
|
|
28
|
+
/** Existing images loaded from API */
|
|
29
|
+
images: ItemImage[];
|
|
30
|
+
/** Callback after any mutation */
|
|
31
|
+
onRefresh: () => void;
|
|
32
|
+
/** Max images allowed. Default: 10 */
|
|
33
|
+
maxImages?: number;
|
|
34
|
+
/** Accepted MIME types. Default: image/* */
|
|
35
|
+
accept?: string;
|
|
36
|
+
/** Disabled state */
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
/** Label text */
|
|
39
|
+
label?: string;
|
|
40
|
+
/** Extra fields to append to upload FormData (e.g. { line_id: "123" }) */
|
|
41
|
+
extraFormData?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function ImageUploadComponent({
|
|
45
|
+
apiBase = process.env.NEXT_PUBLIC_API_URL || "",
|
|
46
|
+
entityId,
|
|
47
|
+
apiPath = "/api/inventory/items/{id}/images",
|
|
48
|
+
deletePath = "/api/inventory/images/{imageId}",
|
|
49
|
+
primaryPath = "/api/inventory/images/{imageId}/primary",
|
|
50
|
+
images = [],
|
|
51
|
+
onRefresh,
|
|
52
|
+
maxImages = 10,
|
|
53
|
+
accept = "image/*",
|
|
54
|
+
disabled = false,
|
|
55
|
+
label = "Images",
|
|
56
|
+
extraFormData = {},
|
|
57
|
+
}: ImageUploadComponentProps) {
|
|
58
|
+
const [uploading, setUploading] = useState(false);
|
|
59
|
+
const [dragOver, setDragOver] = useState(false);
|
|
60
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
61
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
62
|
+
|
|
63
|
+
// Build URL through the Next.js proxy (same as sendRequest does)
|
|
64
|
+
const buildProxyUrl = (path: string) => {
|
|
65
|
+
if (typeof window === "undefined") return `${apiBase}${path}`;
|
|
66
|
+
try {
|
|
67
|
+
const raw = `${apiBase}${path}`;
|
|
68
|
+
const parsed = new URL(raw, window.location.origin);
|
|
69
|
+
if (parsed.origin === window.location.origin) return parsed.toString();
|
|
70
|
+
const proxyPath = parsed.pathname.replace(/^\/+/, "");
|
|
71
|
+
return `/api/proxy/${proxyPath}${parsed.search}`;
|
|
72
|
+
} catch {
|
|
73
|
+
return `${apiBase}${path}`;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const uploadUrl = buildProxyUrl(apiPath.replace("{id}", String(entityId)));
|
|
78
|
+
const multipleUploadUrl = `${uploadUrl}/multiple`;
|
|
79
|
+
|
|
80
|
+
const getAuthHeaders = (): Record<string, string> => {
|
|
81
|
+
const headers: Record<string, string> = {};
|
|
82
|
+
// Add org header
|
|
83
|
+
try {
|
|
84
|
+
const orgRaw = typeof window !== "undefined" ? localStorage.getItem("selected_organization") : null;
|
|
85
|
+
if (orgRaw) {
|
|
86
|
+
const org = JSON.parse(orgRaw);
|
|
87
|
+
if (org?.id) headers["X-Organization-Id"] = String(org.id);
|
|
88
|
+
}
|
|
89
|
+
} catch { /* ignore */ }
|
|
90
|
+
return headers;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleFiles = async (files: FileList | File[]) => {
|
|
94
|
+
if (disabled) return;
|
|
95
|
+
const fileArray = Array.from(files);
|
|
96
|
+
if (fileArray.length === 0) return;
|
|
97
|
+
if (images.length + fileArray.length > maxImages) {
|
|
98
|
+
toast.error(`Max ${maxImages} images allowed`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setUploading(true);
|
|
103
|
+
try {
|
|
104
|
+
if (fileArray.length === 1) {
|
|
105
|
+
const formData = new FormData();
|
|
106
|
+
formData.append("image", fileArray[0]);
|
|
107
|
+
if (images.length === 0) formData.append("is_primary", "1");
|
|
108
|
+
Object.entries(extraFormData).forEach(([k, v]) => formData.append(k, v));
|
|
109
|
+
const resp = await fetch(uploadUrl, { method: "POST", headers: getAuthHeaders(), body: formData });
|
|
110
|
+
const data = await resp.json();
|
|
111
|
+
if (data.is_success) { toast.success("Image uploaded"); onRefresh(); } else toast.error(data.message || "Upload failed");
|
|
112
|
+
} else {
|
|
113
|
+
const formData = new FormData();
|
|
114
|
+
fileArray.forEach(f => formData.append("images[]", f));
|
|
115
|
+
Object.entries(extraFormData).forEach(([k, v]) => formData.append(k, v));
|
|
116
|
+
const resp = await fetch(multipleUploadUrl, { method: "POST", headers: getAuthHeaders(), body: formData });
|
|
117
|
+
const data = await resp.json();
|
|
118
|
+
if (data.is_success) { toast.success(`${fileArray.length} images uploaded`); onRefresh(); } else toast.error(data.message || "Upload failed");
|
|
119
|
+
}
|
|
120
|
+
} catch (e: any) {
|
|
121
|
+
toast.error(e.message || "Upload error");
|
|
122
|
+
} finally {
|
|
123
|
+
setUploading(false);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleDelete = async (imageId: number) => {
|
|
128
|
+
try {
|
|
129
|
+
const url = buildProxyUrl(deletePath.replace("{id}", String(entityId)).replace("{imageId}", String(imageId)));
|
|
130
|
+
const resp = await fetch(url, { method: "DELETE", headers: { ...getAuthHeaders(), "Content-Type": "application/json" } });
|
|
131
|
+
const data = await resp.json();
|
|
132
|
+
if (data.is_success) { toast.success("Image deleted"); onRefresh(); } else toast.error(data.message || "Delete failed");
|
|
133
|
+
} catch (e: any) { toast.error(e.message); }
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleSetPrimary = async (imageId: number) => {
|
|
137
|
+
try {
|
|
138
|
+
const url = buildProxyUrl(primaryPath.replace("{imageId}", String(imageId)));
|
|
139
|
+
const resp = await fetch(url, { method: "POST", headers: { ...getAuthHeaders(), "Content-Type": "application/json" } });
|
|
140
|
+
const data = await resp.json();
|
|
141
|
+
if (data.is_success) { toast.success("Primary image set"); onRefresh(); } else toast.error(data.message || "Failed");
|
|
142
|
+
} catch (e: any) { toast.error(e.message); }
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const onDrop = useCallback((e: React.DragEvent) => {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
setDragOver(false);
|
|
148
|
+
handleFiles(e.dataTransfer.files);
|
|
149
|
+
}, [images.length]);
|
|
150
|
+
|
|
151
|
+
const sorted = [...images].sort((a, b) => a.sort_order - b.sort_order);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div>
|
|
155
|
+
<label className="text-xs font-bold text-gray-500 uppercase mb-2 block">{label} ({images.length}/{maxImages})</label>
|
|
156
|
+
|
|
157
|
+
{/* Image Grid */}
|
|
158
|
+
{sorted.length > 0 && (
|
|
159
|
+
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2 mb-3">
|
|
160
|
+
{sorted.map((img) => (
|
|
161
|
+
<div key={img.id} className="rounded-xl overflow-hidden border border-gray-200 bg-gray-50">
|
|
162
|
+
<div className="relative group aspect-square">
|
|
163
|
+
<img
|
|
164
|
+
src={img.file_url || img.file_path}
|
|
165
|
+
alt={img.original_name || "Image"}
|
|
166
|
+
className="w-full h-full object-cover cursor-pointer"
|
|
167
|
+
onClick={() => setPreviewUrl(img.file_url || img.file_path)}
|
|
168
|
+
/>
|
|
169
|
+
{img.is_primary && (
|
|
170
|
+
<div className="absolute top-1 left-1 bg-yellow-400 text-white rounded-full p-0.5"><Star size={12} fill="white" /></div>
|
|
171
|
+
)}
|
|
172
|
+
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1.5">
|
|
173
|
+
{!img.is_primary && !disabled && (
|
|
174
|
+
<button onClick={() => handleSetPrimary(img.id)} className="p-1.5 bg-white rounded-lg hover:bg-yellow-50 transition-colors" title="Set as primary">
|
|
175
|
+
<Star size={14} className="text-yellow-500" />
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
{!disabled && (
|
|
179
|
+
<button onClick={() => handleDelete(img.id)} className="p-1.5 bg-white rounded-lg hover:bg-red-50 transition-colors" title="Delete">
|
|
180
|
+
<Trash2 size={14} className="text-red-500" />
|
|
181
|
+
</button>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
{img.original_name && (
|
|
186
|
+
<div className="px-2 py-1.5 bg-white border-t border-gray-100">
|
|
187
|
+
<p className="text-[11px] font-semibold text-gray-700 truncate" title={img.original_name}>{img.original_name}</p>
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Upload Zone */}
|
|
196
|
+
{!disabled && images.length < maxImages && (
|
|
197
|
+
<div
|
|
198
|
+
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
|
199
|
+
onDragLeave={() => setDragOver(false)}
|
|
200
|
+
onDrop={onDrop}
|
|
201
|
+
onClick={() => fileInputRef.current?.click()}
|
|
202
|
+
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-xl p-6 cursor-pointer transition-all ${
|
|
203
|
+
dragOver ? "border-blue-400 bg-blue-50" : "border-gray-200 hover:border-blue-300 hover:bg-blue-50/50"
|
|
204
|
+
} ${uploading ? "opacity-50 pointer-events-none" : ""}`}
|
|
205
|
+
>
|
|
206
|
+
{uploading ? (
|
|
207
|
+
<div className="flex items-center gap-2 text-blue-600"><div className="w-5 h-5 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" /><span className="text-sm font-medium">Uploading...</span></div>
|
|
208
|
+
) : (
|
|
209
|
+
<>
|
|
210
|
+
<ImagePlus size={28} className="text-gray-400 mb-2" />
|
|
211
|
+
<p className="text-sm text-gray-500 font-medium">Drop images here or click to browse</p>
|
|
212
|
+
<p className="text-xs text-gray-400 mt-1">Max {maxImages} images • JPG, PNG, WebP</p>
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
215
|
+
<input ref={fileInputRef} type="file" multiple accept={accept} className="hidden" onChange={e => e.target.files && handleFiles(e.target.files)} />
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{/* Image Preview Modal */}
|
|
220
|
+
<Modal isOpen={!!previewUrl} onClose={() => setPreviewUrl(null)} title="Image Preview" size="lg">
|
|
221
|
+
<div className="flex items-center justify-center p-4">
|
|
222
|
+
{previewUrl && <img src={previewUrl} alt="Preview" className="max-w-full max-h-[60vh] object-contain rounded-xl" />}
|
|
223
|
+
</div>
|
|
224
|
+
<ModalFooter><Button variant="soft" color="default" onClick={() => setPreviewUrl(null)}>Close</Button></ModalFooter>
|
|
225
|
+
</Modal>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
4
|
+
import { Input, Modal, ModalFooter, Button, Badge } from "@apptimate/ui";
|
|
5
|
+
import { Banknote, CreditCard, Building2, Smartphone, FileText, Plus, X, Search, ArrowRight, AlertTriangle, Clock } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface PaymentEntry {
|
|
10
|
+
payment_method: PaymentMethod;
|
|
11
|
+
amount: number | string;
|
|
12
|
+
bank_account_id?: number | null;
|
|
13
|
+
bank_account_name?: string;
|
|
14
|
+
cheque_no?: string;
|
|
15
|
+
cheque_bank_name?: string;
|
|
16
|
+
cheque_branch?: string;
|
|
17
|
+
cheque_due_date?: string;
|
|
18
|
+
cheque_direction?: "issued" | "received";
|
|
19
|
+
card_type?: string;
|
|
20
|
+
card_last_4?: string;
|
|
21
|
+
auth_code?: string;
|
|
22
|
+
external_reference?: string;
|
|
23
|
+
notes?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PaymentMethod = "cash" | "card" | "bank_transfer" | "wallet" | "cheque";
|
|
27
|
+
|
|
28
|
+
export interface BankAccount {
|
|
29
|
+
id: number;
|
|
30
|
+
code: string;
|
|
31
|
+
name: string;
|
|
32
|
+
bank_name: string;
|
|
33
|
+
account_number: string;
|
|
34
|
+
book_balance: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PaymentModeProps {
|
|
38
|
+
/** Total amount to be collected */
|
|
39
|
+
totalAmount: number;
|
|
40
|
+
/** Currency symbol */
|
|
41
|
+
currency?: string;
|
|
42
|
+
/** Initial payment entries (for editing) */
|
|
43
|
+
initialPayments?: PaymentEntry[];
|
|
44
|
+
/** Callback fired whenever payments change */
|
|
45
|
+
onChange: (payments: PaymentEntry[]) => void;
|
|
46
|
+
/** Which payment methods are enabled (default: all) */
|
|
47
|
+
enabledMethods?: PaymentMethod[];
|
|
48
|
+
/** Default cheque direction for this context */
|
|
49
|
+
chequeDirection?: "issued" | "received";
|
|
50
|
+
/** Show cash change calculation */
|
|
51
|
+
showChange?: boolean;
|
|
52
|
+
/** Bank accounts list — fetched from API by parent */
|
|
53
|
+
bankAccounts?: BankAccount[];
|
|
54
|
+
/** Layout style: full (multi-row) or compact (single-row POS) */
|
|
55
|
+
layout?: "full" | "compact";
|
|
56
|
+
/** Whether to auto-fill amount for single payment */
|
|
57
|
+
autoFillAmount?: boolean;
|
|
58
|
+
/** Disable editing (read-only mode) */
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
/** Label override */
|
|
61
|
+
label?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Payment Method Config ────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
const METHODS: Record<PaymentMethod, { label: string; icon: typeof Banknote; color: string; bgColor: string; needsBank: boolean; needsCheque: boolean; needsCardDetails: boolean }> = {
|
|
67
|
+
cash: { label: "Cash", icon: Banknote, color: "text-emerald-600", bgColor: "bg-emerald-50 border-emerald-200 hover:border-emerald-400", needsBank: false, needsCheque: false, needsCardDetails: false },
|
|
68
|
+
card: { label: "Card", icon: CreditCard, color: "text-violet-600", bgColor: "bg-violet-50 border-violet-200 hover:border-violet-400", needsBank: true, needsCheque: false, needsCardDetails: true },
|
|
69
|
+
bank_transfer: { label: "Bank Transfer", icon: Building2, color: "text-blue-600", bgColor: "bg-blue-50 border-blue-200 hover:border-blue-400", needsBank: true, needsCheque: false, needsCardDetails: false },
|
|
70
|
+
wallet: { label: "Wallet", icon: Smartphone, color: "text-amber-600", bgColor: "bg-amber-50 border-amber-200 hover:border-amber-400", needsBank: true, needsCheque: false, needsCardDetails: false },
|
|
71
|
+
cheque: { label: "Cheque", icon: FileText, color: "text-orange-600", bgColor: "bg-orange-50 border-orange-200 hover:border-orange-400", needsBank: false, needsCheque: true, needsCardDetails: false },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── Component ────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export default function PaymentModeComponent({
|
|
77
|
+
totalAmount,
|
|
78
|
+
currency = "Rs.",
|
|
79
|
+
initialPayments,
|
|
80
|
+
onChange,
|
|
81
|
+
enabledMethods = ["cash", "card", "bank_transfer", "wallet", "cheque"],
|
|
82
|
+
chequeDirection = "received",
|
|
83
|
+
showChange = true,
|
|
84
|
+
bankAccounts = [],
|
|
85
|
+
layout = "full",
|
|
86
|
+
autoFillAmount = true,
|
|
87
|
+
disabled = false,
|
|
88
|
+
label = "Payment",
|
|
89
|
+
}: PaymentModeProps) {
|
|
90
|
+
const [payments, setPayments] = useState<PaymentEntry[]>(
|
|
91
|
+
initialPayments || [{ payment_method: "cash", amount: autoFillAmount ? totalAmount : "" }]
|
|
92
|
+
);
|
|
93
|
+
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
|
94
|
+
const [bankSelectorOpen, setBankSelectorOpen] = useState<{ index: number } | null>(null);
|
|
95
|
+
const [bankSearch, setBankSearch] = useState("");
|
|
96
|
+
|
|
97
|
+
// Sync when totalAmount changes (for POS where total updates dynamically)
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (autoFillAmount && payments.length === 1 && payments[0].payment_method === "cash") {
|
|
100
|
+
const updated = [{ ...payments[0], amount: totalAmount }];
|
|
101
|
+
setPayments(updated);
|
|
102
|
+
onChange(updated);
|
|
103
|
+
}
|
|
104
|
+
}, [totalAmount]);
|
|
105
|
+
|
|
106
|
+
const fireChange = useCallback((updated: PaymentEntry[]) => {
|
|
107
|
+
setPayments(updated);
|
|
108
|
+
onChange(updated);
|
|
109
|
+
}, [onChange]);
|
|
110
|
+
|
|
111
|
+
// ── Actions ──────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
const addPayment = () => {
|
|
114
|
+
const remaining = totalAmount - getTotalPaid(payments);
|
|
115
|
+
const nextMethod = enabledMethods.find(m => !payments.some(p => p.payment_method === m)) || enabledMethods[0];
|
|
116
|
+
fireChange([...payments, { payment_method: nextMethod, amount: remaining > 0 ? remaining : "" }]);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const removePayment = (index: number) => {
|
|
120
|
+
if (payments.length <= 1) return;
|
|
121
|
+
const updated = payments.filter((_, i) => i !== index);
|
|
122
|
+
fireChange(updated);
|
|
123
|
+
if (expandedIndex === index) setExpandedIndex(null);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const updatePayment = (index: number, field: string, value: any) => {
|
|
127
|
+
const updated = [...payments];
|
|
128
|
+
updated[index] = { ...updated[index], [field]: value };
|
|
129
|
+
|
|
130
|
+
// When changing method, reset method-specific fields
|
|
131
|
+
if (field === "payment_method") {
|
|
132
|
+
updated[index].bank_account_id = null;
|
|
133
|
+
updated[index].bank_account_name = undefined;
|
|
134
|
+
updated[index].cheque_no = undefined;
|
|
135
|
+
updated[index].cheque_bank_name = undefined;
|
|
136
|
+
updated[index].cheque_branch = undefined;
|
|
137
|
+
updated[index].cheque_due_date = undefined;
|
|
138
|
+
updated[index].card_type = undefined;
|
|
139
|
+
updated[index].card_last_4 = undefined;
|
|
140
|
+
updated[index].auth_code = undefined;
|
|
141
|
+
updated[index].external_reference = undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fireChange(updated);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const selectBank = (index: number, bank: BankAccount) => {
|
|
148
|
+
const updated = [...payments];
|
|
149
|
+
updated[index] = { ...updated[index], bank_account_id: bank.id, bank_account_name: `${bank.name} (${bank.account_number})` };
|
|
150
|
+
fireChange(updated);
|
|
151
|
+
setBankSelectorOpen(null);
|
|
152
|
+
setBankSearch("");
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── Calculations ─────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
const getTotalPaid = (p: PaymentEntry[]) => p.reduce((s, e) => s + (parseFloat(String(e.amount)) || 0), 0);
|
|
158
|
+
const totalPaid = getTotalPaid(payments);
|
|
159
|
+
const remaining = Math.max(0, totalAmount - totalPaid);
|
|
160
|
+
const changeAmount = Math.max(0, totalPaid - totalAmount);
|
|
161
|
+
const isFullyPaid = totalPaid >= totalAmount;
|
|
162
|
+
|
|
163
|
+
// ── Filter bank accounts ─────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const filteredBanks = bankAccounts.filter(b =>
|
|
166
|
+
!bankSearch || b.name.toLowerCase().includes(bankSearch.toLowerCase()) ||
|
|
167
|
+
b.account_number.includes(bankSearch) || b.bank_name.toLowerCase().includes(bankSearch.toLowerCase())
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// ── Render ───────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className="space-y-3">
|
|
174
|
+
{label && (
|
|
175
|
+
<div className="flex items-center justify-between">
|
|
176
|
+
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">{label}</label>
|
|
177
|
+
<div className="flex items-center gap-2">
|
|
178
|
+
<span className={`text-xs font-bold ${isFullyPaid ? "text-emerald-600" : "text-red-500"}`}>
|
|
179
|
+
{currency} {totalPaid.toLocaleString("en", { minimumFractionDigits: 2 })} / {currency} {totalAmount.toLocaleString("en", { minimumFractionDigits: 2 })}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{/* Payment Rows */}
|
|
186
|
+
{payments.map((p, i) => {
|
|
187
|
+
const method = METHODS[p.payment_method];
|
|
188
|
+
const Icon = method.icon;
|
|
189
|
+
const isExpanded = expandedIndex === i;
|
|
190
|
+
const needsExtra = method.needsBank || method.needsCheque || method.needsCardDetails;
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div key={i} className={`rounded-xl border-2 transition-all ${method.bgColor} ${disabled ? "opacity-60" : ""}`}>
|
|
194
|
+
{/* Main Row */}
|
|
195
|
+
<div className="flex items-center gap-3 p-3">
|
|
196
|
+
{/* Method Selector (visual tabs) */}
|
|
197
|
+
<div className="flex gap-1">
|
|
198
|
+
{enabledMethods.map(m => {
|
|
199
|
+
const M = METHODS[m];
|
|
200
|
+
const MIcon = M.icon;
|
|
201
|
+
const isActive = p.payment_method === m;
|
|
202
|
+
return (
|
|
203
|
+
<button key={m} type="button" disabled={disabled}
|
|
204
|
+
onClick={() => updatePayment(i, "payment_method", m)}
|
|
205
|
+
title={M.label}
|
|
206
|
+
className={`h-9 w-9 rounded-lg flex items-center justify-center transition-all ${
|
|
207
|
+
isActive
|
|
208
|
+
? `${M.color} bg-white shadow-sm ring-1 ring-gray-200`
|
|
209
|
+
: "text-gray-400 hover:text-gray-600 hover:bg-white/50"
|
|
210
|
+
}`}>
|
|
211
|
+
<MIcon size={16} />
|
|
212
|
+
</button>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Method Label */}
|
|
218
|
+
<div className="flex-shrink-0">
|
|
219
|
+
<span className={`text-xs font-bold ${method.color}`}>{method.label}</span>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Amount */}
|
|
223
|
+
<div className="flex-1">
|
|
224
|
+
<input type="number" disabled={disabled}
|
|
225
|
+
value={p.amount} onChange={e => updatePayment(i, "amount", e.target.value)}
|
|
226
|
+
className="w-full px-3 py-2 bg-white border border-gray-200 rounded-lg text-sm font-bold text-right focus:outline-none focus:ring-2 focus:ring-primary-300 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
227
|
+
placeholder="0.00"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{/* Expand / Remove buttons */}
|
|
232
|
+
<div className="flex items-center gap-1">
|
|
233
|
+
{needsExtra && (
|
|
234
|
+
<button type="button" onClick={() => setExpandedIndex(isExpanded ? null : i)}
|
|
235
|
+
className={`h-7 w-7 rounded-lg flex items-center justify-center transition-colors ${isExpanded ? "bg-white shadow-sm text-gray-700" : "text-gray-400 hover:text-gray-600 hover:bg-white/50"}`}
|
|
236
|
+
title="Additional details">
|
|
237
|
+
<ArrowRight size={14} className={`transition-transform ${isExpanded ? "rotate-90" : ""}`} />
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
240
|
+
{payments.length > 1 && !disabled && (
|
|
241
|
+
<button type="button" onClick={() => removePayment(i)}
|
|
242
|
+
className="h-7 w-7 rounded-lg flex items-center justify-center text-red-400 hover:bg-red-50 hover:text-red-600 transition-colors">
|
|
243
|
+
<X size={14} />
|
|
244
|
+
</button>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Expanded Details */}
|
|
250
|
+
{isExpanded && needsExtra && (
|
|
251
|
+
<div className="px-3 pb-3 space-y-2 border-t border-gray-100 pt-2">
|
|
252
|
+
{/* Bank Account Selector (for card, bank_transfer, wallet) */}
|
|
253
|
+
{method.needsBank && (
|
|
254
|
+
<div>
|
|
255
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Bank Account</label>
|
|
256
|
+
<button type="button" disabled={disabled}
|
|
257
|
+
onClick={() => setBankSelectorOpen({ index: i })}
|
|
258
|
+
className="w-full flex items-center gap-2 px-3 py-2 bg-white border border-gray-200 rounded-lg text-left text-sm hover:border-gray-300 transition-colors">
|
|
259
|
+
{p.bank_account_id ? (
|
|
260
|
+
<>
|
|
261
|
+
<Building2 size={14} className="text-blue-500 flex-shrink-0" />
|
|
262
|
+
<span className="font-medium text-gray-900 truncate">{p.bank_account_name}</span>
|
|
263
|
+
</>
|
|
264
|
+
) : (
|
|
265
|
+
<span className="text-gray-400">Select bank account...</span>
|
|
266
|
+
)}
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
|
|
271
|
+
{/* Card Details */}
|
|
272
|
+
{method.needsCardDetails && (
|
|
273
|
+
<div className="grid grid-cols-3 gap-2">
|
|
274
|
+
<div>
|
|
275
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Card Type</label>
|
|
276
|
+
<select disabled={disabled} value={p.card_type || ""} onChange={e => updatePayment(i, "card_type", e.target.value)}
|
|
277
|
+
className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs">
|
|
278
|
+
<option value="">Select</option>
|
|
279
|
+
<option value="visa">Visa</option>
|
|
280
|
+
<option value="mastercard">Mastercard</option>
|
|
281
|
+
<option value="amex">Amex</option>
|
|
282
|
+
<option value="other">Other</option>
|
|
283
|
+
</select>
|
|
284
|
+
</div>
|
|
285
|
+
<div>
|
|
286
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Last 4</label>
|
|
287
|
+
<input disabled={disabled} value={p.card_last_4 || ""} onChange={e => updatePayment(i, "card_last_4", e.target.value)}
|
|
288
|
+
maxLength={4} className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs" placeholder="0000" />
|
|
289
|
+
</div>
|
|
290
|
+
<div>
|
|
291
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Auth Code</label>
|
|
292
|
+
<input disabled={disabled} value={p.auth_code || ""} onChange={e => updatePayment(i, "auth_code", e.target.value)}
|
|
293
|
+
className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs" placeholder="—" />
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
|
|
298
|
+
{/* Cheque Details */}
|
|
299
|
+
{method.needsCheque && (
|
|
300
|
+
<div className="space-y-2">
|
|
301
|
+
<div className="grid grid-cols-2 gap-2">
|
|
302
|
+
<div>
|
|
303
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Cheque No</label>
|
|
304
|
+
<input disabled={disabled} value={p.cheque_no || ""} onChange={e => updatePayment(i, "cheque_no", e.target.value)}
|
|
305
|
+
className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs font-bold" placeholder="CHQ-001" />
|
|
306
|
+
</div>
|
|
307
|
+
<div>
|
|
308
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Due Date</label>
|
|
309
|
+
<input disabled={disabled} type="date" value={p.cheque_due_date || ""} onChange={e => updatePayment(i, "cheque_due_date", e.target.value)}
|
|
310
|
+
className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs" />
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
<div className="grid grid-cols-2 gap-2">
|
|
314
|
+
<div>
|
|
315
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Bank Name</label>
|
|
316
|
+
<input disabled={disabled} value={p.cheque_bank_name || ""} onChange={e => updatePayment(i, "cheque_bank_name", e.target.value)}
|
|
317
|
+
className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs" placeholder="Bank name" />
|
|
318
|
+
</div>
|
|
319
|
+
<div>
|
|
320
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Branch</label>
|
|
321
|
+
<input disabled={disabled} value={p.cheque_branch || ""} onChange={e => updatePayment(i, "cheque_branch", e.target.value)}
|
|
322
|
+
className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs" placeholder="Branch" />
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
<div className="flex items-center gap-2 px-2 py-1.5 bg-orange-100/50 rounded-lg">
|
|
326
|
+
<AlertTriangle size={12} className="text-orange-500 flex-shrink-0" />
|
|
327
|
+
<span className="text-[10px] text-orange-700 font-medium">Cheque payments do not affect bank balance until cleared</span>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
|
|
332
|
+
{/* Reference for all non-cash methods */}
|
|
333
|
+
{p.payment_method !== "cash" && !method.needsCheque && (
|
|
334
|
+
<div>
|
|
335
|
+
<label className="text-[10px] font-bold text-gray-500 uppercase mb-0.5 block">Reference / Transaction ID</label>
|
|
336
|
+
<input disabled={disabled} value={p.external_reference || ""} onChange={e => updatePayment(i, "external_reference", e.target.value)}
|
|
337
|
+
className="w-full px-2 py-1.5 bg-white border border-gray-200 rounded-lg text-xs" placeholder="Transaction reference..." />
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
})}
|
|
345
|
+
|
|
346
|
+
{/* Add Payment Row */}
|
|
347
|
+
{!disabled && payments.length < enabledMethods.length && (
|
|
348
|
+
<button type="button" onClick={addPayment}
|
|
349
|
+
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 border-2 border-dashed border-gray-200 rounded-xl text-sm font-semibold text-gray-400 hover:text-primary-600 hover:border-primary-300 transition-colors">
|
|
350
|
+
<Plus size={14} /> Split Payment
|
|
351
|
+
</button>
|
|
352
|
+
)}
|
|
353
|
+
|
|
354
|
+
{/* Summary */}
|
|
355
|
+
<div className="bg-gray-50 rounded-xl p-3 space-y-1">
|
|
356
|
+
<div className="flex justify-between text-xs text-gray-500">
|
|
357
|
+
<span>Total Payable</span>
|
|
358
|
+
<span className="font-bold">{currency} {totalAmount.toLocaleString("en", { minimumFractionDigits: 2 })}</span>
|
|
359
|
+
</div>
|
|
360
|
+
<div className="flex justify-between text-xs text-gray-500">
|
|
361
|
+
<span>Paid</span>
|
|
362
|
+
<span className="font-bold">{currency} {totalPaid.toLocaleString("en", { minimumFractionDigits: 2 })}</span>
|
|
363
|
+
</div>
|
|
364
|
+
{remaining > 0 && (
|
|
365
|
+
<div className="flex justify-between text-xs">
|
|
366
|
+
<span className="text-red-500 font-medium">Remaining</span>
|
|
367
|
+
<span className="font-bold text-red-600">{currency} {remaining.toLocaleString("en", { minimumFractionDigits: 2 })}</span>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
{showChange && changeAmount > 0 && (
|
|
371
|
+
<div className="flex justify-between text-sm pt-1 border-t border-gray-200">
|
|
372
|
+
<span className="text-blue-600 font-bold">Change</span>
|
|
373
|
+
<span className="font-bold text-blue-700 text-base">{currency} {changeAmount.toLocaleString("en", { minimumFractionDigits: 2 })}</span>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
{/* Bank Account Selector Modal */}
|
|
379
|
+
<Modal isOpen={!!bankSelectorOpen} onClose={() => { setBankSelectorOpen(null); setBankSearch(""); }} title="Select Bank Account" size="md">
|
|
380
|
+
<div className="space-y-3 p-1">
|
|
381
|
+
<div className="relative">
|
|
382
|
+
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
383
|
+
<input value={bankSearch} onChange={e => setBankSearch(e.target.value)} autoFocus
|
|
384
|
+
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-300"
|
|
385
|
+
placeholder="Search by name, number, bank..." />
|
|
386
|
+
</div>
|
|
387
|
+
<div className="max-h-[300px] overflow-y-auto space-y-1">
|
|
388
|
+
{filteredBanks.length === 0 && <p className="text-xs text-gray-400 text-center py-4">No bank accounts found.</p>}
|
|
389
|
+
{filteredBanks.map(bank => (
|
|
390
|
+
<button key={bank.id} type="button"
|
|
391
|
+
onClick={() => bankSelectorOpen && selectBank(bankSelectorOpen.index, bank)}
|
|
392
|
+
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-gray-50 transition-colors text-left group">
|
|
393
|
+
<div className="h-9 w-9 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
|
|
394
|
+
<Building2 size={16} className="text-blue-600" />
|
|
395
|
+
</div>
|
|
396
|
+
<div className="flex-1 min-w-0">
|
|
397
|
+
<div className="flex items-center gap-2">
|
|
398
|
+
<span className="text-sm font-bold text-gray-900 truncate">{bank.name}</span>
|
|
399
|
+
<span className="text-[10px] text-gray-400 font-medium">{bank.code}</span>
|
|
400
|
+
</div>
|
|
401
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
402
|
+
<span className="text-[10px] text-gray-400 font-bold">{bank.bank_name}</span>
|
|
403
|
+
<span className="text-[10px] text-gray-400">···{bank.account_number.slice(-4)}</span>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
<div className="text-right flex-shrink-0">
|
|
407
|
+
<span className={`text-xs font-bold ${bank.book_balance >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
|
408
|
+
{currency} {Number(bank.book_balance).toLocaleString("en", { minimumFractionDigits: 2 })}
|
|
409
|
+
</span>
|
|
410
|
+
</div>
|
|
411
|
+
</button>
|
|
412
|
+
))}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
<ModalFooter>
|
|
416
|
+
<Button variant="soft" color="default" onClick={() => { setBankSelectorOpen(null); setBankSearch(""); }}>Cancel</Button>
|
|
417
|
+
</ModalFooter>
|
|
418
|
+
</Modal>
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|