@apptimate/ui 6.8.0 → 7.0.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 +94 -14
- package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +73 -11
- package/src/common-components/attendance-shifts/ShiftCalendar.tsx +41 -13
- package/src/common-components/print/TemplateRenderer.tsx +10 -10
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apptimate/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"main": "src/index.tsx",
|
|
5
5
|
"types": "src/index.tsx",
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@apptimate/core-lib": "*",
|
|
8
|
+
"ably": "^2.27.0",
|
|
8
9
|
"apexcharts": "^5.15.2",
|
|
9
10
|
"class-variance-authority": "^0.7.1",
|
|
10
11
|
"clsx": "^2.1.1",
|
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useRef } from "react";
|
|
4
|
-
import { Upload } from "lucide-react";
|
|
5
|
-
|
|
6
|
-
export interface ImageDropzoneProps {
|
|
7
|
-
onDrop: (files: File[]) => void;
|
|
8
|
-
maxSizeMB?: number;
|
|
9
|
-
maxFiles?: number;
|
|
10
|
-
accept?: string;
|
|
11
|
-
className?: string;
|
|
12
|
-
title?: string;
|
|
13
|
-
subtitle?: string;
|
|
14
|
-
isLoading?: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function ImageDropzone({
|
|
18
|
-
onDrop,
|
|
19
|
-
maxSizeMB = 5,
|
|
20
|
-
maxFiles = 10,
|
|
21
|
-
accept = "image/*",
|
|
22
|
-
className = "",
|
|
23
|
-
title = "Drop images here or click to upload",
|
|
24
|
-
subtitle = "JPEG, PNG, WebP — max 5MB each, up to 10 images",
|
|
25
|
-
isLoading = false,
|
|
26
|
-
}: ImageDropzoneProps) {
|
|
27
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
28
|
-
|
|
29
|
-
const handleFileDrop = (e: React.DragEvent) => {
|
|
30
|
-
e.preventDefault();
|
|
31
|
-
if (isLoading) return;
|
|
32
|
-
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
|
33
|
-
if (files.length) {
|
|
34
|
-
onDrop(files.slice(0, maxFiles));
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
39
|
-
if (isLoading) return;
|
|
40
|
-
const files = Array.from(e.target.files || []);
|
|
41
|
-
if (files.length) {
|
|
42
|
-
onDrop(files.slice(0, maxFiles));
|
|
43
|
-
}
|
|
44
|
-
if (fileInputRef.current) {
|
|
45
|
-
fileInputRef.current.value = "";
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<div
|
|
51
|
-
onDragOver={(e) => e.preventDefault()}
|
|
52
|
-
onDrop={handleFileDrop}
|
|
53
|
-
onClick={() => fileInputRef.current?.click()}
|
|
54
|
-
className={`border-2 border-dashed border-gray-200 hover:border-primary-300 rounded-xl p-6 flex flex-col items-center justify-center cursor-pointer transition-all hover:bg-primary-50/30 group ${
|
|
55
|
-
isLoading ? "opacity-50 cursor-not-allowed" : ""
|
|
56
|
-
} ${className}`}
|
|
57
|
-
>
|
|
58
|
-
<div className="w-10 h-10 rounded-full bg-gray-100 group-hover:bg-primary-100 flex items-center justify-center mb-2 transition-colors">
|
|
59
|
-
{isLoading ? (
|
|
60
|
-
<div className="w-5 h-5 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
|
|
61
|
-
) : (
|
|
62
|
-
<Upload size={18} className="text-gray-400 group-hover:text-primary-500" />
|
|
63
|
-
)}
|
|
64
|
-
</div>
|
|
65
|
-
<p className="text-[13px] font-medium text-gray-500 group-hover:text-primary-600">
|
|
66
|
-
{isLoading ? "Uploading..." : title}
|
|
67
|
-
</p>
|
|
68
|
-
<p className="text-[11px] text-gray-400 mt-0.5">{subtitle}</p>
|
|
69
|
-
<input
|
|
70
|
-
ref={fileInputRef}
|
|
71
|
-
type="file"
|
|
72
|
-
accept={accept}
|
|
73
|
-
multiple={maxFiles > 1}
|
|
74
|
-
className="hidden"
|
|
75
|
-
onChange={handleFileSelect}
|
|
76
|
-
disabled={isLoading}
|
|
77
|
-
/>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef } from "react";
|
|
4
|
+
import { Upload } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
export interface ImageDropzoneProps {
|
|
7
|
+
onDrop: (files: File[]) => void;
|
|
8
|
+
maxSizeMB?: number;
|
|
9
|
+
maxFiles?: number;
|
|
10
|
+
accept?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
isLoading?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ImageDropzone({
|
|
18
|
+
onDrop,
|
|
19
|
+
maxSizeMB = 5,
|
|
20
|
+
maxFiles = 10,
|
|
21
|
+
accept = "image/*",
|
|
22
|
+
className = "",
|
|
23
|
+
title = "Drop images here or click to upload",
|
|
24
|
+
subtitle = "JPEG, PNG, WebP — max 5MB each, up to 10 images",
|
|
25
|
+
isLoading = false,
|
|
26
|
+
}: ImageDropzoneProps) {
|
|
27
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
28
|
+
|
|
29
|
+
const handleFileDrop = (e: React.DragEvent) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
if (isLoading) return;
|
|
32
|
+
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
|
33
|
+
if (files.length) {
|
|
34
|
+
onDrop(files.slice(0, maxFiles));
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
39
|
+
if (isLoading) return;
|
|
40
|
+
const files = Array.from(e.target.files || []);
|
|
41
|
+
if (files.length) {
|
|
42
|
+
onDrop(files.slice(0, maxFiles));
|
|
43
|
+
}
|
|
44
|
+
if (fileInputRef.current) {
|
|
45
|
+
fileInputRef.current.value = "";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
onDragOver={(e) => e.preventDefault()}
|
|
52
|
+
onDrop={handleFileDrop}
|
|
53
|
+
onClick={() => fileInputRef.current?.click()}
|
|
54
|
+
className={`border-2 border-dashed border-gray-200 hover:border-primary-300 rounded-xl p-6 flex flex-col items-center justify-center cursor-pointer transition-all hover:bg-primary-50/30 group ${
|
|
55
|
+
isLoading ? "opacity-50 cursor-not-allowed" : ""
|
|
56
|
+
} ${className}`}
|
|
57
|
+
>
|
|
58
|
+
<div className="w-10 h-10 rounded-full bg-gray-100 group-hover:bg-primary-100 flex items-center justify-center mb-2 transition-colors">
|
|
59
|
+
{isLoading ? (
|
|
60
|
+
<div className="w-5 h-5 rounded-full border-2 border-gray-200 border-t-primary-500 animate-spin" />
|
|
61
|
+
) : (
|
|
62
|
+
<Upload size={18} className="text-gray-400 group-hover:text-primary-500" />
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<p className="text-[13px] font-medium text-gray-500 group-hover:text-primary-600">
|
|
66
|
+
{isLoading ? "Uploading..." : title}
|
|
67
|
+
</p>
|
|
68
|
+
<p className="text-[11px] text-gray-400 mt-0.5">{subtitle}</p>
|
|
69
|
+
<input
|
|
70
|
+
ref={fileInputRef}
|
|
71
|
+
type="file"
|
|
72
|
+
accept={accept}
|
|
73
|
+
multiple={maxFiles > 1}
|
|
74
|
+
className="hidden"
|
|
75
|
+
onChange={handleFileSelect}
|
|
76
|
+
disabled={isLoading}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Dropdown, DropdownItem } from '../base-components/Dropdown';
|
|
|
7
7
|
import Link from 'next/link';
|
|
8
8
|
import { usePathname, useRouter } from 'next/navigation';
|
|
9
9
|
import { cn, getProjectConstructionGroups } from '@apptimate/core-lib';
|
|
10
|
+
import { NotificationsPopover } from './NotificationsPopover';
|
|
10
11
|
|
|
11
12
|
export type DashboardMenuConfig = {
|
|
12
13
|
id: string;
|
|
@@ -285,7 +286,8 @@ export function DashboardLayout({
|
|
|
285
286
|
})}
|
|
286
287
|
</nav>
|
|
287
288
|
|
|
288
|
-
<div className="mt-auto flex flex-col items-center">
|
|
289
|
+
<div className="mt-auto flex flex-col items-center gap-4">
|
|
290
|
+
<NotificationsPopover user={user as any} />
|
|
289
291
|
<Dropdown
|
|
290
292
|
align="left"
|
|
291
293
|
valign="top"
|
|
@@ -335,7 +337,7 @@ export function DashboardLayout({
|
|
|
335
337
|
<h3 className="px-2 text-xs font-bold text-gray-400 uppercase tracking-wider mb-2 empty:hidden">{group.label}</h3>
|
|
336
338
|
)}
|
|
337
339
|
<nav className="flex flex-col gap-1">
|
|
338
|
-
{group.items.map((item) => {
|
|
340
|
+
{group.items.map((item: any) => {
|
|
339
341
|
// Find the longest matching path in this group to avoid parent paths being active
|
|
340
342
|
const allGroupItems = activeMenu.groups.flatMap(g => g.items);
|
|
341
343
|
const matchingItems = allGroupItems.filter(i => fullPath === i.path || fullPath.startsWith(`${i.path}/`));
|
|
@@ -495,7 +497,7 @@ export function DashboardLayout({
|
|
|
495
497
|
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1 px-3">
|
|
496
498
|
{group.label}
|
|
497
499
|
</span>
|
|
498
|
-
{group.items.map(item => {
|
|
500
|
+
{group.items.map((item: any) => {
|
|
499
501
|
const allMenuItems = menu.groups.flatMap(g => g.items);
|
|
500
502
|
const matchingItems = allMenuItems.filter(i => fullPath === i.path || fullPath.startsWith(`${i.path}/`));
|
|
501
503
|
const longestMatch = matchingItems.sort((a, b) => b.path.length - a.path.length)[0];
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { Modal } from '../base-components/Modal';
|
|
5
|
+
import { Pagination } from '../base-components/Pagination';
|
|
6
|
+
import { sendRequest, IApiResponse, cn } from '@apptimate/core-lib';
|
|
7
|
+
import { Bell, Check, Search } from 'lucide-react';
|
|
8
|
+
import { NotificationItem } from './NotificationsPopover';
|
|
9
|
+
import toast from 'react-hot-toast';
|
|
10
|
+
import { Input } from '../base-components/Input';
|
|
11
|
+
|
|
12
|
+
interface NotificationsModalProps {
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function NotificationsModal({ isOpen, onClose }: NotificationsModalProps) {
|
|
18
|
+
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
19
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
20
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
21
|
+
const [totalItems, setTotalItems] = useState(0);
|
|
22
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
23
|
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
24
|
+
const itemsPerPage = 10;
|
|
25
|
+
|
|
26
|
+
// Debounce search
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
setDebouncedSearch(searchQuery);
|
|
30
|
+
}, 500);
|
|
31
|
+
return () => clearTimeout(timer);
|
|
32
|
+
}, [searchQuery]);
|
|
33
|
+
|
|
34
|
+
// Reset to page 1 on new search
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setCurrentPage(1);
|
|
37
|
+
}, [debouncedSearch]);
|
|
38
|
+
|
|
39
|
+
const fetchNotifications = async (page: number, search: string) => {
|
|
40
|
+
setIsLoading(true);
|
|
41
|
+
try {
|
|
42
|
+
const response = await sendRequest({
|
|
43
|
+
url: `${process.env.NEXT_PUBLIC_API_URL}/api/notifications?page=${page}&per_page=${itemsPerPage}&search=${encodeURIComponent(search)}`,
|
|
44
|
+
method: 'GET'
|
|
45
|
+
});
|
|
46
|
+
const resData = response.responseData as IApiResponse;
|
|
47
|
+
if (resData.is_success && resData.result) {
|
|
48
|
+
setNotifications(resData.result.data || []);
|
|
49
|
+
setTotalItems(resData.result.meta?.total || 0);
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error("Failed to fetch notifications", e);
|
|
53
|
+
} finally {
|
|
54
|
+
setIsLoading(false);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (isOpen) {
|
|
60
|
+
fetchNotifications(currentPage, debouncedSearch);
|
|
61
|
+
}
|
|
62
|
+
}, [isOpen, currentPage, debouncedSearch]);
|
|
63
|
+
|
|
64
|
+
const markAsRead = async (id: string) => {
|
|
65
|
+
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read_at: new Date().toISOString() } : n));
|
|
66
|
+
try {
|
|
67
|
+
await sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/notifications/${id}/read`, method: 'PATCH' });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.error("Failed to mark notification as read", e);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const markAllAsRead = async () => {
|
|
74
|
+
setNotifications(prev => prev.map(n => ({ ...n, read_at: new Date().toISOString() })));
|
|
75
|
+
try {
|
|
76
|
+
await sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/notifications/read-all`, method: 'POST' });
|
|
77
|
+
toast.success("All notifications marked as read");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.error("Failed to mark all as read", e);
|
|
80
|
+
toast.error("Failed to mark all as read");
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Modal
|
|
86
|
+
isOpen={isOpen}
|
|
87
|
+
onClose={onClose}
|
|
88
|
+
title="All Notifications"
|
|
89
|
+
size="md"
|
|
90
|
+
className="max-h-[85vh] flex flex-col"
|
|
91
|
+
>
|
|
92
|
+
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/30">
|
|
93
|
+
<Input
|
|
94
|
+
placeholder="Search notifications..."
|
|
95
|
+
value={searchQuery}
|
|
96
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
97
|
+
icon={<Search size={16} className="text-gray-400" />}
|
|
98
|
+
className="bg-white"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="flex-1 overflow-y-auto px-4 py-2 min-h-[300px]">
|
|
102
|
+
{isLoading && notifications.length === 0 ? (
|
|
103
|
+
<div className="flex justify-center items-center py-8 text-sm text-gray-400">Loading notifications...</div>
|
|
104
|
+
) : notifications.length === 0 ? (
|
|
105
|
+
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
|
106
|
+
<div className="w-12 h-12 bg-gray-50 rounded-full flex items-center justify-center mb-3">
|
|
107
|
+
<Bell className="text-gray-300" size={24} />
|
|
108
|
+
</div>
|
|
109
|
+
<p className="text-sm font-medium text-gray-800">No notifications yet</p>
|
|
110
|
+
<p className="text-xs text-gray-500 mt-1">When you get notifications, they'll show up here</p>
|
|
111
|
+
</div>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="divide-y divide-gray-50 border border-gray-100 rounded-lg overflow-hidden">
|
|
114
|
+
{notifications.map((notif) => (
|
|
115
|
+
<div
|
|
116
|
+
key={notif.id}
|
|
117
|
+
className={cn(
|
|
118
|
+
"p-4 transition-colors group flex items-start gap-3",
|
|
119
|
+
!notif.read_at ? "bg-primary-50/30" : "bg-white hover:bg-gray-50/50"
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<div className={cn(
|
|
123
|
+
"w-10 h-10 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
|
124
|
+
notif.data.type === 'finance_alert' ? "bg-warning-100 text-warning-600" : "bg-primary-100 text-primary-600"
|
|
125
|
+
)}>
|
|
126
|
+
<Bell size={16} />
|
|
127
|
+
</div>
|
|
128
|
+
<div className="flex-1 min-w-0">
|
|
129
|
+
<div className="flex justify-between items-start mb-1">
|
|
130
|
+
<p className={cn("text-[14px] font-semibold pr-2", !notif.read_at ? "text-gray-900" : "text-gray-700")}>
|
|
131
|
+
{notif.data.title || 'Notification'}
|
|
132
|
+
</p>
|
|
133
|
+
<span className="text-[11px] font-medium text-gray-400 shrink-0 whitespace-nowrap">
|
|
134
|
+
{new Date(notif.created_at).toLocaleString()}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
<p className="text-[13px] text-gray-500 leading-relaxed">
|
|
138
|
+
{notif.data.message || ''}
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
{!notif.read_at && (
|
|
142
|
+
<button
|
|
143
|
+
onClick={() => markAsRead(notif.id)}
|
|
144
|
+
className="p-1.5 rounded-md hover:bg-gray-200 text-gray-400 hover:text-gray-600 ml-1 shrink-0"
|
|
145
|
+
title="Mark as read"
|
|
146
|
+
>
|
|
147
|
+
<Check size={16} />
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="border-t border-gray-100 px-4 py-3 bg-gray-50 flex items-center justify-between">
|
|
157
|
+
<button
|
|
158
|
+
onClick={markAllAsRead}
|
|
159
|
+
disabled={notifications.length === 0}
|
|
160
|
+
className="text-sm font-medium text-primary-600 hover:text-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
161
|
+
>
|
|
162
|
+
Mark all as read
|
|
163
|
+
</button>
|
|
164
|
+
<Pagination
|
|
165
|
+
currentPage={currentPage}
|
|
166
|
+
totalItems={totalItems}
|
|
167
|
+
itemsPerPage={itemsPerPage}
|
|
168
|
+
onPageChange={setCurrentPage}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
</Modal>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import { Bell, Check, X } from 'lucide-react';
|
|
5
|
+
import * as Ably from 'ably';
|
|
6
|
+
import { cn, sendRequest, IApiResponse } from '@apptimate/core-lib';
|
|
7
|
+
import toast from 'react-hot-toast';
|
|
8
|
+
import { NotificationsModal } from './NotificationsModal';
|
|
9
|
+
|
|
10
|
+
export interface NotificationItem {
|
|
11
|
+
id: string;
|
|
12
|
+
type: string;
|
|
13
|
+
data: any;
|
|
14
|
+
read_at: string | null;
|
|
15
|
+
created_at: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function NotificationsPopover({ user }: { user?: { id?: number } }) {
|
|
19
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
20
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
21
|
+
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
22
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
23
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
24
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
const ablyApiKey = process.env.NEXT_PUBLIC_ABLY_API_KEY;
|
|
27
|
+
|
|
28
|
+
// Close on outside click
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
31
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
32
|
+
setIsOpen(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
36
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
// Fetch initial notifications
|
|
40
|
+
const fetchNotifications = async () => {
|
|
41
|
+
setIsLoading(true);
|
|
42
|
+
try {
|
|
43
|
+
const response = await sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/notifications?per_page=50`, method: 'GET' });
|
|
44
|
+
const resData = response.responseData as IApiResponse;
|
|
45
|
+
if (resData.is_success && resData.result) {
|
|
46
|
+
setNotifications(resData.result.data || []);
|
|
47
|
+
setUnreadCount(resData.result.unread_count || 0);
|
|
48
|
+
}
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error("Failed to fetch notifications", e);
|
|
51
|
+
} finally {
|
|
52
|
+
setIsLoading(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (user?.id) {
|
|
58
|
+
fetchNotifications();
|
|
59
|
+
}
|
|
60
|
+
}, [user?.id]);
|
|
61
|
+
|
|
62
|
+
// Ably Realtime integration
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!user?.id || !ablyApiKey) return;
|
|
65
|
+
|
|
66
|
+
const ably = new Ably.Realtime({ key: ablyApiKey });
|
|
67
|
+
const channelName = `private:App.Models.User.${user.id}`;
|
|
68
|
+
const channel = ably.channels.get(channelName);
|
|
69
|
+
|
|
70
|
+
channel.subscribe((message) => {
|
|
71
|
+
// The event name from Laravel is usually the class name of the notification
|
|
72
|
+
const data = message.data;
|
|
73
|
+
|
|
74
|
+
const newNotification: NotificationItem = {
|
|
75
|
+
id: data.id || Math.random().toString(),
|
|
76
|
+
type: message.name || 'Notification',
|
|
77
|
+
data: data,
|
|
78
|
+
read_at: null,
|
|
79
|
+
created_at: new Date().toISOString()
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
setNotifications(prev => [newNotification, ...prev]);
|
|
83
|
+
setUnreadCount(prev => prev + 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
channel.unsubscribe();
|
|
88
|
+
ably.close();
|
|
89
|
+
};
|
|
90
|
+
}, [user?.id, ablyApiKey]);
|
|
91
|
+
|
|
92
|
+
const markAsRead = async (id: string) => {
|
|
93
|
+
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read_at: new Date().toISOString() } : n));
|
|
94
|
+
setUnreadCount(prev => Math.max(0, prev - 1));
|
|
95
|
+
try {
|
|
96
|
+
await sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/notifications/${id}/read`, method: 'PATCH' });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error("Failed to mark notification as read", e);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const markAllAsRead = async () => {
|
|
103
|
+
setNotifications(prev => prev.map(n => ({ ...n, read_at: new Date().toISOString() })));
|
|
104
|
+
setUnreadCount(0);
|
|
105
|
+
try {
|
|
106
|
+
await sendRequest({ url: `${process.env.NEXT_PUBLIC_API_URL}/api/notifications/read-all`, method: 'POST' });
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error("Failed to mark all as read", e);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="relative" ref={dropdownRef}>
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
116
|
+
className="relative w-10 h-10 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1"
|
|
117
|
+
>
|
|
118
|
+
<Bell size={20} />
|
|
119
|
+
{unreadCount > 0 && (
|
|
120
|
+
<span className="absolute top-2 right-2.5 flex h-2 w-2">
|
|
121
|
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
|
122
|
+
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500 ring-2 ring-white"></span>
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</button>
|
|
126
|
+
|
|
127
|
+
{isOpen && (
|
|
128
|
+
<div className="absolute left-full bottom-0 ml-4 mb-2 w-80 sm:w-96 bg-white rounded-2xl shadow-xl shadow-gray-200/50 border border-gray-100 z-[100] overflow-hidden">
|
|
129
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-50 bg-gray-50/50">
|
|
130
|
+
<div className="flex items-center gap-2">
|
|
131
|
+
<h3 className="font-bold text-[14px] text-gray-800 tracking-tight">Notifications</h3>
|
|
132
|
+
{unreadCount > 0 && (
|
|
133
|
+
<span className="bg-primary-100 text-primary-700 text-[10px] font-bold px-2 py-0.5 rounded-full">
|
|
134
|
+
{unreadCount} New
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
{unreadCount > 0 && (
|
|
139
|
+
<button
|
|
140
|
+
onClick={markAllAsRead}
|
|
141
|
+
className="text-[11px] font-medium text-primary-600 hover:text-primary-700 hover:bg-primary-50 px-2 py-1 rounded-md transition-colors"
|
|
142
|
+
>
|
|
143
|
+
Mark all read
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="max-h-[400px] overflow-y-auto">
|
|
149
|
+
{isLoading && notifications.length === 0 ? (
|
|
150
|
+
<div className="flex justify-center items-center py-8 text-sm text-gray-400">Loading...</div>
|
|
151
|
+
) : notifications.length === 0 ? (
|
|
152
|
+
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
|
|
153
|
+
<div className="w-12 h-12 bg-gray-50 rounded-full flex items-center justify-center mb-3">
|
|
154
|
+
<Bell className="text-gray-300" size={24} />
|
|
155
|
+
</div>
|
|
156
|
+
<p className="text-sm font-medium text-gray-800">No notifications yet</p>
|
|
157
|
+
<p className="text-xs text-gray-500 mt-1">When you get notifications, they'll show up here</p>
|
|
158
|
+
</div>
|
|
159
|
+
) : (
|
|
160
|
+
<div className="divide-y divide-gray-50">
|
|
161
|
+
{notifications.map((notif) => (
|
|
162
|
+
<div
|
|
163
|
+
key={notif.id}
|
|
164
|
+
className={cn(
|
|
165
|
+
"p-4 transition-colors hover:bg-gray-50/80 group cursor-default flex items-start gap-3",
|
|
166
|
+
!notif.read_at ? "bg-primary-50/30" : "bg-white"
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
<div className={cn(
|
|
170
|
+
"w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5",
|
|
171
|
+
notif.data.type === 'finance_alert' ? "bg-warning-100 text-warning-600" : "bg-primary-100 text-primary-600"
|
|
172
|
+
)}>
|
|
173
|
+
<Bell size={14} />
|
|
174
|
+
</div>
|
|
175
|
+
<div className="flex-1 min-w-0">
|
|
176
|
+
<div className="flex justify-between items-start mb-1">
|
|
177
|
+
<p className={cn("text-[13px] font-semibold truncate pr-2", !notif.read_at ? "text-gray-900" : "text-gray-700")}>
|
|
178
|
+
{notif.data.title || 'Notification'}
|
|
179
|
+
</p>
|
|
180
|
+
<span className="text-[10px] font-medium text-gray-400 shrink-0 whitespace-nowrap">
|
|
181
|
+
{new Date(notif.created_at).toLocaleDateString()}
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
<p className="text-[12px] text-gray-500 leading-relaxed">
|
|
185
|
+
{notif.data.message || ''}
|
|
186
|
+
</p>
|
|
187
|
+
</div>
|
|
188
|
+
{!notif.read_at && (
|
|
189
|
+
<button
|
|
190
|
+
onClick={() => markAsRead(notif.id)}
|
|
191
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-gray-200 text-gray-400 hover:text-gray-600 ml-1 shrink-0"
|
|
192
|
+
title="Mark as read"
|
|
193
|
+
>
|
|
194
|
+
<Check size={14} />
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
{notifications.length > 0 && (
|
|
203
|
+
<div className="p-3 border-t border-gray-50 bg-gray-50/50 text-center">
|
|
204
|
+
<button
|
|
205
|
+
onClick={() => {
|
|
206
|
+
setIsOpen(false);
|
|
207
|
+
setIsModalOpen(true);
|
|
208
|
+
}}
|
|
209
|
+
className="text-[12px] font-semibold text-gray-500 hover:text-gray-800 transition-colors"
|
|
210
|
+
>
|
|
211
|
+
View all notifications
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
<NotificationsModal
|
|
219
|
+
isOpen={isModalOpen}
|
|
220
|
+
onClose={() => setIsModalOpen(false)}
|
|
221
|
+
/>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -27,8 +27,8 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
27
27
|
const [isLoadingEmployees, setIsLoadingEmployees] = useState(false);
|
|
28
28
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
29
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 }>>({});
|
|
30
|
+
// Time entries state: Record<employee_id, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>
|
|
31
|
+
const [timeEntries, setTimeEntries] = useState<Record<string, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }>>({});
|
|
32
32
|
|
|
33
33
|
useEffect(() => {
|
|
34
34
|
if (isOpen) {
|
|
@@ -36,18 +36,25 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
36
36
|
loadGlobalEmployees();
|
|
37
37
|
setAttendanceDate(new Date().toISOString().split("T")[0]);
|
|
38
38
|
setProjectId(initialProjectId || "");
|
|
39
|
-
setEmployees([]);
|
|
40
39
|
setTimeEntries({});
|
|
40
|
+
|
|
41
|
+
// If initialProjectId is already set, the second useEffect won't trigger because projectId hasn't changed.
|
|
42
|
+
// So we force a load if we already have the ID.
|
|
43
|
+
if (initialProjectId) {
|
|
44
|
+
// loadEmployees will be triggered by the second useEffect if projectId changes.
|
|
45
|
+
// If it doesn't change, we need to call it here.
|
|
46
|
+
// We will just let the second useEffect handle it by adding isOpen to its dependencies.
|
|
47
|
+
}
|
|
41
48
|
}
|
|
42
49
|
}, [isOpen, initialProjectId]);
|
|
43
50
|
|
|
44
51
|
useEffect(() => {
|
|
45
|
-
if (projectId && attendanceDate) {
|
|
52
|
+
if (isOpen && projectId && attendanceDate) {
|
|
46
53
|
loadEmployees();
|
|
47
|
-
} else {
|
|
54
|
+
} else if (!isOpen) {
|
|
48
55
|
setEmployees([]);
|
|
49
56
|
}
|
|
50
|
-
}, [projectId, attendanceDate]);
|
|
57
|
+
}, [projectId, attendanceDate, isOpen]);
|
|
51
58
|
|
|
52
59
|
const loadGlobalEmployees = async () => {
|
|
53
60
|
try {
|
|
@@ -83,9 +90,23 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
83
90
|
setEmployees(res.result);
|
|
84
91
|
|
|
85
92
|
// Initialize time entries for these employees
|
|
86
|
-
const newEntries: Record<string, { in: string, out: string, break: number, isPresent: boolean }> = {};
|
|
93
|
+
const newEntries: Record<string, { in: string, out: string, break: number, isPresent: boolean, salaryAdvance: number }> = {};
|
|
87
94
|
res.result.forEach((emp: any) => {
|
|
88
|
-
|
|
95
|
+
let inTime = "09:00";
|
|
96
|
+
let outTime = "18:00";
|
|
97
|
+
let breakHrs = 0;
|
|
98
|
+
let isPresent = true;
|
|
99
|
+
let salaryAdvance = 0;
|
|
100
|
+
|
|
101
|
+
if (emp.attendance) {
|
|
102
|
+
isPresent = emp.attendance.status === 'Present';
|
|
103
|
+
if (emp.attendance.punch_in_time) inTime = emp.attendance.punch_in_time;
|
|
104
|
+
if (emp.attendance.punch_out_time) outTime = emp.attendance.punch_out_time;
|
|
105
|
+
breakHrs = emp.attendance.break_hours || 0;
|
|
106
|
+
salaryAdvance = emp.attendance.salary_advance || 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
newEntries[emp.id] = { in: inTime, out: outTime, break: breakHrs, isPresent: isPresent, salaryAdvance: salaryAdvance };
|
|
89
110
|
});
|
|
90
111
|
setTimeEntries(newEntries);
|
|
91
112
|
}
|
|
@@ -96,7 +117,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
96
117
|
}
|
|
97
118
|
};
|
|
98
119
|
|
|
99
|
-
const handleTimeChange = (empId: string, type: 'in' | 'out' | 'break' | 'isPresent', value: string | number | boolean) => {
|
|
120
|
+
const handleTimeChange = (empId: string, type: 'in' | 'out' | 'break' | 'isPresent' | 'salaryAdvance', value: string | number | boolean) => {
|
|
100
121
|
setTimeEntries(prev => ({
|
|
101
122
|
...prev,
|
|
102
123
|
[empId]: {
|
|
@@ -111,7 +132,7 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
111
132
|
setEmployees(prev => [...prev, { id: fakeId, is_manual: true }]);
|
|
112
133
|
setTimeEntries(prev => ({
|
|
113
134
|
...prev,
|
|
114
|
-
[fakeId]: { in: "09:00", out: "18:00", break: 0, isPresent: true }
|
|
135
|
+
[fakeId]: { in: "09:00", out: "18:00", break: 0, isPresent: true, salaryAdvance: 0 }
|
|
115
136
|
}));
|
|
116
137
|
};
|
|
117
138
|
|
|
@@ -174,6 +195,16 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
174
195
|
punch_in_time: entry.in || null,
|
|
175
196
|
punch_out_time: entry.out || null,
|
|
176
197
|
break_hours: entry.break || 0,
|
|
198
|
+
salary_advance: entry.salaryAdvance || 0,
|
|
199
|
+
});
|
|
200
|
+
} else if (entry.salaryAdvance > 0) {
|
|
201
|
+
// If they only have a salary advance but no punch
|
|
202
|
+
workforcePunches.push({
|
|
203
|
+
workforce_id: Number(workforceId),
|
|
204
|
+
project_id: Number(projectId),
|
|
205
|
+
date: attendanceDate,
|
|
206
|
+
member_identifier: memberIdentifier,
|
|
207
|
+
salary_advance: entry.salaryAdvance,
|
|
177
208
|
});
|
|
178
209
|
}
|
|
179
210
|
} else {
|
|
@@ -191,8 +222,21 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
191
222
|
employee_id: emp.id,
|
|
192
223
|
punch_at: `${attendanceDate} ${entry.out}:00`,
|
|
193
224
|
type: "O",
|
|
194
|
-
source: "Bulk"
|
|
225
|
+
source: "Bulk",
|
|
226
|
+
salary_advance: entry.salaryAdvance || 0
|
|
195
227
|
});
|
|
228
|
+
} else if (!entry.in && entry.salaryAdvance > 0) {
|
|
229
|
+
// If they only have a salary advance but no punch
|
|
230
|
+
hrPunches.push({
|
|
231
|
+
employee_id: emp.id,
|
|
232
|
+
type: "ADVANCE", // Special type or handled by backend if punch_at is empty
|
|
233
|
+
source: "Bulk",
|
|
234
|
+
salary_advance: entry.salaryAdvance,
|
|
235
|
+
date: attendanceDate
|
|
236
|
+
});
|
|
237
|
+
} else if (hrPunches.length > 0 && entry.salaryAdvance > 0) {
|
|
238
|
+
// Add salary advance to the first punch we pushed for this employee
|
|
239
|
+
hrPunches[hrPunches.length - 1].salary_advance = entry.salaryAdvance;
|
|
196
240
|
}
|
|
197
241
|
}
|
|
198
242
|
});
|
|
@@ -219,8 +263,8 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
219
263
|
// use sendRequest from core-lib which is imported at the top of this file
|
|
220
264
|
const { sendRequest } = require("@apptimate/core-lib");
|
|
221
265
|
const wfRes = await sendRequest({ url: "/api/construction/workforce-attendance/bulk-punch", method: "POST", data: { punches: workforcePunches } });
|
|
222
|
-
if (!
|
|
223
|
-
toast.error(
|
|
266
|
+
if (!wfRes.responseData?.is_success) {
|
|
267
|
+
toast.error(wfRes.responseData?.message || "Failed to log Workforce attendance");
|
|
224
268
|
hasError = true;
|
|
225
269
|
}
|
|
226
270
|
}
|
|
@@ -277,7 +321,8 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
277
321
|
<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
322
|
<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
323
|
<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-
|
|
324
|
+
<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>
|
|
325
|
+
<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
326
|
</TRow>
|
|
282
327
|
</THeader>
|
|
283
328
|
<TBody>
|
|
@@ -353,6 +398,41 @@ export function BulkAttendanceModal({ isOpen, onClose, onSuccess, fetchProjects,
|
|
|
353
398
|
title={!String(emp.id).startsWith('wf-') ? "Break hours are only applicable to Construction Workforce" : ""}
|
|
354
399
|
/>
|
|
355
400
|
</TCell>
|
|
401
|
+
<TCell label="Salary Advance">
|
|
402
|
+
{emp.is_gang ? (
|
|
403
|
+
<div className="flex items-center h-9">
|
|
404
|
+
<span className="text-gray-400">-</span>
|
|
405
|
+
</div>
|
|
406
|
+
) : (
|
|
407
|
+
<div className="flex items-center gap-2">
|
|
408
|
+
<Input
|
|
409
|
+
type="number"
|
|
410
|
+
step="0.01"
|
|
411
|
+
min="0"
|
|
412
|
+
value={timeEntries[emp.id]?.salaryAdvance || 0}
|
|
413
|
+
onChange={(e) => handleTimeChange(emp.id, 'salaryAdvance', Number(e.target.value))}
|
|
414
|
+
className="h-9 w-24 shrink-0"
|
|
415
|
+
disabled={!timeEntries[emp.id]?.isPresent}
|
|
416
|
+
/>
|
|
417
|
+
{emp.balance_due !== undefined && (
|
|
418
|
+
<div className="text-[11px] text-gray-500 font-medium whitespace-nowrap">
|
|
419
|
+
{timeEntries[emp.id]?.salaryAdvance > 0 ? (
|
|
420
|
+
<>
|
|
421
|
+
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>
|
|
422
|
+
</>
|
|
423
|
+
) : (
|
|
424
|
+
<>Owed: {Number(emp.balance_due) + Number(emp.attendance?.salary_advance || 0)}</>
|
|
425
|
+
)}
|
|
426
|
+
{timeEntries[emp.id]?.isPresent && (
|
|
427
|
+
<span className="block text-[9.5px] text-blue-500/90 mt-0.5">
|
|
428
|
+
+ Today's wage
|
|
429
|
+
</span>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
</TCell>
|
|
356
436
|
</TRow>
|
|
357
437
|
))
|
|
358
438
|
)}
|
|
@@ -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
|
)}
|
|
@@ -269,8 +269,8 @@ export function replaceTokens(text: string, entityData: any): string {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
// Format specific currency properties gracefully if they are numbers
|
|
272
|
-
if (val !== undefined && val !== null && (path.includes("amount") || path.includes("total") || path.includes("cost") || path.includes("price"))) {
|
|
273
|
-
if (!isNaN(Number(val))
|
|
272
|
+
if (val !== undefined && val !== null && (path.includes("amount") || path.includes("total") || path.includes("cost") || path.includes("price") || path.includes("due") || path.includes("tax"))) {
|
|
273
|
+
if (!isNaN(Number(val))) {
|
|
274
274
|
val = `${data.currency_code ? data.currency_code + ' ' : ''}${Number(val).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
275
275
|
}
|
|
276
276
|
}
|
|
@@ -462,11 +462,11 @@ function LineRenderer({ line, defaultFont, defaultColor, defaultFontSize, entity
|
|
|
462
462
|
if (!Array.isArray(rowsData)) rowsData = [];
|
|
463
463
|
|
|
464
464
|
return (
|
|
465
|
-
<div style={{ marginTop: `${line.spacing_before || 0}px`, marginBottom: `${line.spacing_after || 0}px`, border: outerBorder ? `1px solid ${borderColor}` : "none", borderRadius: line.border_radius || "0", overflow: "hidden", fontFamily: line.font_family || defaultFont }}>
|
|
465
|
+
<div style={{ width: "100%", marginTop: `${line.spacing_before || 0}px`, marginBottom: `${line.spacing_after || 0}px`, border: outerBorder ? `1px solid ${borderColor}` : "none", borderRadius: line.border_radius || "0", overflow: "hidden", fontFamily: line.font_family || defaultFont }}>
|
|
466
466
|
{showHeader && (
|
|
467
467
|
<div style={{ display: "flex", flexWrap: "wrap", backgroundColor: headerBg, padding: ec.header_padding || "6px 8px" }}>
|
|
468
468
|
{cols.map((col: any, i: number) => (
|
|
469
|
-
<div key={i} style={{ width: `${col.header_width_percent || col.width_percent}%`, color: headerFc, fontSize: `${headerFs}px`, fontWeight: ec.header_bold !== false ? 700 : (isA4 === false ? 600 : 400), textAlign: col.align || "left", whiteSpace: "pre-wrap" }}>
|
|
469
|
+
<div key={i} style={{ boxSizing: "border-box", padding: "0 2px", width: `${col.header_width_percent || col.width_percent}%`, color: headerFc, fontSize: `${headerFs}px`, fontWeight: ec.header_bold !== false ? 700 : (isA4 === false ? 600 : 400), textAlign: col.align || "left", whiteSpace: "pre-wrap", display: col.label ? "block" : "none" }}>
|
|
470
470
|
{typeof col.label === 'string' ? col.label.split('\n').map((t: string, ti: number) => <div key={ti}>{t || <br />}</div>) : col.label}
|
|
471
471
|
</div>
|
|
472
472
|
))}
|
|
@@ -480,8 +480,8 @@ function LineRenderer({ line, defaultFont, defaultColor, defaultFontSize, entity
|
|
|
480
480
|
|
|
481
481
|
const rowDataCtx = {
|
|
482
482
|
...row,
|
|
483
|
-
index: ri + 1,
|
|
484
|
-
description: row.item?.name || row.item_name || "Item",
|
|
483
|
+
index: row.index !== undefined ? row.index : ri + 1,
|
|
484
|
+
description: row.description || row.item?.name || row.item_name || "Item",
|
|
485
485
|
specification: row.item?.code || "",
|
|
486
486
|
|
|
487
487
|
// Rich item details for multi-line support
|
|
@@ -489,14 +489,14 @@ function LineRenderer({ line, defaultFont, defaultColor, defaultFontSize, entity
|
|
|
489
489
|
type: row.receipt_type ? (row.receipt_type === 'bulk' ? 'BULK / LOT' : String(row.receipt_type).replace(/_/g, ' ').toUpperCase()) : (row.item?.item_type ? row.item.item_type.replace('_', ' ').toUpperCase() : (row.item_class || row.item?.item_class ? String(row.item_class || row.item?.item_class).replace('_', ' ').toUpperCase() : (row.item_type || "BULK / LOT").toUpperCase())),
|
|
490
490
|
// New richer fields for GRN matching UI
|
|
491
491
|
material_purity_rich: `${(row.material_type || row.item?.material_type)?.name || "Gold"}\n${row.purity?.purity_percentage || row.purity_percentage || "99.8"}% (${row.purity?.karat || row.purity?.purity_code || "24K"})\nRate: ${fmtCurrency(row.rate_per_gram || row.unit_price)} / g`,
|
|
492
|
-
qty: fmtQty(row.quantity || row.received_quantity || 1),
|
|
492
|
+
qty: row.qty !== undefined ? row.qty : fmtQty(row.quantity || row.received_quantity || 1),
|
|
493
493
|
gross_wt: fmtNum(row.gross_weight || row.weight_grams || row.metal_weight || row.sale_weight || row.weight, ""),
|
|
494
494
|
weight: fmtNum(row.sale_weight || row.weight || row.gross_weight || row.weight_grams || row.metal_weight, ""),
|
|
495
495
|
material_wt: fmtNum(row.sale_weight || row.metal_weight || row.weight || row.gross_weight || row.weight_grams, ""),
|
|
496
496
|
weight_display: (row.weight_grams || row.net_weight || row.estimated_weight || row.material_weight || row.sale_weight || row.weight) && Number(row.weight_grams || row.net_weight || row.estimated_weight || row.material_weight || row.sale_weight || row.weight) > 0 ? `${parseFloat(Number(row.weight_grams || row.net_weight || row.estimated_weight || row.material_weight || row.sale_weight || row.weight).toFixed(4))}g` : "",
|
|
497
497
|
stone_wt: fmtNum(row.sale_stone_weight || row.stone_weight, ""),
|
|
498
498
|
stone_rich: row.stone_weight && Number(row.stone_weight) > 0 ? `${fmtNum(row.stone_weight, "g")}\n@ ${fmtCurrency(row.stone_cost || row.stone_amount)}` : "—",
|
|
499
|
-
item_value: fmtCurrency(row.item_value || row.subtotal || row.
|
|
499
|
+
item_value: row.unit_price !== undefined ? row.unit_price : fmtCurrency(row.item_value || row.subtotal || row.amount),
|
|
500
500
|
charges_rich: [
|
|
501
501
|
(row.making_charge || row.making_charge_value) && Number(row.making_charge || row.making_charge_value) > 0 ? `MC: ${row.making_charge || row.making_charge_value} (${row.making_charge_type || 'fixed'})` : null,
|
|
502
502
|
(row.wastage || row.wastage_value) && Number(row.wastage || row.wastage_value) > 0 ? `W: ${row.wastage || row.wastage_value} (${row.wastage_type || 'fixed'})` : null,
|
|
@@ -504,7 +504,7 @@ function LineRenderer({ line, defaultFont, defaultColor, defaultFontSize, entity
|
|
|
504
504
|
(row.tax_rate || row.tax_percentage) && Number(row.tax_rate || row.tax_percentage) > 0 ? `Tax: ${row.tax_rate || row.tax_percentage}%` : null
|
|
505
505
|
].filter(Boolean).join("\n") || "—",
|
|
506
506
|
|
|
507
|
-
amount: fmtCurrency(row.estimated_total_cost || row.subtotal || row.line_total || row.total_cost || row.
|
|
507
|
+
amount: row.amount !== undefined ? row.amount : fmtCurrency(row.estimated_total_cost || row.subtotal || row.line_total || row.total_cost || row.grand_total),
|
|
508
508
|
amount_num: (row.estimated_total_cost || row.subtotal || row.line_total || row.total_cost || row.amount || row.grand_total)
|
|
509
509
|
? Number(row.estimated_total_cost || row.subtotal || row.line_total || row.total_cost || row.amount || row.grand_total).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
510
510
|
: "—"
|
|
@@ -531,7 +531,7 @@ function LineRenderer({ line, defaultFont, defaultColor, defaultFontSize, entity
|
|
|
531
531
|
}
|
|
532
532
|
|
|
533
533
|
return (
|
|
534
|
-
<div key={ci} style={{ width: `${col.width_percent}%`, fontSize: `${rowFs}px`, color: line.font_color || defaultColor, fontWeight: line.font_weight === "bold" ? 700 : line.font_weight === "light" ? 300 : (isA4 === false ? 600 : 400), textAlign: col.align || "left", whiteSpace: "pre-wrap", fontStyle: line.is_italic ? "italic" : "normal", textDecoration: line.is_underline ? "underline" : "none" }}>
|
|
534
|
+
<div key={ci} style={{ boxSizing: "border-box", padding: "0 2px", width: `${col.width_percent}%`, fontSize: `${rowFs}px`, color: line.font_color || defaultColor, fontWeight: line.font_weight === "bold" ? 700 : line.font_weight === "light" ? 300 : (isA4 === false ? 600 : 400), textAlign: col.align || "left", whiteSpace: "pre-wrap", fontStyle: line.is_italic ? "italic" : "normal", textDecoration: line.is_underline ? "underline" : "none" }}>
|
|
535
535
|
{typeof cellVal === 'string' ? cellVal.split('\n').map((t: string, ti: number) => <div key={ti}>{t || <br />}</div>) : cellVal}
|
|
536
536
|
</div>
|
|
537
537
|
);
|