@apptimate/ui 5.8.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/index.tsx +1 -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')}
|
package/src/index.tsx
CHANGED
|
@@ -71,7 +71,7 @@ export * from './common-components/attendance-shifts/DailyOverview';
|
|
|
71
71
|
export { default as PendingActionsPanel } from './common-components/attendance-shifts/PendingActionsPanel';
|
|
72
72
|
export * from './common-components/attendance-shifts/PendingActionsPanel';
|
|
73
73
|
export * from './common-components/attendance-shifts/ManualPunchModal';
|
|
74
|
-
export * from './common-components/attendance-shifts/
|
|
74
|
+
export * from './common-components/attendance-shifts/BulkAttendanceModal';
|
|
75
75
|
export { default as AttendanceTimesheets } from './common-components/attendance-shifts/AttendanceTimesheets';
|
|
76
76
|
export * from './common-components/attendance-shifts/AttendanceTimesheets';
|
|
77
77
|
|