@trackany-device/components 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 (159) hide show
  1. package/README.md +216 -0
  2. package/package.json +133 -4
  3. package/src/assets/index.ts +120 -0
  4. package/src/assets/media/avatars/300-1.png +0 -0
  5. package/src/assets/media/avatars/300-10.png +0 -0
  6. package/src/assets/media/avatars/300-11.png +0 -0
  7. package/src/assets/media/avatars/300-12.png +0 -0
  8. package/src/assets/media/avatars/300-13.png +0 -0
  9. package/src/assets/media/avatars/300-14.png +0 -0
  10. package/src/assets/media/avatars/300-15.png +0 -0
  11. package/src/assets/media/avatars/300-16.png +0 -0
  12. package/src/assets/media/avatars/300-17.png +0 -0
  13. package/src/assets/media/avatars/300-18.png +0 -0
  14. package/src/assets/media/avatars/300-19.png +0 -0
  15. package/src/assets/media/avatars/300-2.png +0 -0
  16. package/src/assets/media/avatars/300-20.png +0 -0
  17. package/src/assets/media/avatars/300-21.png +0 -0
  18. package/src/assets/media/avatars/300-22.png +0 -0
  19. package/src/assets/media/avatars/300-23.png +0 -0
  20. package/src/assets/media/avatars/300-24.png +0 -0
  21. package/src/assets/media/avatars/300-25.png +0 -0
  22. package/src/assets/media/avatars/300-26.png +0 -0
  23. package/src/assets/media/avatars/300-27.png +0 -0
  24. package/src/assets/media/avatars/300-28.png +0 -0
  25. package/src/assets/media/avatars/300-29.png +0 -0
  26. package/src/assets/media/avatars/300-3.png +0 -0
  27. package/src/assets/media/avatars/300-30.png +0 -0
  28. package/src/assets/media/avatars/300-31.png +0 -0
  29. package/src/assets/media/avatars/300-32.png +0 -0
  30. package/src/assets/media/avatars/300-33.png +0 -0
  31. package/src/assets/media/avatars/300-34.png +0 -0
  32. package/src/assets/media/avatars/300-4.png +0 -0
  33. package/src/assets/media/avatars/300-5.png +0 -0
  34. package/src/assets/media/avatars/300-6.png +0 -0
  35. package/src/assets/media/avatars/300-7.png +0 -0
  36. package/src/assets/media/avatars/300-8.png +0 -0
  37. package/src/assets/media/avatars/300-9.png +0 -0
  38. package/src/assets/media/avatars/blank.png +0 -0
  39. package/src/assets/media/avatars/gray/1.png +0 -0
  40. package/src/assets/media/avatars/gray/2.png +0 -0
  41. package/src/assets/media/avatars/gray/3.png +0 -0
  42. package/src/assets/media/avatars/gray/4.png +0 -0
  43. package/src/assets/media/avatars/gray/5.png +0 -0
  44. package/src/assets/media/illustrations/1-dark.svg +78 -0
  45. package/src/assets/media/illustrations/1.svg +78 -0
  46. package/src/assets/media/illustrations/10-dark.svg +148 -0
  47. package/src/assets/media/illustrations/10.svg +148 -0
  48. package/src/assets/media/illustrations/11-dark.svg +234 -0
  49. package/src/assets/media/illustrations/11.svg +234 -0
  50. package/src/assets/media/illustrations/12.svg +138 -0
  51. package/src/assets/media/illustrations/13.svg +205 -0
  52. package/src/assets/media/illustrations/14.svg +259 -0
  53. package/src/assets/media/illustrations/15.svg +242 -0
  54. package/src/assets/media/illustrations/16.svg +128 -0
  55. package/src/assets/media/illustrations/17.svg +180 -0
  56. package/src/assets/media/illustrations/18-dark.svg +6 -0
  57. package/src/assets/media/illustrations/18.svg +6 -0
  58. package/src/assets/media/illustrations/19-dark.svg +8 -0
  59. package/src/assets/media/illustrations/19.svg +8 -0
  60. package/src/assets/media/illustrations/2-dark.svg +78 -0
  61. package/src/assets/media/illustrations/2.svg +78 -0
  62. package/src/assets/media/illustrations/20-dark.svg +13 -0
  63. package/src/assets/media/illustrations/20.svg +13 -0
  64. package/src/assets/media/illustrations/21-dark.svg +9 -0
  65. package/src/assets/media/illustrations/21.svg +9 -0
  66. package/src/assets/media/illustrations/22-dark.svg +17 -0
  67. package/src/assets/media/illustrations/22.svg +17 -0
  68. package/src/assets/media/illustrations/23-dark.svg +13 -0
  69. package/src/assets/media/illustrations/23.svg +13 -0
  70. package/src/assets/media/illustrations/24.svg +6 -0
  71. package/src/assets/media/illustrations/25.svg +8 -0
  72. package/src/assets/media/illustrations/26.svg +8 -0
  73. package/src/assets/media/illustrations/27.svg +6 -0
  74. package/src/assets/media/illustrations/28-dark.svg +28 -0
  75. package/src/assets/media/illustrations/28.svg +14 -0
  76. package/src/assets/media/illustrations/29-dark.svg +6 -0
  77. package/src/assets/media/illustrations/29.svg +6 -0
  78. package/src/assets/media/illustrations/3-dark.svg +70 -0
  79. package/src/assets/media/illustrations/3.svg +70 -0
  80. package/src/assets/media/illustrations/30-dark.svg +8 -0
  81. package/src/assets/media/illustrations/30.svg +8 -0
  82. package/src/assets/media/illustrations/31-dark.svg +9 -0
  83. package/src/assets/media/illustrations/31.svg +9 -0
  84. package/src/assets/media/illustrations/32-dark.svg +10 -0
  85. package/src/assets/media/illustrations/32.svg +10 -0
  86. package/src/assets/media/illustrations/33-dark.svg +15 -0
  87. package/src/assets/media/illustrations/33.svg +15 -0
  88. package/src/assets/media/illustrations/34-dark.svg +5 -0
  89. package/src/assets/media/illustrations/34.svg +5 -0
  90. package/src/assets/media/illustrations/35-dark.svg +11 -0
  91. package/src/assets/media/illustrations/35.svg +4 -0
  92. package/src/assets/media/illustrations/4-dark.svg +51 -0
  93. package/src/assets/media/illustrations/4.svg +51 -0
  94. package/src/assets/media/illustrations/5-dark.svg +78 -0
  95. package/src/assets/media/illustrations/5.svg +78 -0
  96. package/src/assets/media/illustrations/6.svg +58 -0
  97. package/src/assets/media/illustrations/7.svg +49 -0
  98. package/src/assets/media/illustrations/8.svg +61 -0
  99. package/src/assets/media/illustrations/9.svg +57 -0
  100. package/src/assets/media/misc/placeholder.svg +15 -0
  101. package/src/components/devices/devices-mini-map.tsx +32 -26
  102. package/src/components/devices/map-marker.tsx +98 -0
  103. package/src/components/ui/checklist-item.tsx +55 -0
  104. package/src/components/ui/plan-card.tsx +68 -0
  105. package/src/components/ui/settings-row.tsx +32 -0
  106. package/src/components/ui/settings-section.tsx +22 -0
  107. package/src/components/ui/usage-meter.tsx +35 -0
  108. package/src/index.ts +12 -1
  109. package/src/layouts/LayoutSwitcher.tsx +220 -0
  110. package/src/layouts/app/MegaMenuLayout.tsx +69 -34
  111. package/src/layouts/app/MegaMenuNavbarLayout.tsx +73 -37
  112. package/src/layouts/app/NavbarCollapsibleLayout.tsx +53 -4
  113. package/src/layouts/app/NavbarSidebarLayout.tsx +74 -29
  114. package/src/layouts/app/SidebarDualMenuLayout.tsx +48 -5
  115. package/src/layouts/app/SidebarFixedLayout.tsx +15 -10
  116. package/src/layouts/app/SidebarMinimalLayout.tsx +51 -3
  117. package/src/layouts/app/SidebarTabsLayout.tsx +48 -2
  118. package/src/layouts/app/SplitSidebarLayout.tsx +91 -43
  119. package/src/layouts/app/TopNavLayout.tsx +7 -12
  120. package/src/layouts/app/WorkspaceSidebarLayout.tsx +103 -46
  121. package/src/layouts/app/partials/Navbar.tsx +61 -10
  122. package/src/layouts/app/partials/Toolbar.tsx +1 -1
  123. package/src/layouts/auth/AuthCenteredLayout.tsx +10 -4
  124. package/src/lib/map-markers.ts +21 -3
  125. package/src/pages/login/ConfirmPasswordPage.tsx +35 -0
  126. package/src/pages/login/ForgotPasswordPage.tsx +41 -0
  127. package/src/pages/login/LoginPage.tsx +50 -0
  128. package/src/pages/login/RegisterPage.tsx +41 -0
  129. package/src/pages/login/ResetPasswordPage.tsx +35 -0
  130. package/src/pages/login/TwoFactorChallengePage.tsx +41 -0
  131. package/src/pages/login/VerifyEmailPage.tsx +31 -0
  132. package/src/pages/my/ActivityPage.tsx +160 -0
  133. package/src/pages/my/GetStartedPage.tsx +221 -0
  134. package/src/pages/my/NotificationsPage.tsx +133 -0
  135. package/src/pages/my/ProfilePage.tsx +650 -0
  136. package/src/pages/my/TenantsPage.tsx +37 -0
  137. package/src/pages/tenant/AssigneesPage.tsx +155 -0
  138. package/src/pages/tenant/BeatsPage.tsx +403 -0
  139. package/src/pages/tenant/DashboardPage.tsx +195 -0
  140. package/src/pages/tenant/GeofencePage.tsx +422 -0
  141. package/src/pages/tenant/IncidentsPage.tsx +214 -0
  142. package/src/pages/tenant/IntegrationsPage.tsx +352 -0
  143. package/src/pages/tenant/InvitePage.tsx +153 -0
  144. package/src/pages/tenant/LiveStreamPage.tsx +141 -0
  145. package/src/pages/tenant/MembersPage.tsx +414 -0
  146. package/src/pages/tenant/TenantProfilePage.tsx +701 -0
  147. package/src/platform/adapters/default.tsx +1 -1
  148. package/src/platform/types.ts +2 -0
  149. package/src/styles/components/apexcharts.css +101 -0
  150. package/src/styles/components/image-input.css +51 -0
  151. package/src/styles/components/leaflet.css +25 -0
  152. package/src/styles/components/rating.css +89 -0
  153. package/src/styles/components/scrollable.css +119 -0
  154. package/src/styles/layout.css +24 -0
  155. package/src/styles/layouts/sidebar-fixed.css +93 -138
  156. package/src/styles/themes.css +5 -5
  157. package/src/vite-env.d.ts +21 -0
  158. package/src/layouts/SettingsLayout.tsx +0 -21
  159. package/src/layouts/app-layout.tsx +0 -29
@@ -0,0 +1,195 @@
1
+ import {
2
+ StatCard, Card, CardContent, CardHeader, CardTitle, CardDescription,
3
+ Badge, Timeline, TimelineItem,
4
+ Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
5
+ Button,
6
+ } from '@trackany-device/components';
7
+ import { LayoutResolved } from '../../layouts/LayoutSwitcher';
8
+ import type { LayoutName } from '../../layouts/LayoutSwitcher';
9
+ import { AlertTriangle, Battery, Car, ChevronRight, Clock, MonitorPlay, Route, Shield, Users } from 'lucide-react';
10
+ import { DevicesMiniMap } from '../../components/devices/devices-mini-map';
11
+ import type { MiniMapDevice, MiniMapIncident } from '../../components/devices/devices-mini-map';
12
+
13
+ export type { MiniMapDevice, MiniMapIncident };
14
+
15
+ export type VehicleStatus = 'online' | 'idle' | 'offline';
16
+ export type ActivityEventType = 'incident' | 'trip' | 'battery' | 'geofence';
17
+
18
+ export interface DashboardVehicle {
19
+ reg: string;
20
+ driver: string;
21
+ status: VehicleStatus;
22
+ speed: string;
23
+ }
24
+
25
+ export interface DashboardActivityEvent {
26
+ type: ActivityEventType;
27
+ title: string;
28
+ datetime: string;
29
+ detail: string;
30
+ variant: 'default' | 'danger' | 'warning' | 'success' | 'info';
31
+ }
32
+
33
+ export interface DashboardStats {
34
+ totalVehicles: number;
35
+ onlineNow: number;
36
+ activeIncidents: number;
37
+ lowBattery: number;
38
+ }
39
+
40
+ const vehicleStatusStyle: Record<VehicleStatus, string> = {
41
+ online: 'text-green-600 border-green-200 bg-green-50',
42
+ idle: 'text-amber-600 border-amber-200 bg-amber-50',
43
+ offline: 'text-red-600 border-red-200 bg-red-50',
44
+ };
45
+
46
+ const ACTIVITY_ICON = {
47
+ incident: AlertTriangle,
48
+ trip: Route,
49
+ battery: Battery,
50
+ geofence: Shield,
51
+ } satisfies Record<ActivityEventType, unknown>;
52
+
53
+ export function DashboardPage({
54
+ layout,
55
+ stats,
56
+ vehicles,
57
+ recentActivity,
58
+ devices = [],
59
+ mapIncidents = [],
60
+ }: {
61
+ layout: LayoutName;
62
+ stats: DashboardStats;
63
+ vehicles: DashboardVehicle[];
64
+ recentActivity: DashboardActivityEvent[];
65
+ devices?: MiniMapDevice[];
66
+ mapIncidents?: MiniMapIncident[];
67
+ }) {
68
+ return (
69
+ <LayoutResolved layout={layout} title="Dashboard" currentUrl="/dashboard">
70
+ <div className="p-6 space-y-6">
71
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
72
+ <StatCard icon={Car} label="Total Vehicles" value={String(stats.totalVehicles)} description="+4 this month" />
73
+ <StatCard icon={MonitorPlay} label="Online Now" value={String(stats.onlineNow)} description={`${Math.round((stats.onlineNow / stats.totalVehicles) * 100)}% of fleet`} />
74
+ <StatCard icon={AlertTriangle} label="Active Incidents" value={String(stats.activeIncidents)} description="2 critical" />
75
+ <StatCard icon={Battery} label="Low Battery" value={String(stats.lowBattery)} description="Below 20%" />
76
+ </div>
77
+
78
+ <Card className="overflow-hidden">
79
+ <CardHeader>
80
+ <CardTitle>Live Fleet Map</CardTitle>
81
+ <CardDescription>Real-time vehicle positions</CardDescription>
82
+ </CardHeader>
83
+ <CardContent className="p-0">
84
+ <DevicesMiniMap devices={devices} incidents={mapIncidents} height="256px" className="rounded-none border-0 rounded-b-lg" />
85
+ </CardContent>
86
+ </Card>
87
+
88
+ <div className="grid gap-6 lg:grid-cols-2">
89
+ <Card>
90
+ <CardHeader className="flex flex-row items-center justify-between">
91
+ <div>
92
+ <CardTitle>Vehicles</CardTitle>
93
+ <CardDescription>{vehicles.length} of {stats.totalVehicles} shown</CardDescription>
94
+ </div>
95
+ <Button variant="outline" size="sm">View all <ChevronRight className="ml-1 h-3.5 w-3.5" /></Button>
96
+ </CardHeader>
97
+ <CardContent className="p-0">
98
+ <Table>
99
+ <TableHeader>
100
+ <TableRow>
101
+ <TableHead>Registration</TableHead>
102
+ <TableHead>Driver</TableHead>
103
+ <TableHead>Status</TableHead>
104
+ <TableHead>Speed</TableHead>
105
+ </TableRow>
106
+ </TableHeader>
107
+ <TableBody>
108
+ {vehicles.map((r) => (
109
+ <TableRow key={r.reg}>
110
+ <TableCell className="font-mono text-xs font-medium">{r.reg}</TableCell>
111
+ <TableCell>{r.driver}</TableCell>
112
+ <TableCell>
113
+ <Badge variant="outline" className={vehicleStatusStyle[r.status]}>{r.status}</Badge>
114
+ </TableCell>
115
+ <TableCell className="text-sm">{r.speed}</TableCell>
116
+ </TableRow>
117
+ ))}
118
+ </TableBody>
119
+ </Table>
120
+ </CardContent>
121
+ </Card>
122
+
123
+ <Card>
124
+ <CardHeader>
125
+ <CardTitle>Recent Activity</CardTitle>
126
+ <CardDescription>Last 24 hours</CardDescription>
127
+ </CardHeader>
128
+ <CardContent>
129
+ <Timeline>
130
+ {recentActivity.map((event, i) => {
131
+ const Icon = ACTIVITY_ICON[event.type];
132
+ return (
133
+ <TimelineItem key={i} icon={Icon} title={event.title} datetime={event.datetime} variant={event.variant}>
134
+ {event.detail}
135
+ </TimelineItem>
136
+ );
137
+ })}
138
+ </Timeline>
139
+ </CardContent>
140
+ </Card>
141
+ </div>
142
+ </div>
143
+ </LayoutResolved>
144
+ );
145
+ }
146
+
147
+ export interface IncidentAlert {
148
+ title: string;
149
+ detail: string;
150
+ }
151
+
152
+ export interface ExtendedDashboardStats extends DashboardStats {
153
+ avgResponseTime: string;
154
+ assigneesOnDuty: number;
155
+ assigneesEnrolled: number;
156
+ geofenceExits: number;
157
+ activeTrips: number;
158
+ }
159
+
160
+ export function DashboardWithIncidentPage({
161
+ layout,
162
+ stats,
163
+ alert,
164
+ }: {
165
+ layout: LayoutName;
166
+ stats: ExtendedDashboardStats;
167
+ alert: IncidentAlert;
168
+ }) {
169
+ return (
170
+ <LayoutResolved layout={layout} title="Dashboard" currentUrl="/dashboard">
171
+ <div className="p-6 space-y-6">
172
+ <div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 flex items-center gap-3">
173
+ <AlertTriangle className="h-5 w-5 text-destructive shrink-0" />
174
+ <div>
175
+ <p className="text-sm font-semibold text-destructive">{alert.title}</p>
176
+ <p className="text-xs text-muted-foreground mt-0.5">{alert.detail}</p>
177
+ </div>
178
+ <Button variant="destructive" size="sm" className="ml-auto shrink-0">Review now</Button>
179
+ </div>
180
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
181
+ <StatCard icon={Car} label="Total Vehicles" value={String(stats.totalVehicles)} description="+4 this month" />
182
+ <StatCard icon={MonitorPlay} label="Online Now" value={String(stats.onlineNow)} description={`${Math.round((stats.onlineNow / stats.totalVehicles) * 100)}% of fleet`} />
183
+ <StatCard icon={AlertTriangle} label="Active Incidents" value={String(stats.activeIncidents)} description="2 critical" deltaType="down" delta="↑2" />
184
+ <StatCard icon={Clock} label="Avg Response" value={stats.avgResponseTime} description="vs 5.1m last week" deltaType="up" delta="↓0.9m" />
185
+ </div>
186
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
187
+ <StatCard icon={Users} label="Assignees On Duty" value={String(stats.assigneesOnDuty)} description={`of ${stats.assigneesEnrolled} enrolled`} />
188
+ <StatCard icon={Battery} label="Low Battery" value={String(stats.lowBattery)} description="Below 20%" />
189
+ <StatCard icon={Shield} label="Geofence Exits" value={String(stats.geofenceExits)} description="Today" />
190
+ <StatCard icon={Route} label="Active Trips" value={String(stats.activeTrips)} description="in progress" />
191
+ </div>
192
+ </div>
193
+ </LayoutResolved>
194
+ );
195
+ }
@@ -0,0 +1,422 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from '@trackany-device/components';
3
+ import { LayoutResolved } from '../../layouts/LayoutSwitcher';
4
+ import type { LayoutName } from '../../layouts/LayoutSwitcher';
5
+ import { MapPin, Plus, Trash2, X } from 'lucide-react';
6
+ import { loadGoogleMaps, hasGoogleMapsKey } from '../../lib/google-maps-loader';
7
+
8
+ export type GeofenceType = 'inclusion' | 'exclusion';
9
+ export type GeofenceStatus = 'active' | 'inactive';
10
+
11
+ export type LatLng = { lat: number; lng: number };
12
+
13
+ export interface Geofence {
14
+ id: number;
15
+ name: string;
16
+ type: GeofenceType;
17
+ status: GeofenceStatus;
18
+ triggers: number;
19
+ polygon: LatLng[];
20
+ }
21
+
22
+ const TYPE_COLOR: Record<GeofenceType, string> = {
23
+ inclusion: '#22c55e',
24
+ exclusion: '#ef4444',
25
+ };
26
+
27
+ const TYPE_BADGE: Record<GeofenceType, string> = {
28
+ inclusion: 'text-green-600 border-green-200 bg-green-50',
29
+ exclusion: 'text-red-600 border-red-200 bg-red-50',
30
+ };
31
+
32
+ const STATUS_BADGE: Record<GeofenceStatus, string> = {
33
+ active: 'text-green-600 border-green-200 bg-green-50',
34
+ inactive: 'text-muted-foreground border-border bg-muted',
35
+ };
36
+
37
+ function polygonBounds(maps: typeof google.maps, polygon: LatLng[]): google.maps.LatLngBounds {
38
+ const bounds = new maps.LatLngBounds();
39
+ polygon.forEach((p) => bounds.extend(new maps.LatLng(p.lat, p.lng)));
40
+ return bounds;
41
+ }
42
+
43
+ export function GeofenceListPage({ layout, geofences }: { layout: LayoutName; geofences: Geofence[] }) {
44
+ const [selectedId, setSelectedId] = useState<number | null>(geofences[0]?.id ?? null);
45
+ const [mapReady, setMapReady] = useState(false);
46
+
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+ const mapRef = useRef<google.maps.Map | null>(null);
49
+ const mapsApiRef = useRef<typeof google.maps | null>(null);
50
+ const polysRef = useRef<Map<number, google.maps.Polygon>>(new Map());
51
+
52
+ const inclusionCount = geofences.filter((g) => g.type === 'inclusion').length;
53
+ const exclusionCount = geofences.filter((g) => g.type === 'exclusion').length;
54
+ const activeCount = geofences.filter((g) => g.status === 'active').length;
55
+
56
+ useEffect(() => {
57
+ if (!hasGoogleMapsKey() || !containerRef.current) return;
58
+
59
+ loadGoogleMaps().then((maps) => {
60
+ mapsApiRef.current = maps;
61
+ mapRef.current = new maps.Map(containerRef.current!, {
62
+ center: { lat: 31.5204, lng: 74.3587 },
63
+ zoom: 11,
64
+ disableDefaultUI: true,
65
+ zoomControl: true,
66
+ });
67
+ setMapReady(true);
68
+ });
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ if (!mapReady || !mapsApiRef.current || !mapRef.current) return;
73
+ const maps = mapsApiRef.current;
74
+
75
+ polysRef.current.forEach((poly) => poly.setMap(null));
76
+ polysRef.current.clear();
77
+
78
+ geofences.forEach((gf) => {
79
+ const isSelected = gf.id === selectedId;
80
+ const strokeColor = TYPE_COLOR[gf.type];
81
+ const poly = new maps.Polygon({
82
+ paths: gf.polygon.map((p) => ({ lat: p.lat, lng: p.lng })),
83
+ strokeColor,
84
+ strokeWeight: isSelected ? 3 : 1.5,
85
+ fillColor: strokeColor,
86
+ fillOpacity: isSelected ? 0.3 : 0.15,
87
+ map: mapRef.current!,
88
+ });
89
+ poly.addListener('click', () => setSelectedId(gf.id));
90
+ polysRef.current.set(gf.id, poly);
91
+ });
92
+ }, [mapReady, geofences, selectedId]);
93
+
94
+ useEffect(() => {
95
+ if (!mapReady || !mapsApiRef.current || !mapRef.current || selectedId === null) return;
96
+ const gf = geofences.find((g) => g.id === selectedId);
97
+ if (!gf || gf.polygon.length === 0) return;
98
+ const bounds = polygonBounds(mapsApiRef.current, gf.polygon);
99
+ mapRef.current.fitBounds(bounds, 60);
100
+ }, [mapReady, selectedId, geofences]);
101
+
102
+ return (
103
+ <LayoutResolved layout={layout} title="Geofences" currentUrl="/geofences">
104
+ <div className="flex h-[calc(100vh-3.5rem)]">
105
+ <div className="flex w-1/3 min-w-[260px] flex-col border-r border-border bg-background overflow-hidden">
106
+ <div className="flex items-center justify-between border-b border-border px-4 py-3 shrink-0">
107
+ <div>
108
+ <h1 className="text-base font-semibold">Geofences</h1>
109
+ <p className="text-xs text-muted-foreground">{geofences.length} total</p>
110
+ </div>
111
+ <Button size="sm">
112
+ <Plus className="h-3.5 w-3.5 mr-1" />Add geofence
113
+ </Button>
114
+ </div>
115
+
116
+ <div className="grid grid-cols-4 divide-x divide-border border-b border-border shrink-0">
117
+ {[
118
+ { label: 'Total', value: geofences.length },
119
+ { label: 'Inclusion', value: inclusionCount },
120
+ { label: 'Exclusion', value: exclusionCount },
121
+ { label: 'Active', value: activeCount },
122
+ ].map(({ label, value }) => (
123
+ <div key={label} className="px-2 py-2 text-center">
124
+ <p className="text-base font-bold text-primary">{value}</p>
125
+ <p className="text-[10px] text-muted-foreground leading-tight">{label}</p>
126
+ </div>
127
+ ))}
128
+ </div>
129
+
130
+ <div className="flex-1 overflow-y-auto divide-y divide-border">
131
+ {geofences.map((gf) => {
132
+ const isSelected = gf.id === selectedId;
133
+ return (
134
+ <button
135
+ key={gf.id}
136
+ onClick={() => setSelectedId(gf.id)}
137
+ className={`w-full text-left px-4 py-3 hover:bg-muted/40 transition-colors ${isSelected ? 'bg-muted/60' : ''}`}
138
+ style={isSelected ? { boxShadow: `inset 3px 0 0 ${TYPE_COLOR[gf.type]}` } : undefined}
139
+ >
140
+ <div className="flex items-start justify-between gap-2">
141
+ <div className="flex-1 min-w-0">
142
+ <p className="text-sm font-medium truncate">{gf.name}</p>
143
+ <div className="flex items-center gap-1.5 mt-1 flex-wrap">
144
+ <Badge variant="outline" className={`text-xs ${TYPE_BADGE[gf.type]}`}>{gf.type}</Badge>
145
+ <Badge variant="outline" className={`text-xs ${STATUS_BADGE[gf.status]}`}>{gf.status}</Badge>
146
+ </div>
147
+ </div>
148
+ <div className="text-right shrink-0">
149
+ <p className="text-xs font-medium">{gf.triggers}</p>
150
+ <p className="text-[10px] text-muted-foreground">triggers</p>
151
+ </div>
152
+ </div>
153
+ </button>
154
+ );
155
+ })}
156
+ </div>
157
+ </div>
158
+
159
+ <div className="flex-1 relative bg-muted/20">
160
+ {hasGoogleMapsKey() ? (
161
+ <div ref={containerRef} className="absolute inset-0" />
162
+ ) : (
163
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
164
+ <MapPin className="h-10 w-10" />
165
+ <p className="text-sm font-medium">Map preview unavailable</p>
166
+ <p className="text-xs">Set VITE_GOOGLE_MAPS_API_KEY to enable the map</p>
167
+ </div>
168
+ )}
169
+ </div>
170
+ </div>
171
+ </LayoutResolved>
172
+ );
173
+ }
174
+
175
+ interface PendingZone {
176
+ coords: LatLng[];
177
+ }
178
+
179
+ let nextId = 1000;
180
+
181
+ export function GeofenceEditorPage({ layout, geofences: initialGeofences }: { layout: LayoutName; geofences: Geofence[] }) {
182
+ const [geofences, setGeofences] = useState<Geofence[]>(initialGeofences);
183
+ const [selectedId, setSelectedId] = useState<number | null>(null);
184
+ const [isDrawing, setIsDrawing] = useState(false);
185
+ const [pendingZone, setPendingZone] = useState<PendingZone | null>(null);
186
+ const [pendingName, setPendingName] = useState('');
187
+ const [pendingType, setPendingType] = useState<GeofenceType>('inclusion');
188
+
189
+ const containerRef = useRef<HTMLDivElement>(null);
190
+ const mapRef = useRef<google.maps.Map | null>(null);
191
+ const mapsApiRef = useRef<typeof google.maps | null>(null);
192
+ const polysRef = useRef<Map<number, google.maps.Polygon>>(new Map());
193
+ const drawingRef = useRef<google.maps.drawing.DrawingManager | null>(null);
194
+ const [mapReady, setMapReady] = useState(false);
195
+
196
+ useEffect(() => {
197
+ if (!hasGoogleMapsKey() || !containerRef.current) return;
198
+
199
+ loadGoogleMaps().then((maps) => {
200
+ mapsApiRef.current = maps;
201
+ mapRef.current = new maps.Map(containerRef.current!, {
202
+ center: { lat: 31.5204, lng: 74.3587 },
203
+ zoom: 11,
204
+ disableDefaultUI: true,
205
+ zoomControl: true,
206
+ });
207
+ setMapReady(true);
208
+ });
209
+ }, []);
210
+
211
+ useEffect(() => {
212
+ if (!mapReady || !mapsApiRef.current || !mapRef.current) return;
213
+ const maps = mapsApiRef.current;
214
+
215
+ polysRef.current.forEach((poly) => poly.setMap(null));
216
+ polysRef.current.clear();
217
+
218
+ geofences.forEach((gf) => {
219
+ const isSelected = gf.id === selectedId;
220
+ const strokeColor = TYPE_COLOR[gf.type];
221
+ const poly = new maps.Polygon({
222
+ paths: gf.polygon.map((p) => ({ lat: p.lat, lng: p.lng })),
223
+ strokeColor,
224
+ strokeWeight: isSelected ? 3 : 1.5,
225
+ fillColor: strokeColor,
226
+ fillOpacity: isSelected ? 0.3 : 0.15,
227
+ map: mapRef.current!,
228
+ });
229
+ poly.addListener('click', () => setSelectedId(gf.id));
230
+ polysRef.current.set(gf.id, poly);
231
+ });
232
+ }, [mapReady, geofences, selectedId]);
233
+
234
+ useEffect(() => {
235
+ if (!mapReady || !mapsApiRef.current || !mapRef.current || selectedId === null) return;
236
+ const gf = geofences.find((g) => g.id === selectedId);
237
+ if (!gf || gf.polygon.length === 0) return;
238
+ const bounds = polygonBounds(mapsApiRef.current, gf.polygon);
239
+ mapRef.current.fitBounds(bounds, 60);
240
+ }, [mapReady, selectedId]);
241
+
242
+ function activateDrawing() {
243
+ if (!mapsApiRef.current || !mapRef.current) return;
244
+ const maps = mapsApiRef.current;
245
+
246
+ if (drawingRef.current) {
247
+ drawingRef.current.setMap(null);
248
+ }
249
+
250
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
251
+ const dm = new (maps as any).drawing.DrawingManager({
252
+ drawingMode: (maps as any).drawing.OverlayType.POLYGON,
253
+ drawingControl: false,
254
+ polygonOptions: {
255
+ strokeWeight: 2,
256
+ fillOpacity: 0.2,
257
+ editable: false,
258
+ },
259
+ map: mapRef.current,
260
+ });
261
+
262
+ maps.event.addListener(dm, 'polygoncomplete', (poly: google.maps.Polygon) => {
263
+ dm.setDrawingMode(null);
264
+ const coords = poly.getPath().getArray().map((p) => ({ lat: p.lat(), lng: p.lng() }));
265
+ poly.setMap(null);
266
+ setPendingZone({ coords });
267
+ setIsDrawing(false);
268
+ });
269
+
270
+ drawingRef.current = dm;
271
+ setIsDrawing(true);
272
+ setPendingZone(null);
273
+ }
274
+
275
+ function saveZone() {
276
+ if (!pendingZone || !pendingName.trim()) return;
277
+ const newGf: Geofence = {
278
+ id: ++nextId,
279
+ name: pendingName.trim(),
280
+ type: pendingType,
281
+ status: 'active',
282
+ triggers: 0,
283
+ polygon: pendingZone.coords,
284
+ };
285
+ setGeofences((prev) => [...prev, newGf]);
286
+ setSelectedId(newGf.id);
287
+ setPendingZone(null);
288
+ setPendingName('');
289
+ setPendingType('inclusion');
290
+ }
291
+
292
+ function discardZone() {
293
+ setPendingZone(null);
294
+ setPendingName('');
295
+ setPendingType('inclusion');
296
+ }
297
+
298
+ function deleteGeofence(id: number) {
299
+ setGeofences((prev) => prev.filter((g) => g.id !== id));
300
+ if (selectedId === id) setSelectedId(null);
301
+ }
302
+
303
+ return (
304
+ <LayoutResolved layout={layout} title="Geofence Editor" currentUrl="/geofences/edit">
305
+ <div className="flex h-[calc(100vh-3.5rem)]">
306
+ <div className="flex w-[280px] shrink-0 flex-col border-r border-border bg-background overflow-hidden">
307
+ <div className="border-b border-border px-4 py-3">
308
+ <h1 className="text-base font-semibold">Geofences</h1>
309
+ </div>
310
+
311
+ <div className="border-b border-border p-3">
312
+ <Button
313
+ size="sm"
314
+ className="w-full"
315
+ onClick={activateDrawing}
316
+ disabled={isDrawing}
317
+ >
318
+ <Plus className="h-3.5 w-3.5 mr-1.5" />Draw new zone
319
+ </Button>
320
+ </div>
321
+
322
+ <div className="flex-1 overflow-y-auto divide-y divide-border">
323
+ {geofences.map((gf) => {
324
+ const isSelected = gf.id === selectedId;
325
+ return (
326
+ <div
327
+ key={gf.id}
328
+ className={`flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors ${isSelected ? 'bg-muted/60' : ''}`}
329
+ style={isSelected ? { boxShadow: `inset 3px 0 0 ${TYPE_COLOR[gf.type]}` } : undefined}
330
+ onClick={() => setSelectedId(gf.id)}
331
+ >
332
+ <div className="flex-1 min-w-0">
333
+ <p className="text-sm font-medium truncate">{gf.name}</p>
334
+ <Badge variant="outline" className={`text-xs mt-0.5 ${TYPE_BADGE[gf.type]}`}>{gf.type}</Badge>
335
+ </div>
336
+ <button
337
+ onClick={(e) => { e.stopPropagation(); deleteGeofence(gf.id); }}
338
+ className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
339
+ >
340
+ <Trash2 className="h-3.5 w-3.5" />
341
+ </button>
342
+ </div>
343
+ );
344
+ })}
345
+ </div>
346
+ </div>
347
+
348
+ <div className="flex-1 relative bg-muted/20">
349
+ {hasGoogleMapsKey() ? (
350
+ <>
351
+ <div ref={containerRef} className="absolute inset-0" />
352
+
353
+ {(isDrawing || pendingZone) && (
354
+ <div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
355
+ {isDrawing && (
356
+ <Badge className="bg-primary text-primary-foreground shadow-md">Drawing</Badge>
357
+ )}
358
+ {isDrawing && (
359
+ <div className="rounded-md border border-border bg-background/95 px-3 py-1.5 text-xs shadow-md backdrop-blur-sm">
360
+ Click the map to start drawing. Click first point to close.
361
+ </div>
362
+ )}
363
+ </div>
364
+ )}
365
+
366
+ {pendingZone && (
367
+ <div className="absolute bottom-4 left-4 z-10 w-64">
368
+ <Card className="shadow-lg">
369
+ <CardHeader className="pb-2 pt-3 px-4">
370
+ <div className="flex items-center justify-between">
371
+ <CardTitle className="text-sm">Name this zone</CardTitle>
372
+ <button onClick={discardZone} className="text-muted-foreground hover:text-foreground">
373
+ <X className="h-4 w-4" />
374
+ </button>
375
+ </div>
376
+ </CardHeader>
377
+ <CardContent className="px-4 pb-4 space-y-3">
378
+ <div className="space-y-1.5">
379
+ <Label className="text-xs">Type</Label>
380
+ <select
381
+ value={pendingType}
382
+ onChange={(e) => setPendingType(e.target.value as GeofenceType)}
383
+ className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
384
+ >
385
+ <option value="inclusion">Inclusion</option>
386
+ <option value="exclusion">Exclusion</option>
387
+ </select>
388
+ </div>
389
+ <div className="space-y-1.5">
390
+ <Label className="text-xs">Name</Label>
391
+ <Input
392
+ value={pendingName}
393
+ onChange={(e) => setPendingName(e.target.value)}
394
+ placeholder="Zone name"
395
+ onKeyDown={(e) => { if (e.key === 'Enter') saveZone(); }}
396
+ />
397
+ </div>
398
+ <div className="flex gap-2 pt-1">
399
+ <Button size="sm" className="flex-1" onClick={saveZone} disabled={!pendingName.trim()}>
400
+ Save
401
+ </Button>
402
+ <Button size="sm" variant="outline" onClick={discardZone}>
403
+ Cancel
404
+ </Button>
405
+ </div>
406
+ </CardContent>
407
+ </Card>
408
+ </div>
409
+ )}
410
+ </>
411
+ ) : (
412
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
413
+ <MapPin className="h-10 w-10" />
414
+ <p className="text-sm font-medium">Map preview unavailable</p>
415
+ <p className="text-xs">Set VITE_GOOGLE_MAPS_API_KEY to enable the map</p>
416
+ </div>
417
+ )}
418
+ </div>
419
+ </div>
420
+ </LayoutResolved>
421
+ );
422
+ }