@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +7 -7
  2. package/package.json +22 -18
  3. package/src/{Button.tsx → base-components/Button.tsx} +21 -13
  4. package/src/{ChipInput.tsx → base-components/ChipInput.tsx} +1 -1
  5. package/src/base-components/DateLabel.tsx +68 -0
  6. package/src/base-components/DateRangePicker.tsx +392 -0
  7. package/src/base-components/DesktopFilterPopover.tsx +53 -0
  8. package/src/base-components/DesktopSearchPopover.tsx +51 -0
  9. package/src/{Dropdown.tsx → base-components/Dropdown.tsx} +11 -5
  10. package/src/base-components/ImageDropzone.tsx +80 -0
  11. package/src/{Input.tsx → base-components/Input.tsx} +8 -7
  12. package/src/base-components/Label.tsx +24 -0
  13. package/src/{Modal.tsx → base-components/Modal.tsx} +36 -10
  14. package/src/{PopConfirm.tsx → base-components/PopConfirm.tsx} +15 -4
  15. package/src/base-components/RecordCount.tsx +19 -0
  16. package/src/{SearchableSelect.tsx → base-components/SearchableSelect.tsx} +9 -9
  17. package/src/{Select.tsx → base-components/Select.tsx} +7 -6
  18. package/src/base-components/Table.tsx +83 -0
  19. package/src/base-components/TableMobileOptions.tsx +51 -0
  20. package/src/common-components/DashboardLayout.tsx +473 -0
  21. package/src/common-components/charts/ApexCharts.tsx +222 -0
  22. package/src/common-components/charts/RBarChart.tsx +145 -0
  23. package/src/common-components/charts/RLineChart.tsx +116 -0
  24. package/src/common-components/charts/index.ts +3 -0
  25. package/src/common-components/charts/palette.ts +10 -0
  26. package/src/components/shared/CustomerSelectorComponent.tsx +163 -0
  27. package/src/components/shared/ImageUploadComponent.tsx +228 -0
  28. package/src/components/shared/PaymentModeComponent.tsx +421 -0
  29. package/src/components/shared/ProcurementManagerSelector.tsx +5 -0
  30. package/src/components/shared/ProductSelectorComponent.tsx +166 -0
  31. package/src/components/shared/SupplierSelectorComponent.tsx +5 -0
  32. package/src/index.tsx +30 -18
  33. package/src/Table.tsx +0 -78
  34. /package/src/{Accordion.tsx → base-components/Accordion.tsx} +0 -0
  35. /package/src/{Badge.tsx → base-components/Badge.tsx} +0 -0
  36. /package/src/{ButtonGroup.tsx → base-components/ButtonGroup.tsx} +0 -0
  37. /package/src/{Card.tsx → base-components/Card.tsx} +0 -0
  38. /package/src/{Checkbox.tsx → base-components/Checkbox.tsx} +0 -0
  39. /package/src/{Divider.tsx → base-components/Divider.tsx} +0 -0
  40. /package/src/{EmptyState.tsx → base-components/EmptyState.tsx} +0 -0
  41. /package/src/{HintIcon.tsx → base-components/HintIcon.tsx} +0 -0
  42. /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
+ }