@apptimate/ui 5.7.0 → 5.9.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/base-components/SearchableSelect.tsx +5 -1
- package/src/base-components/Select.tsx +1 -1
- package/src/base-components/Table.tsx +2 -2
- package/src/common-components/DashboardLayout.tsx +63 -21
- package/src/common-components/attendance-shifts/BulkAttendanceModal.tsx +380 -0
- package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +1 -1
- package/src/common-components/attendance-shifts/ManualPunchModal.tsx +1 -1
- package/src/common-components/attendance-shifts/ShiftCalendar.tsx +4 -4
- package/src/common-components/pickers/CurrencyPicker.tsx +155 -0
- package/src/common-components/pickers/PartyPicker.tsx +26 -4
- package/src/common-components/pickers/index.ts +1 -0
- package/src/finance-components/PaymentForm.tsx +84 -0
- package/src/index.tsx +2 -1
package/package.json
CHANGED
|
@@ -208,7 +208,11 @@ function SelectCore<T extends Record<string, unknown>>({
|
|
|
208
208
|
// ── Default value ────────────────────────────────────────────────────────
|
|
209
209
|
const defaultValString = JSON.stringify(defaultValue || '');
|
|
210
210
|
useEffect(() => {
|
|
211
|
-
if (!defaultValue)
|
|
211
|
+
if (!defaultValue) {
|
|
212
|
+
setSelectedValue(multiple ? [] : ({} as T));
|
|
213
|
+
setValue(multiple ? [] : '');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
212
216
|
if (multiple && Array.isArray(defaultValue) && defaultValue.length > 0) {
|
|
213
217
|
const vals = (defaultValue as T[]).map((v) => v[opt.value] as string | number);
|
|
214
218
|
setSelectedValue(defaultValue as T[]);
|
|
@@ -25,7 +25,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
|
25
25
|
<select
|
|
26
26
|
ref={ref}
|
|
27
27
|
className={cn(
|
|
28
|
-
"w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-gray-300 focus:bg-surface-1 appearance-none cursor-pointer",
|
|
28
|
+
"w-full bg-surface-0 border-[1.5px] border-border-subtle rounded-[10px] px-3.5 py-2.5 text-[13.5px] text-foreground-1 outline-none transition-all hover:border-gray-300 focus:border-gray-300 focus:bg-surface-1 appearance-none cursor-pointer disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed",
|
|
29
29
|
error && "border-danger-alt focus:border-danger-alt hover:border-danger-alt"
|
|
30
30
|
)}
|
|
31
31
|
{...props}
|
|
@@ -60,8 +60,8 @@ export const TCell = ({ children, className, isHeader = false, colSpan, rowSpan,
|
|
|
60
60
|
!isHeader && "flex justify-between items-center lg:table-cell border-b border-gray-100 last:border-b-0 lg:last:border-b", // Adds separator line across rows on desktop
|
|
61
61
|
className
|
|
62
62
|
)}>
|
|
63
|
-
{label && <span className="lg:hidden text-[11px] font-bold text-gray-400 uppercase tracking-wider">{label}</span>}
|
|
64
|
-
<div className="flex justify-end lg:block w-
|
|
63
|
+
{label && <span className="lg:hidden text-[11px] font-bold text-gray-400 uppercase tracking-wider shrink-0 mr-4">{label}</span>}
|
|
64
|
+
<div className="flex justify-end lg:block flex-1 min-w-0 lg:w-auto">{children}</div>
|
|
65
65
|
</Tag>
|
|
66
66
|
);
|
|
67
67
|
};
|
|
@@ -36,6 +36,7 @@ export type OrganizationConfig = {
|
|
|
36
36
|
name: string;
|
|
37
37
|
code?: string;
|
|
38
38
|
organization_type?: string;
|
|
39
|
+
parent_organization_id?: number;
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
export function DashboardLayout({
|
|
@@ -71,11 +72,40 @@ export function DashboardLayout({
|
|
|
71
72
|
const pathname = usePathname() || "";
|
|
72
73
|
const router = useRouter();
|
|
73
74
|
|
|
75
|
+
// Helper to build hierarchy
|
|
76
|
+
const flattenedOrganizations = React.useMemo(() => {
|
|
77
|
+
if (!organizations || organizations.length === 0) return [];
|
|
78
|
+
|
|
79
|
+
const orgMap = new Map<number, any>();
|
|
80
|
+
const roots: any[] = [];
|
|
81
|
+
|
|
82
|
+
organizations.forEach(org => {
|
|
83
|
+
orgMap.set(org.id, { ...org, children: [] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
organizations.forEach(org => {
|
|
87
|
+
const node = orgMap.get(org.id)!;
|
|
88
|
+
if (org.parent_organization_id && orgMap.has(org.parent_organization_id)) {
|
|
89
|
+
orgMap.get(org.parent_organization_id)!.children.push(node);
|
|
90
|
+
} else {
|
|
91
|
+
roots.push(node);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const flattened: (OrganizationConfig & { _level: number })[] = [];
|
|
96
|
+
const traverse = (nodes: any[], level: number) => {
|
|
97
|
+
nodes.forEach(node => {
|
|
98
|
+
flattened.push({ ...node, _level: level });
|
|
99
|
+
traverse(node.children, level + 1);
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
traverse(roots, 0);
|
|
103
|
+
return flattened;
|
|
104
|
+
}, [organizations]);
|
|
105
|
+
|
|
74
106
|
// Find which main menu should be active based on current path.
|
|
75
107
|
// When basePath is set, prefer the menu whose items are mostly within basePath.
|
|
76
|
-
const fullPath =
|
|
77
|
-
? pathname
|
|
78
|
-
: basePath + (pathname === "/" && basePath ? "" : pathname);
|
|
108
|
+
const fullPath = basePath + (pathname === "/" && basePath ? "" : pathname);
|
|
79
109
|
const currentMainMenu = (() => {
|
|
80
110
|
let bestMenu = menus[0];
|
|
81
111
|
let bestScore = -1;
|
|
@@ -213,18 +243,15 @@ export function DashboardLayout({
|
|
|
213
243
|
setIsSubSidebarOpen(true);
|
|
214
244
|
}
|
|
215
245
|
}}
|
|
216
|
-
className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-
|
|
246
|
+
className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-colors w-full px-1 ${isActive ? "text-[#2D3142]" : "text-gray-400 hover:text-[#2D3142]"
|
|
217
247
|
}`}
|
|
218
248
|
>
|
|
219
|
-
{isActive && (
|
|
220
|
-
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-indigo-600 rounded-r-full" />
|
|
221
|
-
)}
|
|
222
249
|
{/* Clone the icon to dynamically apply styling based on active state */}
|
|
223
250
|
{React.cloneElement(menu.icon as React.ReactElement<any>, {
|
|
224
251
|
size: 22,
|
|
225
252
|
className: isActive ? "stroke-[2.5]" : "stroke-[2]"
|
|
226
253
|
})}
|
|
227
|
-
<span className={`text-[11px] text-center leading-tight truncate block w-full max-w-[70px] ${isActive ? "font-bold
|
|
254
|
+
<span className={`text-[11px] text-center leading-tight truncate block w-full max-w-[70px] ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
|
|
228
255
|
</div>
|
|
229
256
|
);
|
|
230
257
|
})}
|
|
@@ -291,9 +318,9 @@ export function DashboardLayout({
|
|
|
291
318
|
: !externalPaths.some(ext => item.path.startsWith(ext));
|
|
292
319
|
const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
|
|
293
320
|
|
|
294
|
-
const className = `
|
|
295
|
-
? "bg-
|
|
296
|
-
: "
|
|
321
|
+
const className = `px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${isItemActive
|
|
322
|
+
? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
|
|
323
|
+
: "text-gray-500 font-medium hover:bg-gray-50"
|
|
297
324
|
}`;
|
|
298
325
|
|
|
299
326
|
const content = (
|
|
@@ -329,7 +356,12 @@ export function DashboardLayout({
|
|
|
329
356
|
onClick={() => setIsOrgModalOpen(true)}
|
|
330
357
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#F4F5F7] hover:bg-gray-200/70 transition-all duration-200 group cursor-pointer"
|
|
331
358
|
>
|
|
332
|
-
<div className=
|
|
359
|
+
<div className={cn(
|
|
360
|
+
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 shadow-sm",
|
|
361
|
+
selectedOrganization?.organization_type === 'project'
|
|
362
|
+
? "bg-gradient-to-br from-teal-400 to-teal-600"
|
|
363
|
+
: "bg-gradient-to-br from-indigo-500 to-purple-600"
|
|
364
|
+
)}>
|
|
333
365
|
<Building2 size={14} className="text-white" />
|
|
334
366
|
</div>
|
|
335
367
|
<div className="flex-1 min-w-0 text-left">
|
|
@@ -361,7 +393,12 @@ export function DashboardLayout({
|
|
|
361
393
|
onClick={() => setIsOrgModalOpen(true)}
|
|
362
394
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#F4F5F7] hover:bg-gray-200/70 transition-all cursor-pointer"
|
|
363
395
|
>
|
|
364
|
-
<div className=
|
|
396
|
+
<div className={cn(
|
|
397
|
+
"w-5 h-5 rounded-md flex items-center justify-center shrink-0",
|
|
398
|
+
selectedOrganization?.organization_type === 'project'
|
|
399
|
+
? "bg-gradient-to-br from-teal-400 to-teal-600"
|
|
400
|
+
: "bg-gradient-to-br from-indigo-500 to-purple-600"
|
|
401
|
+
)}>
|
|
365
402
|
<Building2 size={10} className="text-white" />
|
|
366
403
|
</div>
|
|
367
404
|
<span className="text-[12px] font-semibold text-[#2D3142] max-w-[100px] truncate">
|
|
@@ -526,32 +563,37 @@ export function DashboardLayout({
|
|
|
526
563
|
{/* Organization List */}
|
|
527
564
|
<div className="px-4 py-3 max-h-[360px] overflow-y-auto">
|
|
528
565
|
<div className="flex flex-col gap-1">
|
|
529
|
-
{
|
|
566
|
+
{flattenedOrganizations.map((org) => {
|
|
530
567
|
const isSelected = selectedOrganization?.id === org.id;
|
|
568
|
+
const pl = org._level > 0 ? org._level * 24 : 0;
|
|
569
|
+
const isProject = org.organization_type === 'project';
|
|
570
|
+
|
|
531
571
|
return (
|
|
532
572
|
<button
|
|
533
573
|
key={org.id}
|
|
534
574
|
id={`org-option-${org.id}`}
|
|
535
575
|
onClick={() => handleSelectOrg(org)}
|
|
576
|
+
style={{ paddingLeft: pl ? (pl + 16) + 'px' : undefined }}
|
|
536
577
|
className={cn(
|
|
537
578
|
"w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-150 cursor-pointer",
|
|
538
579
|
isSelected
|
|
539
|
-
? "bg-indigo-50 border border-indigo-200 shadow-sm"
|
|
540
|
-
: "hover:bg-gray-50 border border-transparent"
|
|
580
|
+
? (isProject ? "bg-teal-50 border border-teal-200 shadow-sm" : "bg-indigo-50 border border-indigo-200 shadow-sm")
|
|
581
|
+
: (isProject ? "hover:bg-teal-50 border border-transparent" : "hover:bg-gray-50 border border-transparent"),
|
|
582
|
+
org._level > 0 ? "relative before:content-[''] before:absolute before:left-[-12px] before:top-1/2 before:w-3 before:h-px before:bg-gray-200" : ""
|
|
541
583
|
)}
|
|
542
584
|
>
|
|
543
585
|
<div className={cn(
|
|
544
586
|
"w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors",
|
|
545
587
|
isSelected
|
|
546
|
-
? "bg-gradient-to-br from-indigo-500 to-purple-600 shadow-sm"
|
|
547
|
-
: "bg-gray-100"
|
|
588
|
+
? (isProject ? "bg-gradient-to-br from-teal-400 to-teal-600 shadow-sm" : "bg-gradient-to-br from-indigo-500 to-purple-600 shadow-sm")
|
|
589
|
+
: (isProject ? "bg-teal-100/50" : "bg-gray-100")
|
|
548
590
|
)}>
|
|
549
|
-
<Building2 size={14} className={isSelected ? "text-white" : "text-gray-500"} />
|
|
591
|
+
<Building2 size={14} className={isSelected ? "text-white" : (isProject ? "text-teal-600" : "text-gray-500")} />
|
|
550
592
|
</div>
|
|
551
593
|
<div className="flex-1 min-w-0">
|
|
552
594
|
<p className={cn(
|
|
553
595
|
"text-[14px] font-semibold truncate leading-tight",
|
|
554
|
-
isSelected ? "text-indigo-700" : "text-[#2D3142]"
|
|
596
|
+
isSelected ? (isProject ? "text-teal-700" : "text-indigo-700") : (isProject ? "text-teal-700" : "text-[#2D3142]")
|
|
555
597
|
)}>
|
|
556
598
|
{org.name}
|
|
557
599
|
</p>
|
|
@@ -563,7 +605,7 @@ export function DashboardLayout({
|
|
|
563
605
|
</p>
|
|
564
606
|
</div>
|
|
565
607
|
{isSelected && (
|
|
566
|
-
<div className="w-6 h-6 rounded-full
|
|
608
|
+
<div className={cn("w-6 h-6 rounded-full flex items-center justify-center shrink-0", isProject ? "bg-teal-500" : "bg-indigo-500")}>
|
|
567
609
|
<Check size={12} className="text-white stroke-[3]" />
|
|
568
610
|
</div>
|
|
569
611
|
)}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Modal, ModalFooter } from "../../base-components/Modal";
|
|
3
|
+
import { Button } from "../../base-components/Button";
|
|
4
|
+
import { Input } from "../../base-components/Input";
|
|
5
|
+
import { Select } from "../../base-components/Select";
|
|
6
|
+
import { Checkbox } from "../../base-components/Checkbox";
|
|
7
|
+
import { Table, THeader, TRow, TCell, TBody } from "../../base-components/Table";
|
|
8
|
+
import { bulkPunchAttendance, getWorkforceAttendanceLookup, getEmployeeLookup } from "@apptimate/core-lib";
|
|
9
|
+
import { Plus } from "lucide-react";
|
|
10
|
+
import toast from "react-hot-toast";
|
|
11
|
+
|
|
12
|
+
interface BulkAttendanceModalProps {
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
onSuccess: () => void;
|
|
16
|
+
fetchProjects?: () => Promise<any>;
|
|
17
|
+
initialProjectId?: string;
|
|
18
|
+
isFixedProject?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects, initialProjectId, isFixedProject }: BulkAttendanceModalProps) {
|
|
22
|
+
const [projects, setProjects] = useState<any[]>([]);
|
|
23
|
+
const [projectId, setProjectId] = useState<string>(initialProjectId || "");
|
|
24
|
+
const [attendanceDate, setAttendanceDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
|
25
|
+
const [employees, setEmployees] = useState<any[]>([]);
|
|
26
|
+
const [globalEmployees, setGlobalEmployees] = useState<any[]>([]);
|
|
27
|
+
const [isLoadingEmployees, setIsLoadingEmployees] = useState(false);
|
|
28
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
29
|
+
|
|
30
|
+
// Time entries state: Record<employee_id, { in: string, out: string, break: number, isPresent: boolean }>
|
|
31
|
+
const [timeEntries, setTimeEntries] = useState<Record<string, { in: string, out: string, break: number, isPresent: boolean }>>({});
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (isOpen) {
|
|
35
|
+
loadProjects();
|
|
36
|
+
loadGlobalEmployees();
|
|
37
|
+
setAttendanceDate(new Date().toISOString().split("T")[0]);
|
|
38
|
+
setProjectId(initialProjectId || "");
|
|
39
|
+
setEmployees([]);
|
|
40
|
+
setTimeEntries({});
|
|
41
|
+
}
|
|
42
|
+
}, [isOpen, initialProjectId]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (projectId && attendanceDate) {
|
|
46
|
+
loadEmployees();
|
|
47
|
+
} else {
|
|
48
|
+
setEmployees([]);
|
|
49
|
+
}
|
|
50
|
+
}, [projectId, attendanceDate]);
|
|
51
|
+
|
|
52
|
+
const loadGlobalEmployees = async () => {
|
|
53
|
+
try {
|
|
54
|
+
const res = await getEmployeeLookup();
|
|
55
|
+
if (res.is_success && res.result) {
|
|
56
|
+
setGlobalEmployees(res.result);
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error(e);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const loadProjects = async () => {
|
|
64
|
+
try {
|
|
65
|
+
if (fetchProjects) {
|
|
66
|
+
const res = await fetchProjects();
|
|
67
|
+
if (res.is_success && res.result?.data) {
|
|
68
|
+
setProjects(res.result.data);
|
|
69
|
+
} else if (res.is_success && Array.isArray(res.result)) {
|
|
70
|
+
setProjects(res.result);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.error(e);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const loadEmployees = async () => {
|
|
79
|
+
try {
|
|
80
|
+
setIsLoadingEmployees(true);
|
|
81
|
+
const res = await getWorkforceAttendanceLookup({ project_id: Number(projectId), date: attendanceDate });
|
|
82
|
+
if (res.is_success && res.result) {
|
|
83
|
+
setEmployees(res.result);
|
|
84
|
+
|
|
85
|
+
// Initialize time entries for these employees
|
|
86
|
+
const newEntries: Record<string, { in: string, out: string, break: number, isPresent: boolean }> = {};
|
|
87
|
+
res.result.forEach((emp: any) => {
|
|
88
|
+
newEntries[emp.id] = { in: "09:00", out: "18:00", break: 0, isPresent: true };
|
|
89
|
+
});
|
|
90
|
+
setTimeEntries(newEntries);
|
|
91
|
+
}
|
|
92
|
+
} catch (e: any) {
|
|
93
|
+
toast.error("Failed to load allocated employees");
|
|
94
|
+
} finally {
|
|
95
|
+
setIsLoadingEmployees(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleTimeChange = (empId: string, type: 'in' | 'out' | 'break' | 'isPresent', value: string | number | boolean) => {
|
|
100
|
+
setTimeEntries(prev => ({
|
|
101
|
+
...prev,
|
|
102
|
+
[empId]: {
|
|
103
|
+
...prev[empId],
|
|
104
|
+
[type]: value
|
|
105
|
+
}
|
|
106
|
+
}));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleAddEmployeeRow = () => {
|
|
110
|
+
const fakeId = `manual-${Date.now()}`;
|
|
111
|
+
setEmployees(prev => [...prev, { id: fakeId, is_manual: true }]);
|
|
112
|
+
setTimeEntries(prev => ({
|
|
113
|
+
...prev,
|
|
114
|
+
[fakeId]: { in: "09:00", out: "18:00", break: 0, isPresent: true }
|
|
115
|
+
}));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleEmployeeSelectChange = (fakeId: string, newEmpId: string) => {
|
|
119
|
+
const selectedEmp = globalEmployees.find((e) => String(e.id) === newEmpId);
|
|
120
|
+
if (!selectedEmp) return;
|
|
121
|
+
|
|
122
|
+
setEmployees(prev => prev.map(emp =>
|
|
123
|
+
emp.id === fakeId
|
|
124
|
+
? { ...emp, id: newEmpId, first_name: selectedEmp.first_name, last_name: selectedEmp.last_name, employee_code: selectedEmp.employee_code, is_manual: true }
|
|
125
|
+
: emp
|
|
126
|
+
));
|
|
127
|
+
|
|
128
|
+
setTimeEntries(prev => {
|
|
129
|
+
const newEntries = { ...prev };
|
|
130
|
+
newEntries[newEmpId] = newEntries[fakeId];
|
|
131
|
+
delete newEntries[fakeId];
|
|
132
|
+
return newEntries;
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleRemoveEmployeeRow = (empId: string) => {
|
|
137
|
+
setEmployees(prev => prev.filter(emp => emp.id !== empId));
|
|
138
|
+
setTimeEntries(prev => {
|
|
139
|
+
const newEntries = { ...prev };
|
|
140
|
+
delete newEntries[empId];
|
|
141
|
+
return newEntries;
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleSubmit = async () => {
|
|
146
|
+
if (employees.length === 0) {
|
|
147
|
+
toast.error("No employees to log attendance for");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
setIsSubmitting(true);
|
|
153
|
+
|
|
154
|
+
const hrPunches: any[] = [];
|
|
155
|
+
const workforcePunches: any[] = [];
|
|
156
|
+
|
|
157
|
+
employees.forEach(emp => {
|
|
158
|
+
const entry = timeEntries[emp.id];
|
|
159
|
+
|
|
160
|
+
if (!entry || !entry.isPresent) return;
|
|
161
|
+
|
|
162
|
+
if (String(emp.id).startsWith('wf-')) {
|
|
163
|
+
// It's a workforce gang worker
|
|
164
|
+
const parts = String(emp.id).split('-'); // e.g. wf-12-1
|
|
165
|
+
const workforceId = parts[1];
|
|
166
|
+
const memberIdentifier = parts[2] || null;
|
|
167
|
+
|
|
168
|
+
if (entry.in || entry.out) {
|
|
169
|
+
workforcePunches.push({
|
|
170
|
+
workforce_id: Number(workforceId),
|
|
171
|
+
project_id: Number(projectId),
|
|
172
|
+
date: attendanceDate,
|
|
173
|
+
member_identifier: memberIdentifier,
|
|
174
|
+
punch_in_time: entry.in || null,
|
|
175
|
+
punch_out_time: entry.out || null,
|
|
176
|
+
break_hours: entry.break || 0,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// It's a standard HR employee
|
|
181
|
+
if (entry.in) {
|
|
182
|
+
hrPunches.push({
|
|
183
|
+
employee_id: emp.id,
|
|
184
|
+
punch_at: `${attendanceDate} ${entry.in}:00`,
|
|
185
|
+
type: "I",
|
|
186
|
+
source: "Bulk"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (entry.out) {
|
|
190
|
+
hrPunches.push({
|
|
191
|
+
employee_id: emp.id,
|
|
192
|
+
punch_at: `${attendanceDate} ${entry.out}:00`,
|
|
193
|
+
type: "O",
|
|
194
|
+
source: "Bulk"
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (hrPunches.length === 0 && workforcePunches.length === 0) {
|
|
201
|
+
toast.error("Please fill in at least one time entry");
|
|
202
|
+
setIsSubmitting(false);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let hasError = false;
|
|
207
|
+
|
|
208
|
+
// Log HR punches
|
|
209
|
+
if (hrPunches.length > 0) {
|
|
210
|
+
const hrRes = await bulkPunchAttendance({ punches: hrPunches });
|
|
211
|
+
if (!hrRes.is_success) {
|
|
212
|
+
toast.error(hrRes.message || "Failed to log HR attendance");
|
|
213
|
+
hasError = true;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Log Workforce punches
|
|
218
|
+
if (workforcePunches.length > 0) {
|
|
219
|
+
// use sendRequest from core-lib which is imported at the top of this file
|
|
220
|
+
const { sendRequest } = require("@apptimate/core-lib");
|
|
221
|
+
const wfRes = await sendRequest({ url: "/api/construction/workforce-attendance/bulk-punch", method: "POST", data: { punches: workforcePunches } });
|
|
222
|
+
if (!(wfRes as any).is_success) {
|
|
223
|
+
toast.error((wfRes as any).message || "Failed to log Workforce attendance");
|
|
224
|
+
hasError = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!hasError) {
|
|
229
|
+
toast.success("Bulk attendance logged successfully");
|
|
230
|
+
onSuccess();
|
|
231
|
+
onClose();
|
|
232
|
+
} else {
|
|
233
|
+
toast.error("Failed to log some attendance records");
|
|
234
|
+
}
|
|
235
|
+
} catch (e: any) {
|
|
236
|
+
toast.error(e.message || "An error occurred");
|
|
237
|
+
} finally {
|
|
238
|
+
setIsSubmitting(false);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<Modal isOpen={isOpen} onClose={onClose} title="Bulk Attendance Log" size="2xl">
|
|
244
|
+
<div className="space-y-4 py-2">
|
|
245
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
246
|
+
<div>
|
|
247
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Date <span className="text-danger-500">*</span></label>
|
|
248
|
+
<Input
|
|
249
|
+
type="date"
|
|
250
|
+
value={attendanceDate}
|
|
251
|
+
onChange={(e) => setAttendanceDate(e.target.value)}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<div>
|
|
255
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">Project <span className="text-danger-500">*</span></label>
|
|
256
|
+
<Select
|
|
257
|
+
value={projectId}
|
|
258
|
+
onChange={(e) => setProjectId(e.target.value)}
|
|
259
|
+
disabled={isFixedProject}
|
|
260
|
+
options={[
|
|
261
|
+
{ value: "", label: "Select Project..." },
|
|
262
|
+
...projects.map((p) => ({
|
|
263
|
+
value: String(p.id),
|
|
264
|
+
label: p.name
|
|
265
|
+
}))
|
|
266
|
+
]}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{projectId && attendanceDate && (
|
|
272
|
+
<div className="mt-6 border border-gray-100 rounded-xl overflow-hidden bg-white shadow-sm">
|
|
273
|
+
<Table>
|
|
274
|
+
<THeader>
|
|
275
|
+
<TRow>
|
|
276
|
+
<TCell isHeader className="bg-gray-50 text-gray-600 font-semibold tracking-wide border-t-0 border-r-0 border-l-0">Employee</TCell>
|
|
277
|
+
<TCell isHeader className="bg-gray-50 text-gray-600 font-semibold tracking-wide border-t-0 border-r-0 border-l-0 w-24">Present</TCell>
|
|
278
|
+
<TCell isHeader className="bg-gray-50 text-gray-600 font-semibold tracking-wide border-t-0 border-r-0 border-l-0 w-32">First In</TCell>
|
|
279
|
+
<TCell isHeader className="bg-gray-50 text-gray-600 font-semibold tracking-wide border-t-0 border-r-0 border-l-0 w-32">Last Out</TCell>
|
|
280
|
+
<TCell isHeader className="bg-gray-50 text-gray-600 font-semibold tracking-wide border-t-0 border-r-0 border-l-0 w-32">Break (hrs)</TCell>
|
|
281
|
+
</TRow>
|
|
282
|
+
</THeader>
|
|
283
|
+
<TBody>
|
|
284
|
+
{isLoadingEmployees ? (
|
|
285
|
+
<TRow>
|
|
286
|
+
<TCell colSpan={4} className="text-center py-8 text-gray-400">Loading employees...</TCell>
|
|
287
|
+
</TRow>
|
|
288
|
+
) : employees.length === 0 ? (
|
|
289
|
+
<TRow>
|
|
290
|
+
<TCell colSpan={4} className="text-center py-8 text-gray-500">No employees allocated to this project on this date.</TCell>
|
|
291
|
+
</TRow>
|
|
292
|
+
) : (
|
|
293
|
+
employees.map(emp => (
|
|
294
|
+
<TRow key={emp.id} className="hover:bg-gray-50/50 transition-colors">
|
|
295
|
+
<TCell label="Employee">
|
|
296
|
+
{emp.is_manual ? (
|
|
297
|
+
<div className="flex items-center gap-2">
|
|
298
|
+
<Select
|
|
299
|
+
value={String(emp.id).startsWith('manual') ? "" : String(emp.id)}
|
|
300
|
+
onChange={(e) => handleEmployeeSelectChange(emp.id, e.target.value)}
|
|
301
|
+
options={[
|
|
302
|
+
{ value: "", label: "Select Employee..." },
|
|
303
|
+
...globalEmployees.map(ge => ({
|
|
304
|
+
value: String(ge.id),
|
|
305
|
+
label: `${ge.first_name} ${ge.last_name}${ge.employee_code ? ` (${ge.employee_code})` : ''}`
|
|
306
|
+
}))
|
|
307
|
+
]}
|
|
308
|
+
/>
|
|
309
|
+
<button onClick={() => handleRemoveEmployeeRow(emp.id)} className="text-red-500 p-1 hover:bg-red-50 rounded">
|
|
310
|
+
×
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
) : (
|
|
314
|
+
<>
|
|
315
|
+
<div className="font-semibold text-[#2D3142]">{emp.first_name} {emp.last_name}</div>
|
|
316
|
+
{emp.employee_code && <div className="text-xs text-gray-500">{emp.employee_code}</div>}
|
|
317
|
+
</>
|
|
318
|
+
)}
|
|
319
|
+
</TCell>
|
|
320
|
+
<TCell label="Present">
|
|
321
|
+
<Checkbox
|
|
322
|
+
checked={timeEntries[emp.id]?.isPresent || false}
|
|
323
|
+
onChange={(e) => handleTimeChange(emp.id, 'isPresent', e.target.checked)}
|
|
324
|
+
/>
|
|
325
|
+
</TCell>
|
|
326
|
+
<TCell label="First In">
|
|
327
|
+
<Input
|
|
328
|
+
type="time"
|
|
329
|
+
value={timeEntries[emp.id]?.in || ""}
|
|
330
|
+
onChange={(e) => handleTimeChange(emp.id, 'in', e.target.value)}
|
|
331
|
+
className="h-9 w-32"
|
|
332
|
+
disabled={!timeEntries[emp.id]?.isPresent}
|
|
333
|
+
/>
|
|
334
|
+
</TCell>
|
|
335
|
+
<TCell label="Last Out">
|
|
336
|
+
<Input
|
|
337
|
+
type="time"
|
|
338
|
+
value={timeEntries[emp.id]?.out || ""}
|
|
339
|
+
onChange={(e) => handleTimeChange(emp.id, 'out', e.target.value)}
|
|
340
|
+
className="h-9 w-32"
|
|
341
|
+
disabled={!timeEntries[emp.id]?.isPresent}
|
|
342
|
+
/>
|
|
343
|
+
</TCell>
|
|
344
|
+
<TCell label="Break (hrs)">
|
|
345
|
+
<Input
|
|
346
|
+
type="number"
|
|
347
|
+
step="0.5"
|
|
348
|
+
min="0"
|
|
349
|
+
value={timeEntries[emp.id]?.break || 0}
|
|
350
|
+
onChange={(e) => handleTimeChange(emp.id, 'break', Number(e.target.value))}
|
|
351
|
+
className="h-9 w-32"
|
|
352
|
+
disabled={!timeEntries[emp.id]?.isPresent || !String(emp.id).startsWith('wf-')}
|
|
353
|
+
title={!String(emp.id).startsWith('wf-') ? "Break hours are only applicable to Construction Workforce" : ""}
|
|
354
|
+
/>
|
|
355
|
+
</TCell>
|
|
356
|
+
</TRow>
|
|
357
|
+
))
|
|
358
|
+
)}
|
|
359
|
+
</TBody>
|
|
360
|
+
</Table>
|
|
361
|
+
<div className="p-3 border-t border-gray-100 bg-gray-50">
|
|
362
|
+
<Button variant="flat" color="secondary" onClick={handleAddEmployeeRow} icon={<Plus size={16} />} iconPosition="left">
|
|
363
|
+
Add Row
|
|
364
|
+
</Button>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<ModalFooter>
|
|
371
|
+
<Button variant="flat" color="secondary" onClick={onClose} isDisabled={isSubmitting}>
|
|
372
|
+
Cancel
|
|
373
|
+
</Button>
|
|
374
|
+
<Button color="primary" onClick={handleSubmit} isLoading={isSubmitting} isDisabled={employees.length === 0}>
|
|
375
|
+
Save All
|
|
376
|
+
</Button>
|
|
377
|
+
</ModalFooter>
|
|
378
|
+
</Modal>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
@@ -16,7 +16,7 @@ export default function DailySchedulePanel({ day, events, onCancelClick, onExcha
|
|
|
16
16
|
const shifts = events.filter(e => e.type === 'shift' && e.segmentsInfo);
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
|
-
<div className="w-80 flex-shrink-0 bg-white rounded-[16px] border border-gray-200 overflow-hidden flex flex-col h-[calc(100vh-140px)]">
|
|
19
|
+
<div className="absolute inset-0 z-30 lg:static lg:z-auto w-full lg:w-80 flex-shrink-0 bg-white rounded-[16px] border border-gray-200 overflow-hidden flex flex-col h-[calc(100vh-140px)] shadow-2xl lg:shadow-none">
|
|
20
20
|
{/* Header */}
|
|
21
21
|
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
|
|
22
22
|
<div className="flex items-center gap-3">
|
|
@@ -89,7 +89,7 @@ export function ManualPunchModal({ isOpen, onClose, onSuccess, fetchEmployees }:
|
|
|
89
89
|
onChange={(e) => setFormData({ ...formData, employee_id: e.target.value })}
|
|
90
90
|
options={employees.map((emp) => ({
|
|
91
91
|
value: String(emp.id),
|
|
92
|
-
label: `${emp.first_name} ${emp.last_name} (${emp.employee_code
|
|
92
|
+
label: `${emp.first_name} ${emp.last_name}${emp.employee_code ? ` (${emp.employee_code})` : ''}`
|
|
93
93
|
}))}
|
|
94
94
|
/>
|
|
95
95
|
</div>
|
|
@@ -287,17 +287,17 @@ export default function ShiftCalendar({ employeeId, projectId, workforceId, show
|
|
|
287
287
|
};
|
|
288
288
|
|
|
289
289
|
return (
|
|
290
|
-
<div className="flex gap-4 items-start w-full">
|
|
290
|
+
<div className="flex flex-col lg:flex-row gap-4 items-start w-full relative">
|
|
291
291
|
{/* Main Calendar View */}
|
|
292
|
-
<div className="flex-1 bg-white rounded-[16px] border border-gray-200 overflow-hidden shadow-sm flex flex-col relative h-[calc(100vh-140px)]">
|
|
292
|
+
<div className="flex-1 w-full bg-white rounded-[16px] border border-gray-200 overflow-hidden shadow-sm flex flex-col relative h-[calc(100vh-140px)]">
|
|
293
293
|
{/* Header */}
|
|
294
|
-
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
|
|
294
|
+
<div className="p-4 border-b border-gray-100 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
295
295
|
<h2 className="text-xl font-bold text-[#2D3142]">
|
|
296
296
|
{viewMode === 'month'
|
|
297
297
|
? format(currentDate, 'MMMM yyyy')
|
|
298
298
|
: `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`}
|
|
299
299
|
</h2>
|
|
300
|
-
<div className="flex items-center gap-4">
|
|
300
|
+
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
|
|
301
301
|
<div className="flex p-0.5 bg-gray-100 rounded-lg">
|
|
302
302
|
<button
|
|
303
303
|
onClick={() => setViewMode('month')}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
import { Coins } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
EntityPickerModal,
|
|
7
|
+
PickerItem,
|
|
8
|
+
PickerTrigger,
|
|
9
|
+
} from "./EntityPickerModal";
|
|
10
|
+
|
|
11
|
+
interface CurrencyPickerProps {
|
|
12
|
+
/** Currently selected currency id */
|
|
13
|
+
value?: number | string | null;
|
|
14
|
+
/** Display label for the currently selected currency (e.g. "USD — US Dollar") */
|
|
15
|
+
displayValue?: string | null;
|
|
16
|
+
/** Callback when a currency is selected */
|
|
17
|
+
onChange: (currency: { id: number; code: string; name: string; symbol: string } | null) => void;
|
|
18
|
+
/** Label shown above the trigger */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Placeholder text when no currency is selected */
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/** Whether the field is required */
|
|
23
|
+
isRequired?: boolean;
|
|
24
|
+
/** Whether the picker is disabled */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Fetch function for loading currencies. Should accept { search, page, per_page } and return IApiResponse with data array. */
|
|
27
|
+
fetchCurrencies: (params: Record<string, string | number>) => Promise<any>;
|
|
28
|
+
/** Optional custom trigger renderer */
|
|
29
|
+
customTrigger?: (onClick: () => void) => React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CurrencyPicker({
|
|
33
|
+
value,
|
|
34
|
+
displayValue,
|
|
35
|
+
onChange,
|
|
36
|
+
label = "Currency",
|
|
37
|
+
placeholder = "Select currency…",
|
|
38
|
+
isRequired = false,
|
|
39
|
+
disabled = false,
|
|
40
|
+
fetchCurrencies,
|
|
41
|
+
customTrigger,
|
|
42
|
+
}: CurrencyPickerProps) {
|
|
43
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
44
|
+
const [search, setSearch] = useState("");
|
|
45
|
+
const [currencies, setCurrencies] = useState<any[]>([]);
|
|
46
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
47
|
+
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
48
|
+
|
|
49
|
+
const loadCurrencies = useCallback(
|
|
50
|
+
async (query: string) => {
|
|
51
|
+
setIsLoading(true);
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetchCurrencies({ search: query, page: 1, per_page: 50 });
|
|
54
|
+
const data = res?.result?.data || res?.result || [];
|
|
55
|
+
setCurrencies(Array.isArray(data) ? data : []);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error("Failed to load currencies", e);
|
|
58
|
+
setCurrencies([]);
|
|
59
|
+
} finally {
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[fetchCurrencies]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Load currencies when modal opens
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (isOpen) {
|
|
69
|
+
loadCurrencies("");
|
|
70
|
+
}
|
|
71
|
+
}, [isOpen, loadCurrencies]);
|
|
72
|
+
|
|
73
|
+
// Debounced search
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!isOpen) return;
|
|
76
|
+
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
|
77
|
+
searchTimeoutRef.current = setTimeout(() => {
|
|
78
|
+
loadCurrencies(search);
|
|
79
|
+
}, 300);
|
|
80
|
+
return () => {
|
|
81
|
+
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
|
82
|
+
};
|
|
83
|
+
}, [search, isOpen, loadCurrencies]);
|
|
84
|
+
|
|
85
|
+
const handleSelect = (currency: any) => {
|
|
86
|
+
onChange({
|
|
87
|
+
id: currency.id,
|
|
88
|
+
code: currency.code,
|
|
89
|
+
name: currency.name,
|
|
90
|
+
symbol: currency.symbol || currency.code,
|
|
91
|
+
});
|
|
92
|
+
setIsOpen(false);
|
|
93
|
+
setSearch("");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleClear = () => {
|
|
97
|
+
onChange(null);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
{customTrigger ? (
|
|
103
|
+
customTrigger(() => setIsOpen(true))
|
|
104
|
+
) : (
|
|
105
|
+
<PickerTrigger
|
|
106
|
+
label={label}
|
|
107
|
+
value={displayValue || undefined}
|
|
108
|
+
placeholder={placeholder}
|
|
109
|
+
isRequired={isRequired}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
onClick={() => setIsOpen(true)}
|
|
112
|
+
onClear={value ? handleClear : undefined}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<EntityPickerModal
|
|
117
|
+
isOpen={isOpen}
|
|
118
|
+
onClose={() => { setIsOpen(false); setSearch(""); }}
|
|
119
|
+
title="Select Currency"
|
|
120
|
+
search={search}
|
|
121
|
+
onSearchChange={setSearch}
|
|
122
|
+
searchPlaceholder="Search by code or name…"
|
|
123
|
+
selectedId={value}
|
|
124
|
+
size="sm"
|
|
125
|
+
zIndex={210}
|
|
126
|
+
>
|
|
127
|
+
{isLoading ? (
|
|
128
|
+
<div className="py-8 text-center">
|
|
129
|
+
<div className="inline-block w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
|
|
130
|
+
<p className="text-xs text-gray-400 mt-2">Loading currencies…</p>
|
|
131
|
+
</div>
|
|
132
|
+
) : currencies.length === 0 ? (
|
|
133
|
+
<div className="py-8 text-center">
|
|
134
|
+
<Coins size={28} className="mx-auto text-gray-300 mb-2" />
|
|
135
|
+
<p className="text-sm text-gray-400 font-medium">
|
|
136
|
+
{search ? "No currencies match your search" : "No currencies available"}
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="flex flex-col gap-0.5">
|
|
141
|
+
{currencies.map((c) => (
|
|
142
|
+
<PickerItem
|
|
143
|
+
key={c.id}
|
|
144
|
+
label={`${c.code}${c.symbol && c.symbol !== c.code ? ` (${c.symbol})` : ''}`}
|
|
145
|
+
sublabel={c.name}
|
|
146
|
+
isSelected={String(c.id) === String(value)}
|
|
147
|
+
onClick={() => handleSelect(c)}
|
|
148
|
+
/>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</EntityPickerModal>
|
|
153
|
+
</>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -19,7 +19,7 @@ import toast from "react-hot-toast";
|
|
|
19
19
|
interface PartyPickerProps {
|
|
20
20
|
value?: number | string | null;
|
|
21
21
|
displayValue?: string | null;
|
|
22
|
-
onChange: (party: { id: number; name: string; code?: string; type?: string } | null) => void;
|
|
22
|
+
onChange: (party: { id: number; name: string; code?: string; type?: string; default_currency_id?: number | null; default_currency?: { id: number; code: string; name: string; symbol: string } | null } | null) => void;
|
|
23
23
|
label?: string;
|
|
24
24
|
placeholder?: string;
|
|
25
25
|
isRequired?: boolean;
|
|
@@ -120,7 +120,14 @@ export function PartyPicker({
|
|
|
120
120
|
};
|
|
121
121
|
|
|
122
122
|
const handleSelect = (party: any) => {
|
|
123
|
-
onChange({
|
|
123
|
+
onChange({
|
|
124
|
+
id: party.id,
|
|
125
|
+
name: party.name,
|
|
126
|
+
code: party.code,
|
|
127
|
+
type: party.type,
|
|
128
|
+
default_currency_id: party.default_currency_id,
|
|
129
|
+
default_currency: party.default_currency,
|
|
130
|
+
});
|
|
124
131
|
setIsOpen(false);
|
|
125
132
|
};
|
|
126
133
|
|
|
@@ -150,7 +157,19 @@ export function PartyPicker({
|
|
|
150
157
|
toast.success("Party created");
|
|
151
158
|
const party = res.result;
|
|
152
159
|
const partyName = party.name || party.full_name || [party.first_name, party.last_name].filter(Boolean).join(" ");
|
|
153
|
-
onChange({
|
|
160
|
+
onChange({
|
|
161
|
+
id: party.id,
|
|
162
|
+
name: partyName,
|
|
163
|
+
code: party.code,
|
|
164
|
+
type: party.type,
|
|
165
|
+
default_currency_id: quickAddCurrencyDisplay?.id,
|
|
166
|
+
default_currency: quickAddCurrencyDisplay ? {
|
|
167
|
+
id: quickAddCurrencyDisplay.id,
|
|
168
|
+
code: quickAddCurrencyDisplay.code,
|
|
169
|
+
name: quickAddCurrencyDisplay.name,
|
|
170
|
+
symbol: quickAddCurrencyDisplay.symbol
|
|
171
|
+
} : undefined
|
|
172
|
+
});
|
|
154
173
|
setIsQuickAddOpen(false);
|
|
155
174
|
setQuickAddFirstName("");
|
|
156
175
|
setQuickAddLastName("");
|
|
@@ -343,7 +362,10 @@ export function PartyPicker({
|
|
|
343
362
|
} catch { return []; }
|
|
344
363
|
}}
|
|
345
364
|
option={{ label: "displayLabel", value: "id", keysToSearch: ["name", "code"] }}
|
|
346
|
-
onChange={(val) =>
|
|
365
|
+
onChange={(val, selectedObj: any) => {
|
|
366
|
+
setQuickAddCurrencyId(val as string | number);
|
|
367
|
+
setQuickAddCurrencyDisplay(selectedObj);
|
|
368
|
+
}}
|
|
347
369
|
key={quickAddCurrencyDisplay ? quickAddCurrencyDisplay.code : 'empty'}
|
|
348
370
|
defaultValue={quickAddCurrencyDisplay || undefined}
|
|
349
371
|
error={errors.default_currency_id?.[0]}
|
|
@@ -13,6 +13,14 @@ interface PaymentFormProps {
|
|
|
13
13
|
fetchPaymentModes: () => Promise<any>;
|
|
14
14
|
fetchBankAccounts: () => Promise<any>;
|
|
15
15
|
fetchChequeLeaves?: (query: string, page?: number) => Promise<any>;
|
|
16
|
+
/** Currency code of the invoice (e.g. 'USD'). If not provided, no multi-currency UI. */
|
|
17
|
+
currencyCode?: string;
|
|
18
|
+
/** Exchange rate used when the invoice was created */
|
|
19
|
+
invoiceExchangeRate?: number;
|
|
20
|
+
/** Whether this is a multi-currency (foreign currency) invoice */
|
|
21
|
+
isMultiCurrency?: boolean;
|
|
22
|
+
/** Base currency code (e.g. 'LKR') */
|
|
23
|
+
baseCurrencyCode?: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
export function PaymentForm({
|
|
@@ -23,6 +31,10 @@ export function PaymentForm({
|
|
|
23
31
|
fetchPaymentModes,
|
|
24
32
|
fetchBankAccounts,
|
|
25
33
|
fetchChequeLeaves,
|
|
34
|
+
currencyCode,
|
|
35
|
+
invoiceExchangeRate = 1,
|
|
36
|
+
isMultiCurrency = false,
|
|
37
|
+
baseCurrencyCode = 'LKR',
|
|
26
38
|
}: PaymentFormProps) {
|
|
27
39
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
28
40
|
const outstanding = Math.abs(Number(invoice.amount_due || 0) - Number(invoice.amount_settled || invoice.amount_received || 0));
|
|
@@ -32,6 +44,25 @@ export function PaymentForm({
|
|
|
32
44
|
const [remarks, setRemarks] = useState("");
|
|
33
45
|
const [payments, setPayments] = useState<PaymentEntry[]>([]);
|
|
34
46
|
|
|
47
|
+
// Exchange rate state (only used for multi-currency invoices)
|
|
48
|
+
const [exchangeRate, setExchangeRate] = useState<string>(String(invoiceExchangeRate));
|
|
49
|
+
|
|
50
|
+
const currentRate = Number(exchangeRate) || 0;
|
|
51
|
+
const totalPaymentAmount = payments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
|
|
52
|
+
|
|
53
|
+
// Compute exchange difference for display
|
|
54
|
+
const exchangeDifference = isMultiCurrency && currentRate > 0
|
|
55
|
+
? (totalPaymentAmount * currentRate) - (totalPaymentAmount * invoiceExchangeRate)
|
|
56
|
+
: 0;
|
|
57
|
+
|
|
58
|
+
// For AP (Payables), paying less in base currency is a GAIN.
|
|
59
|
+
// For AR (Receivables), receiving less in base currency is a LOSS.
|
|
60
|
+
const isAP = invoice?.type === 'ap';
|
|
61
|
+
const isExchangeGain = isAP ? exchangeDifference < 0 : exchangeDifference > 0;
|
|
62
|
+
const absExchangeDiff = Math.abs(exchangeDifference);
|
|
63
|
+
|
|
64
|
+
const fmt = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
65
|
+
|
|
35
66
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
36
67
|
e.preventDefault();
|
|
37
68
|
if (payments.length === 0) {
|
|
@@ -45,6 +76,11 @@ export function PaymentForm({
|
|
|
45
76
|
return;
|
|
46
77
|
}
|
|
47
78
|
|
|
79
|
+
if (isMultiCurrency && (!exchangeRate || currentRate <= 0)) {
|
|
80
|
+
toast.error("Please enter a valid exchange rate");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
setIsSubmitting(true);
|
|
49
85
|
let successCount = 0;
|
|
50
86
|
|
|
@@ -61,6 +97,11 @@ export function PaymentForm({
|
|
|
61
97
|
reference: reference || null,
|
|
62
98
|
};
|
|
63
99
|
|
|
100
|
+
// Include exchange rate for multi-currency invoices
|
|
101
|
+
if (isMultiCurrency) {
|
|
102
|
+
payload.exchange_rate = currentRate;
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
if (p.metadata) {
|
|
65
106
|
if (p.metadata.reference) payload.reference = p.metadata.reference;
|
|
66
107
|
if (p.metadata.bank_account_id) payload.bank_account_id = p.metadata.bank_account_id;
|
|
@@ -94,6 +135,49 @@ export function PaymentForm({
|
|
|
94
135
|
/>
|
|
95
136
|
</div>
|
|
96
137
|
|
|
138
|
+
{/* Exchange Rate Section — only for multi-currency invoices */}
|
|
139
|
+
{isMultiCurrency && (
|
|
140
|
+
<div className="space-y-3">
|
|
141
|
+
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
|
142
|
+
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider mb-1">Invoice Rate</p>
|
|
143
|
+
<p className="text-sm font-medium text-gray-700">1 {currencyCode} = {invoiceExchangeRate.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 })} {baseCurrencyCode}</p>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex flex-col gap-1.5">
|
|
146
|
+
<label className="text-[11px] font-bold text-gray-500 uppercase tracking-wider">
|
|
147
|
+
Current Exchange Rate (1 {currencyCode} = ? {baseCurrencyCode}) *
|
|
148
|
+
</label>
|
|
149
|
+
<Input
|
|
150
|
+
type="number"
|
|
151
|
+
step="any"
|
|
152
|
+
placeholder="0.000000"
|
|
153
|
+
value={exchangeRate}
|
|
154
|
+
onChange={(e) => setExchangeRate(e.target.value)}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
{/* Live calculation */}
|
|
158
|
+
{totalPaymentAmount > 0 && currentRate > 0 && (
|
|
159
|
+
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100 space-y-2">
|
|
160
|
+
<div className="flex items-center justify-between">
|
|
161
|
+
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Base Currency Amount</p>
|
|
162
|
+
<p className="text-sm font-bold text-gray-800">
|
|
163
|
+
{baseCurrencyCode} {fmt(totalPaymentAmount * currentRate)}
|
|
164
|
+
</p>
|
|
165
|
+
</div>
|
|
166
|
+
{absExchangeDiff >= 0.01 && (
|
|
167
|
+
<div className={`flex items-center justify-between px-2 py-1.5 rounded-md ${isExchangeGain ? 'bg-green-50 border border-green-100' : 'bg-red-50 border border-red-100'}`}>
|
|
168
|
+
<span className={`text-[10px] font-bold uppercase tracking-wider ${isExchangeGain ? 'text-green-600' : 'text-red-600'}`}>
|
|
169
|
+
Exchange {isExchangeGain ? 'Gain' : 'Loss'}
|
|
170
|
+
</span>
|
|
171
|
+
<span className={`text-xs font-bold ${isExchangeGain ? 'text-green-700' : 'text-red-700'}`}>
|
|
172
|
+
{baseCurrencyCode} {fmt(absExchangeDiff)}
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
97
181
|
<PaymentSection
|
|
98
182
|
totalAmount={outstanding}
|
|
99
183
|
payments={payments}
|
package/src/index.tsx
CHANGED
|
@@ -43,6 +43,7 @@ export * from './base-components/WizardModal';
|
|
|
43
43
|
// Pickers
|
|
44
44
|
export * from './common-components/pickers/BrandPicker';
|
|
45
45
|
export * from './common-components/pickers/CategoryPicker';
|
|
46
|
+
export * from './common-components/pickers/CurrencyPicker';
|
|
46
47
|
export * from './common-components/pickers/EmployeePicker';
|
|
47
48
|
export * from './common-components/pickers/EntityPickerModal';
|
|
48
49
|
export * from './common-components/pickers/UomGroupPicker';
|
|
@@ -70,7 +71,7 @@ export * from './common-components/attendance-shifts/DailyOverview';
|
|
|
70
71
|
export { default as PendingActionsPanel } from './common-components/attendance-shifts/PendingActionsPanel';
|
|
71
72
|
export * from './common-components/attendance-shifts/PendingActionsPanel';
|
|
72
73
|
export * from './common-components/attendance-shifts/ManualPunchModal';
|
|
73
|
-
export * from './common-components/attendance-shifts/
|
|
74
|
+
export * from './common-components/attendance-shifts/BulkAttendanceModal';
|
|
74
75
|
export { default as AttendanceTimesheets } from './common-components/attendance-shifts/AttendanceTimesheets';
|
|
75
76
|
export * from './common-components/attendance-shifts/AttendanceTimesheets';
|
|
76
77
|
|