@apptimate/ui 1.0.0 → 1.2.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/CHANGELOG.md +7 -7
- package/package.json +22 -18
- package/src/{Button.tsx → base-components/Button.tsx} +21 -13
- package/src/{ChipInput.tsx → base-components/ChipInput.tsx} +1 -1
- package/src/base-components/DateLabel.tsx +68 -0
- package/src/base-components/DateRangePicker.tsx +392 -0
- package/src/base-components/DesktopFilterPopover.tsx +53 -0
- package/src/base-components/DesktopSearchPopover.tsx +51 -0
- package/src/{Dropdown.tsx → base-components/Dropdown.tsx} +11 -5
- package/src/base-components/ImageDropzone.tsx +80 -0
- package/src/{Input.tsx → base-components/Input.tsx} +8 -7
- package/src/base-components/Label.tsx +24 -0
- package/src/{Modal.tsx → base-components/Modal.tsx} +36 -10
- package/src/{PopConfirm.tsx → base-components/PopConfirm.tsx} +15 -4
- package/src/base-components/RecordCount.tsx +19 -0
- package/src/{SearchableSelect.tsx → base-components/SearchableSelect.tsx} +9 -9
- package/src/{Select.tsx → base-components/Select.tsx} +7 -6
- package/src/base-components/Table.tsx +83 -0
- package/src/base-components/TableMobileOptions.tsx +51 -0
- package/src/common-components/DashboardLayout.tsx +473 -0
- package/src/common-components/charts/ApexCharts.tsx +222 -0
- package/src/common-components/charts/RBarChart.tsx +145 -0
- package/src/common-components/charts/RLineChart.tsx +116 -0
- package/src/common-components/charts/index.ts +3 -0
- package/src/common-components/charts/palette.ts +10 -0
- package/src/components/shared/CustomerSelectorComponent.tsx +163 -0
- package/src/components/shared/ImageUploadComponent.tsx +228 -0
- package/src/components/shared/PaymentModeComponent.tsx +421 -0
- package/src/components/shared/ProcurementManagerSelector.tsx +5 -0
- package/src/components/shared/ProductSelectorComponent.tsx +166 -0
- package/src/components/shared/SupplierSelectorComponent.tsx +5 -0
- package/src/index.tsx +30 -18
- package/src/Table.tsx +0 -78
- /package/src/{Accordion.tsx → base-components/Accordion.tsx} +0 -0
- /package/src/{Badge.tsx → base-components/Badge.tsx} +0 -0
- /package/src/{ButtonGroup.tsx → base-components/ButtonGroup.tsx} +0 -0
- /package/src/{Card.tsx → base-components/Card.tsx} +0 -0
- /package/src/{Checkbox.tsx → base-components/Checkbox.tsx} +0 -0
- /package/src/{Divider.tsx → base-components/Divider.tsx} +0 -0
- /package/src/{EmptyState.tsx → base-components/EmptyState.tsx} +0 -0
- /package/src/{HintIcon.tsx → base-components/HintIcon.tsx} +0 -0
- /package/src/{Pagination.tsx → base-components/Pagination.tsx} +0 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
|
+
import { Briefcase, CreditCard, Settings, Menu, X, LogOut, User as UserIcon, Building2, ChevronRight, Check } from 'lucide-react';
|
|
5
|
+
import { Dropdown, DropdownItem } from '../base-components/Dropdown';
|
|
6
|
+
|
|
7
|
+
import Link from 'next/link';
|
|
8
|
+
import { usePathname } from 'next/navigation';
|
|
9
|
+
import { cn } from '@apptimate/core-lib';
|
|
10
|
+
|
|
11
|
+
export type DashboardMenuConfig = {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
icon: React.ReactNode;
|
|
15
|
+
groups: {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
items: {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
path: string;
|
|
22
|
+
}[]
|
|
23
|
+
}[]
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type UserConfig = {
|
|
27
|
+
name: string;
|
|
28
|
+
avatar?: string;
|
|
29
|
+
role?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type OrganizationConfig = {
|
|
33
|
+
id: number;
|
|
34
|
+
name: string;
|
|
35
|
+
code?: string;
|
|
36
|
+
organization_type?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function DashboardLayout({
|
|
40
|
+
children,
|
|
41
|
+
logo,
|
|
42
|
+
menus = [],
|
|
43
|
+
basePath = "",
|
|
44
|
+
externalPaths = [],
|
|
45
|
+
user,
|
|
46
|
+
onLogout,
|
|
47
|
+
onProfile,
|
|
48
|
+
organizations = [],
|
|
49
|
+
selectedOrganization,
|
|
50
|
+
onOrganizationChange
|
|
51
|
+
}: {
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
logo?: React.ReactNode;
|
|
54
|
+
menus?: DashboardMenuConfig[];
|
|
55
|
+
basePath?: string;
|
|
56
|
+
externalPaths?: string[];
|
|
57
|
+
user?: UserConfig;
|
|
58
|
+
onLogout?: () => void;
|
|
59
|
+
onProfile?: () => void;
|
|
60
|
+
organizations?: OrganizationConfig[];
|
|
61
|
+
selectedOrganization?: OrganizationConfig | null;
|
|
62
|
+
onOrganizationChange?: (org: OrganizationConfig) => void;
|
|
63
|
+
}) {
|
|
64
|
+
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
65
|
+
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
|
|
66
|
+
const pathname = usePathname() || "";
|
|
67
|
+
|
|
68
|
+
// Find which main menu should be active based on current path, or default to first
|
|
69
|
+
const fullPath = basePath + (pathname === "/" && basePath ? "" : pathname);
|
|
70
|
+
|
|
71
|
+
const currentMainMenu = menus.find(menu =>
|
|
72
|
+
menu.groups.some(group => group.items.some(item =>
|
|
73
|
+
fullPath === item.path ||
|
|
74
|
+
fullPath.startsWith(item.path + "/") ||
|
|
75
|
+
item.path.startsWith(fullPath + "/")
|
|
76
|
+
))
|
|
77
|
+
) || menus[0];
|
|
78
|
+
|
|
79
|
+
const [activeMenuId, setActiveMenuId] = useState(currentMainMenu?.id || "");
|
|
80
|
+
|
|
81
|
+
// Keep it updated if route changes and matches another main menu
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
if (currentMainMenu) {
|
|
84
|
+
setActiveMenuId(currentMainMenu.id);
|
|
85
|
+
}
|
|
86
|
+
}, [currentMainMenu?.id]);
|
|
87
|
+
|
|
88
|
+
const activeMenu = menus.find(m => m.id === activeMenuId) || menus[0];
|
|
89
|
+
|
|
90
|
+
const handleSelectOrg = (org: OrganizationConfig) => {
|
|
91
|
+
onOrganizationChange?.(org);
|
|
92
|
+
setIsOrgModalOpen(false);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Truncated org name for display
|
|
96
|
+
const orgDisplayName = selectedOrganization?.name
|
|
97
|
+
? (selectedOrganization.name.length > 22 ? selectedOrganization.name.substring(0, 20) + '…' : selectedOrganization.name)
|
|
98
|
+
: 'Select Org';
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex h-screen bg-[#F4F5F7] overflow-hidden font-sans">
|
|
102
|
+
{/* Desktop Global Sidebar */}
|
|
103
|
+
<aside className="hidden md:flex w-[80px] bg-white border-r border-gray-200 flex-col items-center py-6 shrink-0 z-20">
|
|
104
|
+
<div className="w-12 h-12 flex items-center justify-center mb-8">
|
|
105
|
+
{logo}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<nav className="flex flex-col gap-6 flex-1 w-full">
|
|
109
|
+
{menus.map((menu) => {
|
|
110
|
+
const isActive = activeMenuId === menu.id;
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
key={menu.id}
|
|
114
|
+
onClick={() => setActiveMenuId(menu.id)}
|
|
115
|
+
className={`flex flex-col items-center justify-center gap-1.5 cursor-pointer transition-colors ${isActive ? "text-[#2D3142]" : "text-gray-400 hover:text-[#2D3142]"
|
|
116
|
+
}`}
|
|
117
|
+
>
|
|
118
|
+
{/* Clone the icon to dynamically apply styling based on active state */}
|
|
119
|
+
{React.cloneElement(menu.icon as React.ReactElement, {
|
|
120
|
+
size: 22,
|
|
121
|
+
className: isActive ? "stroke-[2.5]" : "stroke-[2]"
|
|
122
|
+
})}
|
|
123
|
+
<span className={`text-[11px] ${isActive ? "font-bold" : "font-medium"}`}>{menu.label}</span>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
})}
|
|
127
|
+
</nav>
|
|
128
|
+
|
|
129
|
+
<div className="mt-auto flex flex-col items-center">
|
|
130
|
+
<Dropdown
|
|
131
|
+
align="left"
|
|
132
|
+
valign="top"
|
|
133
|
+
className="w-full flex justify-center"
|
|
134
|
+
trigger={
|
|
135
|
+
<div className="w-10 h-10 rounded-full bg-[#2D3142] flex items-center justify-center text-white text-[10px] font-bold shadow-md cursor-pointer hover:bg-[#3d4258] transition-colors overflow-hidden border-2 border-white">
|
|
136
|
+
{user?.avatar ? (
|
|
137
|
+
<img src={user.avatar} alt={user.name} className="w-full h-full object-cover" />
|
|
138
|
+
) : (
|
|
139
|
+
user?.name ? user.name.substring(0, 2).toUpperCase() : "User"
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
<div className="px-3 py-2 border-b border-gray-50 mb-1">
|
|
145
|
+
<p className="text-[13px] font-bold text-[#2D3142] truncate">{user?.name || "Guest"}</p>
|
|
146
|
+
<p className="text-[11px] text-gray-400 font-medium truncate">{user?.role || "Member"}</p>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div className="my-1 h-px bg-gray-50" />
|
|
150
|
+
|
|
151
|
+
<DropdownItem onClick={onLogout} className="text-danger-600 hover:bg-danger-50 hover:text-danger-700">
|
|
152
|
+
<LogOut size={16} />
|
|
153
|
+
<span>Log out</span>
|
|
154
|
+
</DropdownItem>
|
|
155
|
+
</Dropdown>
|
|
156
|
+
</div>
|
|
157
|
+
</aside>
|
|
158
|
+
|
|
159
|
+
{/* Desktop Secondary Sidebar */}
|
|
160
|
+
{activeMenu && activeMenu.groups.length > 0 && (
|
|
161
|
+
<aside className="hidden md:flex w-[240px] bg-white border-gray-200 flex-col py-8 shrink-0 z-10">
|
|
162
|
+
<h2 className="px-6 text-xl font-bold text-[#2D3142]/80 mb-6">{activeMenu.label}</h2>
|
|
163
|
+
<div className="flex-1 overflow-y-auto px-4 space-y-6">
|
|
164
|
+
{activeMenu.groups.map((group) => (
|
|
165
|
+
<div key={group.id}>
|
|
166
|
+
<h3 className="px-2 text-xs font-bold text-gray-400 uppercase tracking-wider mb-2">{group.label}</h3>
|
|
167
|
+
<nav className="flex flex-col gap-1">
|
|
168
|
+
{group.items.map((item) => {
|
|
169
|
+
// Find the longest matching path in this group to avoid parent paths being active
|
|
170
|
+
const allGroupItems = activeMenu.groups.flatMap(g => g.items);
|
|
171
|
+
const matchingItems = allGroupItems.filter(i => fullPath === i.path || fullPath.startsWith(`${i.path}/`));
|
|
172
|
+
const longestMatch = matchingItems.sort((a, b) => b.path.length - a.path.length)[0];
|
|
173
|
+
const isItemActive = longestMatch?.path === item.path;
|
|
174
|
+
const isInternal = basePath
|
|
175
|
+
? item.path.startsWith(basePath)
|
|
176
|
+
: !externalPaths.some(ext => item.path.startsWith(ext));
|
|
177
|
+
const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
|
|
178
|
+
|
|
179
|
+
const className = `px-3 py-2 rounded-lg text-sm transition-colors block ${isItemActive
|
|
180
|
+
? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
|
|
181
|
+
: "text-gray-500 font-medium hover:bg-gray-50"
|
|
182
|
+
}`;
|
|
183
|
+
|
|
184
|
+
return isInternal ? (
|
|
185
|
+
<Link key={item.id} href={href} className={className}>
|
|
186
|
+
{item.label}
|
|
187
|
+
</Link>
|
|
188
|
+
) : (
|
|
189
|
+
<a key={item.id} href={href} className={className}>
|
|
190
|
+
{item.label}
|
|
191
|
+
</a>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</nav>
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Organization Selector - Bottom of Secondary Sidebar */}
|
|
200
|
+
{organizations.length > 0 && (
|
|
201
|
+
<div className="px-4 pt-4 mt-2 border-t border-gray-100">
|
|
202
|
+
<button
|
|
203
|
+
id="org-selector-desktop"
|
|
204
|
+
onClick={() => setIsOrgModalOpen(true)}
|
|
205
|
+
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#F4F5F7] hover:bg-gray-200/70 transition-all duration-200 group cursor-pointer"
|
|
206
|
+
>
|
|
207
|
+
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0 shadow-sm">
|
|
208
|
+
<Building2 size={14} className="text-white" />
|
|
209
|
+
</div>
|
|
210
|
+
<div className="flex-1 min-w-0 text-left">
|
|
211
|
+
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider leading-none mb-0.5">Organization</p>
|
|
212
|
+
<p className="text-[13px] font-bold text-[#2D3142] truncate leading-tight">{orgDisplayName}</p>
|
|
213
|
+
</div>
|
|
214
|
+
<ChevronRight size={14} className="text-gray-400 group-hover:text-[#2D3142] transition-colors shrink-0" />
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</aside>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* Main Content Area & Mobile Header */}
|
|
222
|
+
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
223
|
+
{/* Mobile Header */}
|
|
224
|
+
<header className="md:hidden flex items-center justify-between p-4 bg-white border-b border-gray-200 shrink-0 z-10">
|
|
225
|
+
<div className="flex items-center justify-center w-8 h-8">
|
|
226
|
+
{logo}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="flex items-center gap-2">
|
|
230
|
+
{/* Organization Selector - Mobile Header */}
|
|
231
|
+
{organizations.length > 0 && selectedOrganization && (
|
|
232
|
+
<button
|
|
233
|
+
id="org-selector-mobile"
|
|
234
|
+
onClick={() => setIsOrgModalOpen(true)}
|
|
235
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-[#F4F5F7] hover:bg-gray-200/70 transition-all cursor-pointer"
|
|
236
|
+
>
|
|
237
|
+
<div className="w-5 h-5 rounded-md bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shrink-0">
|
|
238
|
+
<Building2 size={10} className="text-white" />
|
|
239
|
+
</div>
|
|
240
|
+
<span className="text-[12px] font-semibold text-[#2D3142] max-w-[100px] truncate">
|
|
241
|
+
{selectedOrganization.code || selectedOrganization.name?.substring(0, 10)}
|
|
242
|
+
</span>
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
<button
|
|
246
|
+
className="p-1.5 rounded-md hover:bg-gray-100 text-[#2D3142]"
|
|
247
|
+
onClick={() => setIsMobileMenuOpen(true)}
|
|
248
|
+
>
|
|
249
|
+
<Menu size={24} />
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</header>
|
|
253
|
+
|
|
254
|
+
{/* Main Content Scrollable Area */}
|
|
255
|
+
<main className="flex-1 p-4 md:p-6 lg:p-8 overflow-hidden min-h-0">
|
|
256
|
+
<div className="bg-white rounded-2xl h-full p-4 md:p-8 flex flex-col overflow-y-auto">
|
|
257
|
+
{children}
|
|
258
|
+
</div>
|
|
259
|
+
</main>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Mobile Drawer Overlay */}
|
|
263
|
+
{isMobileMenuOpen && (
|
|
264
|
+
<div className="fixed inset-0 z-50 flex md:hidden">
|
|
265
|
+
{/* Backdrop */}
|
|
266
|
+
<div
|
|
267
|
+
className="fixed inset-0 bg-[#1a1a2e]/50 backdrop-blur-sm transition-opacity"
|
|
268
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
269
|
+
/>
|
|
270
|
+
|
|
271
|
+
{/* Drawer Content */}
|
|
272
|
+
<div className="relative flex w-[80%] max-w-[300px] flex-col bg-white h-full shadow-2xl animate-in slide-in-from-left-full duration-300">
|
|
273
|
+
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
|
274
|
+
<div className="flex items-center gap-3">
|
|
275
|
+
<div className="flex items-center justify-center w-8 h-8">
|
|
276
|
+
{logo}
|
|
277
|
+
</div>
|
|
278
|
+
<span className="font-bold text-[#2D3142] tracking-tight">Apptimate</span>
|
|
279
|
+
</div>
|
|
280
|
+
<button
|
|
281
|
+
className="p-1.5 rounded-md hover:bg-gray-100 text-gray-500"
|
|
282
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
283
|
+
>
|
|
284
|
+
<X size={20} />
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div className="flex-1 overflow-y-auto py-6 px-4">
|
|
289
|
+
<nav className="flex flex-col gap-8">
|
|
290
|
+
{menus.map((menu) => (
|
|
291
|
+
<div key={menu.id}>
|
|
292
|
+
<div className="flex items-center gap-3 text-[#2D3142] font-bold mb-3 px-2">
|
|
293
|
+
{React.cloneElement(menu.icon as React.ReactElement, {
|
|
294
|
+
size: 20,
|
|
295
|
+
className: "stroke-[2.5]"
|
|
296
|
+
})}
|
|
297
|
+
<span>{menu.label}</span>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<div className="flex flex-col pl-4 border-l-2 border-gray-100 ml-4 gap-4">
|
|
301
|
+
{menu.groups.map(group => (
|
|
302
|
+
<div key={group.id} className="flex flex-col gap-1">
|
|
303
|
+
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1 px-3">
|
|
304
|
+
{group.label}
|
|
305
|
+
</span>
|
|
306
|
+
{group.items.map(item => {
|
|
307
|
+
const allMenuItems = menu.groups.flatMap(g => g.items);
|
|
308
|
+
const matchingItems = allMenuItems.filter(i => fullPath === i.path || fullPath.startsWith(`${i.path}/`));
|
|
309
|
+
const longestMatch = matchingItems.sort((a, b) => b.path.length - a.path.length)[0];
|
|
310
|
+
const isItemActive = longestMatch?.path === item.path;
|
|
311
|
+
const isInternal = basePath
|
|
312
|
+
? item.path.startsWith(basePath)
|
|
313
|
+
: !externalPaths.some(ext => item.path.startsWith(ext));
|
|
314
|
+
const href = basePath && isInternal ? item.path.replace(basePath, "") || "/" : item.path;
|
|
315
|
+
|
|
316
|
+
const className = `px-3 py-2 rounded-lg text-sm transition-colors block ${isItemActive
|
|
317
|
+
? "bg-[#F4F5F7] text-[#2D3142] font-semibold"
|
|
318
|
+
: "text-gray-500 font-medium hover:bg-gray-50"
|
|
319
|
+
}`;
|
|
320
|
+
|
|
321
|
+
return isInternal ? (
|
|
322
|
+
<Link
|
|
323
|
+
key={item.id}
|
|
324
|
+
href={href}
|
|
325
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
326
|
+
className={className}
|
|
327
|
+
>
|
|
328
|
+
{item.label}
|
|
329
|
+
</Link>
|
|
330
|
+
) : (
|
|
331
|
+
<a
|
|
332
|
+
key={item.id}
|
|
333
|
+
href={href}
|
|
334
|
+
onClick={() => setIsMobileMenuOpen(false)}
|
|
335
|
+
className={className}
|
|
336
|
+
>
|
|
337
|
+
{item.label}
|
|
338
|
+
</a>
|
|
339
|
+
);
|
|
340
|
+
})}
|
|
341
|
+
</div>
|
|
342
|
+
))}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
))}
|
|
346
|
+
</nav>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<div className="p-4 border-t border-gray-100 flex items-center gap-3">
|
|
350
|
+
<div className="w-10 h-10 rounded-full bg-[#2D3142] flex items-center justify-center text-white text-[10px] font-bold shrink-0 overflow-hidden">
|
|
351
|
+
{user?.avatar ? (
|
|
352
|
+
<img src={user.avatar} alt={user?.name || 'User'} className="w-full h-full object-cover" />
|
|
353
|
+
) : (
|
|
354
|
+
user?.name ? user.name.substring(0, 2).toUpperCase() : "User"
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
<div className="flex flex-col">
|
|
358
|
+
<span className="text-sm font-semibold text-[#2D3142]">{user?.name || "Guest"}</span>
|
|
359
|
+
<span className="text-xs text-gray-500">{user?.role || "Member"}</span>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
|
|
366
|
+
{/* Organization Selector Modal */}
|
|
367
|
+
{isOrgModalOpen && (
|
|
368
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
|
369
|
+
{/* Backdrop */}
|
|
370
|
+
<div
|
|
371
|
+
className="absolute inset-0 bg-[#1a1a2e]/60 backdrop-blur-sm"
|
|
372
|
+
onClick={() => setIsOrgModalOpen(false)}
|
|
373
|
+
/>
|
|
374
|
+
|
|
375
|
+
{/* Modal */}
|
|
376
|
+
<div
|
|
377
|
+
className="relative w-full max-w-[440px] bg-white rounded-2xl shadow-[0_20px_60px_rgba(0,0,0,0.2)] overflow-hidden"
|
|
378
|
+
style={{ animation: 'orgModalIn 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
|
379
|
+
>
|
|
380
|
+
{/* Header */}
|
|
381
|
+
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100">
|
|
382
|
+
<div className="flex items-center gap-3">
|
|
383
|
+
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center shadow-sm">
|
|
384
|
+
<Building2 size={16} className="text-white" />
|
|
385
|
+
</div>
|
|
386
|
+
<div>
|
|
387
|
+
<h3 className="text-[16px] font-bold text-[#2D3142] tracking-tight">Switch Organization</h3>
|
|
388
|
+
<p className="text-[12px] text-gray-400 font-medium">Select the organization to work with</p>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
<button
|
|
392
|
+
onClick={() => setIsOrgModalOpen(false)}
|
|
393
|
+
className="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:bg-gray-100 hover:text-[#2D3142] transition-all"
|
|
394
|
+
>
|
|
395
|
+
<X size={18} />
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
{/* Organization List */}
|
|
400
|
+
<div className="px-4 py-3 max-h-[360px] overflow-y-auto">
|
|
401
|
+
<div className="flex flex-col gap-1">
|
|
402
|
+
{organizations.map((org) => {
|
|
403
|
+
const isSelected = selectedOrganization?.id === org.id;
|
|
404
|
+
return (
|
|
405
|
+
<button
|
|
406
|
+
key={org.id}
|
|
407
|
+
id={`org-option-${org.id}`}
|
|
408
|
+
onClick={() => handleSelectOrg(org)}
|
|
409
|
+
className={cn(
|
|
410
|
+
"w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all duration-150 cursor-pointer",
|
|
411
|
+
isSelected
|
|
412
|
+
? "bg-indigo-50 border border-indigo-200 shadow-sm"
|
|
413
|
+
: "hover:bg-gray-50 border border-transparent"
|
|
414
|
+
)}
|
|
415
|
+
>
|
|
416
|
+
<div className={cn(
|
|
417
|
+
"w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors",
|
|
418
|
+
isSelected
|
|
419
|
+
? "bg-gradient-to-br from-indigo-500 to-purple-600 shadow-sm"
|
|
420
|
+
: "bg-gray-100"
|
|
421
|
+
)}>
|
|
422
|
+
<Building2 size={14} className={isSelected ? "text-white" : "text-gray-500"} />
|
|
423
|
+
</div>
|
|
424
|
+
<div className="flex-1 min-w-0">
|
|
425
|
+
<p className={cn(
|
|
426
|
+
"text-[14px] font-semibold truncate leading-tight",
|
|
427
|
+
isSelected ? "text-indigo-700" : "text-[#2D3142]"
|
|
428
|
+
)}>
|
|
429
|
+
{org.name}
|
|
430
|
+
</p>
|
|
431
|
+
<p className="text-[11px] text-gray-400 font-medium truncate">
|
|
432
|
+
{org.code && <span className="uppercase">{org.code}</span>}
|
|
433
|
+
{org.code && org.organization_type && <span className="mx-1">·</span>}
|
|
434
|
+
{org.organization_type && <span className="capitalize">{org.organization_type}</span>}
|
|
435
|
+
{!org.code && !org.organization_type && <span>ID: {org.id}</span>}
|
|
436
|
+
</p>
|
|
437
|
+
</div>
|
|
438
|
+
{isSelected && (
|
|
439
|
+
<div className="w-6 h-6 rounded-full bg-indigo-500 flex items-center justify-center shrink-0">
|
|
440
|
+
<Check size={12} className="text-white stroke-[3]" />
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
</button>
|
|
444
|
+
);
|
|
445
|
+
})}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{/* Footer */}
|
|
450
|
+
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
|
|
451
|
+
<p className="text-[11px] text-gray-400 font-medium text-center">
|
|
452
|
+
All data and actions will be scoped to the selected organization
|
|
453
|
+
</p>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<style dangerouslySetInnerHTML={{ __html: `
|
|
458
|
+
@keyframes orgModalIn {
|
|
459
|
+
from {
|
|
460
|
+
opacity: 0;
|
|
461
|
+
transform: scale(0.95) translateY(10px);
|
|
462
|
+
}
|
|
463
|
+
to {
|
|
464
|
+
opacity: 1;
|
|
465
|
+
transform: scale(1) translateY(0);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
`}} />
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
);
|
|
473
|
+
}
|