@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 CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@apptimate/ui",
3
- "version": "6.8.0",
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
- newEntries[emp.id] = { in: "09:00", out: "18:00", break: 0, isPresent: true };
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 (!(wfRes as any).is_success) {
223
- toast.error((wfRes as any).message || "Failed to log Workforce attendance");
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-32">Break (hrs)</TCell>
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
- <div className="border border-dashed border-gray-200 rounded-xl p-4 flex items-center gap-3 text-gray-400">
39
- <CalendarIcon className="w-5 h-5" />
40
- <div className="text-sm">
41
- <div className="font-semibold text-gray-500">Available for Swap</div>
42
- <div className="text-xs mt-0.5">No shifts scheduled today</div>
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
- </div>
101
+ )
45
102
  ) : (
46
- shifts.map((shift, shiftIdx) => (
47
- <div key={shiftIdx} className="space-y-4">
48
- {shift.segmentsInfo.map((info: any, idx: number) => (
49
- <div key={idx} className="border border-gray-200 rounded-xl overflow-hidden relative shadow-sm">
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 && d.status) {
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 === 'shift'
377
- ? 'bg-[#0D6EFD] text-white p-1.5 flex flex-col gap-0.5'
378
- : evt.type === 'exchange'
379
- ? evt.isPending
380
- ? 'bg-amber-50 text-amber-700 border border-amber-200 p-1.5 border-dashed'
381
- : 'bg-indigo-50 text-indigo-700 border border-indigo-200 p-1.5'
382
- : evt.type === 'off_day'
383
- ? 'bg-white text-red-600 border border-red-200 p-1.5'
384
- : 'bg-purple-50 text-purple-700 border border-purple-200 p-1.5'
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)) && Number(val) > 0) {
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.unit_price || row.amount),
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.amount || row.grand_total),
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
  );