@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.
- package/README.md +216 -0
- package/package.json +133 -4
- package/src/assets/index.ts +120 -0
- package/src/assets/media/avatars/300-1.png +0 -0
- package/src/assets/media/avatars/300-10.png +0 -0
- package/src/assets/media/avatars/300-11.png +0 -0
- package/src/assets/media/avatars/300-12.png +0 -0
- package/src/assets/media/avatars/300-13.png +0 -0
- package/src/assets/media/avatars/300-14.png +0 -0
- package/src/assets/media/avatars/300-15.png +0 -0
- package/src/assets/media/avatars/300-16.png +0 -0
- package/src/assets/media/avatars/300-17.png +0 -0
- package/src/assets/media/avatars/300-18.png +0 -0
- package/src/assets/media/avatars/300-19.png +0 -0
- package/src/assets/media/avatars/300-2.png +0 -0
- package/src/assets/media/avatars/300-20.png +0 -0
- package/src/assets/media/avatars/300-21.png +0 -0
- package/src/assets/media/avatars/300-22.png +0 -0
- package/src/assets/media/avatars/300-23.png +0 -0
- package/src/assets/media/avatars/300-24.png +0 -0
- package/src/assets/media/avatars/300-25.png +0 -0
- package/src/assets/media/avatars/300-26.png +0 -0
- package/src/assets/media/avatars/300-27.png +0 -0
- package/src/assets/media/avatars/300-28.png +0 -0
- package/src/assets/media/avatars/300-29.png +0 -0
- package/src/assets/media/avatars/300-3.png +0 -0
- package/src/assets/media/avatars/300-30.png +0 -0
- package/src/assets/media/avatars/300-31.png +0 -0
- package/src/assets/media/avatars/300-32.png +0 -0
- package/src/assets/media/avatars/300-33.png +0 -0
- package/src/assets/media/avatars/300-34.png +0 -0
- package/src/assets/media/avatars/300-4.png +0 -0
- package/src/assets/media/avatars/300-5.png +0 -0
- package/src/assets/media/avatars/300-6.png +0 -0
- package/src/assets/media/avatars/300-7.png +0 -0
- package/src/assets/media/avatars/300-8.png +0 -0
- package/src/assets/media/avatars/300-9.png +0 -0
- package/src/assets/media/avatars/blank.png +0 -0
- package/src/assets/media/avatars/gray/1.png +0 -0
- package/src/assets/media/avatars/gray/2.png +0 -0
- package/src/assets/media/avatars/gray/3.png +0 -0
- package/src/assets/media/avatars/gray/4.png +0 -0
- package/src/assets/media/avatars/gray/5.png +0 -0
- package/src/assets/media/illustrations/1-dark.svg +78 -0
- package/src/assets/media/illustrations/1.svg +78 -0
- package/src/assets/media/illustrations/10-dark.svg +148 -0
- package/src/assets/media/illustrations/10.svg +148 -0
- package/src/assets/media/illustrations/11-dark.svg +234 -0
- package/src/assets/media/illustrations/11.svg +234 -0
- package/src/assets/media/illustrations/12.svg +138 -0
- package/src/assets/media/illustrations/13.svg +205 -0
- package/src/assets/media/illustrations/14.svg +259 -0
- package/src/assets/media/illustrations/15.svg +242 -0
- package/src/assets/media/illustrations/16.svg +128 -0
- package/src/assets/media/illustrations/17.svg +180 -0
- package/src/assets/media/illustrations/18-dark.svg +6 -0
- package/src/assets/media/illustrations/18.svg +6 -0
- package/src/assets/media/illustrations/19-dark.svg +8 -0
- package/src/assets/media/illustrations/19.svg +8 -0
- package/src/assets/media/illustrations/2-dark.svg +78 -0
- package/src/assets/media/illustrations/2.svg +78 -0
- package/src/assets/media/illustrations/20-dark.svg +13 -0
- package/src/assets/media/illustrations/20.svg +13 -0
- package/src/assets/media/illustrations/21-dark.svg +9 -0
- package/src/assets/media/illustrations/21.svg +9 -0
- package/src/assets/media/illustrations/22-dark.svg +17 -0
- package/src/assets/media/illustrations/22.svg +17 -0
- package/src/assets/media/illustrations/23-dark.svg +13 -0
- package/src/assets/media/illustrations/23.svg +13 -0
- package/src/assets/media/illustrations/24.svg +6 -0
- package/src/assets/media/illustrations/25.svg +8 -0
- package/src/assets/media/illustrations/26.svg +8 -0
- package/src/assets/media/illustrations/27.svg +6 -0
- package/src/assets/media/illustrations/28-dark.svg +28 -0
- package/src/assets/media/illustrations/28.svg +14 -0
- package/src/assets/media/illustrations/29-dark.svg +6 -0
- package/src/assets/media/illustrations/29.svg +6 -0
- package/src/assets/media/illustrations/3-dark.svg +70 -0
- package/src/assets/media/illustrations/3.svg +70 -0
- package/src/assets/media/illustrations/30-dark.svg +8 -0
- package/src/assets/media/illustrations/30.svg +8 -0
- package/src/assets/media/illustrations/31-dark.svg +9 -0
- package/src/assets/media/illustrations/31.svg +9 -0
- package/src/assets/media/illustrations/32-dark.svg +10 -0
- package/src/assets/media/illustrations/32.svg +10 -0
- package/src/assets/media/illustrations/33-dark.svg +15 -0
- package/src/assets/media/illustrations/33.svg +15 -0
- package/src/assets/media/illustrations/34-dark.svg +5 -0
- package/src/assets/media/illustrations/34.svg +5 -0
- package/src/assets/media/illustrations/35-dark.svg +11 -0
- package/src/assets/media/illustrations/35.svg +4 -0
- package/src/assets/media/illustrations/4-dark.svg +51 -0
- package/src/assets/media/illustrations/4.svg +51 -0
- package/src/assets/media/illustrations/5-dark.svg +78 -0
- package/src/assets/media/illustrations/5.svg +78 -0
- package/src/assets/media/illustrations/6.svg +58 -0
- package/src/assets/media/illustrations/7.svg +49 -0
- package/src/assets/media/illustrations/8.svg +61 -0
- package/src/assets/media/illustrations/9.svg +57 -0
- package/src/assets/media/misc/placeholder.svg +15 -0
- package/src/components/devices/devices-mini-map.tsx +32 -26
- package/src/components/devices/map-marker.tsx +98 -0
- package/src/components/ui/checklist-item.tsx +55 -0
- package/src/components/ui/plan-card.tsx +68 -0
- package/src/components/ui/settings-row.tsx +32 -0
- package/src/components/ui/settings-section.tsx +22 -0
- package/src/components/ui/usage-meter.tsx +35 -0
- package/src/index.ts +12 -1
- package/src/layouts/LayoutSwitcher.tsx +220 -0
- package/src/layouts/app/MegaMenuLayout.tsx +69 -34
- package/src/layouts/app/MegaMenuNavbarLayout.tsx +73 -37
- package/src/layouts/app/NavbarCollapsibleLayout.tsx +53 -4
- package/src/layouts/app/NavbarSidebarLayout.tsx +74 -29
- package/src/layouts/app/SidebarDualMenuLayout.tsx +48 -5
- package/src/layouts/app/SidebarFixedLayout.tsx +15 -10
- package/src/layouts/app/SidebarMinimalLayout.tsx +51 -3
- package/src/layouts/app/SidebarTabsLayout.tsx +48 -2
- package/src/layouts/app/SplitSidebarLayout.tsx +91 -43
- package/src/layouts/app/TopNavLayout.tsx +7 -12
- package/src/layouts/app/WorkspaceSidebarLayout.tsx +103 -46
- package/src/layouts/app/partials/Navbar.tsx +61 -10
- package/src/layouts/app/partials/Toolbar.tsx +1 -1
- package/src/layouts/auth/AuthCenteredLayout.tsx +10 -4
- package/src/lib/map-markers.ts +21 -3
- package/src/pages/login/ConfirmPasswordPage.tsx +35 -0
- package/src/pages/login/ForgotPasswordPage.tsx +41 -0
- package/src/pages/login/LoginPage.tsx +50 -0
- package/src/pages/login/RegisterPage.tsx +41 -0
- package/src/pages/login/ResetPasswordPage.tsx +35 -0
- package/src/pages/login/TwoFactorChallengePage.tsx +41 -0
- package/src/pages/login/VerifyEmailPage.tsx +31 -0
- package/src/pages/my/ActivityPage.tsx +160 -0
- package/src/pages/my/GetStartedPage.tsx +221 -0
- package/src/pages/my/NotificationsPage.tsx +133 -0
- package/src/pages/my/ProfilePage.tsx +650 -0
- package/src/pages/my/TenantsPage.tsx +37 -0
- package/src/pages/tenant/AssigneesPage.tsx +155 -0
- package/src/pages/tenant/BeatsPage.tsx +403 -0
- package/src/pages/tenant/DashboardPage.tsx +195 -0
- package/src/pages/tenant/GeofencePage.tsx +422 -0
- package/src/pages/tenant/IncidentsPage.tsx +214 -0
- package/src/pages/tenant/IntegrationsPage.tsx +352 -0
- package/src/pages/tenant/InvitePage.tsx +153 -0
- package/src/pages/tenant/LiveStreamPage.tsx +141 -0
- package/src/pages/tenant/MembersPage.tsx +414 -0
- package/src/pages/tenant/TenantProfilePage.tsx +701 -0
- package/src/platform/adapters/default.tsx +1 -1
- package/src/platform/types.ts +2 -0
- package/src/styles/components/apexcharts.css +101 -0
- package/src/styles/components/image-input.css +51 -0
- package/src/styles/components/leaflet.css +25 -0
- package/src/styles/components/rating.css +89 -0
- package/src/styles/components/scrollable.css +119 -0
- package/src/styles/layout.css +24 -0
- package/src/styles/layouts/sidebar-fixed.css +93 -138
- package/src/styles/themes.css +5 -5
- package/src/vite-env.d.ts +21 -0
- package/src/layouts/SettingsLayout.tsx +0 -21
- package/src/layouts/app-layout.tsx +0 -29
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { LayoutResolved } from '../../layouts/LayoutSwitcher';
|
|
2
|
+
import type { LayoutName } from '../../layouts/LayoutSwitcher';
|
|
3
|
+
import { Building2, ChevronRight } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
export interface Tenant {
|
|
6
|
+
id: number;
|
|
7
|
+
slug: string;
|
|
8
|
+
display_name: string;
|
|
9
|
+
sub_brand: string | null;
|
|
10
|
+
devices: number;
|
|
11
|
+
role: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TenantsList({ layout, tenants }: { layout: LayoutName; tenants: Tenant[] }) {
|
|
15
|
+
return (
|
|
16
|
+
<LayoutResolved layout={layout} title="My Tenants" currentUrl="/my-tenants">
|
|
17
|
+
<div className="p-6">
|
|
18
|
+
<h1 className="text-2xl font-semibold mb-6">My Tenants</h1>
|
|
19
|
+
<div className="space-y-3">
|
|
20
|
+
{tenants.map((tenant) => (
|
|
21
|
+
<a key={tenant.id} href={`https://${tenant.slug}.track-any-device.com`} className="flex items-center justify-between rounded-xl border border-border bg-card p-4 shadow-sm hover:border-primary/50 transition-colors">
|
|
22
|
+
<div className="flex items-center gap-3">
|
|
23
|
+
<div className="rounded-lg bg-primary/10 p-2.5"><Building2 className="h-5 w-5 text-primary" /></div>
|
|
24
|
+
<div>
|
|
25
|
+
<p className="font-medium">{tenant.display_name}</p>
|
|
26
|
+
{tenant.sub_brand && <p className="text-xs text-muted-foreground">{tenant.sub_brand}</p>}
|
|
27
|
+
<p className="text-xs text-muted-foreground mt-0.5">{tenant.devices} devices · {tenant.role}</p>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
31
|
+
</a>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</LayoutResolved>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge, StatCard, Avatar, AvatarFallback,
|
|
3
|
+
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
|
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 { Users, Wifi, WifiOff } from 'lucide-react';
|
|
10
|
+
import { DevicesMiniMap } from '../../components/devices/devices-mini-map';
|
|
11
|
+
import type { MiniMapDevice } from '../../components/devices/devices-mini-map';
|
|
12
|
+
|
|
13
|
+
export type { MiniMapDevice };
|
|
14
|
+
|
|
15
|
+
export type AssigneeStatus = 'inside' | 'outside' | 'offline';
|
|
16
|
+
export type AssigneeType = 'Worker' | 'Vehicle';
|
|
17
|
+
|
|
18
|
+
export interface Assignee {
|
|
19
|
+
id: number;
|
|
20
|
+
name: string;
|
|
21
|
+
initials: string;
|
|
22
|
+
type: AssigneeType;
|
|
23
|
+
beat: string;
|
|
24
|
+
status: AssigneeStatus;
|
|
25
|
+
device: string;
|
|
26
|
+
signal: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const statusMeta: Record<AssigneeStatus, { label: string; style: string }> = {
|
|
30
|
+
inside: { label: 'Inside Beat', style: 'text-green-600 border-green-200 bg-green-50' },
|
|
31
|
+
outside: { label: 'Out of Beat', style: 'text-red-600 border-red-200 bg-red-50' },
|
|
32
|
+
offline: { label: 'Offline', style: 'text-gray-500 border-gray-200 bg-gray-50' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function AssigneesTablePage({ layout, assignees }: { layout: LayoutName; assignees: Assignee[] }) {
|
|
36
|
+
const online = assignees.filter((a) => a.status !== 'offline').length;
|
|
37
|
+
const offline = assignees.filter((a) => a.status === 'offline').length;
|
|
38
|
+
return (
|
|
39
|
+
<LayoutResolved layout={layout} title="Assignees" currentUrl="/assignees">
|
|
40
|
+
<div className="p-6 space-y-6">
|
|
41
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
|
42
|
+
<StatCard icon={Users} label="Total Assignees" value={String(assignees.length)} description="12 divisions" />
|
|
43
|
+
<StatCard icon={Wifi} label="Online" value={String(online)} description={`${Math.round((online / assignees.length) * 100)}% of total`} />
|
|
44
|
+
<StatCard icon={WifiOff} label="Offline" value={String(offline)} description={`${Math.round((offline / assignees.length) * 100)}% of total`} />
|
|
45
|
+
</div>
|
|
46
|
+
<Card>
|
|
47
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
48
|
+
<div>
|
|
49
|
+
<CardTitle>Assignees</CardTitle>
|
|
50
|
+
<CardDescription>{assignees.length} of 248 shown</CardDescription>
|
|
51
|
+
</div>
|
|
52
|
+
<Button size="sm">Add assignee</Button>
|
|
53
|
+
</CardHeader>
|
|
54
|
+
<CardContent className="p-0">
|
|
55
|
+
<Table>
|
|
56
|
+
<TableHeader>
|
|
57
|
+
<TableRow>
|
|
58
|
+
<TableHead>Assignee</TableHead>
|
|
59
|
+
<TableHead>Type</TableHead>
|
|
60
|
+
<TableHead>Beat</TableHead>
|
|
61
|
+
<TableHead>Device</TableHead>
|
|
62
|
+
<TableHead>Signal</TableHead>
|
|
63
|
+
<TableHead>Status</TableHead>
|
|
64
|
+
</TableRow>
|
|
65
|
+
</TableHeader>
|
|
66
|
+
<TableBody>
|
|
67
|
+
{assignees.map((a) => (
|
|
68
|
+
<TableRow key={a.id}>
|
|
69
|
+
<TableCell>
|
|
70
|
+
<div className="flex items-center gap-2">
|
|
71
|
+
<Avatar className="h-7 w-7">
|
|
72
|
+
<AvatarFallback className="text-xs">{a.initials}</AvatarFallback>
|
|
73
|
+
</Avatar>
|
|
74
|
+
<span className="font-medium text-sm">{a.name}</span>
|
|
75
|
+
</div>
|
|
76
|
+
</TableCell>
|
|
77
|
+
<TableCell className="text-muted-foreground text-sm">{a.type}</TableCell>
|
|
78
|
+
<TableCell className="text-sm">{a.beat}</TableCell>
|
|
79
|
+
<TableCell className="font-mono text-xs text-muted-foreground">{a.device}</TableCell>
|
|
80
|
+
<TableCell>
|
|
81
|
+
<div className="flex items-center gap-1.5">
|
|
82
|
+
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
|
|
83
|
+
<div
|
|
84
|
+
className={`h-full rounded-full ${a.signal > 60 ? 'bg-green-500' : a.signal > 30 ? 'bg-amber-500' : 'bg-red-500'}`}
|
|
85
|
+
style={{ width: `${a.signal}%` }}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
<span className="text-xs text-muted-foreground">{a.signal}%</span>
|
|
89
|
+
</div>
|
|
90
|
+
</TableCell>
|
|
91
|
+
<TableCell>
|
|
92
|
+
<Badge variant="outline" className={statusMeta[a.status].style}>
|
|
93
|
+
{statusMeta[a.status].label}
|
|
94
|
+
</Badge>
|
|
95
|
+
</TableCell>
|
|
96
|
+
</TableRow>
|
|
97
|
+
))}
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>
|
|
100
|
+
</CardContent>
|
|
101
|
+
</Card>
|
|
102
|
+
</div>
|
|
103
|
+
</LayoutResolved>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function AssigneesMapPage({
|
|
108
|
+
layout,
|
|
109
|
+
assignees,
|
|
110
|
+
devices = [],
|
|
111
|
+
}: {
|
|
112
|
+
layout: LayoutName;
|
|
113
|
+
assignees: Assignee[];
|
|
114
|
+
devices?: MiniMapDevice[];
|
|
115
|
+
}) {
|
|
116
|
+
const online = assignees.filter((a) => a.status !== 'offline').length;
|
|
117
|
+
const offline = assignees.filter((a) => a.status === 'offline').length;
|
|
118
|
+
return (
|
|
119
|
+
<LayoutResolved layout={layout} title="Assignees — Map" currentUrl="/assignees/map">
|
|
120
|
+
<div className="flex h-[calc(100vh-3.5rem)]">
|
|
121
|
+
<div className="flex-1 relative">
|
|
122
|
+
<DevicesMiniMap
|
|
123
|
+
devices={devices}
|
|
124
|
+
height="100%"
|
|
125
|
+
className="rounded-none border-0"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="w-72 border-l border-border bg-background overflow-y-auto">
|
|
129
|
+
<div className="p-4 border-b border-border">
|
|
130
|
+
<p className="text-sm font-semibold">Assignees</p>
|
|
131
|
+
<p className="text-xs text-muted-foreground">{online} online · {offline} offline</p>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="divide-y divide-border">
|
|
134
|
+
{assignees.map((a) => (
|
|
135
|
+
<div key={a.id} className="p-3 hover:bg-muted/30 cursor-pointer">
|
|
136
|
+
<div className="flex items-center justify-between">
|
|
137
|
+
<div className="flex items-center gap-2">
|
|
138
|
+
<Avatar className="h-6 w-6">
|
|
139
|
+
<AvatarFallback className="text-xs">{a.initials}</AvatarFallback>
|
|
140
|
+
</Avatar>
|
|
141
|
+
<span className="text-sm font-medium">{a.name}</span>
|
|
142
|
+
</div>
|
|
143
|
+
<Badge variant="outline" className={`text-xs ${statusMeta[a.status].style}`}>
|
|
144
|
+
{statusMeta[a.status].label}
|
|
145
|
+
</Badge>
|
|
146
|
+
</div>
|
|
147
|
+
<p className="text-xs text-muted-foreground mt-1 ml-8">{a.beat}</p>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</LayoutResolved>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Badge, Button, Card, CardContent, Input, Label } from '@trackany-device/components';
|
|
3
|
+
import { LayoutResolved } from '../../layouts/LayoutSwitcher';
|
|
4
|
+
import type { LayoutName } from '../../layouts/LayoutSwitcher';
|
|
5
|
+
import { MapPin, Plus, Users } from 'lucide-react';
|
|
6
|
+
import { loadGoogleMaps, hasGoogleMapsKey } from '../../lib/google-maps-loader';
|
|
7
|
+
|
|
8
|
+
export type LatLng = { lat: number; lng: number };
|
|
9
|
+
export type BeatStatus = 'active' | 'inactive';
|
|
10
|
+
|
|
11
|
+
export interface Beat {
|
|
12
|
+
id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
zone: string;
|
|
15
|
+
description: string;
|
|
16
|
+
status: BeatStatus;
|
|
17
|
+
assignees: number;
|
|
18
|
+
color: string;
|
|
19
|
+
polygon: LatLng[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const STATUS_STYLE: Record<BeatStatus, string> = {
|
|
23
|
+
active: 'text-green-600 border-green-200 bg-green-50',
|
|
24
|
+
inactive: 'text-muted-foreground border-border bg-muted',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function hexToRgba(hex: string, alpha: number): string {
|
|
28
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
29
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
30
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
31
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function polygonBounds(maps: typeof google.maps, polygon: LatLng[]): google.maps.LatLngBounds {
|
|
35
|
+
const bounds = new maps.LatLngBounds();
|
|
36
|
+
polygon.forEach((p) => bounds.extend(new maps.LatLng(p.lat, p.lng)));
|
|
37
|
+
return bounds;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function BeatsListPage({ layout, beats }: { layout: LayoutName; beats: Beat[] }) {
|
|
41
|
+
const [selectedId, setSelectedId] = useState<number | null>(beats[0]?.id ?? null);
|
|
42
|
+
const [mapReady, setMapReady] = useState(false);
|
|
43
|
+
|
|
44
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
const mapRef = useRef<google.maps.Map | null>(null);
|
|
46
|
+
const mapsApiRef = useRef<typeof google.maps | null>(null);
|
|
47
|
+
const polysRef = useRef<Map<number, google.maps.Polygon>>(new Map());
|
|
48
|
+
|
|
49
|
+
const active = beats.filter((b) => b.status === 'active').length;
|
|
50
|
+
const inactive = beats.filter((b) => b.status === 'inactive').length;
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!hasGoogleMapsKey() || !containerRef.current) return;
|
|
54
|
+
|
|
55
|
+
loadGoogleMaps().then((maps) => {
|
|
56
|
+
mapsApiRef.current = maps;
|
|
57
|
+
mapRef.current = new maps.Map(containerRef.current!, {
|
|
58
|
+
center: { lat: 31.5204, lng: 74.3587 },
|
|
59
|
+
zoom: 11,
|
|
60
|
+
disableDefaultUI: true,
|
|
61
|
+
zoomControl: true,
|
|
62
|
+
});
|
|
63
|
+
setMapReady(true);
|
|
64
|
+
});
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!mapReady || !mapsApiRef.current || !mapRef.current) return;
|
|
69
|
+
const maps = mapsApiRef.current;
|
|
70
|
+
|
|
71
|
+
polysRef.current.forEach((poly) => poly.setMap(null));
|
|
72
|
+
polysRef.current.clear();
|
|
73
|
+
|
|
74
|
+
beats.forEach((beat) => {
|
|
75
|
+
const isSelected = beat.id === selectedId;
|
|
76
|
+
const poly = new maps.Polygon({
|
|
77
|
+
paths: beat.polygon.map((p) => ({ lat: p.lat, lng: p.lng })),
|
|
78
|
+
strokeColor: beat.color,
|
|
79
|
+
strokeWeight: isSelected ? 3 : 1.5,
|
|
80
|
+
fillColor: beat.color,
|
|
81
|
+
fillOpacity: isSelected ? 0.3 : 0.15,
|
|
82
|
+
map: mapRef.current!,
|
|
83
|
+
});
|
|
84
|
+
poly.addListener('click', () => setSelectedId(beat.id));
|
|
85
|
+
polysRef.current.set(beat.id, poly);
|
|
86
|
+
});
|
|
87
|
+
}, [mapReady, beats, selectedId]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!mapReady || !mapsApiRef.current || !mapRef.current || selectedId === null) return;
|
|
91
|
+
const beat = beats.find((b) => b.id === selectedId);
|
|
92
|
+
if (!beat || beat.polygon.length === 0) return;
|
|
93
|
+
const bounds = polygonBounds(mapsApiRef.current, beat.polygon);
|
|
94
|
+
mapRef.current.fitBounds(bounds, 60);
|
|
95
|
+
}, [mapReady, selectedId, beats]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<LayoutResolved layout={layout} title="Beats" currentUrl="/beats">
|
|
99
|
+
<div className="flex h-[calc(100vh-3.5rem)]">
|
|
100
|
+
<div className="flex w-1/3 min-w-[260px] flex-col border-r border-border bg-background overflow-hidden">
|
|
101
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3 shrink-0">
|
|
102
|
+
<div>
|
|
103
|
+
<h1 className="text-base font-semibold">Beats</h1>
|
|
104
|
+
<p className="text-xs text-muted-foreground">{beats.length} total</p>
|
|
105
|
+
</div>
|
|
106
|
+
<Button size="sm">
|
|
107
|
+
<Plus className="h-3.5 w-3.5 mr-1" />Add beat
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div className="grid grid-cols-3 divide-x divide-border border-b border-border shrink-0">
|
|
112
|
+
{[
|
|
113
|
+
{ label: 'Total', value: beats.length },
|
|
114
|
+
{ label: 'Active', value: active },
|
|
115
|
+
{ label: 'Inactive', value: inactive },
|
|
116
|
+
].map(({ label, value }) => (
|
|
117
|
+
<div key={label} className="px-3 py-2 text-center">
|
|
118
|
+
<p className="text-lg font-bold text-primary">{value}</p>
|
|
119
|
+
<p className="text-xs text-muted-foreground">{label}</p>
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="flex-1 overflow-y-auto divide-y divide-border">
|
|
125
|
+
{beats.map((beat) => {
|
|
126
|
+
const isSelected = beat.id === selectedId;
|
|
127
|
+
return (
|
|
128
|
+
<button
|
|
129
|
+
key={beat.id}
|
|
130
|
+
onClick={() => setSelectedId(beat.id)}
|
|
131
|
+
className={`w-full text-left px-4 py-3 hover:bg-muted/40 transition-colors ${isSelected ? 'bg-muted/60' : ''}`}
|
|
132
|
+
style={isSelected ? { boxShadow: `inset 3px 0 0 ${beat.color}` } : undefined}
|
|
133
|
+
>
|
|
134
|
+
<div className="flex items-start gap-2.5">
|
|
135
|
+
<span
|
|
136
|
+
className="mt-0.5 h-3 w-3 rounded-sm shrink-0"
|
|
137
|
+
style={{ backgroundColor: beat.color }}
|
|
138
|
+
/>
|
|
139
|
+
<div className="flex-1 min-w-0">
|
|
140
|
+
<div className="flex items-center justify-between gap-1.5">
|
|
141
|
+
<p className="text-sm font-medium truncate">{beat.name}</p>
|
|
142
|
+
<Badge variant="outline" className={`text-xs shrink-0 ${STATUS_STYLE[beat.status]}`}>
|
|
143
|
+
{beat.status}
|
|
144
|
+
</Badge>
|
|
145
|
+
</div>
|
|
146
|
+
<p className="text-xs text-muted-foreground mt-0.5">{beat.zone}</p>
|
|
147
|
+
<div className="flex items-center gap-1 mt-1.5 text-xs text-muted-foreground">
|
|
148
|
+
<Users className="h-3 w-3" />
|
|
149
|
+
<span>{beat.assignees} assignee{beat.assignees !== 1 ? 's' : ''}</span>
|
|
150
|
+
</div>
|
|
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
|
+
const PRESET_COLORS = [
|
|
176
|
+
{ label: 'Green', value: '#22c55e' },
|
|
177
|
+
{ label: 'Blue', value: '#3b82f6' },
|
|
178
|
+
{ label: 'Purple', value: '#a855f7' },
|
|
179
|
+
{ label: 'Red', value: '#ef4444' },
|
|
180
|
+
{ label: 'Orange', value: '#f97316' },
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
export function BeatEditorPage({ layout, beat }: { layout: LayoutName; beat?: Partial<Beat> }) {
|
|
184
|
+
const isNew = !beat?.id;
|
|
185
|
+
|
|
186
|
+
const [name, setName] = useState(beat?.name ?? '');
|
|
187
|
+
const [zone, setZone] = useState(beat?.zone ?? '');
|
|
188
|
+
const [description, setDescription] = useState(beat?.description ?? '');
|
|
189
|
+
const [status, setStatus] = useState<BeatStatus>(beat?.status ?? 'active');
|
|
190
|
+
const [color, setColor] = useState(beat?.color ?? '#22c55e');
|
|
191
|
+
const [vertexCount, setVertexCount] = useState(beat?.polygon?.length ?? 0);
|
|
192
|
+
|
|
193
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
194
|
+
const mapRef = useRef<google.maps.Map | null>(null);
|
|
195
|
+
const mapsApiRef = useRef<typeof google.maps | null>(null);
|
|
196
|
+
const polygonRef = useRef<google.maps.Polygon | null>(null);
|
|
197
|
+
const drawingRef = useRef<google.maps.drawing.DrawingManager | null>(null);
|
|
198
|
+
const [mapReady, setMapReady] = useState(false);
|
|
199
|
+
const [isDrawing, setIsDrawing] = useState(!beat?.polygon?.length);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!hasGoogleMapsKey() || !containerRef.current) return;
|
|
203
|
+
|
|
204
|
+
loadGoogleMaps().then((maps) => {
|
|
205
|
+
mapsApiRef.current = maps;
|
|
206
|
+
mapRef.current = new maps.Map(containerRef.current!, {
|
|
207
|
+
center: { lat: 31.5204, lng: 74.3587 },
|
|
208
|
+
zoom: 11,
|
|
209
|
+
disableDefaultUI: true,
|
|
210
|
+
zoomControl: true,
|
|
211
|
+
});
|
|
212
|
+
setMapReady(true);
|
|
213
|
+
});
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!mapReady || !mapsApiRef.current || !mapRef.current) return;
|
|
218
|
+
const maps = mapsApiRef.current;
|
|
219
|
+
|
|
220
|
+
if (beat?.polygon && beat.polygon.length > 0 && !isDrawing) {
|
|
221
|
+
if (polygonRef.current) polygonRef.current.setMap(null);
|
|
222
|
+
polygonRef.current = new maps.Polygon({
|
|
223
|
+
paths: beat.polygon.map((p) => ({ lat: p.lat, lng: p.lng })),
|
|
224
|
+
strokeColor: color,
|
|
225
|
+
strokeWeight: 2,
|
|
226
|
+
fillColor: color,
|
|
227
|
+
fillOpacity: 0.2,
|
|
228
|
+
editable: true,
|
|
229
|
+
draggable: false,
|
|
230
|
+
map: mapRef.current,
|
|
231
|
+
});
|
|
232
|
+
setVertexCount(beat.polygon.length);
|
|
233
|
+
const bounds = polygonBounds(maps, beat.polygon);
|
|
234
|
+
mapRef.current.fitBounds(bounds, 60);
|
|
235
|
+
}
|
|
236
|
+
}, [mapReady]);
|
|
237
|
+
|
|
238
|
+
function activateDrawing() {
|
|
239
|
+
if (!mapsApiRef.current || !mapRef.current) return;
|
|
240
|
+
const maps = mapsApiRef.current;
|
|
241
|
+
|
|
242
|
+
if (polygonRef.current) {
|
|
243
|
+
polygonRef.current.setMap(null);
|
|
244
|
+
polygonRef.current = null;
|
|
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
|
+
strokeColor: color,
|
|
256
|
+
strokeWeight: 2,
|
|
257
|
+
fillColor: color,
|
|
258
|
+
fillOpacity: 0.2,
|
|
259
|
+
editable: true,
|
|
260
|
+
draggable: false,
|
|
261
|
+
},
|
|
262
|
+
map: mapRef.current,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
maps.event.addListener(dm, 'polygoncomplete', (poly: google.maps.Polygon) => {
|
|
266
|
+
dm.setDrawingMode(null);
|
|
267
|
+
setIsDrawing(false);
|
|
268
|
+
polygonRef.current = poly;
|
|
269
|
+
const path = poly.getPath();
|
|
270
|
+
setVertexCount(path.getLength());
|
|
271
|
+
path.addListener('insert_at', () => setVertexCount(poly.getPath().getLength()));
|
|
272
|
+
path.addListener('remove_at', () => setVertexCount(poly.getPath().getLength()));
|
|
273
|
+
path.addListener('set_at', () => setVertexCount(poly.getPath().getLength()));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
drawingRef.current = dm;
|
|
277
|
+
setIsDrawing(true);
|
|
278
|
+
setVertexCount(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
if (mapReady && isDrawing && !beat?.polygon?.length) {
|
|
283
|
+
activateDrawing();
|
|
284
|
+
}
|
|
285
|
+
}, [mapReady]);
|
|
286
|
+
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!polygonRef.current) return;
|
|
289
|
+
polygonRef.current.setOptions({ strokeColor: color, fillColor: color });
|
|
290
|
+
}, [color]);
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<LayoutResolved layout={layout} title={isNew ? 'New Beat' : 'Edit Beat'} currentUrl="/beats/edit">
|
|
294
|
+
<div className="flex h-[calc(100vh-3.5rem)]">
|
|
295
|
+
<div className="flex w-[300px] shrink-0 flex-col border-r border-border bg-background overflow-y-auto">
|
|
296
|
+
<div className="border-b border-border px-4 py-3">
|
|
297
|
+
<h1 className="text-base font-semibold">{isNew ? 'New Beat' : 'Edit Beat'}</h1>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<div className="flex-1 px-4 py-4 space-y-4">
|
|
301
|
+
<div className="space-y-1.5">
|
|
302
|
+
<Label className="text-xs">Name</Label>
|
|
303
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Beat name" />
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div className="space-y-1.5">
|
|
307
|
+
<Label className="text-xs">Zone</Label>
|
|
308
|
+
<Input value={zone} onChange={(e) => setZone(e.target.value)} placeholder="Zone or district" />
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
<div className="space-y-1.5">
|
|
312
|
+
<Label className="text-xs">Description</Label>
|
|
313
|
+
<textarea
|
|
314
|
+
value={description}
|
|
315
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
316
|
+
placeholder="Brief description…"
|
|
317
|
+
rows={3}
|
|
318
|
+
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none"
|
|
319
|
+
/>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div className="space-y-1.5">
|
|
323
|
+
<Label className="text-xs">Status</Label>
|
|
324
|
+
<select
|
|
325
|
+
value={status}
|
|
326
|
+
onChange={(e) => setStatus(e.target.value as BeatStatus)}
|
|
327
|
+
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
328
|
+
>
|
|
329
|
+
<option value="active">Active</option>
|
|
330
|
+
<option value="inactive">Inactive</option>
|
|
331
|
+
</select>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<div className="space-y-2">
|
|
335
|
+
<Label className="text-xs">Color</Label>
|
|
336
|
+
<div className="flex gap-2 flex-wrap">
|
|
337
|
+
{PRESET_COLORS.map((c) => (
|
|
338
|
+
<label key={c.value} className="cursor-pointer">
|
|
339
|
+
<input
|
|
340
|
+
type="radio"
|
|
341
|
+
name="beat-color"
|
|
342
|
+
value={c.value}
|
|
343
|
+
checked={color === c.value}
|
|
344
|
+
onChange={() => setColor(c.value)}
|
|
345
|
+
className="sr-only"
|
|
346
|
+
/>
|
|
347
|
+
<span
|
|
348
|
+
className={`block h-6 w-6 rounded-full border-2 transition-all ${color === c.value ? 'border-foreground scale-110' : 'border-transparent'}`}
|
|
349
|
+
style={{ backgroundColor: c.value }}
|
|
350
|
+
title={c.label}
|
|
351
|
+
/>
|
|
352
|
+
</label>
|
|
353
|
+
))}
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<div className="rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground">
|
|
358
|
+
Click on the map to draw the beat polygon. Click the first point to close it.
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<p className="text-xs text-muted-foreground">{vertexCount} {vertexCount === 1 ? 'vertex' : 'vertices'} defined</p>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div className="border-t border-border px-4 py-3 flex gap-2">
|
|
365
|
+
<Button size="sm" className="flex-1">Save Beat</Button>
|
|
366
|
+
<Button variant="outline" size="sm">Discard</Button>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<div className="flex-1 relative bg-muted/20">
|
|
371
|
+
{hasGoogleMapsKey() ? (
|
|
372
|
+
<>
|
|
373
|
+
<div ref={containerRef} className="absolute inset-0" />
|
|
374
|
+
<div className="absolute bottom-4 right-4 z-10">
|
|
375
|
+
<Button
|
|
376
|
+
size="sm"
|
|
377
|
+
variant="outline"
|
|
378
|
+
className="bg-background shadow-md"
|
|
379
|
+
onClick={activateDrawing}
|
|
380
|
+
>
|
|
381
|
+
Redraw polygon
|
|
382
|
+
</Button>
|
|
383
|
+
</div>
|
|
384
|
+
{isDrawing && (
|
|
385
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10">
|
|
386
|
+
<div className="rounded-md border border-border bg-background/95 px-3 py-1.5 text-xs shadow-md backdrop-blur-sm">
|
|
387
|
+
Click to add points · Click the first point to close
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
</>
|
|
392
|
+
) : (
|
|
393
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
394
|
+
<MapPin className="h-10 w-10" />
|
|
395
|
+
<p className="text-sm font-medium">Map preview unavailable</p>
|
|
396
|
+
<p className="text-xs">Set VITE_GOOGLE_MAPS_API_KEY to enable the map</p>
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</LayoutResolved>
|
|
402
|
+
);
|
|
403
|
+
}
|