@apptimate/ui 7.0.0 → 7.2.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 +1 -1
- package/src/common-components/PaymentSection.tsx +66 -25
- package/src/common-components/attendance-shifts/BulkAttendanceModal.tsx +70 -25
- package/src/common-components/transaction/MetadataPanel.tsx +26 -17
- package/src/common-components/transaction/ProductSelectionPanel.tsx +1 -1
- package/src/common-components/transaction/ProductTransactionScreen.tsx +32 -2
- package/src/common-components/transaction/types.ts +2 -0
- package/src/finance-components/DirectExpenseModal.tsx +248 -0
- package/src/finance-components/DirectTransactionDetailModal.tsx +145 -0
- package/src/index.tsx +2 -0
package/package.json
CHANGED
|
@@ -56,6 +56,8 @@ export interface PaymentSectionProps {
|
|
|
56
56
|
onPaymentsChange: (payments: PaymentEntry[]) => void;
|
|
57
57
|
/** Optional: show compact variant */
|
|
58
58
|
compact?: boolean;
|
|
59
|
+
/** When true, initially show only Cash/Card with a 'More Options' toggle for other modes (used in POS) */
|
|
60
|
+
collapseModes?: boolean;
|
|
59
61
|
/** Fetcher for payment modes */
|
|
60
62
|
fetchPaymentModes: () => Promise<{ is_success: boolean; result?: any }>;
|
|
61
63
|
/** Fetcher for bank accounts */
|
|
@@ -72,11 +74,12 @@ export interface PaymentSectionProps {
|
|
|
72
74
|
|
|
73
75
|
/* ── Component ── */
|
|
74
76
|
|
|
75
|
-
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [], fetchChequeLeaves, variant = "default" }: PaymentSectionProps) {
|
|
77
|
+
export function PaymentSection({ totalAmount, payments, onPaymentsChange, compact, collapseModes, fetchPaymentModes, fetchBankAccounts, materialTypes = [], allowedSpecialModes = [], fetchChequeLeaves, variant = "default" }: PaymentSectionProps) {
|
|
76
78
|
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
77
79
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
78
80
|
const [isLoading, setIsLoading] = useState(false);
|
|
79
81
|
const [selectionMode, setSelectionMode] = useState<"single" | "split">("single");
|
|
82
|
+
const [showMoreModes, setShowMoreModes] = useState(false);
|
|
80
83
|
const paymentsRef = useRef(payments);
|
|
81
84
|
|
|
82
85
|
// Keep ref in sync
|
|
@@ -214,36 +217,74 @@ export function PaymentSection({ totalAmount, payments, onPaymentsChange, compac
|
|
|
214
217
|
return (
|
|
215
218
|
<div className="space-y-3">
|
|
216
219
|
{/* ── Mode Selection Buttons ── */}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
220
|
+
{(() => {
|
|
221
|
+
const activeModes = paymentModes.filter((m) => m.is_active);
|
|
222
|
+
const primaryModes = activeModes.filter((m) => ["cash", "card"].includes(m.code));
|
|
223
|
+
const secondaryModes = activeModes.filter((m) => !["cash", "card"].includes(m.code));
|
|
224
|
+
// If a secondary mode is currently selected, auto-expand
|
|
225
|
+
const isSecondaryActive = selectionMode === "single" && payments.length === 1 && secondaryModes.some(m => m.id === payments[0].mode_id);
|
|
226
|
+
const shouldShowMore = !collapseModes || showMoreModes || isSecondaryActive || selectionMode === "split";
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div className="flex flex-wrap gap-2">
|
|
230
|
+
{primaryModes.map((mode) => {
|
|
231
|
+
const isActive = selectionMode === "single" && payments.length === 1 && payments[0].mode_id === mode.id;
|
|
232
|
+
return (
|
|
233
|
+
<button key={mode.id} type="button" onClick={() => selectSingleMode(mode)}
|
|
234
|
+
className={cn(
|
|
235
|
+
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
236
|
+
isActive
|
|
237
|
+
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
238
|
+
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
239
|
+
)}>
|
|
240
|
+
{getModeIcon(mode.code)}
|
|
241
|
+
{mode.name}
|
|
242
|
+
</button>
|
|
243
|
+
);
|
|
244
|
+
})}
|
|
245
|
+
|
|
246
|
+
{/* More Options toggle */}
|
|
247
|
+
{secondaryModes.length > 0 && !shouldShowMore && (
|
|
248
|
+
<button type="button" onClick={() => setShowMoreModes(true)}
|
|
249
|
+
className="inline-flex items-center gap-1.5 px-3.5 py-2.5 text-[12px] font-semibold rounded-[10px] border border-dashed border-border-subtle text-foreground-subtle hover:border-primary/40 hover:text-primary hover:bg-primary/5 transition-all">
|
|
250
|
+
<span className="text-[14px]">···</span>
|
|
251
|
+
More Options
|
|
252
|
+
</button>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{/* Secondary modes (shown when expanded) */}
|
|
256
|
+
{shouldShowMore && secondaryModes.map((mode) => {
|
|
257
|
+
const isActive = selectionMode === "single" && payments.length === 1 && payments[0].mode_id === mode.id;
|
|
258
|
+
return (
|
|
259
|
+
<button key={mode.id} type="button" onClick={() => selectSingleMode(mode)}
|
|
260
|
+
className={cn(
|
|
261
|
+
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
262
|
+
isActive
|
|
263
|
+
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
264
|
+
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
265
|
+
)}>
|
|
266
|
+
{getModeIcon(mode.code)}
|
|
267
|
+
{mode.name}
|
|
268
|
+
</button>
|
|
269
|
+
);
|
|
270
|
+
})}
|
|
271
|
+
|
|
272
|
+
{/* Split Button (shown when expanded) */}
|
|
273
|
+
{shouldShowMore && (
|
|
274
|
+
<button type="button" onClick={activateSplitMode}
|
|
224
275
|
className={cn(
|
|
225
276
|
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
226
|
-
|
|
277
|
+
selectionMode === "split"
|
|
227
278
|
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
228
279
|
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
229
280
|
)}>
|
|
230
|
-
{
|
|
231
|
-
|
|
281
|
+
<Split size={14} />
|
|
282
|
+
Multiple Methods
|
|
232
283
|
</button>
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
className={cn(
|
|
238
|
-
"inline-flex items-center gap-2 px-4 py-2.5 text-[12px] font-semibold rounded-[10px] border transition-all",
|
|
239
|
-
selectionMode === "split"
|
|
240
|
-
? "bg-primary text-primary-foreground border-primary shadow-sm"
|
|
241
|
-
: "bg-surface-0 border-border-subtle text-foreground-1 hover:border-primary/40 hover:bg-primary/5 hover:text-primary"
|
|
242
|
-
)}>
|
|
243
|
-
<Split size={14} />
|
|
244
|
-
Multiple Methods
|
|
245
|
-
</button>
|
|
246
|
-
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
})()}
|
|
247
288
|
|
|
248
289
|
{/* ── Single Mode: Entry with fields (no delete) ── */}
|
|
249
290
|
{selectionMode === "single" && payments.length === 1 && (
|
|
@@ -14,11 +14,12 @@ interface BulkAttendanceModalProps {
|
|
|
14
14
|
onClose: () => void;
|
|
15
15
|
onSuccess: () => void;
|
|
16
16
|
fetchProjects?: () => Promise<any>;
|
|
17
|
+
fetchAccounts?: (type?: string) => Promise<any>;
|
|
17
18
|
initialProjectId?: string;
|
|
18
19
|
isFixedProject?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects, initialProjectId, isFixedProject }: BulkAttendanceModalProps) {
|
|
22
|
+
export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects, fetchAccounts, initialProjectId, isFixedProject }: BulkAttendanceModalProps) {
|
|
22
23
|
const [projects, setProjects] = useState<any[]>([]);
|
|
23
24
|
const [projectId, setProjectId] = useState<string>(initialProjectId || "");
|
|
24
25
|
const [attendanceDate, setAttendanceDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
|
@@ -26,6 +27,8 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
26
27
|
const [globalEmployees, setGlobalEmployees] = useState<any[]>([]);
|
|
27
28
|
const [isLoadingEmployees, setIsLoadingEmployees] = useState(false);
|
|
28
29
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
30
|
+
const [accounts, setAccounts] = useState<any[]>([]);
|
|
31
|
+
const [paymentAccountId, setPaymentAccountId] = useState<string>("");
|
|
29
32
|
|
|
30
33
|
// Time entries state: Record<employee_id, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>
|
|
31
34
|
const [timeEntries, setTimeEntries] = useState<Record<string, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>>({});
|
|
@@ -34,8 +37,16 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
34
37
|
if (isOpen) {
|
|
35
38
|
loadProjects();
|
|
36
39
|
loadGlobalEmployees();
|
|
40
|
+
if (fetchAccounts) {
|
|
41
|
+
fetchAccounts("asset").then((res) => {
|
|
42
|
+
if (res.is_success && res.result) {
|
|
43
|
+
setAccounts(res.result);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
37
47
|
setAttendanceDate(new Date().toISOString().split("T")[0]);
|
|
38
48
|
setProjectId(initialProjectId || "");
|
|
49
|
+
setPaymentAccountId("");
|
|
39
50
|
setTimeEntries({});
|
|
40
51
|
|
|
41
52
|
// If initialProjectId is already set, the second useEffect won't trigger because projectId hasn't changed.
|
|
@@ -178,7 +189,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
178
189
|
employees.forEach(emp => {
|
|
179
190
|
const entry = timeEntries[emp.id];
|
|
180
191
|
|
|
181
|
-
if (!entry
|
|
192
|
+
if (!entry) return;
|
|
182
193
|
|
|
183
194
|
if (String(emp.id).startsWith('wf-')) {
|
|
184
195
|
// It's a workforce gang worker
|
|
@@ -186,29 +197,45 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
186
197
|
const workforceId = parts[1];
|
|
187
198
|
const memberIdentifier = parts[2] || null;
|
|
188
199
|
|
|
189
|
-
if (entry.
|
|
200
|
+
if (entry.isPresent) {
|
|
201
|
+
if (entry.in || entry.out) {
|
|
202
|
+
workforcePunches.push({
|
|
203
|
+
workforce_id: Number(workforceId),
|
|
204
|
+
project_id: Number(projectId),
|
|
205
|
+
date: attendanceDate,
|
|
206
|
+
member_identifier: memberIdentifier,
|
|
207
|
+
punch_in_time: entry.in || null,
|
|
208
|
+
punch_out_time: entry.out || null,
|
|
209
|
+
break_hours: entry.break || 0,
|
|
210
|
+
salary_advance: entry.salaryAdvance || 0,
|
|
211
|
+
});
|
|
212
|
+
} else if (entry.salaryAdvance > 0) {
|
|
213
|
+
// If they only have a salary advance but no punch
|
|
214
|
+
workforcePunches.push({
|
|
215
|
+
workforce_id: Number(workforceId),
|
|
216
|
+
project_id: Number(projectId),
|
|
217
|
+
date: attendanceDate,
|
|
218
|
+
member_identifier: memberIdentifier,
|
|
219
|
+
salary_advance: entry.salaryAdvance,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
// ABSENT
|
|
190
224
|
workforcePunches.push({
|
|
191
225
|
workforce_id: Number(workforceId),
|
|
192
226
|
project_id: Number(projectId),
|
|
193
227
|
date: attendanceDate,
|
|
194
228
|
member_identifier: memberIdentifier,
|
|
195
|
-
punch_in_time:
|
|
196
|
-
punch_out_time:
|
|
197
|
-
break_hours:
|
|
229
|
+
punch_in_time: null,
|
|
230
|
+
punch_out_time: null,
|
|
231
|
+
break_hours: 0,
|
|
198
232
|
salary_advance: entry.salaryAdvance || 0,
|
|
199
233
|
});
|
|
200
|
-
} else if (entry.salaryAdvance > 0) {
|
|
201
|
-
// If they only have a salary advance but no punch
|
|
202
|
-
workforcePunches.push({
|
|
203
|
-
workforce_id: Number(workforceId),
|
|
204
|
-
project_id: Number(projectId),
|
|
205
|
-
date: attendanceDate,
|
|
206
|
-
member_identifier: memberIdentifier,
|
|
207
|
-
salary_advance: entry.salaryAdvance,
|
|
208
|
-
});
|
|
209
234
|
}
|
|
210
235
|
} else {
|
|
211
236
|
// It's a standard HR employee
|
|
237
|
+
if (!entry.isPresent) return; // Skip absent for HR for now
|
|
238
|
+
|
|
212
239
|
if (entry.in) {
|
|
213
240
|
hrPunches.push({
|
|
214
241
|
employee_id: emp.id,
|
|
@@ -251,7 +278,10 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
251
278
|
|
|
252
279
|
// Log HR punches
|
|
253
280
|
if (hrPunches.length > 0) {
|
|
254
|
-
const hrRes = await bulkPunchAttendance({
|
|
281
|
+
const hrRes = await bulkPunchAttendance({
|
|
282
|
+
punches: hrPunches,
|
|
283
|
+
payment_account_id: paymentAccountId ? Number(paymentAccountId) : undefined
|
|
284
|
+
});
|
|
255
285
|
if (!hrRes.is_success) {
|
|
256
286
|
toast.error(hrRes.message || "Failed to log HR attendance");
|
|
257
287
|
hasError = true;
|
|
@@ -262,7 +292,14 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
262
292
|
if (workforcePunches.length > 0) {
|
|
263
293
|
// use sendRequest from core-lib which is imported at the top of this file
|
|
264
294
|
const { sendRequest } = require("@apptimate/core-lib");
|
|
265
|
-
const wfRes = await sendRequest({
|
|
295
|
+
const wfRes = await sendRequest({
|
|
296
|
+
url: "/api/construction/workforce-attendance/bulk-punch",
|
|
297
|
+
method: "POST",
|
|
298
|
+
data: {
|
|
299
|
+
punches: workforcePunches,
|
|
300
|
+
payment_account_id: paymentAccountId ? Number(paymentAccountId) : undefined
|
|
301
|
+
}
|
|
302
|
+
});
|
|
266
303
|
if (!wfRes.responseData?.is_success) {
|
|
267
304
|
toast.error(wfRes.responseData?.message || "Failed to log Workforce attendance");
|
|
268
305
|
hasError = true;
|
|
@@ -286,7 +323,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
286
323
|
return (
|
|
287
324
|
<Modal isOpen={isOpen} onClose={onClose} title="Bulk Attendance Log" size="2xl">
|
|
288
325
|
<div className="space-y-4 py-2">
|
|
289
|
-
<div className="grid grid-cols-1 sm:grid-cols-
|
|
326
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
290
327
|
<div>
|
|
291
328
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date <span className="text-danger-500">*</span></label>
|
|
292
329
|
<Input
|
|
@@ -298,20 +335,28 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
298
335
|
<div>
|
|
299
336
|
<label className="block text-sm font-medium text-gray-700 mb-1">Project <span className="text-danger-500">*</span></label>
|
|
300
337
|
<Select
|
|
338
|
+
options={projects.map((p) => ({ value: String(p.id), label: p.name }))}
|
|
301
339
|
value={projectId}
|
|
302
340
|
onChange={(e) => setProjectId(e.target.value)}
|
|
303
341
|
disabled={isFixedProject}
|
|
304
|
-
options={[
|
|
305
|
-
{ value: "", label: "Select Project..." },
|
|
306
|
-
...projects.map((p) => ({
|
|
307
|
-
value: String(p.id),
|
|
308
|
-
label: p.name
|
|
309
|
-
}))
|
|
310
|
-
]}
|
|
311
342
|
/>
|
|
312
343
|
</div>
|
|
344
|
+
{fetchAccounts && (
|
|
345
|
+
<div>
|
|
346
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Account <span className="text-gray-400 font-normal text-xs">(Salary Advances)</span></label>
|
|
347
|
+
<Select
|
|
348
|
+
options={[
|
|
349
|
+
{ value: "", label: "Default mapping" },
|
|
350
|
+
...accounts.map((a) => ({ value: String(a.id), label: `${a.code} - ${a.name}` }))
|
|
351
|
+
]}
|
|
352
|
+
value={paymentAccountId}
|
|
353
|
+
onChange={(e) => setPaymentAccountId(e.target.value)}
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
313
357
|
</div>
|
|
314
358
|
|
|
359
|
+
|
|
315
360
|
{projectId && attendanceDate && (
|
|
316
361
|
<div className="mt-6 border border-gray-100 rounded-xl overflow-hidden bg-white shadow-sm">
|
|
317
362
|
<Table>
|
|
@@ -36,12 +36,13 @@ interface MetadataPanelProps {
|
|
|
36
36
|
isSubmitting: boolean;
|
|
37
37
|
renderExtraMeta?: () => React.ReactNode;
|
|
38
38
|
fetchChequeLeaves?: (query: string, page: number) => Promise<{ is_success: boolean; result?: any[] }>;
|
|
39
|
+
isFullscreen?: boolean;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export function MetadataPanel({
|
|
42
43
|
config, lineItems, partyId, partyName, warehouseId, warehouseName, transactionDate, expectedDeliveryDate, notes, payments,
|
|
43
44
|
onPartyChange, onWarehouseChange, onDateChange, onExpectedDeliveryDateChange, onNotesChange, onPaymentsChange, onSubmit, isSubmitting,
|
|
44
|
-
renderExtraMeta, fetchChequeLeaves,
|
|
45
|
+
renderExtraMeta, fetchChequeLeaves, isFullscreen,
|
|
45
46
|
}: MetadataPanelProps) {
|
|
46
47
|
const [paymentModes, setPaymentModes] = useState<PaymentMode[]>([]);
|
|
47
48
|
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
|
@@ -132,7 +133,9 @@ export function MetadataPanel({
|
|
|
132
133
|
);
|
|
133
134
|
|
|
134
135
|
return (
|
|
135
|
-
<div className="flex flex-col h-full
|
|
136
|
+
<div className="flex flex-col h-full">
|
|
137
|
+
{/* ── Scrollable Content ── */}
|
|
138
|
+
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-6 pr-1">
|
|
136
139
|
{/* ── Extra Module-Specific Meta (e.g. adjustment type/reason) ── */}
|
|
137
140
|
{renderExtraMeta && renderExtraMeta()}
|
|
138
141
|
|
|
@@ -216,8 +219,8 @@ export function MetadataPanel({
|
|
|
216
219
|
</Section>
|
|
217
220
|
)}
|
|
218
221
|
|
|
219
|
-
{/* ── Totals ── */}
|
|
220
|
-
{config.showPrices && lineItems.length > 0 && (
|
|
222
|
+
{/* ── Totals (hidden for POS — shown at bottom of left panel instead) ── */}
|
|
223
|
+
{config.showPrices && config.type !== 'pos' && lineItems.length > 0 && (
|
|
221
224
|
<div className="bg-surface-0 rounded-[12px] border border-border-subtle p-4 space-y-2">
|
|
222
225
|
<TotalRow label="Subtotal" value={formatCurrency(subtotal)} />
|
|
223
226
|
{discountTotal > 0 && (
|
|
@@ -239,23 +242,29 @@ export function MetadataPanel({
|
|
|
239
242
|
fetchBankAccounts={getBankAccountsLookup}
|
|
240
243
|
fetchChequeLeaves={fetchChequeLeaves}
|
|
241
244
|
compact={true}
|
|
245
|
+
collapseModes={config.type === 'pos'}
|
|
242
246
|
/>
|
|
243
247
|
</Section>
|
|
244
248
|
)}
|
|
249
|
+
</div>
|
|
245
250
|
|
|
246
|
-
{/* ──
|
|
247
|
-
<div className="
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
251
|
+
{/* ── Submit Button (sticky bottom) ── */}
|
|
252
|
+
<div className="shrink-0 pt-4">
|
|
253
|
+
<Button
|
|
254
|
+
type="submit"
|
|
255
|
+
color="primary"
|
|
256
|
+
isDisabled={lineItems.length === 0}
|
|
257
|
+
isLoading={isSubmitting}
|
|
258
|
+
className={cn(
|
|
259
|
+
"!w-full !font-semibold transition-all",
|
|
260
|
+
isFullscreen
|
|
261
|
+
? "!h-[72px] !text-[18px] !rounded-[12px] shadow-lg shadow-primary/20"
|
|
262
|
+
: "!py-4 !text-[14px] !rounded-[10px]"
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
{config.submitLabel}
|
|
266
|
+
</Button>
|
|
267
|
+
</div>
|
|
259
268
|
</div>
|
|
260
269
|
);
|
|
261
270
|
}
|
|
@@ -353,7 +353,7 @@ export function ProductSelectionPanel({
|
|
|
353
353
|
};
|
|
354
354
|
|
|
355
355
|
return (
|
|
356
|
-
<div className="flex flex-col h-full">
|
|
356
|
+
<div className="flex flex-col flex-1 min-h-0 w-full">
|
|
357
357
|
{/* ── Search Bar ── */}
|
|
358
358
|
{!config.disableItemSelection && (
|
|
359
359
|
<div ref={panelRef} className="relative mb-4">
|
|
@@ -31,6 +31,7 @@ export function ProductTransactionScreen({
|
|
|
31
31
|
fetchChequeLeaves,
|
|
32
32
|
initialData,
|
|
33
33
|
onAddNewItem,
|
|
34
|
+
isFullscreen,
|
|
34
35
|
}: ProductTransactionScreenProps) {
|
|
35
36
|
// ── State ──
|
|
36
37
|
const [lineItems, setLineItems] = useState<TransactionLineItem[]>([]);
|
|
@@ -503,7 +504,7 @@ export function ProductTransactionScreen({
|
|
|
503
504
|
{/* ── Two-Panel Layout ── */}
|
|
504
505
|
<div className="flex-1 flex gap-5 min-h-0">
|
|
505
506
|
{/* Left: Product Selection (75%) */}
|
|
506
|
-
<div className="flex-[3] min-w-0 flex flex-col">
|
|
507
|
+
<div className="flex-[3] min-w-0 flex flex-col min-h-0">
|
|
507
508
|
<ProductSelectionPanel
|
|
508
509
|
config={config}
|
|
509
510
|
partyId={partyId}
|
|
@@ -515,13 +516,41 @@ export function ProductTransactionScreen({
|
|
|
515
516
|
onToggleExpand={handleToggleExpand}
|
|
516
517
|
onAddNewItem={onAddNewItem}
|
|
517
518
|
/>
|
|
519
|
+
|
|
520
|
+
{/* ── POS: Amount to Pay Summary Bar ── */}
|
|
521
|
+
{config.type === 'pos' && lineItems.length > 0 && (
|
|
522
|
+
<div className="shrink-0 mt-3 bg-surface-0 rounded-[14px] border border-border-subtle px-5 py-3.5 flex items-center justify-between gap-6">
|
|
523
|
+
<div className="flex items-center gap-6">
|
|
524
|
+
<div className="flex items-center gap-2">
|
|
525
|
+
<span className="text-[11.5px] font-semibold text-foreground-subtle uppercase tracking-wide">Subtotal</span>
|
|
526
|
+
<span className="text-[13.5px] font-bold text-foreground-1">
|
|
527
|
+
{subtotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
528
|
+
</span>
|
|
529
|
+
</div>
|
|
530
|
+
{discountTotal > 0 && (
|
|
531
|
+
<div className="flex items-center gap-2">
|
|
532
|
+
<span className="text-[11.5px] font-semibold text-foreground-subtle uppercase tracking-wide">Discount</span>
|
|
533
|
+
<span className="text-[13.5px] font-bold text-green-600">
|
|
534
|
+
-{discountTotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
535
|
+
</span>
|
|
536
|
+
</div>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
<div className="flex items-center gap-3">
|
|
540
|
+
<span className="text-[12px] font-bold text-foreground-subtle uppercase tracking-wide">Amount to Pay</span>
|
|
541
|
+
<span className="text-[22px] font-extrabold text-foreground-0 tracking-tight tabular-nums">
|
|
542
|
+
{grandTotal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
543
|
+
</span>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
518
547
|
</div>
|
|
519
548
|
|
|
520
549
|
{/* Divider */}
|
|
521
550
|
<div className="w-px bg-border-subtle/60 shrink-0" />
|
|
522
551
|
|
|
523
552
|
{/* Right: Metadata (25%) */}
|
|
524
|
-
<div className="flex-1 min-w-0 flex flex-col
|
|
553
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
525
554
|
<MetadataPanel
|
|
526
555
|
config={config}
|
|
527
556
|
lineItems={lineItems}
|
|
@@ -617,6 +646,7 @@ export function ProductTransactionScreen({
|
|
|
617
646
|
isSubmitting={submitting}
|
|
618
647
|
renderExtraMeta={renderExtraMeta}
|
|
619
648
|
fetchChequeLeaves={fetchChequeLeaves}
|
|
649
|
+
isFullscreen={isFullscreen}
|
|
620
650
|
/>
|
|
621
651
|
</div>
|
|
622
652
|
</div>
|
|
@@ -319,6 +319,8 @@ export interface ProductTransactionScreenProps {
|
|
|
319
319
|
initialData?: any;
|
|
320
320
|
/** Optional callback to create a new item directly from the search bar */
|
|
321
321
|
onAddNewItem?: (callbacks: { setItemSearch: (value: string) => void }) => void;
|
|
322
|
+
/** Whether the screen is currently in fullscreen mode */
|
|
323
|
+
isFullscreen?: boolean;
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
export interface TransactionPayload {
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { Button, Input, ModalFooter, ChartOfAccountPicker, Modal } from "../index";
|
|
5
|
+
import { PaymentSection, PaymentEntry } from "../index";
|
|
6
|
+
import toast from "react-hot-toast";
|
|
7
|
+
import { Upload, X } from "lucide-react";
|
|
8
|
+
import { recordDirectExpenseShared, getPaymentModesShared, getBankAccountsLookupShared, getChartOfAccountsLookupShared } from "@apptimate/core-lib";
|
|
9
|
+
|
|
10
|
+
interface DirectExpenseModalProps {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
onSuccess: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function DirectExpenseModal({ isOpen, onClose, onSuccess }: DirectExpenseModalProps) {
|
|
17
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
18
|
+
|
|
19
|
+
const [transactionDate, setTransactionDate] = useState(new Date().toISOString().split("T")[0]);
|
|
20
|
+
const [expenseAccountId, setExpenseAccountId] = useState<number | string>("");
|
|
21
|
+
const [totalAmount, setTotalAmount] = useState<string>("");
|
|
22
|
+
const [reference, setReference] = useState("");
|
|
23
|
+
const [description, setDescription] = useState("");
|
|
24
|
+
const [payments, setPayments] = useState<PaymentEntry[]>([]);
|
|
25
|
+
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
26
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (!isOpen) {
|
|
30
|
+
setTransactionDate(new Date().toISOString().split("T")[0]);
|
|
31
|
+
setExpenseAccountId("");
|
|
32
|
+
setTotalAmount("");
|
|
33
|
+
setReference("");
|
|
34
|
+
setDescription("");
|
|
35
|
+
setPayments([]);
|
|
36
|
+
setSelectedFiles([]);
|
|
37
|
+
}
|
|
38
|
+
}, [isOpen]);
|
|
39
|
+
|
|
40
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
|
|
43
|
+
if (!expenseAccountId) {
|
|
44
|
+
toast.error("Please select an Expense Account");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const amountNum = parseFloat(totalAmount);
|
|
49
|
+
if (isNaN(amountNum) || amountNum <= 0) {
|
|
50
|
+
toast.error("Please enter a valid total amount");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (payments.length === 0) {
|
|
55
|
+
toast.error("Please add at least one payment method");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const totalToPay = payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
|
60
|
+
if (Math.abs(totalToPay - amountNum) > 0.01) {
|
|
61
|
+
toast.error(`Payment split (${totalToPay.toFixed(2)}) must exactly match the total amount (${amountNum.toFixed(2)})`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setIsSubmitting(true);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const formattedPayments = payments.map(p => {
|
|
69
|
+
const payload: any = {
|
|
70
|
+
payment_method: p.mode_code,
|
|
71
|
+
payment_amount: p.amount,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (p.metadata) {
|
|
75
|
+
if (p.metadata.bank_account_id) payload.bank_account_id = p.metadata.bank_account_id;
|
|
76
|
+
}
|
|
77
|
+
return payload;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const res = await recordDirectExpenseShared({
|
|
81
|
+
expense_account_id: expenseAccountId,
|
|
82
|
+
transaction_date: transactionDate,
|
|
83
|
+
description: description || null,
|
|
84
|
+
reference: reference || null,
|
|
85
|
+
payments: formattedPayments,
|
|
86
|
+
attachments: selectedFiles,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (res.is_success) {
|
|
90
|
+
toast.success("Direct expense recorded successfully");
|
|
91
|
+
onSuccess();
|
|
92
|
+
handleClose();
|
|
93
|
+
} else {
|
|
94
|
+
toast.error(res.message || "Failed to record expense");
|
|
95
|
+
}
|
|
96
|
+
} catch (e: any) {
|
|
97
|
+
toast.error(e.message || "An error occurred");
|
|
98
|
+
} finally {
|
|
99
|
+
setIsSubmitting(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleClose = () => {
|
|
104
|
+
setTransactionDate(new Date().toISOString().split("T")[0]);
|
|
105
|
+
setExpenseAccountId("");
|
|
106
|
+
setTotalAmount("");
|
|
107
|
+
setReference("");
|
|
108
|
+
setDescription("");
|
|
109
|
+
setPayments([]);
|
|
110
|
+
setSelectedFiles([]);
|
|
111
|
+
onClose();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (!isOpen) return null;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Modal
|
|
118
|
+
isOpen={isOpen}
|
|
119
|
+
onClose={handleClose}
|
|
120
|
+
title="Record Direct Expense"
|
|
121
|
+
size="md"
|
|
122
|
+
>
|
|
123
|
+
<form onSubmit={handleSubmit} className="space-y-5">
|
|
124
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
125
|
+
<div className="flex flex-col gap-1.5">
|
|
126
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Date *</label>
|
|
127
|
+
<Input
|
|
128
|
+
type="date"
|
|
129
|
+
value={transactionDate}
|
|
130
|
+
onChange={(e) => setTransactionDate(e.target.value)}
|
|
131
|
+
required
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
<div className="flex flex-col gap-1.5">
|
|
135
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Expense Account *</label>
|
|
136
|
+
<ChartOfAccountPicker
|
|
137
|
+
value={expenseAccountId}
|
|
138
|
+
onChange={(val) => setExpenseAccountId(val)}
|
|
139
|
+
fetchAccounts={getChartOfAccountsLookupShared}
|
|
140
|
+
filterType="expense"
|
|
141
|
+
label=""
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div className="flex flex-col gap-1.5">
|
|
147
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Total Amount *</label>
|
|
148
|
+
<Input
|
|
149
|
+
type="number"
|
|
150
|
+
step="0.01"
|
|
151
|
+
min="0"
|
|
152
|
+
value={totalAmount}
|
|
153
|
+
onChange={(e) => setTotalAmount(e.target.value)}
|
|
154
|
+
placeholder="0.00"
|
|
155
|
+
required
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{parseFloat(totalAmount) > 0 && (
|
|
160
|
+
<div className="border border-border-subtle rounded-xl p-4 bg-gray-50/50">
|
|
161
|
+
<PaymentSection
|
|
162
|
+
totalAmount={parseFloat(totalAmount) || 0}
|
|
163
|
+
payments={payments}
|
|
164
|
+
onPaymentsChange={setPayments}
|
|
165
|
+
fetchPaymentModes={async () => {
|
|
166
|
+
const res = await getPaymentModesShared();
|
|
167
|
+
if (res.is_success && Array.isArray(res.result)) {
|
|
168
|
+
res.result = res.result.filter((m: any) => {
|
|
169
|
+
const code = m.code?.toLowerCase();
|
|
170
|
+
return code !== "credit" && code !== "credit-metal" && code !== "credit-gold" && code !== "cheque";
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return res;
|
|
174
|
+
}}
|
|
175
|
+
fetchBankAccounts={getBankAccountsLookupShared}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
181
|
+
<div className="flex flex-col gap-1.5">
|
|
182
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Reference No</label>
|
|
183
|
+
<Input
|
|
184
|
+
value={reference}
|
|
185
|
+
onChange={(e) => setReference(e.target.value)}
|
|
186
|
+
placeholder="e.g. Receipt #1234"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="flex flex-col gap-1.5">
|
|
190
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Description</label>
|
|
191
|
+
<Input
|
|
192
|
+
value={description}
|
|
193
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
194
|
+
placeholder="What was this for?"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Attachments */}
|
|
200
|
+
<div className="flex flex-col gap-1.5">
|
|
201
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">Attachments</label>
|
|
202
|
+
<div
|
|
203
|
+
className="border-2 border-dashed border-gray-200 rounded-xl p-4 flex flex-col items-center justify-center bg-gray-50/50 hover:bg-gray-100 transition-colors cursor-pointer group"
|
|
204
|
+
onClick={() => fileInputRef.current?.click()}
|
|
205
|
+
>
|
|
206
|
+
<input
|
|
207
|
+
type="file"
|
|
208
|
+
multiple
|
|
209
|
+
className="hidden"
|
|
210
|
+
ref={fileInputRef}
|
|
211
|
+
onChange={(e) => {
|
|
212
|
+
if (e.target.files) {
|
|
213
|
+
setSelectedFiles(prev => [...prev, ...Array.from(e.target.files!)]);
|
|
214
|
+
}
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
<div className="w-8 h-8 bg-white rounded-full shadow-sm flex items-center justify-center text-gray-500 mb-2 group-hover:scale-110 transition-transform">
|
|
218
|
+
<Upload size={14} />
|
|
219
|
+
</div>
|
|
220
|
+
<p className="text-xs font-bold text-gray-700">Upload receipts or documents</p>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{selectedFiles.length > 0 && (
|
|
224
|
+
<div className="flex flex-wrap gap-2 mt-2">
|
|
225
|
+
{selectedFiles.map((file, index) => (
|
|
226
|
+
<div key={`new-${index}`} className="relative bg-gray-100 rounded-md px-2 py-1 flex items-center gap-2 border border-gray-200 text-xs">
|
|
227
|
+
<span className="truncate max-w-[120px]" title={file.name}>{file.name}</span>
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
onClick={() => setSelectedFiles(prev => prev.filter((_, i) => i !== index))}
|
|
231
|
+
className="text-red-500 hover:text-red-700"
|
|
232
|
+
>
|
|
233
|
+
<X size={12} />
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<ModalFooter>
|
|
242
|
+
<Button type="button" variant="flat" color="default" onClick={handleClose}>Cancel</Button>
|
|
243
|
+
<Button type="submit" color="primary" isLoading={isSubmitting}>Record Expense</Button>
|
|
244
|
+
</ModalFooter>
|
|
245
|
+
</form>
|
|
246
|
+
</Modal>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Modal, ModalFooter, Button, Badge, DateLabel } from "../index";
|
|
5
|
+
|
|
6
|
+
interface DirectTransactionDetailModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
transaction: any | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function DirectTransactionDetailModal({
|
|
13
|
+
isOpen,
|
|
14
|
+
onClose,
|
|
15
|
+
transaction,
|
|
16
|
+
}: DirectTransactionDetailModalProps) {
|
|
17
|
+
if (!isOpen || !transaction) return null;
|
|
18
|
+
|
|
19
|
+
const formatCurrency = (val: any) => {
|
|
20
|
+
return Number(val).toLocaleString("en-US", {
|
|
21
|
+
minimumFractionDigits: 2,
|
|
22
|
+
maximumFractionDigits: 2,
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Modal isOpen={isOpen} onClose={onClose} title="Transaction Details" size="md">
|
|
30
|
+
<div className="space-y-6 text-sm">
|
|
31
|
+
<div className="grid grid-cols-2 gap-y-4 gap-x-6 border-b border-gray-100 pb-5">
|
|
32
|
+
<div className="space-y-1">
|
|
33
|
+
<p className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
|
34
|
+
Type
|
|
35
|
+
</p>
|
|
36
|
+
<div>
|
|
37
|
+
<Badge
|
|
38
|
+
color={transaction.type === "income" ? "success" : "danger"}
|
|
39
|
+
variant="flat"
|
|
40
|
+
>
|
|
41
|
+
{transaction.type === "income" ? "Income" : "Expense"}
|
|
42
|
+
</Badge>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="space-y-1">
|
|
46
|
+
<p className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
|
47
|
+
Date
|
|
48
|
+
</p>
|
|
49
|
+
<div className="font-medium text-gray-900">
|
|
50
|
+
<DateLabel date={transaction.transaction_date} />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div className="space-y-1">
|
|
54
|
+
<p className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
|
55
|
+
Reference
|
|
56
|
+
</p>
|
|
57
|
+
<p className="font-medium text-gray-900">
|
|
58
|
+
{transaction.reference || "-"}
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="space-y-1">
|
|
62
|
+
<p className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
|
63
|
+
Bank Account
|
|
64
|
+
</p>
|
|
65
|
+
<p className="font-medium text-gray-900">
|
|
66
|
+
{transaction.payment_method === "cash"
|
|
67
|
+
? "Cash"
|
|
68
|
+
: transaction.bank_account?.account_name || "-"}
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="col-span-2 space-y-1">
|
|
72
|
+
<p className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
|
73
|
+
Description
|
|
74
|
+
</p>
|
|
75
|
+
<p className="font-medium text-gray-900 whitespace-pre-wrap">
|
|
76
|
+
{transaction.description || "-"}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex items-center justify-between pt-2 pb-5 border-b border-gray-100">
|
|
82
|
+
<p className="text-gray-500 font-medium">Total Amount</p>
|
|
83
|
+
<p className="text-xl font-bold text-gray-900">
|
|
84
|
+
{formatCurrency(transaction.amount)}
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Attachments Section */}
|
|
89
|
+
{transaction.attachments && transaction.attachments.length > 0 && (
|
|
90
|
+
<div className="space-y-3 pt-2">
|
|
91
|
+
<h3 className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
|
92
|
+
Attachments ({transaction.attachments.length})
|
|
93
|
+
</h3>
|
|
94
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
95
|
+
{transaction.attachments.map((attachment: any, idx: number) => {
|
|
96
|
+
const url = typeof attachment === 'string'
|
|
97
|
+
? (attachment.startsWith('http') ? attachment : `${API_URL}/storage/${attachment}`)
|
|
98
|
+
: attachment.url;
|
|
99
|
+
|
|
100
|
+
const isImage = url?.match(/\.(jpeg|jpg|gif|png|webp)$/i) != null ||
|
|
101
|
+
(typeof attachment === 'object' && attachment.mime_type?.startsWith('image/'));
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<a
|
|
105
|
+
key={idx}
|
|
106
|
+
href={url}
|
|
107
|
+
target="_blank"
|
|
108
|
+
rel="noopener noreferrer"
|
|
109
|
+
className="block group relative rounded-lg border border-gray-200 overflow-hidden hover:border-primary/50 transition-colors bg-gray-50"
|
|
110
|
+
>
|
|
111
|
+
{isImage ? (
|
|
112
|
+
<div className="aspect-square w-full">
|
|
113
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
114
|
+
<img
|
|
115
|
+
src={url}
|
|
116
|
+
alt="Attachment"
|
|
117
|
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
) : (
|
|
121
|
+
<div className="aspect-square w-full flex flex-col items-center justify-center p-3 text-center">
|
|
122
|
+
<div className="w-10 h-10 rounded-full bg-white shadow-sm flex items-center justify-center mb-2 text-gray-400">
|
|
123
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
124
|
+
</div>
|
|
125
|
+
<span className="text-[10px] font-medium text-gray-600 truncate w-full" title={attachment.original_name || attachment.filename || `File ${idx + 1}`}>
|
|
126
|
+
{attachment.original_name || attachment.filename || `File ${idx + 1}`}
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</a>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<ModalFooter>
|
|
139
|
+
<Button variant="flat" color="secondary" onClick={onClose}>
|
|
140
|
+
Close
|
|
141
|
+
</Button>
|
|
142
|
+
</ModalFooter>
|
|
143
|
+
</Modal>
|
|
144
|
+
);
|
|
145
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -79,3 +79,5 @@ export * from './common-components/attendance-shifts/AttendanceTimesheets';
|
|
|
79
79
|
export * from "./base-components/ChartOfAccountPicker";
|
|
80
80
|
export * from './common-components/print/TemplateRenderer';
|
|
81
81
|
export { default as TransactionPrintSelector } from './common-components/print/TransactionPrintSelector';
|
|
82
|
+
export { default as DirectExpenseModal } from "./finance-components/DirectExpenseModal";
|
|
83
|
+
export { default as DirectTransactionDetailModal } from "./finance-components/DirectTransactionDetailModal";
|