@apptimate/ui 5.2.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.
@@ -0,0 +1,249 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { Modal, Input, Button } from "../../index";
5
+ import toast from "react-hot-toast";
6
+ import { Plus, Trash2 } from "lucide-react";
7
+ import { getShiftTemplate, createShiftTemplate, updateShiftTemplate } from "@apptimate/core-lib";
8
+
9
+ interface ShiftTemplateFormProps {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ onSuccess: () => void;
13
+ templateId: number | null;
14
+ }
15
+
16
+ export default function ShiftTemplateForm({ isOpen, onClose, onSuccess, templateId }: ShiftTemplateFormProps) {
17
+ const isNew = !templateId;
18
+
19
+ const [loading, setLoading] = useState(!isNew);
20
+ const [saving, setSaving] = useState(false);
21
+
22
+ const [formData, setFormData] = useState<any>({
23
+ name: "",
24
+ code: "",
25
+ break_minutes: 0,
26
+ grace_late_min: 10,
27
+ grace_early_min: 10,
28
+ is_active: true,
29
+ segments: [{ start_time: "", end_time: "" }],
30
+ });
31
+
32
+ useEffect(() => {
33
+ if (isOpen) {
34
+ if (!isNew && templateId) {
35
+ loadTemplate(templateId);
36
+ } else {
37
+ setLoading(false);
38
+ }
39
+ } else {
40
+ setFormData({
41
+ name: "",
42
+ code: "",
43
+ break_minutes: 0,
44
+ grace_late_min: 10,
45
+ grace_early_min: 10,
46
+ is_active: true,
47
+ segments: [{ start_time: "", end_time: "" }],
48
+ });
49
+ }
50
+ }, [isOpen, templateId]);
51
+
52
+ const loadTemplate = async (id: number) => {
53
+ try {
54
+ setLoading(true);
55
+ const res = await getShiftTemplate(id);
56
+ if (res.is_success && res.result) {
57
+ // Time format might be HH:mm:ss, truncate to HH:mm for input type="time"
58
+ const formattedSegments = (res.result.segments || []).map((seg: any) => ({
59
+ start_time: seg.start_time ? seg.start_time.substring(0, 5) : "",
60
+ end_time: seg.end_time ? seg.end_time.substring(0, 5) : "",
61
+ }));
62
+
63
+ setFormData({
64
+ name: res.result.name || "",
65
+ code: res.result.code || "",
66
+ break_minutes: res.result.break_minutes || 0,
67
+ grace_late_min: res.result.grace_late_min || 10,
68
+ grace_early_min: res.result.grace_early_min || 10,
69
+ is_active: res.result.is_active ?? true,
70
+ segments: formattedSegments.length > 0 ? formattedSegments : [{ start_time: "", end_time: "" }],
71
+ });
72
+ } else {
73
+ toast.error("Failed to load template");
74
+ onClose();
75
+ }
76
+ } catch {
77
+ toast.error("Error loading template");
78
+ onClose();
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ };
83
+
84
+ const handleSegmentChange = (index: number, field: string, value: string) => {
85
+ const updated = [...formData.segments];
86
+ updated[index][field] = value;
87
+ setFormData({ ...formData, segments: updated });
88
+ };
89
+
90
+ const addSegment = () => {
91
+ setFormData({ ...formData, segments: [...formData.segments, { start_time: "", end_time: "" }] });
92
+ };
93
+
94
+ const removeSegment = (index: number) => {
95
+ if (formData.segments.length === 1) return;
96
+ const updated = formData.segments.filter((_: any, i: number) => i !== index);
97
+ setFormData({ ...formData, segments: updated });
98
+ };
99
+
100
+ const saveTemplate = async () => {
101
+ if (!formData.name) {
102
+ toast.error("Template name is required");
103
+ return;
104
+ }
105
+ if (!formData.code) {
106
+ toast.error("Template code is required");
107
+ return;
108
+ }
109
+
110
+ // append :00 for seconds if missing
111
+ const payload = {
112
+ ...formData,
113
+ segments: formData.segments.map((seg: any) => ({
114
+ start_time: seg.start_time.length === 5 ? `${seg.start_time}:00` : seg.start_time,
115
+ end_time: seg.end_time.length === 5 ? `${seg.end_time}:00` : seg.end_time,
116
+ }))
117
+ };
118
+
119
+ try {
120
+ setSaving(true);
121
+ const res = isNew
122
+ ? await createShiftTemplate(payload)
123
+ : await updateShiftTemplate(templateId as number, payload);
124
+
125
+ if (res.is_success) {
126
+ toast.success(`Template ${isNew ? "created" : "updated"}`);
127
+ onSuccess();
128
+ } else {
129
+ toast.error(res.message || "Failed to save template");
130
+ }
131
+ } catch (e: any) {
132
+ toast.error(e.message || "Error saving template");
133
+ } finally {
134
+ setSaving(false);
135
+ }
136
+ };
137
+
138
+ return (
139
+ <Modal
140
+ isOpen={isOpen}
141
+ onClose={onClose}
142
+ title={isNew ? "Add Shift Template" : "Edit Shift Template"}
143
+ size="2xl"
144
+ footer={
145
+ <div className="flex justify-end gap-2">
146
+ <Button variant="flat" color="secondary" onClick={onClose}>Cancel</Button>
147
+ <Button color="primary" onClick={saveTemplate} isLoading={saving}>Save Template</Button>
148
+ </div>
149
+ }
150
+ >
151
+ {loading ? (
152
+ <div className="py-20 flex justify-center">
153
+ <div className="w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
154
+ </div>
155
+ ) : (
156
+ <div className="space-y-6">
157
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
158
+ <Input
159
+ label="Template Name *"
160
+ value={formData.name}
161
+ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
162
+ placeholder="e.g. Day Shift"
163
+ />
164
+ <Input
165
+ label="Code *"
166
+ value={formData.code}
167
+ onChange={(e) => setFormData({ ...formData, code: e.target.value })}
168
+ placeholder="e.g. DAY"
169
+ />
170
+ <Input
171
+ label="Break Minutes"
172
+ type="number"
173
+ min="0"
174
+ value={formData.break_minutes}
175
+ onChange={(e) => setFormData({ ...formData, break_minutes: parseInt(e.target.value) || 0 })}
176
+ />
177
+ <div className="grid grid-cols-2 gap-2">
178
+ <Input
179
+ label="Grace Late (Min)"
180
+ type="number"
181
+ min="0"
182
+ value={formData.grace_late_min}
183
+ onChange={(e) => setFormData({ ...formData, grace_late_min: parseInt(e.target.value) || 0 })}
184
+ />
185
+ <Input
186
+ label="Grace Early (Min)"
187
+ type="number"
188
+ min="0"
189
+ value={formData.grace_early_min}
190
+ onChange={(e) => setFormData({ ...formData, grace_early_min: parseInt(e.target.value) || 0 })}
191
+ />
192
+ </div>
193
+ </div>
194
+
195
+ <div className="border-t border-gray-100 pt-6">
196
+ <div className="flex items-center justify-between mb-4">
197
+ <h3 className="font-bold text-gray-900">Time Segments</h3>
198
+ <Button
199
+ size="small"
200
+ variant="solid"
201
+ className="!bg-black !text-white hover:!bg-gray-800 !border-none"
202
+ onClick={addSegment}
203
+ icon={<Plus size={14} strokeWidth={2.5} />}
204
+ iconPosition="left"
205
+ >
206
+ Add Segment
207
+ </Button>
208
+ </div>
209
+
210
+ <div className="space-y-3">
211
+ {formData.segments.map((segment: any, index: number) => (
212
+ <div key={index} className="flex items-end gap-3 p-3 bg-gray-50 border border-gray-200 rounded-lg">
213
+ <div className="flex-1">
214
+ <Input
215
+ label="Start Time *"
216
+ type="time"
217
+ value={segment.start_time}
218
+ onChange={(e) => handleSegmentChange(index, "start_time", e.target.value)}
219
+ />
220
+ </div>
221
+ <div className="flex-1">
222
+ <Input
223
+ label="End Time *"
224
+ type="time"
225
+ value={segment.end_time}
226
+ onChange={(e) => handleSegmentChange(index, "end_time", e.target.value)}
227
+ />
228
+ </div>
229
+ {formData.segments.length > 1 && (
230
+ <button
231
+ onClick={() => removeSegment(index)}
232
+ className="flex items-center justify-center h-[38px] w-[38px] text-gray-400 hover:text-danger-500 transition-colors bg-white border border-gray-200 rounded-[10px] hover:border-danger-200 hover:bg-danger-50"
233
+ title="Remove Segment"
234
+ >
235
+ <Trash2 size={16} />
236
+ </button>
237
+ )}
238
+ </div>
239
+ ))}
240
+ </div>
241
+ <p className="text-xs text-gray-500 mt-3">
242
+ Add multiple segments to create a split shift (e.g., 08:00 - 12:00 and 13:00 - 18:00).
243
+ </p>
244
+ </div>
245
+ </div>
246
+ )}
247
+ </Modal>
248
+ );
249
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import {
5
+ Briefcase,
6
+ CheckCircle2,
7
+ XCircle,
8
+ CalendarOff,
9
+ AlertTriangle,
10
+ Palmtree,
11
+ CalendarDays,
12
+ Clock,
13
+ Hourglass,
14
+ Timer,
15
+ } from "lucide-react";
16
+
17
+ export interface SummaryData {
18
+ workingDays: number;
19
+ present: number;
20
+ absent: number;
21
+ leave: number;
22
+ lop: number;
23
+ holidays: number;
24
+ weeklyOffs: number;
25
+ otDays: number;
26
+ otHours: number;
27
+ lates: number;
28
+ }
29
+
30
+ interface SummaryStripProps {
31
+ data: SummaryData;
32
+ }
33
+
34
+ const statConfig = [
35
+ { key: "workingDays", label: "Working Days", icon: Briefcase, color: "text-blue-600", bg: "bg-blue-50", ring: "ring-blue-100" },
36
+ { key: "present", label: "Present", icon: CheckCircle2, color: "text-emerald-600", bg: "bg-emerald-50", ring: "ring-emerald-100" },
37
+ { key: "absent", label: "Absent", icon: XCircle, color: "text-red-500", bg: "bg-red-50", ring: "ring-red-100" },
38
+ { key: "leave", label: "Leave", icon: CalendarOff, color: "text-amber-600", bg: "bg-amber-50", ring: "ring-amber-100" },
39
+ { key: "lop", label: "LOP", icon: AlertTriangle, color: "text-orange-600", bg: "bg-orange-50", ring: "ring-orange-100" },
40
+ { key: "holidays", label: "Holidays", icon: Palmtree, color: "text-indigo-600", bg: "bg-indigo-50", ring: "ring-indigo-100" },
41
+ { key: "weeklyOffs", label: "Weekly Offs", icon: CalendarDays, color: "text-gray-500", bg: "bg-gray-50", ring: "ring-gray-200" },
42
+ { key: "otDays", label: "OT Days", icon: Clock, color: "text-purple-600", bg: "bg-purple-50", ring: "ring-purple-100" },
43
+ { key: "otHours", label: "OT Hours", icon: Hourglass, color: "text-violet-600", bg: "bg-violet-50", ring: "ring-violet-100" },
44
+ { key: "lates", label: "Lates", icon: Timer, color: "text-rose-500", bg: "bg-rose-50", ring: "ring-rose-100" },
45
+ ] as const;
46
+
47
+ export default function SummaryStrip({ data }: SummaryStripProps) {
48
+ return (
49
+ <div className="relative overflow-hidden rounded-[16px] border border-gray-200/60 shadow-sm bg-white/80 backdrop-blur-sm">
50
+ {/* Subtle gradient background */}
51
+ <div className="absolute inset-0 bg-gradient-to-r from-slate-50/80 via-white/60 to-blue-50/40 pointer-events-none" />
52
+
53
+ <div className="relative flex items-stretch gap-0 overflow-x-auto scrollbar-hide">
54
+ {statConfig.map((stat, idx) => {
55
+ const Icon = stat.icon;
56
+ const value = data[stat.key as keyof SummaryData];
57
+ return (
58
+ <div
59
+ key={stat.key}
60
+ className={`flex items-center gap-2.5 px-4 py-3.5 min-w-[130px] flex-1 transition-all duration-200 hover:bg-gray-50/80 group cursor-default ${
61
+ idx < statConfig.length - 1 ? "border-r border-gray-100" : ""
62
+ }`}
63
+ >
64
+ <div className={`w-8 h-8 rounded-lg ${stat.bg} ring-1 ${stat.ring} flex items-center justify-center flex-shrink-0 transition-transform duration-200 group-hover:scale-110`}>
65
+ <Icon size={15} className={stat.color} />
66
+ </div>
67
+ <div className="flex flex-col min-w-0">
68
+ <span className="text-[18px] font-bold text-gray-900 leading-tight tracking-tight">
69
+ {value ?? 0}
70
+ </span>
71
+ <span className="text-[10.5px] font-medium text-gray-400 uppercase tracking-wider truncate leading-tight">
72
+ {stat.label}
73
+ </span>
74
+ </div>
75
+ </div>
76
+ );
77
+ })}
78
+ </div>
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { Users } from "lucide-react";
5
+ import { EntityPickerModal, PickerItem, PickerTrigger } from "./EntityPickerModal";
6
+ import { getEmployeeLookup } from "@apptimate/core-lib";
7
+
8
+ interface EmployeePickerProps {
9
+ value?: number | string | null;
10
+ displayValue?: string | null;
11
+ onChange: (emp: { id: number; name: string; code?: string } | null) => void;
12
+ label?: string;
13
+ placeholder?: string;
14
+ isRequired?: boolean;
15
+ excludeId?: number | string;
16
+ }
17
+
18
+ export function EmployeePicker({
19
+ value,
20
+ displayValue,
21
+ onChange,
22
+ label = "Employee",
23
+ placeholder = "Select employee...",
24
+ isRequired = false,
25
+ excludeId,
26
+ }: EmployeePickerProps) {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+ const [search, setSearch] = useState("");
29
+ const [employees, setEmployees] = useState<any[]>([]);
30
+ const [isLoading, setIsLoading] = useState(false);
31
+
32
+ const fetchData = useCallback(async () => {
33
+ setIsLoading(true);
34
+ try {
35
+ const res = await getEmployeeLookup();
36
+ if (res.is_success && res.result) {
37
+ setEmployees(res.result);
38
+ }
39
+ } catch {}
40
+ setIsLoading(false);
41
+ }, []);
42
+
43
+ const handleOpen = () => {
44
+ setSearch("");
45
+ setIsOpen(true);
46
+ fetchData();
47
+ };
48
+
49
+ const handleSelect = (emp: any) => {
50
+ const fullName = `${emp.first_name} ${emp.last_name || ""}`.trim();
51
+ onChange({ id: emp.id, name: fullName, code: emp.employee_code });
52
+ setIsOpen(false);
53
+ };
54
+
55
+ const handleClear = () => {
56
+ onChange(null);
57
+ };
58
+
59
+ const searchLower = (search || "").toLowerCase();
60
+
61
+ const filtered = employees.filter((e) => {
62
+ if (excludeId && String(e.id) === String(excludeId)) return false;
63
+
64
+ const fullName = `${e.first_name} ${e.last_name || ""}`.trim().toLowerCase();
65
+ const code = (e.employee_code || "").toLowerCase();
66
+ return fullName.includes(searchLower) || code.includes(searchLower);
67
+ });
68
+
69
+ return (
70
+ <>
71
+ <PickerTrigger
72
+ label={label}
73
+ value={displayValue || null}
74
+ placeholder={placeholder}
75
+ isRequired={isRequired}
76
+ onClick={handleOpen}
77
+ onClear={value ? handleClear : undefined}
78
+ />
79
+
80
+ <EntityPickerModal
81
+ isOpen={isOpen}
82
+ onClose={() => setIsOpen(false)}
83
+ onSelect={handleSelect}
84
+ title={`Select ${label}`}
85
+ searchPlaceholder="Search employees..."
86
+ selectedId={value}
87
+ search={search}
88
+ onSearchChange={setSearch}
89
+ size="sm"
90
+ >
91
+ {isLoading ? (
92
+ <div className="py-12 text-center">
93
+ <div className="inline-block h-6 w-6 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
94
+ <p className="mt-3 text-sm text-gray-400">Loading employees...</p>
95
+ </div>
96
+ ) : filtered.length === 0 ? (
97
+ <div className="py-12 text-center">
98
+ <Users size={32} className="mx-auto text-gray-300 mb-2" />
99
+ <p className="text-sm text-gray-400">No employees found</p>
100
+ </div>
101
+ ) : (
102
+ <div className="space-y-0.5 py-1">
103
+ {filtered.map((emp) => {
104
+ const fullName = `${emp.first_name} ${emp.last_name || ""}`.trim();
105
+ return (
106
+ <PickerItem
107
+ key={emp.id}
108
+ label={fullName}
109
+ sublabel={emp.employee_code ? `ID: ${emp.employee_code}` : undefined}
110
+ isSelected={String(value) === String(emp.id)}
111
+ onClick={() => handleSelect(emp)}
112
+ />
113
+ );
114
+ })}
115
+ </div>
116
+ )}
117
+ </EntityPickerModal>
118
+ </>
119
+ );
120
+ }
@@ -192,10 +192,12 @@ export function PickerTrigger({
192
192
  }: PickerTriggerProps) {
193
193
  return (
194
194
  <div className="flex flex-col gap-1.5 w-full">
195
- <label className="text-[11px] font-bold text-foreground-subtle uppercase tracking-wider">
196
- {label}
197
- {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
198
- </label>
195
+ {label && (
196
+ <label className="text-[11px] font-bold text-foreground-subtle uppercase tracking-wider">
197
+ {label}
198
+ {isRequired && <span className="text-danger-alt ml-0.5">*</span>}
199
+ </label>
200
+ )}
199
201
  <button
200
202
  type="button"
201
203
  disabled={disabled}
package/src/index.tsx CHANGED
@@ -19,6 +19,8 @@ export * from './base-components/TableMobileOptions';
19
19
  export * from './base-components/DesktopSearchPopover';
20
20
  export * from './base-components/DesktopFilterPopover';
21
21
  export * from './base-components/Input';
22
+ export * from './base-components/RichEmailEditor';
23
+ export * from './base-components/ColorPicker';
22
24
  export * from './base-components/Textarea';
23
25
  export * from './base-components/Label';
24
26
  export * from './base-components/Modal';
@@ -41,6 +43,7 @@ export * from './base-components/WizardModal';
41
43
  // Pickers
42
44
  export * from './common-components/pickers/BrandPicker';
43
45
  export * from './common-components/pickers/CategoryPicker';
46
+ export * from './common-components/pickers/EmployeePicker';
44
47
  export * from './common-components/pickers/EntityPickerModal';
45
48
  export * from './common-components/pickers/UomGroupPicker';
46
49
  export * from './common-components/pickers/UomPicker';
@@ -56,3 +59,19 @@ export * from './common-components/item-wizard/SkuConfigModal';
56
59
  // Transaction
57
60
  export * from './common-components/transaction';
58
61
  export * from './finance-components/PaymentForm';
62
+
63
+ // Attendance & Shifts
64
+ export { default as ShiftCalendar } from './common-components/attendance-shifts/ShiftCalendar';
65
+ export * from './common-components/attendance-shifts/ShiftCalendar';
66
+ export { default as ShiftTemplateForm } from './common-components/attendance-shifts/ShiftTemplateForm';
67
+ export * from './common-components/attendance-shifts/ShiftTemplateForm';
68
+ export { default as DailyOverview } from './common-components/attendance-shifts/DailyOverview';
69
+ export * from './common-components/attendance-shifts/DailyOverview';
70
+ export { default as PendingActionsPanel } from './common-components/attendance-shifts/PendingActionsPanel';
71
+ export * from './common-components/attendance-shifts/PendingActionsPanel';
72
+ export * from './common-components/attendance-shifts/ManualPunchModal';
73
+ export * from './common-components/attendance-shifts/ImportExcelModal';
74
+ export { default as AttendanceTimesheets } from './common-components/attendance-shifts/AttendanceTimesheets';
75
+ export * from './common-components/attendance-shifts/AttendanceTimesheets';
76
+
77
+ export * from "./base-components/ChartOfAccountPicker";