@apptimate/ui 6.9.0 → 7.1.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 +2 -1
- package/src/base-components/ImageDropzone.tsx +80 -80
- package/src/common-components/DashboardLayout.tsx +5 -3
- package/src/common-components/NotificationsModal.tsx +173 -0
- package/src/common-components/NotificationsPopover.tsx +224 -0
- package/src/common-components/attendance-shifts/BulkAttendanceModal.tsx +155 -30
- package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +73 -11
- package/src/common-components/attendance-shifts/ShiftCalendar.tsx +41 -13
- package/src/finance-components/DirectExpenseModal.tsx +248 -0
- package/src/index.tsx +1 -0
|
@@ -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,28 +27,45 @@ 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
|
-
// 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 }>>({});
|
|
33
|
+
// Time entries state: Record<employee_id, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>
|
|
34
|
+
const [timeEntries, setTimeEntries] = useState<Record<string, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>>({});
|
|
32
35
|
|
|
33
36
|
useEffect(() => {
|
|
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 || "");
|
|
39
|
-
|
|
49
|
+
setPaymentAccountId("");
|
|
40
50
|
setTimeEntries({});
|
|
51
|
+
|
|
52
|
+
// If initialProjectId is already set, the second useEffect won't trigger because projectId hasn't changed.
|
|
53
|
+
// So we force a load if we already have the ID.
|
|
54
|
+
if (initialProjectId) {
|
|
55
|
+
// loadEmployees will be triggered by the second useEffect if projectId changes.
|
|
56
|
+
// If it doesn't change, we need to call it here.
|
|
57
|
+
// We will just let the second useEffect handle it by adding isOpen to its dependencies.
|
|
58
|
+
}
|
|
41
59
|
}
|
|
42
60
|
}, [isOpen, initialProjectId]);
|
|
43
61
|
|
|
44
62
|
useEffect(() => {
|
|
45
|
-
if (projectId && attendanceDate) {
|
|
63
|
+
if (isOpen && projectId && attendanceDate) {
|
|
46
64
|
loadEmployees();
|
|
47
|
-
} else {
|
|
65
|
+
} else if (!isOpen) {
|
|
48
66
|
setEmployees([]);
|
|
49
67
|
}
|
|
50
|
-
}, [projectId, attendanceDate]);
|
|
68
|
+
}, [projectId, attendanceDate, isOpen]);
|
|
51
69
|
|
|
52
70
|
const loadGlobalEmployees = async () => {
|
|
53
71
|
try {
|
|
@@ -83,9 +101,23 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
83
101
|
setEmployees(res.result);
|
|
84
102
|
|
|
85
103
|
// Initialize time entries for these employees
|
|
86
|
-
const newEntries: Record<string, { in: string, out: string, break: number, isPresent: boolean }> = {};
|
|
104
|
+
const newEntries: Record<string, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }> = {};
|
|
87
105
|
res.result.forEach((emp: any) => {
|
|
88
|
-
|
|
106
|
+
let inTime = "09:00";
|
|
107
|
+
let outTime = "18:00";
|
|
108
|
+
let breakHrs = 0;
|
|
109
|
+
let isPresent = true;
|
|
110
|
+
let salaryAdvance = 0;
|
|
111
|
+
|
|
112
|
+
if (emp.attendance) {
|
|
113
|
+
isPresent = emp.attendance.status === 'Present';
|
|
114
|
+
if (emp.attendance.punch_in_time) inTime = emp.attendance.punch_in_time;
|
|
115
|
+
if (emp.attendance.punch_out_time) outTime = emp.attendance.punch_out_time;
|
|
116
|
+
breakHrs = emp.attendance.break_hours || 0;
|
|
117
|
+
salaryAdvance = emp.attendance.salary_advance || 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
newEntries[emp.id] = { in: inTime, out: outTime, break: breakHrs, isPresent: isPresent, salaryAdvance: salaryAdvance };
|
|
89
121
|
});
|
|
90
122
|
setTimeEntries(newEntries);
|
|
91
123
|
}
|
|
@@ -96,7 +128,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
96
128
|
}
|
|
97
129
|
};
|
|
98
130
|
|
|
99
|
-
const handleTimeChange = (empId: string, type: 'in' | 'out' | 'break' | 'isPresent', value: string | number | boolean) => {
|
|
131
|
+
const handleTimeChange = (empId: string, type: 'in' | 'out' | 'break' | 'isPresent' | 'salaryAdvance', value: string | number | boolean) => {
|
|
100
132
|
setTimeEntries(prev => ({
|
|
101
133
|
...prev,
|
|
102
134
|
[empId]: {
|
|
@@ -111,7 +143,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
111
143
|
setEmployees(prev => [...prev, { id: fakeId, is_manual: true }]);
|
|
112
144
|
setTimeEntries(prev => ({
|
|
113
145
|
...prev,
|
|
114
|
-
[fakeId]: { in: "09:00", out: "18:00", break: 0, isPresent: true }
|
|
146
|
+
[fakeId]: { in: "09:00", out: "18:00", break: 0, isPresent: true, salaryAdvance: 0 }
|
|
115
147
|
}));
|
|
116
148
|
};
|
|
117
149
|
|
|
@@ -157,7 +189,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
157
189
|
employees.forEach(emp => {
|
|
158
190
|
const entry = timeEntries[emp.id];
|
|
159
191
|
|
|
160
|
-
if (!entry
|
|
192
|
+
if (!entry) return;
|
|
161
193
|
|
|
162
194
|
if (String(emp.id).startsWith('wf-')) {
|
|
163
195
|
// It's a workforce gang worker
|
|
@@ -165,19 +197,45 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
165
197
|
const workforceId = parts[1];
|
|
166
198
|
const memberIdentifier = parts[2] || null;
|
|
167
199
|
|
|
168
|
-
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
|
|
169
224
|
workforcePunches.push({
|
|
170
225
|
workforce_id: Number(workforceId),
|
|
171
226
|
project_id: Number(projectId),
|
|
172
227
|
date: attendanceDate,
|
|
173
228
|
member_identifier: memberIdentifier,
|
|
174
|
-
punch_in_time:
|
|
175
|
-
punch_out_time:
|
|
176
|
-
break_hours:
|
|
229
|
+
punch_in_time: null,
|
|
230
|
+
punch_out_time: null,
|
|
231
|
+
break_hours: 0,
|
|
232
|
+
salary_advance: entry.salaryAdvance || 0,
|
|
177
233
|
});
|
|
178
234
|
}
|
|
179
235
|
} else {
|
|
180
236
|
// It's a standard HR employee
|
|
237
|
+
if (!entry.isPresent) return; // Skip absent for HR for now
|
|
238
|
+
|
|
181
239
|
if (entry.in) {
|
|
182
240
|
hrPunches.push({
|
|
183
241
|
employee_id: emp.id,
|
|
@@ -191,8 +249,21 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
191
249
|
employee_id: emp.id,
|
|
192
250
|
punch_at: `${attendanceDate} ${entry.out}:00`,
|
|
193
251
|
type: "O",
|
|
194
|
-
source: "Bulk"
|
|
252
|
+
source: "Bulk",
|
|
253
|
+
salary_advance: entry.salaryAdvance || 0
|
|
195
254
|
});
|
|
255
|
+
} else if (!entry.in && entry.salaryAdvance > 0) {
|
|
256
|
+
// If they only have a salary advance but no punch
|
|
257
|
+
hrPunches.push({
|
|
258
|
+
employee_id: emp.id,
|
|
259
|
+
type: "ADVANCE", // Special type or handled by backend if punch_at is empty
|
|
260
|
+
source: "Bulk",
|
|
261
|
+
salary_advance: entry.salaryAdvance,
|
|
262
|
+
date: attendanceDate
|
|
263
|
+
});
|
|
264
|
+
} else if (hrPunches.length > 0 && entry.salaryAdvance > 0) {
|
|
265
|
+
// Add salary advance to the first punch we pushed for this employee
|
|
266
|
+
hrPunches[hrPunches.length - 1].salary_advance = entry.salaryAdvance;
|
|
196
267
|
}
|
|
197
268
|
}
|
|
198
269
|
});
|
|
@@ -207,7 +278,10 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
207
278
|
|
|
208
279
|
// Log HR punches
|
|
209
280
|
if (hrPunches.length > 0) {
|
|
210
|
-
const hrRes = await bulkPunchAttendance({
|
|
281
|
+
const hrRes = await bulkPunchAttendance({
|
|
282
|
+
punches: hrPunches,
|
|
283
|
+
payment_account_id: paymentAccountId ? Number(paymentAccountId) : undefined
|
|
284
|
+
});
|
|
211
285
|
if (!hrRes.is_success) {
|
|
212
286
|
toast.error(hrRes.message || "Failed to log HR attendance");
|
|
213
287
|
hasError = true;
|
|
@@ -218,9 +292,16 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
218
292
|
if (workforcePunches.length > 0) {
|
|
219
293
|
// use sendRequest from core-lib which is imported at the top of this file
|
|
220
294
|
const { sendRequest } = require("@apptimate/core-lib");
|
|
221
|
-
const wfRes = await sendRequest({
|
|
222
|
-
|
|
223
|
-
|
|
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
|
+
});
|
|
303
|
+
if (!wfRes.responseData?.is_success) {
|
|
304
|
+
toast.error(wfRes.responseData?.message || "Failed to log Workforce attendance");
|
|
224
305
|
hasError = true;
|
|
225
306
|
}
|
|
226
307
|
}
|
|
@@ -242,7 +323,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
242
323
|
return (
|
|
243
324
|
<Modal isOpen={isOpen} onClose={onClose} title="Bulk Attendance Log" size="2xl">
|
|
244
325
|
<div className="space-y-4 py-2">
|
|
245
|
-
<div className="grid grid-cols-1 sm:grid-cols-
|
|
326
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
246
327
|
<div>
|
|
247
328
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date <span className="text-danger-500">*</span></label>
|
|
248
329
|
<Input
|
|
@@ -254,20 +335,28 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
254
335
|
<div>
|
|
255
336
|
<label className="block text-sm font-medium text-gray-700 mb-1">Project <span className="text-danger-500">*</span></label>
|
|
256
337
|
<Select
|
|
338
|
+
options={projects.map((p) => ({ value: String(p.id), label: p.name }))}
|
|
257
339
|
value={projectId}
|
|
258
340
|
onChange={(e) => setProjectId(e.target.value)}
|
|
259
341
|
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
342
|
/>
|
|
268
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
|
+
)}
|
|
269
357
|
</div>
|
|
270
358
|
|
|
359
|
+
|
|
271
360
|
{projectId && attendanceDate && (
|
|
272
361
|
<div className="mt-6 border border-gray-100 rounded-xl overflow-hidden bg-white shadow-sm">
|
|
273
362
|
<Table>
|
|
@@ -277,7 +366,8 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
277
366
|
<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
367
|
<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
368
|
<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-
|
|
369
|
+
<TCell isHeader className="bg-gray-50 text-gray-600 font-semibold tracking-wide border-t-0 border-r-0 border-l-0 w-24">Break (hrs)</TCell>
|
|
370
|
+
<TCell isHeader className="bg-gray-50 text-gray-600 font-semibold tracking-wide border-t-0 border-r-0 border-l-0 w-48">Salary Advance</TCell>
|
|
281
371
|
</TRow>
|
|
282
372
|
</THeader>
|
|
283
373
|
<TBody>
|
|
@@ -353,6 +443,41 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
353
443
|
title={!String(emp.id).startsWith('wf-') ? "Break hours are only applicable to Construction Workforce" : ""}
|
|
354
444
|
/>
|
|
355
445
|
</TCell>
|
|
446
|
+
<TCell label="Salary Advance">
|
|
447
|
+
{emp.is_gang ? (
|
|
448
|
+
<div className="flex items-center h-9">
|
|
449
|
+
<span className="text-gray-400">-</span>
|
|
450
|
+
</div>
|
|
451
|
+
) : (
|
|
452
|
+
<div className="flex items-center gap-2">
|
|
453
|
+
<Input
|
|
454
|
+
type="number"
|
|
455
|
+
step="0.01"
|
|
456
|
+
min="0"
|
|
457
|
+
value={timeEntries[emp.id]?.salaryAdvance || 0}
|
|
458
|
+
onChange={(e) => handleTimeChange(emp.id, 'salaryAdvance', Number(e.target.value))}
|
|
459
|
+
className="h-9 w-24 shrink-0"
|
|
460
|
+
disabled={!timeEntries[emp.id]?.isPresent}
|
|
461
|
+
/>
|
|
462
|
+
{emp.balance_due !== undefined && (
|
|
463
|
+
<div className="text-[11px] text-gray-500 font-medium whitespace-nowrap">
|
|
464
|
+
{timeEntries[emp.id]?.salaryAdvance > 0 ? (
|
|
465
|
+
<>
|
|
466
|
+
Owed: {Number(emp.balance_due) + Number(emp.attendance?.salary_advance || 0)} - {timeEntries[emp.id].salaryAdvance} = <strong className="text-gray-700">{Number(emp.balance_due) + Number(emp.attendance?.salary_advance || 0) - timeEntries[emp.id].salaryAdvance}</strong>
|
|
467
|
+
</>
|
|
468
|
+
) : (
|
|
469
|
+
<>Owed: {Number(emp.balance_due) + Number(emp.attendance?.salary_advance || 0)}</>
|
|
470
|
+
)}
|
|
471
|
+
{timeEntries[emp.id]?.isPresent && (
|
|
472
|
+
<span className="block text-[9.5px] text-blue-500/90 mt-0.5">
|
|
473
|
+
+ Today's wage
|
|
474
|
+
</span>
|
|
475
|
+
)}
|
|
476
|
+
</div>
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
)}
|
|
480
|
+
</TCell>
|
|
356
481
|
</TRow>
|
|
357
482
|
))
|
|
358
483
|
)}
|
|
@@ -14,6 +14,7 @@ interface DailySchedulePanelProps {
|
|
|
14
14
|
|
|
15
15
|
export default function DailySchedulePanel({ day, events, onCancelClick, onExchangeClick, onCancelExchangeRequest, onClose }: DailySchedulePanelProps) {
|
|
16
16
|
const shifts = events.filter(e => e.type === 'shift' && e.segmentsInfo);
|
|
17
|
+
const projectAttendances = events.filter(e => e.type === 'project_attendance');
|
|
17
18
|
|
|
18
19
|
return (
|
|
19
20
|
<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">
|
|
@@ -34,19 +35,79 @@ export default function DailySchedulePanel({ day, events, onCancelClick, onExcha
|
|
|
34
35
|
</div>
|
|
35
36
|
|
|
36
37
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
38
|
+
{projectAttendances.length > 0 && (
|
|
39
|
+
<div className="space-y-3">
|
|
40
|
+
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider">Project Summary</h3>
|
|
41
|
+
{projectAttendances.map((pa, idx) => (
|
|
42
|
+
<div key={idx} className="bg-emerald-50 border border-emerald-100 rounded-xl overflow-hidden shadow-sm">
|
|
43
|
+
<div className="p-4 border-b border-emerald-100/50">
|
|
44
|
+
<div className="font-bold text-emerald-900 mb-2">{pa.name}</div>
|
|
45
|
+
<div className="flex justify-between items-center text-sm font-medium">
|
|
46
|
+
<span className="text-emerald-700 flex items-center gap-1.5">
|
|
47
|
+
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
|
|
48
|
+
{pa.present} Present
|
|
49
|
+
</span>
|
|
50
|
+
<span className="text-red-700 flex items-center gap-1.5">
|
|
51
|
+
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
|
52
|
+
{pa.absent} Absent
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
{pa.workers && pa.workers.length > 0 && (
|
|
57
|
+
<div className="bg-white/60 p-3 space-y-2">
|
|
58
|
+
{pa.workers.map((worker: any, wIdx: number) => (
|
|
59
|
+
<div key={wIdx} className="flex flex-col border-b border-gray-100 last:border-0 pb-2 last:pb-0">
|
|
60
|
+
<div className="flex justify-between items-center text-xs">
|
|
61
|
+
<span className="font-medium text-gray-700 truncate pr-2">{worker.name}</span>
|
|
62
|
+
<span className={`px-2 py-0.5 rounded-full font-semibold shrink-0 text-[10px] ${
|
|
63
|
+
worker.status === 'Present'
|
|
64
|
+
? 'bg-emerald-100 text-emerald-700'
|
|
65
|
+
: 'bg-red-100 text-red-700'
|
|
66
|
+
}`}>
|
|
67
|
+
{worker.status}
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
{worker.status === 'Present' && (
|
|
71
|
+
<div className="flex justify-between items-center text-[10px] text-gray-500 mt-1">
|
|
72
|
+
<div className="flex items-center gap-1">
|
|
73
|
+
<Clock className="w-3 h-3" />
|
|
74
|
+
<span>
|
|
75
|
+
{worker.punch_in_time ? worker.punch_in_time.substring(0,5) : '--:--'} - {worker.punch_out_time ? worker.punch_out_time.substring(0,5) : '--:--'}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
{worker.break_hours > 0 ? (
|
|
79
|
+
<span className="bg-gray-100 px-1.5 py-0.5 rounded font-medium">Break: {worker.break_hours}h</span>
|
|
80
|
+
) : null}
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
37
92
|
{shifts.length === 0 ? (
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
<div className="
|
|
42
|
-
|
|
93
|
+
projectAttendances.length === 0 && (
|
|
94
|
+
<div className="border border-dashed border-gray-200 rounded-xl p-4 flex items-center gap-3 text-gray-400">
|
|
95
|
+
<CalendarIcon className="w-5 h-5" />
|
|
96
|
+
<div className="text-sm">
|
|
97
|
+
<div className="font-semibold text-gray-500">Available for Swap</div>
|
|
98
|
+
<div className="text-xs mt-0.5">No shifts scheduled today</div>
|
|
99
|
+
</div>
|
|
43
100
|
</div>
|
|
44
|
-
|
|
101
|
+
)
|
|
45
102
|
) : (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
103
|
+
<div className="space-y-3">
|
|
104
|
+
{projectAttendances.length > 0 && (
|
|
105
|
+
<h3 className="text-xs font-bold text-gray-400 uppercase tracking-wider mt-2">Shifts</h3>
|
|
106
|
+
)}
|
|
107
|
+
{shifts.map((shift, shiftIdx) => (
|
|
108
|
+
<div key={shiftIdx} className="space-y-4">
|
|
109
|
+
{shift.segmentsInfo.map((info: any, idx: number) => (
|
|
110
|
+
<div key={idx} className="border border-gray-200 rounded-xl overflow-hidden relative shadow-sm">
|
|
50
111
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#0D6EFD]"></div>
|
|
51
112
|
<div className="p-4 pl-5">
|
|
52
113
|
<div className="flex justify-between items-start mb-1">
|
|
@@ -131,7 +192,8 @@ export default function DailySchedulePanel({ day, events, onCancelClick, onExcha
|
|
|
131
192
|
</div>
|
|
132
193
|
))}
|
|
133
194
|
</div>
|
|
134
|
-
))
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
135
197
|
)}
|
|
136
198
|
</div>
|
|
137
199
|
</div>
|
|
@@ -32,7 +32,7 @@ export default function ShiftCalendar({ employeeId, projectId, workforceId, show
|
|
|
32
32
|
|
|
33
33
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
34
34
|
const [loading, setLoading] = useState(false);
|
|
35
|
-
const [data, setData] = useState<{ holidays: any[], leaves: any[], rosters: any[], exchanges: any[] }>({
|
|
35
|
+
const [data, setData] = useState<{ holidays: any[], leaves: any[], rosters: any[], exchanges: any[], projectAttendance?: Record<string, any> }>({
|
|
36
36
|
holidays: [], leaves: [], rosters: [], exchanges: []
|
|
37
37
|
});
|
|
38
38
|
|
|
@@ -96,14 +96,18 @@ export default function ShiftCalendar({ employeeId, projectId, workforceId, show
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
if (attendanceRes.status === 'fulfilled' && attendanceRes.value && (attendanceRes.value as any).is_success) {
|
|
99
|
-
const days = (attendanceRes.value as any).result?.days || [];
|
|
99
|
+
const days = (attendanceRes.value as any).result?.days || (attendanceRes.value as any).result?.data || [];
|
|
100
100
|
const map: Record<string, string> = {};
|
|
101
|
+
const fullMap: Record<string, any> = {};
|
|
101
102
|
days.forEach((d: any) => {
|
|
102
|
-
if (d.date
|
|
103
|
-
map[d.date] = d.status;
|
|
103
|
+
if (d.date) {
|
|
104
|
+
if (d.status) map[d.date] = d.status;
|
|
105
|
+
fullMap[d.date] = d;
|
|
104
106
|
}
|
|
105
107
|
});
|
|
106
108
|
setAttendanceMap(map);
|
|
109
|
+
// Also save the full day data so we can display project attendance counts
|
|
110
|
+
setData(prev => ({ ...prev, projectAttendance: fullMap }));
|
|
107
111
|
}
|
|
108
112
|
} catch {
|
|
109
113
|
// Silently fail for summary/attendance
|
|
@@ -202,6 +206,20 @@ export default function ShiftCalendar({ employeeId, projectId, workforceId, show
|
|
|
202
206
|
|
|
203
207
|
// Exchanges are now mapped directly into shift segmentsInfo.
|
|
204
208
|
|
|
209
|
+
// 4. Project Workforce Attendance (Summary)
|
|
210
|
+
if (data.projectAttendance) {
|
|
211
|
+
const pData = data.projectAttendance[format(day, 'yyyy-MM-dd')];
|
|
212
|
+
if (pData) {
|
|
213
|
+
events.push({
|
|
214
|
+
type: 'project_attendance',
|
|
215
|
+
name: 'Workforce Attendance',
|
|
216
|
+
present: parseInt(pData.present_count || '0', 10),
|
|
217
|
+
absent: parseInt(pData.absent_count || '0', 10),
|
|
218
|
+
workers: pData.workers || [],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
205
223
|
return events;
|
|
206
224
|
};
|
|
207
225
|
|
|
@@ -373,15 +391,17 @@ export default function ShiftCalendar({ employeeId, projectId, workforceId, show
|
|
|
373
391
|
? evt.isOptional ? 'bg-warning-50 text-warning-700 border border-warning-100 p-1.5' : 'bg-primary-50 text-primary-700 border border-primary-100 p-1.5'
|
|
374
392
|
: evt.type === 'leave'
|
|
375
393
|
? evt.status === 'approved' ? 'bg-success-50 text-success-700 border border-success-100 p-1.5' : 'bg-gray-100 text-gray-700 border border-gray-200 p-1.5'
|
|
376
|
-
: evt.type === '
|
|
377
|
-
? 'bg-
|
|
378
|
-
: evt.type === '
|
|
379
|
-
?
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
394
|
+
: evt.type === 'project_attendance'
|
|
395
|
+
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 p-1.5'
|
|
396
|
+
: evt.type === 'shift'
|
|
397
|
+
? 'bg-[#0D6EFD] text-white p-1.5 flex flex-col gap-0.5'
|
|
398
|
+
: evt.type === 'exchange'
|
|
399
|
+
? evt.isPending
|
|
400
|
+
? 'bg-amber-50 text-amber-700 border border-amber-200 p-1.5 border-dashed'
|
|
401
|
+
: 'bg-indigo-50 text-indigo-700 border border-indigo-200 p-1.5'
|
|
402
|
+
: evt.type === 'off_day'
|
|
403
|
+
? 'bg-white text-red-600 border border-red-200 p-1.5'
|
|
404
|
+
: 'bg-purple-50 text-purple-700 border border-purple-200 p-1.5'
|
|
385
405
|
}`}
|
|
386
406
|
>
|
|
387
407
|
{evt.type === 'shift' ? (
|
|
@@ -397,6 +417,14 @@ export default function ShiftCalendar({ employeeId, projectId, workforceId, show
|
|
|
397
417
|
</div>
|
|
398
418
|
)}
|
|
399
419
|
</>
|
|
420
|
+
) : evt.type === 'project_attendance' ? (
|
|
421
|
+
<div className="flex flex-col gap-0.5">
|
|
422
|
+
<div className="truncate font-semibold text-xs leading-tight">{evt.name}</div>
|
|
423
|
+
<div className="flex gap-2 text-[10px] opacity-80 mt-0.5">
|
|
424
|
+
<span className="font-medium text-emerald-800">{evt.present} Present</span>
|
|
425
|
+
<span className="font-medium text-red-800">{evt.absent} Absent</span>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
400
428
|
) : (
|
|
401
429
|
<div className="truncate font-semibold text-xs leading-tight">{evt.name}</div>
|
|
402
430
|
)}
|