@apptimate/ui 6.9.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/base-components/ImageDropzone.tsx +80 -80
- package/src/common-components/DashboardLayout.tsx +5 -3
- package/src/common-components/NotificationsModal.tsx +173 -0
- package/src/common-components/NotificationsPopover.tsx +224 -0
- package/src/common-components/attendance-shifts/BulkAttendanceModal.tsx +155 -30
- package/src/common-components/attendance-shifts/DailySchedulePanel.tsx +73 -11
- package/src/common-components/attendance-shifts/ShiftCalendar.tsx +41 -13
- package/src/finance-components/DirectExpenseModal.tsx +248 -0
- package/src/index.tsx +1 -0
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apptimate/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.1.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
|
+
}
|