@apptimate/ui 5.1.0 → 5.3.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.
Files changed (33) hide show
  1. package/package.json +3 -2
  2. package/src/base-components/ChartOfAccountPicker.tsx +203 -0
  3. package/src/base-components/ColorPicker.tsx +204 -0
  4. package/src/base-components/Modal.tsx +17 -7
  5. package/src/base-components/RichEmailEditor.tsx +137 -0
  6. package/src/common-components/DashboardLayout.tsx +17 -1
  7. package/src/common-components/attendance-shifts/AttendanceTimesheets.tsx +43 -0
  8. package/src/common-components/attendance-shifts/CalendarLegend.tsx +27 -0
  9. package/src/common-components/attendance-shifts/CancelShiftModal.tsx +148 -0
  10. package/src/common-components/attendance-shifts/DailyOverview.tsx +198 -0
  11. package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +139 -0
  12. package/src/common-components/attendance-shifts/ExchangeShiftModal.tsx +132 -0
  13. package/src/common-components/attendance-shifts/ImportExcelModal.tsx +183 -0
  14. package/src/common-components/attendance-shifts/ManualPunchModal.tsx +139 -0
  15. package/src/common-components/attendance-shifts/PendingActionsPanel.tsx +321 -0
  16. package/src/common-components/attendance-shifts/ShiftCalendar.tsx +451 -0
  17. package/src/common-components/attendance-shifts/ShiftTemplateForm.tsx +249 -0
  18. package/src/common-components/attendance-shifts/SummaryStrip.tsx +81 -0
  19. package/src/common-components/item-wizard/ItemFormWizard.tsx +8 -1
  20. package/src/common-components/item-wizard/SkuConfigModal.tsx +6 -1
  21. package/src/common-components/pickers/BrandPicker.tsx +5 -1
  22. package/src/common-components/pickers/CategoryPicker.tsx +5 -1
  23. package/src/common-components/pickers/EmployeePicker.tsx +120 -0
  24. package/src/common-components/pickers/EntityPickerModal.tsx +10 -5
  25. package/src/common-components/pickers/PartyPicker.tsx +5 -1
  26. package/src/common-components/pickers/UomGroupPicker.tsx +5 -1
  27. package/src/common-components/pickers/UomPicker.tsx +6 -1
  28. package/src/common-components/pickers/WarehousePicker.tsx +5 -1
  29. package/src/components/shared/CustomerSelectorComponent.tsx +7 -1
  30. package/src/components/shared/ImageUploadComponent.tsx +4 -1
  31. package/src/components/shared/PaymentModeComponent.tsx +6 -1
  32. package/src/components/shared/ProductSelectorComponent.tsx +7 -1
  33. 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
+ }