mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
1138 lines
50 KiB
Markdown
1138 lines
50 KiB
Markdown
import React, { useMemo, useState } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import {
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
Clock,
|
||
MapPin,
|
||
TrendingUp,
|
||
Building2,
|
||
Wrench,
|
||
Search,
|
||
Filter,
|
||
ChevronRight,
|
||
ShieldAlert,
|
||
Truck,
|
||
Lightbulb,
|
||
} from "lucide-react";
|
||
import {
|
||
ResponsiveContainer,
|
||
LineChart,
|
||
Line,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
BarChart,
|
||
Bar,
|
||
} from "recharts";
|
||
|
||
// --- Mock data (replace with API later) ---
|
||
const DISTRICTS = ["All districts", "Mitte", "Nord", "Süd", "West", "Ost"];
|
||
const CATEGORIES = [
|
||
"All categories",
|
||
"Waste",
|
||
"Roads",
|
||
"Lighting",
|
||
"Noise",
|
||
"Parking",
|
||
"Water",
|
||
];
|
||
|
||
const kpisBase = {
|
||
openedToday: 184,
|
||
closedToday: 159,
|
||
backlog: 1328,
|
||
slaBreaches: 42,
|
||
repeatIncidents: 61,
|
||
medianResolveHours: 38,
|
||
citizenSatisfaction: 4.1,
|
||
};
|
||
|
||
const trendResolve = [
|
||
{ week: "W-3", hours: 44 },
|
||
{ week: "W-2", hours: 41 },
|
||
{ week: "W-1", hours: 39 },
|
||
{ week: "This", hours: 38 },
|
||
];
|
||
|
||
const backlogAging = [
|
||
{ bucket: "0–2d", count: 424 },
|
||
{ bucket: "3–7d", count: 366 },
|
||
{ bucket: "8–30d", count: 401 },
|
||
{ bucket: "30d+", count: 137 },
|
||
];
|
||
|
||
const hotspots = [
|
||
{
|
||
id: "HS-101",
|
||
district: "Nord",
|
||
category: "Waste",
|
||
title: "Overflowing bins near transit stop",
|
||
location: "Rosenweg 12",
|
||
ageDays: 6,
|
||
priority: "High",
|
||
repeats: 3,
|
||
},
|
||
{
|
||
id: "HS-102",
|
||
district: "Mitte",
|
||
category: "Lighting",
|
||
title: "Streetlights out on main crossing",
|
||
location: "Alexanderplatz East",
|
||
ageDays: 2,
|
||
priority: "Critical",
|
||
repeats: 1,
|
||
},
|
||
{
|
||
id: "HS-103",
|
||
district: "Süd",
|
||
category: "Roads",
|
||
title: "Potholes expanding after rain",
|
||
location: "Bergstraße 7–15",
|
||
ageDays: 11,
|
||
priority: "High",
|
||
repeats: 2,
|
||
},
|
||
{
|
||
id: "HS-104",
|
||
district: "West",
|
||
category: "Parking",
|
||
title: "Illegal parking blocking fire lane",
|
||
location: "Kantstraße 22",
|
||
ageDays: 1,
|
||
priority: "Medium",
|
||
repeats: 4,
|
||
},
|
||
];
|
||
|
||
const demandSupply = [
|
||
{
|
||
district: "Nord",
|
||
category: "Waste",
|
||
demandScore: 86,
|
||
supplyScore: 42,
|
||
gap: 44,
|
||
note: "Contractors concentrated in Mitte; long travel times",
|
||
},
|
||
{
|
||
district: "Süd",
|
||
category: "Roads",
|
||
demandScore: 74,
|
||
supplyScore: 51,
|
||
gap: 23,
|
||
note: "Asphalt crew scheduled; permitting delays",
|
||
},
|
||
{
|
||
district: "Mitte",
|
||
category: "Lighting",
|
||
demandScore: 61,
|
||
supplyScore: 68,
|
||
gap: -7,
|
||
note: "Healthy coverage; parts inventory is bottleneck",
|
||
},
|
||
{
|
||
district: "West",
|
||
category: "Parking",
|
||
demandScore: 70,
|
||
supplyScore: 33,
|
||
gap: 37,
|
||
note: "Enforcement staff shortage; peak weekend loads",
|
||
},
|
||
];
|
||
|
||
const providers = [
|
||
{
|
||
id: "P-201",
|
||
name: "NordClean GmbH",
|
||
category: "Waste",
|
||
district: "Nord",
|
||
license: "Verified",
|
||
etaHours: 6,
|
||
rating: 4.6,
|
||
capacity: "High",
|
||
},
|
||
{
|
||
id: "P-202",
|
||
name: "CityLight Service",
|
||
category: "Lighting",
|
||
district: "Mitte",
|
||
license: "Verified",
|
||
etaHours: 10,
|
||
rating: 4.3,
|
||
capacity: "Medium",
|
||
},
|
||
{
|
||
id: "P-203",
|
||
name: "WestPark Patrol",
|
||
category: "Parking",
|
||
district: "West",
|
||
license: "Pending",
|
||
etaHours: 12,
|
||
rating: 4.0,
|
||
capacity: "Low",
|
||
},
|
||
{
|
||
id: "P-204",
|
||
name: "Bergstraße Asphalt",
|
||
category: "Roads",
|
||
district: "Süd",
|
||
license: "Verified",
|
||
etaHours: 18,
|
||
rating: 4.4,
|
||
capacity: "Medium",
|
||
},
|
||
];
|
||
|
||
function clamp(n, min, max) {
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
|
||
function priorityBadge(priority) {
|
||
const base = "rounded-full";
|
||
if (priority === "Critical") return <Badge className={base}>{priority}</Badge>;
|
||
if (priority === "High") return <Badge variant="secondary" className={base}>{priority}</Badge>;
|
||
return <Badge variant="outline" className={base}>{priority}</Badge>;
|
||
}
|
||
|
||
function formatHours(h) {
|
||
if (h < 24) return `${h}h`;
|
||
const d = Math.round((h / 24) * 10) / 10;
|
||
return `${d}d`;
|
||
}
|
||
|
||
function SmallKPI({ icon: Icon, label, value, hint, tone = "default" }) {
|
||
const toneClass =
|
||
tone === "danger"
|
||
? "border-destructive/30"
|
||
: tone === "good"
|
||
? "border-emerald-500/20"
|
||
: "border-border";
|
||
|
||
return (
|
||
<Card className={`shadow-sm ${toneClass}`}>
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-xs text-muted-foreground">{label}</div>
|
||
<div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
|
||
{hint ? (
|
||
<div className="mt-1 text-xs text-muted-foreground">{hint}</div>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-1 rounded-2xl border bg-background p-2">
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function RowBar({ label, left, right }) {
|
||
const total = Math.max(1, left + right);
|
||
const leftPct = (left / total) * 100;
|
||
return (
|
||
<div className="space-y-1">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="text-muted-foreground">{label}</span>
|
||
<span className="font-medium">{left} / {right}</span>
|
||
</div>
|
||
<div className="h-2 w-full overflow-hidden rounded-full border bg-muted">
|
||
<div
|
||
className="h-full bg-foreground/70"
|
||
style={{ width: `${clamp(leftPct, 2, 98)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function TurashCityDashboardPrototype() {
|
||
const [district, setDistrict] = useState("All districts");
|
||
const [category, setCategory] = useState("All categories");
|
||
const [query, setQuery] = useState("");
|
||
const [publicMode, setPublicMode] = useState(false);
|
||
const [selectedHotspot, setSelectedHotspot] = useState(null);
|
||
const [budgetBoost, setBudgetBoost] = useState(12); // percent
|
||
|
||
const filteredHotspots = useMemo(() => {
|
||
return hotspots
|
||
.filter((h) => (district === "All districts" ? true : h.district === district))
|
||
.filter((h) => (category === "All categories" ? true : h.category === category))
|
||
.filter((h) => {
|
||
if (!query.trim()) return true;
|
||
const q = query.toLowerCase();
|
||
return (
|
||
h.title.toLowerCase().includes(q) ||
|
||
h.location.toLowerCase().includes(q) ||
|
||
h.id.toLowerCase().includes(q)
|
||
);
|
||
});
|
||
}, [district, category, query]);
|
||
|
||
const suggestedProviders = useMemo(() => {
|
||
if (!selectedHotspot) return [];
|
||
const c = selectedHotspot.category;
|
||
const d = selectedHotspot.district;
|
||
return providers
|
||
.filter((p) => p.category === c)
|
||
.sort((a, b) => {
|
||
const aMatch = a.district === d ? 0 : 1;
|
||
const bMatch = b.district === d ? 0 : 1;
|
||
if (aMatch !== bMatch) return aMatch - bMatch;
|
||
return a.etaHours - b.etaHours;
|
||
})
|
||
.slice(0, 4);
|
||
}, [selectedHotspot]);
|
||
|
||
const simulated = useMemo(() => {
|
||
// ultra-simplified “scenario”: more budget reduces median resolution and SLA breaches
|
||
const factor = budgetBoost / 100;
|
||
const median = Math.round(kpisBase.medianResolveHours *(1 - 0.35* factor));
|
||
const breaches = Math.round(kpisBase.slaBreaches *(1 - 0.55* factor));
|
||
const satisfaction = Math.round((kpisBase.citizenSatisfaction + 0.35 *factor)* 10) / 10;
|
||
return {
|
||
medianResolveHours: clamp(median, 18, 60),
|
||
slaBreaches: clamp(breaches, 8, 80),
|
||
citizenSatisfaction: clamp(satisfaction, 3.2, 4.8),
|
||
};
|
||
}, [budgetBoost]);
|
||
|
||
return (
|
||
<div className="min-h-screen w-full bg-background">
|
||
<div className="mx-auto max-w-7xl px-4 py-6">
|
||
{/*Header*/}
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1">
|
||
<div className="flex items-center gap-2">
|
||
<div className="rounded-2xl border bg-background p-2 shadow-sm">
|
||
<Wrench className="h-5 w-5" />
|
||
</div>
|
||
<h1 className="text-xl font-semibold tracking-tight">Turash — City Management Cockpit</h1>
|
||
<Badge variant="outline" className="rounded-full">Prototype</Badge>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
Operational awareness + vendor routing + economic insight (one thin layer, no 12‑month IT project).
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<div className="flex items-center gap-2 rounded-2xl border bg-background px-3 py-2 shadow-sm">
|
||
<div className="text-xs text-muted-foreground">Public dashboard</div>
|
||
<Switch checked={publicMode} onCheckedChange={setPublicMode} />
|
||
</div>
|
||
<Button variant="secondary" className="rounded-2xl">
|
||
Export briefing
|
||
</Button>
|
||
<Button className="rounded-2xl">Create work order</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-12">
|
||
<Card className="md:col-span-12 shadow-sm">
|
||
<CardContent className="p-4">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="flex flex-1 flex-col gap-3 md:flex-row md:items-center">
|
||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Filter className="h-4 w-4" />
|
||
<span>Filters</span>
|
||
</div>
|
||
<div className="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
|
||
<Select value={district} onValueChange={setDistrict}>
|
||
<SelectTrigger className="rounded-2xl">
|
||
<SelectValue placeholder="District" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{DISTRICTS.map((d) => (
|
||
<SelectItem key={d} value={d}>{d}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Select value={category} onValueChange={setCategory}>
|
||
<SelectTrigger className="rounded-2xl">
|
||
<SelectValue placeholder="Category" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{CATEGORIES.map((c) => (
|
||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex w-full items-center gap-2 md:w-[360px]">
|
||
<div className="relative w-full">
|
||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Search issue id, location, title…"
|
||
className="rounded-2xl pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||
<span className="inline-flex items-center gap-1">
|
||
<Clock className="h-3.5 w-3.5" /> Last updated: just now (mock)
|
||
</span>
|
||
<Separator orientation="vertical" className="h-4" />
|
||
<span className="inline-flex items-center gap-1">
|
||
<CheckCircle2 className="h-3.5 w-3.5" /> Sources: 311 intake, permits, vendor registry
|
||
</span>
|
||
<Separator orientation="vertical" className="h-4" />
|
||
<span className="inline-flex items-center gap-1">
|
||
<ShieldAlert className="h-3.5 w-3.5" /> Data quality: medium (2 integrations pending)
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* KPIs */}
|
||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<SmallKPI
|
||
icon={TrendingUp}
|
||
label="Opened today"
|
||
value={kpisBase.openedToday}
|
||
hint="Citizen + sensor intake"
|
||
/>
|
||
<SmallKPI
|
||
icon={CheckCircle2}
|
||
label="Closed today"
|
||
value={kpisBase.closedToday}
|
||
hint="Includes contractor completions"
|
||
tone="good"
|
||
/>
|
||
<SmallKPI
|
||
icon={Clock}
|
||
label="Median resolve time"
|
||
value={`${simulated.medianResolveHours}h`}
|
||
hint={`Scenario: +${budgetBoost}% ops budget`}
|
||
/>
|
||
<SmallKPI
|
||
icon={AlertTriangle}
|
||
label="SLA breaches"
|
||
value={simulated.slaBreaches}
|
||
hint="Requires escalation"
|
||
tone="danger"
|
||
/>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="mt-4">
|
||
<Tabs defaultValue="pulse" className="w-full">
|
||
<TabsList className="rounded-2xl">
|
||
<TabsTrigger value="pulse" className="rounded-xl">Pulse</TabsTrigger>
|
||
<TabsTrigger value="service" className="rounded-xl">Service performance</TabsTrigger>
|
||
<TabsTrigger value="economy" className="rounded-xl">Economy & supply</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* PULSE */}
|
||
<TabsContent value="pulse" className="mt-4">
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
|
||
<Card className="lg:col-span-7 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Incident hotspots (map)</CardTitle>
|
||
<CardDescription>
|
||
Heatmap + what changed vs last week. Click a hotspot to route work.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="relative overflow-hidden rounded-2xl border bg-muted">
|
||
<div className="absolute inset-0 opacity-60" />
|
||
<div className="p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium">Map placeholder</div>
|
||
<Badge variant="outline" className="rounded-full inline-flex items-center gap-1">
|
||
<MapPin className="h-3.5 w-3.5" /> {district}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-2 gap-2 md:grid-cols-4">
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Heat</div>
|
||
<div className="text-lg font-semibold">↑ 12%</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">New clusters</div>
|
||
<div className="text-lg font-semibold">3</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Critical</div>
|
||
<div className="text-lg font-semibold">7</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Repeat sites</div>
|
||
<div className="text-lg font-semibold">{kpisBase.repeatIncidents}</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 text-xs text-muted-foreground">
|
||
In real deployments, this panel uses your GIS tile server or a lightweight map SDK.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="lg:col-span-5 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Top hotspots</CardTitle>
|
||
<CardDescription>Sorted by priority × age × repeats</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{filteredHotspots.map((h) => (
|
||
<button
|
||
key={h.id}
|
||
onClick={() => setSelectedHotspot(h)}
|
||
className="w-full rounded-2xl border bg-background p-3 text-left shadow-sm transition hover:shadow"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-sm font-medium">{h.title}</div>
|
||
{priorityBadge(h.priority)}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
<span className="inline-flex items-center gap-1"><MapPin className="h-3.5 w-3.5" /> {h.location}</span>
|
||
<span className="mx-2">•</span>
|
||
<span>{h.district}</span>
|
||
<span className="mx-2">•</span>
|
||
<span>{h.category}</span>
|
||
</div>
|
||
</div>
|
||
<ChevronRight className="mt-1 h-4 w-4 text-muted-foreground" />
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||
<div className="rounded-xl border bg-muted/30 px-2 py-1">
|
||
<div className="text-[10px] uppercase text-muted-foreground">Age</div>
|
||
<div className="text-sm font-semibold">{h.ageDays}d</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-muted/30 px-2 py-1">
|
||
<div className="text-[10px] uppercase text-muted-foreground">Repeats</div>
|
||
<div className="text-sm font-semibold">{h.repeats}</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-muted/30 px-2 py-1">
|
||
<div className="text-[10px] uppercase text-muted-foreground">ID</div>
|
||
<div className="text-sm font-semibold">{h.id}</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
|
||
{filteredHotspots.length === 0 ? (
|
||
<div className="rounded-2xl border p-6 text-center text-sm text-muted-foreground">
|
||
No hotspots match the current filters.
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Side panel */}
|
||
<AnimatePresence>
|
||
{selectedHotspot ? (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: 12 }}
|
||
className="mt-3"
|
||
>
|
||
<Card className="shadow-sm">
|
||
<CardHeader>
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-1">
|
||
<CardTitle className="text-base">Selected hotspot</CardTitle>
|
||
<CardDescription>
|
||
Route work to local providers; track completion + citizen satisfaction.
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
className="rounded-2xl"
|
||
onClick={() => setSelectedHotspot(null)}
|
||
>
|
||
Close
|
||
</Button>
|
||
<Button className="rounded-2xl">Dispatch</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
|
||
<div className="lg:col-span-7">
|
||
<div className="rounded-2xl border bg-background p-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-lg font-semibold tracking-tight">{selectedHotspot.title}</div>
|
||
{priorityBadge(selectedHotspot.priority)}
|
||
<Badge variant="outline" className="rounded-full">{selectedHotspot.id}</Badge>
|
||
</div>
|
||
|
||
<div className="mt-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
||
<div className="rounded-xl border bg-muted/30 p-3">
|
||
<div className="text-xs text-muted-foreground">Location</div>
|
||
<div className="mt-1 text-sm font-medium inline-flex items-center gap-1">
|
||
<MapPin className="h-4 w-4" /> {selectedHotspot.location}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-muted/30 p-3">
|
||
<div className="text-xs text-muted-foreground">District</div>
|
||
<div className="mt-1 text-sm font-medium">{selectedHotspot.district}</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-muted/30 p-3">
|
||
<div className="text-xs text-muted-foreground">Category</div>
|
||
<div className="mt-1 text-sm font-medium">{selectedHotspot.category}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-2 gap-2 md:grid-cols-4">
|
||
<div className="rounded-xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Age</div>
|
||
<div className="mt-1 text-sm font-semibold">{selectedHotspot.ageDays} days</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Repeats</div>
|
||
<div className="mt-1 text-sm font-semibold">{selectedHotspot.repeats}</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">SLA</div>
|
||
<div className="mt-1 text-sm font-semibold">{selectedHotspot.priority === "Critical" ? "24h" : "72h"}</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Escalation</div>
|
||
<div className="mt-1 text-sm font-semibold">{selectedHotspot.ageDays > 7 ? "Yes" : "No"}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-2xl border bg-muted/20 p-4">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4" />
|
||
<div>
|
||
<div className="text-sm font-medium">Root cause hint</div>
|
||
<div className="text-xs text-muted-foreground mt-1">
|
||
This is the kind of “repeat incident” flag city leadership reacts to: same site keeps resurfacing.
|
||
Turash can propose a systemic fix and show cost-to-serve if left unresolved.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="lg:col-span-5">
|
||
<div className="rounded-2xl border bg-background p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-semibold">Suggested providers</div>
|
||
<Badge variant="outline" className="rounded-full inline-flex items-center gap-1">
|
||
<Building2 className="h-3.5 w-3.5" /> Local-first
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-3 space-y-2">
|
||
{suggestedProviders.map((p) => (
|
||
<div key={p.id} className="rounded-2xl border bg-muted/20 p-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-medium">{p.name}</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
{p.category} • {p.district} • {p.license}
|
||
</div>
|
||
</div>
|
||
<Badge className="rounded-full">ETA {formatHours(p.etaHours)}</Badge>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
|
||
<div className="rounded-xl border bg-background px-2 py-1">
|
||
<div className="text-[10px] uppercase text-muted-foreground">Rating</div>
|
||
<div className="font-semibold">{p.rating}</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-background px-2 py-1">
|
||
<div className="text-[10px] uppercase text-muted-foreground">Capacity</div>
|
||
<div className="font-semibold">{p.capacity}</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-background px-2 py-1">
|
||
<div className="text-[10px] uppercase text-muted-foreground">ID</div>
|
||
<div className="font-semibold">{p.id}</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button size="sm" className="rounded-2xl">Assign</Button>
|
||
<Button size="sm" variant="secondary" className="rounded-2xl">Broadcast</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{suggestedProviders.length === 0 ? (
|
||
<div className="rounded-2xl border p-4 text-sm text-muted-foreground">
|
||
No matching providers found. (This is a procurement-gap signal.)
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Micro-procurement mode</div>
|
||
<div className="mt-1 text-sm">
|
||
Turn small jobs into fast routing — without replacing the city’s procurement system.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
) : null}
|
||
</AnimatePresence>
|
||
</TabsContent>
|
||
|
||
{/* SERVICE PERFORMANCE */}
|
||
<TabsContent value="service" className="mt-4">
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
|
||
<Card className="lg:col-span-7 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Median resolution time (4 weeks)</CardTitle>
|
||
<CardDescription>What leadership cares about: trend direction + exceptions</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="h-[300px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart data={trendResolve} margin={{ top: 10, right: 12, bottom: 0, left: -10 }}>
|
||
<XAxis dataKey="week" tickLine={false} axisLine={false} />
|
||
<YAxis tickLine={false} axisLine={false} />
|
||
<Tooltip />
|
||
<Line type="monotone" dataKey="hours" stroke="currentColor" strokeWidth={2} dot={false} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="lg:col-span-5 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Backlog aging</CardTitle>
|
||
<CardDescription>Where the system is silently failing</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="h-[300px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<BarChart data={backlogAging} margin={{ top: 10, right: 12, bottom: 0, left: -10 }}>
|
||
<XAxis dataKey="bucket" tickLine={false} axisLine={false} />
|
||
<YAxis tickLine={false} axisLine={false} />
|
||
<Tooltip />
|
||
<Bar dataKey="count" fill="currentColor" radius={[12, 12, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-12">
|
||
<Card className="lg:col-span-7 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Department performance (example)</CardTitle>
|
||
<CardDescription>Simple, comparable, action-driving metrics</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<RowBar label="Waste ops (closed / opened)" left={63} right={78} />
|
||
<RowBar label="Road maintenance (closed / opened)" left={44} right={61} />
|
||
<RowBar label="Lighting (closed / opened)" left={36} right={29} />
|
||
<RowBar label="Parking enforcement (closed / opened)" left={22} right={48} />
|
||
<div className="rounded-2xl border bg-muted/20 p-4 text-sm">
|
||
<div className="flex items-start gap-2">
|
||
<ShieldAlert className="mt-0.5 h-4 w-4" />
|
||
<div>
|
||
<div className="font-medium">Escalation rule (example)</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
If backlog 30d+ grows for 2 consecutive weeks → auto-create an escalation brief with cost & hotspot list.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="lg:col-span-5 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Scenario slider</CardTitle>
|
||
<CardDescription>Budget → measurable outcomes (simple, honest)</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium">Ops budget increase</div>
|
||
<Badge className="rounded-full">+{budgetBoost}%</Badge>
|
||
</div>
|
||
<input
|
||
className="w-full"
|
||
type="range"
|
||
min={0}
|
||
max={30}
|
||
value={budgetBoost}
|
||
onChange={(e) => setBudgetBoost(parseInt(e.target.value, 10))}
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 gap-2">
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Median resolve time</div>
|
||
<div className="mt-1 text-lg font-semibold">{simulated.medianResolveHours}h</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">SLA breaches</div>
|
||
<div className="mt-1 text-lg font-semibold">{simulated.slaBreaches}</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Citizen satisfaction</div>
|
||
<div className="mt-1 text-lg font-semibold">{simulated.citizenSatisfaction} / 5</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border bg-muted/20 p-4 text-xs text-muted-foreground">
|
||
This stays credible if you keep it conservative and show confidence bands once you have data.
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* ECONOMY & SUPPLY */}
|
||
<TabsContent value="economy" className="mt-4">
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-12">
|
||
<Card className="lg:col-span-7 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Demand–Supply Gap (by district × category)</CardTitle>
|
||
<CardDescription>
|
||
Your wedge: connect city needs to local supplier capacity — and prove “local-first” works.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{demandSupply
|
||
.filter((r) => (district === "All districts" ? true : r.district === district))
|
||
.filter((r) => (category === "All categories" ? true : r.category === category))
|
||
.map((r, idx) => (
|
||
<div key={idx} className="rounded-2xl border bg-background p-3">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-sm font-semibold">{r.district}</div>
|
||
<Badge variant="outline" className="rounded-full">{r.category}</Badge>
|
||
<Badge className="rounded-full inline-flex items-center gap-1">
|
||
<TrendingUp className="h-3.5 w-3.5" /> Gap {r.gap > 0 ? `+${r.gap}` : r.gap}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">{r.note}</div>
|
||
</div>
|
||
|
||
<div className="w-full md:w-[220px]">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="rounded-xl border bg-muted/20 px-2 py-2">
|
||
<div className="text-[10px] uppercase text-muted-foreground">Demand</div>
|
||
<div className="text-sm font-semibold">{r.demandScore}</div>
|
||
</div>
|
||
<div className="rounded-xl border bg-muted/20 px-2 py-2">
|
||
<div className="text-[10px] uppercase text-muted-foreground">Supply</div>
|
||
<div className="text-sm font-semibold">{r.supplyScore}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<Truck className="h-4 w-4" /> Operational implication
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
High gap → longer ETAs and SLA breaches unless you re-balance coverage.
|
||
</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<Building2 className="h-4 w-4" /> Economic implication
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
Gap is a target list for SME support / attracting providers.
|
||
</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<Wrench className="h-4 w-4" /> Suggested action
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
Broadcast micro-jobs + verify local suppliers + track completion quality.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="rounded-2xl border bg-muted/20 p-4 text-xs text-muted-foreground">
|
||
If this becomes real: your hardest part is *vendor registry accuracy* (licenses, categories, coverage, availability).
|
||
Don’t fake it — design the workflow to improve data quality over time.
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="lg:col-span-5 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Business ecosystem signals</CardTitle>
|
||
<CardDescription>Politically useful, but must be defensible</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="grid grid-cols-1 gap-2">
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium inline-flex items-center gap-2">
|
||
<Building2 className="h-4 w-4" /> New openings (30d)
|
||
</div>
|
||
<div className="text-lg font-semibold">+28</div>
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
Most: food service, repairs, local retail
|
||
</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium inline-flex items-center gap-2">
|
||
<Lightbulb className="h-4 w-4" /> Permit pipeline
|
||
</div>
|
||
<div className="text-lg font-semibold">312</div>
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
Avg approval: 9.6 days • 18 blocked
|
||
</div>
|
||
</div>
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium inline-flex items-center gap-2">
|
||
<TrendingUp className="h-4 w-4" /> Local-first share
|
||
</div>
|
||
<div className="text-lg font-semibold">62%</div>
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
Share of routed work completed by local providers
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border bg-muted/20 p-4">
|
||
<div className="text-sm font-medium">What makes this credible</div>
|
||
<ul className="mt-2 list-disc pl-5 text-xs text-muted-foreground space-y-1">
|
||
<li>Show your sources (permits, registry, verified vendors)</li>
|
||
<li>Separate “estimated” from “measured”</li>
|
||
<li>Keep numbers conservative until you have time-series evidence</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border bg-background p-3">
|
||
<div className="text-xs text-muted-foreground">Next best feature for Turash</div>
|
||
<div className="mt-1 text-sm">
|
||
“Broadcast micro-jobs” + vendor verification loop → grows supply data as a side effect.
|
||
</div>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button size="sm" className="rounded-2xl">Open vendor registry</Button>
|
||
<Button size="sm" variant="secondary" className="rounded-2xl">View gaps</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
|
||
{/* Footer note */}
|
||
<div className="mt-6 text-xs text-muted-foreground">
|
||
Prototype intent: demonstrate the *feel* and “why it wins.” Replace mock data with your APIs, then keep the surface area small.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
Which information do you think city management should be seing and what would be useful and appealing for turash project
|
||
Thought for 8s
|
||
|
||
City management doesn’t want “more data”. They want situational awareness + accountability + budget justification in one screen. If Turash can give them that (without a 12-month IT project), it’s appealing.
|
||
|
||
What they should be seeing (the “executive cockpit”)
|
||
|
||
These are the few things mayors / city managers actually act on:
|
||
|
||
City pulse (today / this week)
|
||
|
||
of citizen issues opened/closed (311-style), backlog, SLA breaches
|
||
|
||
Top 5 problem categories trending (waste, roads, lighting, noise, parking)
|
||
|
||
Hotspots on a map (heatmap + “what changed vs last week”)
|
||
|
||
Service performance (accountability)
|
||
|
||
Median time-to-resolve by department and category
|
||
|
||
“Aging” of open issues (0–2d, 3–7d, 8–30d, 30+d)
|
||
|
||
Repeat incidents (same location/category reappearing = systemic failure)
|
||
|
||
Budget impact (the language of decisions)
|
||
|
||
Cost-to-serve per category (even rough ranges)
|
||
|
||
Overtime spend, contractor spend, unit costs (€/pothole, €/lamp repair)
|
||
|
||
“If we fund X, SLA improves by Y” (simple scenario sliders)
|
||
|
||
Economic activity (politically valuable)
|
||
|
||
New business openings/closures by district
|
||
|
||
Permit pipeline (submitted/approved/blocked + time in each stage)
|
||
|
||
Local business engagement: demand vs supply gaps (more below)
|
||
|
||
Risk & compliance (things that explode later)
|
||
|
||
Safety hazards unresolved (sidewalk/lighting/traffic)
|
||
|
||
Critical infrastructure incidents (water leaks, power issues)
|
||
|
||
Compliance queue (inspections, deadlines, renewals)
|
||
|
||
What’s uniquely useful for Turash (your edge)
|
||
|
||
Turash sounds like a city CRM + local business graph. Your unfair advantage is connecting problems → local suppliers → resolution.
|
||
|
||
City management will care about:
|
||
|
||
A) “Demand–Supply Gap” map (killer feature)
|
||
|
||
Show where citizens/municipal needs exist but local supply is missing or overloaded:
|
||
|
||
Example: lots of complaints about waste pickup in District A + few active waste contractors there
|
||
|
||
Or: high renovation permits + not enough licensed electricians
|
||
|
||
This is gold for:
|
||
|
||
Economic development (attract suppliers)
|
||
|
||
Procurement strategy (reduce response time)
|
||
|
||
Policy (support training programs in shortage trades)
|
||
|
||
B) Procurement-lite routing (without calling it procurement)
|
||
|
||
Cities hate “new procurement systems”. But they love:
|
||
|
||
“Suggested local vendors for this job category (licensed, available, near)”
|
||
|
||
“Broadcast small jobs to verified local providers”
|
||
|
||
“Track acceptance, completion, and citizen satisfaction”
|
||
|
||
That’s a real operational wedge.
|
||
|
||
C) Business ecosystem health (simple, visual, political)
|
||
|
||
“Top 10 categories growing / shrinking”
|
||
|
||
“Vacancy signals” (if you can proxy: listings, footfall, complaints, closures)
|
||
|
||
“Local spend kept local” estimate (even directional)
|
||
|
||
The 12 widgets that sell the product in 5 minutes
|
||
|
||
If you want a demo dashboard that lands, build these first:
|
||
|
||
Open issues today / closed today
|
||
|
||
SLA breach counter + top violating departments
|
||
|
||
Backlog aging histogram
|
||
|
||
Heatmap of incidents with category filters
|
||
|
||
Repeat-problem locations list (top 20)
|
||
|
||
Median resolution time trend (4-week)
|
||
|
||
Permit funnel + average approval time
|
||
|
||
Business openings/closures by district
|
||
|
||
Demand–supply gap map by category
|
||
|
||
“Suggested providers” for a selected issue (with distance/licensing)
|
||
|
||
Citizen satisfaction score after closure (1–5)
|
||
|
||
Cost estimate: “this month’s service workload” vs last month
|
||
|
||
Hard truth: cities won’t integrate 20 systems for your MVP
|
||
|
||
Assumption to kill early: “we’ll ingest all city data”. You won’t—at least not at first.
|
||
|
||
Win with a thin layer:
|
||
|
||
Start with one workflow (e.g., citizen issues + routing to local contractors)
|
||
|
||
Use manual import / CSV / email parsing / simple API initially
|
||
|
||
Prove: backlog down, resolution faster, local providers engaged
|
||
|
||
Once they see impact, integration budgets appear.
|
||
|
||
What will be “appealing” internally (how they justify buying)
|
||
|
||
Frame your outputs as:
|
||
|
||
SLA improvement (measurable)
|
||
|
||
Lower cost-to-serve (even directional)
|
||
|
||
Economic uplift (support local SMEs, reduce outside contractors)
|
||
|
||
Transparency (public dashboard option, if they’re brave)
|
||
|
||
If you tell me who the buyer is (mayor’s office, economic development, operations, or a “smart city” unit) and what Turash currently has (business directory only? CRM? issue tracking?), I’ll pin this down into a tight MVP scope and a demo flow that closes.
|
||
|
||
Can you design example in web canvas to get the feel
|
||
Thought for 12s
|
||
|
||
Done — I sketched a Turash “City Management Cockpit” React prototype in canvas: filters (district/category/search), KPI row, and 3 tabs:
|
||
|
||
Pulse: hotspot list + “map” placeholder + click-to-open routing panel with suggested providers
|
||
|
||
Service performance: resolution-time trend + backlog aging + a conservative scenario slider (budget → SLA impact)
|
||
|
||
Economy & supply: Demand–Supply Gap view + “business signals” card (defensible, not fluffy)
|
||
|
||
Tell me which persona you want to optimize for first (Mayor / Ops director / Economic development / IT) and I’ll reshape the layout and the “default story” of the dashboard accordingly.
|
||
|
||
ChatGPT can make mistakes. Check important info. See Cookie Preferences.
|