@apptimate/ui 5.2.0 → 5.4.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 +3 -2
- package/src/base-components/ChartOfAccountPicker.tsx +203 -0
- package/src/base-components/ColorPicker.tsx +204 -0
- package/src/base-components/Modal.tsx +17 -7
- package/src/base-components/RichEmailEditor.tsx +137 -0
- package/src/common-components/DashboardLayout.tsx +17 -1
- package/src/common-components/attendance-shifts/AttendanceTimesheets.tsx +43 -0
- package/src/common-components/attendance-shifts/CalendarLegend.tsx +27 -0
- package/src/common-components/attendance-shifts/CancelShiftModal.tsx +148 -0
- package/src/common-components/attendance-shifts/DailyOverview.tsx +198 -0
- package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +139 -0
- package/src/common-components/attendance-shifts/ExchangeShiftModal.tsx +132 -0
- package/src/common-components/attendance-shifts/ImportExcelModal.tsx +183 -0
- package/src/common-components/attendance-shifts/ManualPunchModal.tsx +139 -0
- package/src/common-components/attendance-shifts/PendingActionsPanel.tsx +321 -0
- package/src/common-components/attendance-shifts/ShiftCalendar.tsx +451 -0
- package/src/common-components/attendance-shifts/ShiftTemplateForm.tsx +249 -0
- package/src/common-components/attendance-shifts/SummaryStrip.tsx +81 -0
- package/src/common-components/pickers/EmployeePicker.tsx +120 -0
- package/src/common-components/pickers/EntityPickerModal.tsx +6 -4
- package/src/index.tsx +19 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { Button } from '../../index';
|
|
5
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
6
|
+
import { format, addMonths, subMonths, addWeeks, subWeeks, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay } from 'date-fns';
|
|
7
|
+
import { getEmployeeCalendar, toggleSegmentCancellation, requestSegmentExchange, cancelSegmentExchange, getCalendarSummary, getAttendanceDays } from '@apptimate/core-lib';
|
|
8
|
+
import DailySchedulePanel from './DailySchedulePanel';
|
|
9
|
+
import CancelShiftModal from './CancelShiftModal';
|
|
10
|
+
import ExchangeShiftModal from './ExchangeShiftModal';
|
|
11
|
+
import SummaryStrip, { type SummaryData } from './SummaryStrip';
|
|
12
|
+
import CalendarLegend from './CalendarLegend';
|
|
13
|
+
import toast from 'react-hot-toast';
|
|
14
|
+
|
|
15
|
+
interface ShiftCalendarProps {
|
|
16
|
+
employeeId?: string | number;
|
|
17
|
+
projectId?: string | number;
|
|
18
|
+
workforceId?: string | number;
|
|
19
|
+
showSummary?: boolean;
|
|
20
|
+
showLegend?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function ShiftCalendar({ employeeId, projectId, workforceId, showSummary = true, showLegend = true }: ShiftCalendarProps) {
|
|
24
|
+
const [viewMode, setViewMode] = useState<'month' | 'week'>('month');
|
|
25
|
+
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
|
26
|
+
|
|
27
|
+
// Modal states
|
|
28
|
+
const [cancelModalOpen, setCancelModalOpen] = useState(false);
|
|
29
|
+
const [exchangeModalOpen, setExchangeModalOpen] = useState(false);
|
|
30
|
+
const [activeShift, setActiveShift] = useState<any>(null);
|
|
31
|
+
const [activeSegmentNo, setActiveSegmentNo] = useState<number | null>(null);
|
|
32
|
+
|
|
33
|
+
const [currentDate, setCurrentDate] = useState(new Date());
|
|
34
|
+
const [loading, setLoading] = useState(false);
|
|
35
|
+
const [data, setData] = useState<{ holidays: any[], leaves: any[], rosters: any[], exchanges: any[] }>({
|
|
36
|
+
holidays: [], leaves: [], rosters: [], exchanges: []
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const [summaryData, setSummaryData] = useState<SummaryData>({
|
|
40
|
+
workingDays: 0, present: 0, absent: 0, leave: 0, lop: 0,
|
|
41
|
+
holidays: 0, weeklyOffs: 0, otDays: 0, otHours: 0, lates: 0,
|
|
42
|
+
});
|
|
43
|
+
const [attendanceMap, setAttendanceMap] = useState<Record<string, string>>({});
|
|
44
|
+
|
|
45
|
+
const fetchCalendarData = useCallback(async (start: Date, end: Date) => {
|
|
46
|
+
try {
|
|
47
|
+
setLoading(true);
|
|
48
|
+
const startStr = format(start, 'yyyy-MM-dd');
|
|
49
|
+
const endStr = format(end, 'yyyy-MM-dd');
|
|
50
|
+
const res = await getEmployeeCalendar(employeeId || 0, startStr, endStr, projectId, workforceId);
|
|
51
|
+
if (res.is_success && res.result) {
|
|
52
|
+
setData(res.result);
|
|
53
|
+
}
|
|
54
|
+
} catch (e: any) {
|
|
55
|
+
toast.error(e.message || "Failed to load calendar data");
|
|
56
|
+
} finally {
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
}, [employeeId]);
|
|
60
|
+
|
|
61
|
+
const monthStart = startOfMonth(currentDate);
|
|
62
|
+
const monthEnd = endOfMonth(monthStart);
|
|
63
|
+
|
|
64
|
+
const startDate = viewMode === 'month' ? startOfWeek(monthStart) : startOfWeek(currentDate);
|
|
65
|
+
const endDate = viewMode === 'month' ? endOfWeek(monthEnd) : endOfWeek(currentDate);
|
|
66
|
+
|
|
67
|
+
const startMs = startDate.getTime();
|
|
68
|
+
const endMs = endDate.getTime();
|
|
69
|
+
|
|
70
|
+
const fetchSummaryAndAttendance = useCallback(async (start: Date, end: Date) => {
|
|
71
|
+
const monthStr = format(start, 'yyyy-MM');
|
|
72
|
+
const startStr = format(start, 'yyyy-MM-dd');
|
|
73
|
+
const endStr = format(end, 'yyyy-MM-dd');
|
|
74
|
+
try {
|
|
75
|
+
const [summaryRes, attendanceRes] = await Promise.allSettled([
|
|
76
|
+
showSummary ? getCalendarSummary(employeeId, monthStr) : Promise.resolve(null),
|
|
77
|
+
getAttendanceDays(employeeId || 0, startStr, endStr, projectId, workforceId),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
if (summaryRes.status === 'fulfilled' && summaryRes.value && (summaryRes.value as any).is_success) {
|
|
81
|
+
const r = (summaryRes.value as any).result;
|
|
82
|
+
if (r) {
|
|
83
|
+
setSummaryData({
|
|
84
|
+
workingDays: r.working_days ?? 0,
|
|
85
|
+
present: r.present ?? 0,
|
|
86
|
+
absent: r.absent ?? 0,
|
|
87
|
+
leave: r.leave ?? 0,
|
|
88
|
+
lop: r.lop ?? 0,
|
|
89
|
+
holidays: r.holidays ?? 0,
|
|
90
|
+
weeklyOffs: r.weekly_offs ?? 0,
|
|
91
|
+
otDays: r.ot_days ?? 0,
|
|
92
|
+
otHours: r.ot_hours ?? 0,
|
|
93
|
+
lates: r.lates ?? 0,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (attendanceRes.status === 'fulfilled' && attendanceRes.value && (attendanceRes.value as any).is_success) {
|
|
99
|
+
const days = (attendanceRes.value as any).result?.days || [];
|
|
100
|
+
const map: Record<string, string> = {};
|
|
101
|
+
days.forEach((d: any) => {
|
|
102
|
+
if (d.date && d.status) {
|
|
103
|
+
map[d.date] = d.status;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
setAttendanceMap(map);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Silently fail for summary/attendance
|
|
110
|
+
}
|
|
111
|
+
}, [employeeId, showSummary]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
fetchCalendarData(startDate, endDate);
|
|
115
|
+
fetchSummaryAndAttendance(monthStart, monthEnd);
|
|
116
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
117
|
+
}, [startMs, endMs, fetchCalendarData, fetchSummaryAndAttendance]);
|
|
118
|
+
|
|
119
|
+
const prev = () => setCurrentDate(viewMode === 'month' ? subMonths(currentDate, 1) : subWeeks(currentDate, 1));
|
|
120
|
+
const next = () => setCurrentDate(viewMode === 'month' ? addMonths(currentDate, 1) : addWeeks(currentDate, 1));
|
|
121
|
+
|
|
122
|
+
const dateFormat = "d";
|
|
123
|
+
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
|
124
|
+
|
|
125
|
+
const getAttendanceBorderClass = (day: Date): string => {
|
|
126
|
+
const key = format(day, 'yyyy-MM-dd');
|
|
127
|
+
const status = attendanceMap[key];
|
|
128
|
+
switch (status) {
|
|
129
|
+
case 'P': return 'border-l-[3px] border-l-emerald-400';
|
|
130
|
+
case 'A': return 'border-l-[3px] border-l-red-400';
|
|
131
|
+
case 'HD': return 'border-l-[3px] border-l-orange-400';
|
|
132
|
+
case 'L': return 'border-l-[3px] border-l-teal-400';
|
|
133
|
+
default: return '';
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const getDayEvents = (day: Date) => {
|
|
138
|
+
const events = [];
|
|
139
|
+
|
|
140
|
+
// 1. Holidays
|
|
141
|
+
const holiday = data.holidays?.find(h => isSameDay(new Date(h.date), day));
|
|
142
|
+
if (holiday) {
|
|
143
|
+
events.push({ type: 'holiday', name: holiday.name, isOptional: holiday.is_optional });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. Leaves
|
|
147
|
+
const leave = data.leaves?.find(l => {
|
|
148
|
+
if (!l.from_date || !l.to_date) return false;
|
|
149
|
+
const from = new Date(l.from_date);
|
|
150
|
+
const to = new Date(l.to_date);
|
|
151
|
+
from.setHours(0, 0, 0, 0);
|
|
152
|
+
to.setHours(23, 59, 59, 999);
|
|
153
|
+
return day >= from && day <= to;
|
|
154
|
+
});
|
|
155
|
+
if (leave) {
|
|
156
|
+
events.push({ type: 'leave', name: `${leave.leave_type?.name} (${leave.status})`, status: leave.status });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. Shifts & Work Off
|
|
160
|
+
const roster = data.rosters?.find(r => r.roster_date && isSameDay(new Date(r.roster_date), day));
|
|
161
|
+
if (roster) {
|
|
162
|
+
if (roster.day_type === 'off') {
|
|
163
|
+
events.push({ type: 'off_day', name: 'Work Off' });
|
|
164
|
+
} else if (roster.template) {
|
|
165
|
+
const template = roster.template;
|
|
166
|
+
const rosterCancelled = roster.cancelled_segments || [];
|
|
167
|
+
// Check for exchanges on this day
|
|
168
|
+
const dayExchanges = data.exchanges?.filter(e =>
|
|
169
|
+
(e.requester_roster_date && isSameDay(new Date(e.requester_roster_date), day)) ||
|
|
170
|
+
(e.responder_roster_date && isSameDay(new Date(e.responder_roster_date), day))
|
|
171
|
+
) || [];
|
|
172
|
+
|
|
173
|
+
const segmentsInfo = template.segments.map((s: any) => {
|
|
174
|
+
const isCancelled = rosterCancelled.some((c: any) =>
|
|
175
|
+
(typeof c === 'object' ? c.segment_no === s.segment_no : c === s.segment_no)
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const pendingExchange = dayExchanges.find(e =>
|
|
179
|
+
e.status === 'pending' &&
|
|
180
|
+
e.requester_segments &&
|
|
181
|
+
e.requester_segments.includes(s.segment_no)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
segment_no: s.segment_no,
|
|
186
|
+
str: `${s.start_time?.slice(0, 5) || ''} - ${s.end_time?.slice(0, 5) || ''}`,
|
|
187
|
+
isCancelled,
|
|
188
|
+
isExchangePending: !!pendingExchange,
|
|
189
|
+
pendingExchange
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
events.push({
|
|
194
|
+
type: 'shift',
|
|
195
|
+
name: template.name,
|
|
196
|
+
segmentsInfo,
|
|
197
|
+
roster_date: roster.roster_date,
|
|
198
|
+
template: roster.template
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Exchanges are now mapped directly into shift segmentsInfo.
|
|
204
|
+
|
|
205
|
+
return events;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handleToggleCancel = async (roster_date: string, segment_no: number, reason?: string) => {
|
|
209
|
+
try {
|
|
210
|
+
const res = await toggleSegmentCancellation(employeeId, { roster_date, segment_no, reason });
|
|
211
|
+
if (res.is_success) {
|
|
212
|
+
toast.success(res.message);
|
|
213
|
+
fetchCalendarData(startDate, endDate);
|
|
214
|
+
} else {
|
|
215
|
+
toast.error(res.message || "Failed to update segment");
|
|
216
|
+
}
|
|
217
|
+
} catch (err: any) {
|
|
218
|
+
toast.error(err.message || "An error occurred");
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handleExchange = async (roster_date: string, segment_no: number, responder_id: number) => {
|
|
223
|
+
try {
|
|
224
|
+
// Stub for now. Once we create the Exchange API endpoint, we will call it here.
|
|
225
|
+
toast.success(`Exchange initiated with Employee ID ${responder_id} for segment ${segment_no}`);
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
toast.error(err.message || "An error occurred");
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const handleDayClick = (day: Date) => {
|
|
232
|
+
setSelectedDay(day);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handlePanelCancel = (shift: any, segment_no: number) => {
|
|
236
|
+
setActiveShift(shift);
|
|
237
|
+
setActiveSegmentNo(segment_no);
|
|
238
|
+
setCancelModalOpen(true);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const handlePanelExchange = (shift: any, segment_no: number) => {
|
|
242
|
+
setActiveShift(shift);
|
|
243
|
+
setActiveSegmentNo(segment_no);
|
|
244
|
+
setExchangeModalOpen(true);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const handleConfirmCancel = async (reason: string) => {
|
|
248
|
+
setCancelModalOpen(false);
|
|
249
|
+
if (!selectedDay || activeSegmentNo === null) return;
|
|
250
|
+
await handleToggleCancel(format(selectedDay, 'yyyy-MM-dd'), activeSegmentNo, reason);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const handleCancelExchangeRequest = async (exchangeId: number) => {
|
|
254
|
+
try {
|
|
255
|
+
const res = await cancelSegmentExchange(employeeId, exchangeId);
|
|
256
|
+
if (res.is_success) {
|
|
257
|
+
toast.success(res.message || "Exchange request cancelled");
|
|
258
|
+
fetchCalendarData(startDate, endDate);
|
|
259
|
+
} else {
|
|
260
|
+
toast.error(res.message || "Failed to cancel request");
|
|
261
|
+
}
|
|
262
|
+
} catch (e: any) {
|
|
263
|
+
toast.error(e.message || "An error occurred");
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleConfirmExchange = async (responderId: number, note: string) => {
|
|
268
|
+
if (!selectedDay || activeSegmentNo === null) return;
|
|
269
|
+
try {
|
|
270
|
+
const res = await requestSegmentExchange(employeeId, {
|
|
271
|
+
roster_date: format(selectedDay, 'yyyy-MM-dd'),
|
|
272
|
+
segment_no: activeSegmentNo,
|
|
273
|
+
requester_id: Number(employeeId === 'me' ? 0 : employeeId), // Ideally we pass the real ID, but controller uses data['requester_id']. Wait, in ESS `me` is tricky. Let's just pass `employeeId` if not 'me'.
|
|
274
|
+
responder_id: responderId,
|
|
275
|
+
reason: note
|
|
276
|
+
});
|
|
277
|
+
if (res.is_success) {
|
|
278
|
+
toast.success("Exchange requested successfully!");
|
|
279
|
+
setExchangeModalOpen(false);
|
|
280
|
+
fetchCalendarData(startDate, endDate);
|
|
281
|
+
} else {
|
|
282
|
+
toast.error(res.message || "Failed to request exchange");
|
|
283
|
+
}
|
|
284
|
+
} catch (e: any) {
|
|
285
|
+
toast.error(e.message || "An error occurred");
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="flex gap-4 items-start w-full">
|
|
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)]">
|
|
293
|
+
{/* Header */}
|
|
294
|
+
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
|
|
295
|
+
<h2 className="text-xl font-bold text-[#2D3142]">
|
|
296
|
+
{viewMode === 'month'
|
|
297
|
+
? format(currentDate, 'MMMM yyyy')
|
|
298
|
+
: `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`}
|
|
299
|
+
</h2>
|
|
300
|
+
<div className="flex items-center gap-4">
|
|
301
|
+
<div className="flex p-0.5 bg-gray-100 rounded-lg">
|
|
302
|
+
<button
|
|
303
|
+
onClick={() => setViewMode('month')}
|
|
304
|
+
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${viewMode === 'month' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
|
305
|
+
>
|
|
306
|
+
Month
|
|
307
|
+
</button>
|
|
308
|
+
<button
|
|
309
|
+
onClick={() => setViewMode('week')}
|
|
310
|
+
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${viewMode === 'week' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
|
311
|
+
>
|
|
312
|
+
Week
|
|
313
|
+
</button>
|
|
314
|
+
</div>
|
|
315
|
+
<div className="flex items-center gap-1.5">
|
|
316
|
+
<Button variant="bordered" size="small" onClick={prev} icon={<ChevronLeft size={16} />} />
|
|
317
|
+
<Button variant="bordered" size="small" onClick={() => setCurrentDate(new Date())}>Today</Button>
|
|
318
|
+
<Button variant="bordered" size="small" onClick={next} icon={<ChevronRight size={16} />} />
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{/* Summary Strip */}
|
|
324
|
+
{showSummary && (
|
|
325
|
+
<div className="px-4 pt-3">
|
|
326
|
+
<SummaryStrip data={summaryData} />
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{/* Legend */}
|
|
331
|
+
{showLegend && (
|
|
332
|
+
<div className="px-4 pt-1">
|
|
333
|
+
<CalendarLegend />
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
{/* Grid */}
|
|
338
|
+
<div className="flex-1 w-full bg-gray-50/50 p-4">
|
|
339
|
+
{/* Days Header */}
|
|
340
|
+
<div className="grid grid-cols-7 mb-2">
|
|
341
|
+
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => (
|
|
342
|
+
<div key={d} className="text-center text-[12px] font-bold text-gray-400 uppercase tracking-wider">{d}</div>
|
|
343
|
+
))}
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{/* Days Grid */}
|
|
347
|
+
<div className="grid grid-cols-7 gap-3">
|
|
348
|
+
{days.map((day, idx) => {
|
|
349
|
+
const isCurrentMonth = isSameMonth(day, monthStart);
|
|
350
|
+
const isToday = isSameDay(day, new Date());
|
|
351
|
+
const events = (viewMode === 'month' && !isCurrentMonth) ? [] : getDayEvents(day);
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<div
|
|
355
|
+
key={idx}
|
|
356
|
+
onClick={() => handleDayClick(day)}
|
|
357
|
+
className={`rounded-xl p-3 border transition-colors cursor-pointer ${
|
|
358
|
+
viewMode === 'month' ? 'min-h-[120px]' : 'min-h-[250px]'
|
|
359
|
+
} ${
|
|
360
|
+
isCurrentMonth || viewMode === 'week' ? 'bg-white border-gray-200 hover:border-gray-300' : 'bg-gray-50/50 border-transparent text-gray-400'
|
|
361
|
+
} ${isToday ? 'ring-2 ring-primary-500 ring-offset-2' : ''} ${getAttendanceBorderClass(day)}`}
|
|
362
|
+
>
|
|
363
|
+
<div className={`text-sm font-semibold mb-2 ${isToday ? 'text-primary-600' : isCurrentMonth ? 'text-gray-700' : 'text-gray-400'}`}>
|
|
364
|
+
{format(day, dateFormat)}
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<div className="flex flex-col gap-1.5">
|
|
368
|
+
{events.map((evt, i) => (
|
|
369
|
+
<div
|
|
370
|
+
key={i}
|
|
371
|
+
className={`overflow-hidden rounded-md ${
|
|
372
|
+
evt.type === 'holiday'
|
|
373
|
+
? 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
|
+
: evt.type === 'leave'
|
|
375
|
+
? 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 === 'shift'
|
|
377
|
+
? 'bg-[#0D6EFD] text-white p-1.5 flex flex-col gap-0.5'
|
|
378
|
+
: evt.type === 'exchange'
|
|
379
|
+
? evt.isPending
|
|
380
|
+
? 'bg-amber-50 text-amber-700 border border-amber-200 p-1.5 border-dashed'
|
|
381
|
+
: 'bg-indigo-50 text-indigo-700 border border-indigo-200 p-1.5'
|
|
382
|
+
: evt.type === 'off_day'
|
|
383
|
+
? 'bg-white text-red-600 border border-red-200 p-1.5'
|
|
384
|
+
: 'bg-purple-50 text-purple-700 border border-purple-200 p-1.5'
|
|
385
|
+
}`}
|
|
386
|
+
>
|
|
387
|
+
{evt.type === 'shift' ? (
|
|
388
|
+
<>
|
|
389
|
+
<div className="truncate font-semibold text-xs leading-tight">{evt.name}</div>
|
|
390
|
+
{evt.segmentsInfo && evt.segmentsInfo.length > 0 && (
|
|
391
|
+
<div className="flex flex-col">
|
|
392
|
+
{evt.segmentsInfo.map((info: any, idx: number) => (
|
|
393
|
+
<div key={idx} className={`truncate text-[10px] leading-tight ${info.isCancelled ? 'line-through text-blue-200' : 'text-blue-100'}`}>
|
|
394
|
+
{info.str} {info.isExchangePending && <span className="text-amber-300 ml-1 font-bold">(Swap Pending)</span>}
|
|
395
|
+
</div>
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
</>
|
|
400
|
+
) : (
|
|
401
|
+
<div className="truncate font-semibold text-xs leading-tight">{evt.name}</div>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
))}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
})}
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
{loading && (
|
|
413
|
+
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm flex items-center justify-center z-10">
|
|
414
|
+
<div className="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin"></div>
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
{/* Right Sidebar - Daily Schedule */}
|
|
421
|
+
{selectedDay && (
|
|
422
|
+
<DailySchedulePanel
|
|
423
|
+
day={selectedDay}
|
|
424
|
+
events={getDayEvents(selectedDay)}
|
|
425
|
+
onCancelClick={handlePanelCancel}
|
|
426
|
+
onExchangeClick={handlePanelExchange}
|
|
427
|
+
onCancelExchangeRequest={handleCancelExchangeRequest}
|
|
428
|
+
onClose={() => setSelectedDay(null)}
|
|
429
|
+
/>
|
|
430
|
+
)}
|
|
431
|
+
{/* Modals */}
|
|
432
|
+
<CancelShiftModal
|
|
433
|
+
isOpen={cancelModalOpen}
|
|
434
|
+
onClose={() => setCancelModalOpen(false)}
|
|
435
|
+
day={selectedDay}
|
|
436
|
+
shift={activeShift}
|
|
437
|
+
segmentNo={activeSegmentNo}
|
|
438
|
+
onConfirm={handleConfirmCancel}
|
|
439
|
+
/>
|
|
440
|
+
|
|
441
|
+
<ExchangeShiftModal
|
|
442
|
+
isOpen={exchangeModalOpen}
|
|
443
|
+
onClose={() => setExchangeModalOpen(false)}
|
|
444
|
+
day={selectedDay}
|
|
445
|
+
shift={activeShift}
|
|
446
|
+
segmentNo={activeSegmentNo}
|
|
447
|
+
onConfirm={handleConfirmExchange}
|
|
448
|
+
/>
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
}
|