first commit

This commit is contained in:
2026-09-16 20:11:27 +05:30
commit 87808621b9
81 changed files with 15969 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
import { CalendarClock, CircleSlash, Coffee, UserCheck, Users } from "lucide-react"
import { useMemo, useState } from "react"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge, StatusDot } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { AVAILABILITY, STAFF_COUNTS, TOMORROW, UPCOMING_SHIFTS } from "@/data/mockData"
import { percentOfStaff, staffCoverage } from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
export function AvailabilityPage() {
useWorkforceRevision()
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const rows = useMemo(
() =>
AVAILABILITY.filter(
(a) =>
(status === "all" || a.status === status) &&
(query === "" ||
`${a.name} ${a.role} ${a.assignedTo ?? ""}`
.toLowerCase()
.includes(query.toLowerCase()))
),
[query, status]
)
return (
<Page>
<PageIntro
title="Staff Availability"
description="Who is on shift, who is on break and who can still be allocated today."
actions={<Button variant="outline">Request availability</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Active Now"
value={String(STAFF_COUNTS.active)}
sub={`${staffCoverage()}% of tracked staff`}
icon={UserCheck}
badge="On shift"
badgeTone="good"
tone="primary"
progress={staffCoverage()}
/>
<MetricCard
label="On Break"
value={String(STAFF_COUNTS.onBreak)}
sub={`${percentOfStaff(STAFF_COUNTS.onBreak)}% of tracked staff`}
icon={Coffee}
badge="Returning"
badgeTone="warn"
tone="primary"
/>
<MetricCard
label="Unavailable"
value={String(STAFF_COUNTS.unavailable)}
sub={`${percentOfStaff(STAFF_COUNTS.unavailable)}% of tracked staff`}
icon={CircleSlash}
badge="Off duty"
badgeTone="bad"
tone={STAFF_COUNTS.unavailable > 0 ? "warn" : "primary"}
/>
<MetricCard
label="Total Roster"
value={String(STAFF_COUNTS.total)}
sub="Across 3 staffing pools"
icon={Users}
tone="primary"
/>
</div>
<SectionCard
title="Availability & Allocation"
icon={Users}
bodyClassName="p-0"
>
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search staff, role or location…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Active", "On Break", "Unavailable"],
},
]}
/>
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Role</TableHead>
<TableHead>Available</TableHead>
<TableHead>Allocated to</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((a) => (
<TableRow key={a.staffId}>
<TableCell className="font-medium text-ink">{a.name}</TableCell>
<TableCell>{a.role}</TableCell>
<TableCell className="whitespace-nowrap">
{a.from === "—" ? (
<span className="text-ink-3">{a.note}</span>
) : (
`${a.from} – ${a.to}`
)}
</TableCell>
<TableCell>
{a.assignedTo ?? <span className="text-ink-3">Unallocated</span>}
</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<StatusDot value={a.status} />
<StatusBadge value={a.status} size="sm" />
</span>
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-ink-3">
No staff match those filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</SectionCard>
<div className="grid gap-3 md:grid-cols-2">
<SectionCard title="Upcoming Shifts" icon={CalendarClock} bodyClassName="p-3">
<div className="space-y-2">
{UPCOMING_SHIFTS.map((s) => (
<div
key={s.ref}
className="flex items-center gap-3 rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2.5"
>
<span className="grid size-9 shrink-0 place-items-center rounded-[9px] border border-primary-edge bg-primary-soft text-[14px] font-bold text-primary">
{s.count}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-ink">{s.when}</div>
<div className="truncate text-[12px] text-ink-3">{s.location}</div>
</div>
</div>
))}
</div>
</SectionCard>
<SectionCard title="Tomorrow's Roster" icon={CalendarClock} bodyClassName="p-3">
<div className="space-y-2">
{TOMORROW.map((t) => (
<div
key={t.name}
className="flex items-center gap-2.5 rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-ink">{t.name}</div>
<div className="truncate text-[12px] text-ink-3">
{t.role} · {t.shift}
</div>
</div>
<StatusBadge
value={t.status === "Unconfirmed" ? "Review" : t.status}
size="sm"
/>
</div>
))}
</div>
</SectionCard>
</div>
</Page>
)
}

204
src/pages/BenefitsPage.tsx Normal file
View File

@@ -0,0 +1,204 @@
import { Banknote, CalendarClock, Gift, UserCheck } from "lucide-react"
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { BENEFITS, STAFF, STAFF_COUNTS } from "@/data/mockData"
import { money } from "@/data/selectors"
const TIER_ORDER = ["Enhanced", "Standard", "Basic"]
export function BenefitsPage() {
const enrolled = BENEFITS.reduce((s, b) => s + b.enrolled, 0)
const eligible = BENEFITS.reduce((s, b) => s + b.eligible, 0)
const cost = BENEFITS.reduce((s, b) => s + b.monthlyCost, 0)
const inReview = BENEFITS.filter((b) => b.status === "Review")
const SHORT_NAME: Record<string, string> = {
"BEN-01": "Health",
"BEN-02": "Pension",
"BEN-03": "Holiday",
"BEN-04": "EAP",
"BEN-05": "Cycle",
"BEN-06": "PPE",
}
const chartData = BENEFITS.map((b) => ({
name: SHORT_NAME[b.id] ?? b.name,
Enrolled: b.enrolled,
Eligible: b.eligible,
}))
return (
<Page>
<PageIntro
title="Benefits"
description="Plan enrolment, eligibility and employer cost across the workforce."
actions={<Button variant="outline">Manage plans</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Total Enrolments"
value={String(enrolled)}
sub={`Across ${BENEFITS.length} active plans`}
icon={UserCheck}
badge="Live"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Eligibility Take-up"
value={`${Math.round((enrolled / eligible) * 100)}%`}
sub={`${enrolled} of ${eligible} eligible places`}
icon={Gift}
tone="primary"
progress={Math.round((enrolled / eligible) * 100)}
/>
<MetricCard
label="Monthly Cost"
value={money(cost)}
sub="Employer funded contribution"
icon={Banknote}
badge="Recurring"
badgeTone="neutral"
tone="primary"
/>
<MetricCard
label="Plans in Review"
value={String(inReview.length)}
sub="Renewal decisions pending"
icon={CalendarClock}
badge={inReview.length ? "Review" : "Clear"}
badgeTone={inReview.length ? "warn" : "good"}
tone={inReview.length ? "warn" : "primary"}
/>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<SectionCard title="Enrolment vs Eligibility" icon={Gift} className="xl:col-span-2">
<div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 10.5, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
interval={0}
/>
<YAxis
tick={{ fontSize: 11, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
/>
<Tooltip
cursor={{ fill: "rgba(29,78,216,.06)" }}
contentStyle={{
borderRadius: 10,
border: "1px solid #e4ebf6",
fontSize: 12,
}}
/>
<Bar dataKey="Eligible" fill="#dce8fe" radius={[5, 5, 0, 0]} maxBarSize={30} />
<Bar dataKey="Enrolled" fill="#1d4ed8" radius={[5, 5, 0, 0]} maxBarSize={30} />
</BarChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Benefit Tiers" icon={UserCheck} bodyClassName="p-4">
<div className="space-y-3.5">
{TIER_ORDER.map((tier) => {
const count = STAFF.filter((s) => s.benefitsTier === tier).length
const pct = Math.round((count / STAFF.length) * 100)
return (
<div key={tier}>
<div className="mb-1.5 flex items-baseline justify-between gap-2">
<span className="text-[13px] font-semibold text-ink">{tier}</span>
<span className="text-[12px] text-ink-3">
{count} staff · {pct}%
</span>
</div>
<Progress value={pct} />
</div>
)
})}
<p className="border-t border-line-soft pt-3 text-[12px] text-ink-3">
Tier is assigned on contract type and length of service across the{" "}
{STAFF_COUNTS.total}-person roster.
</p>
</div>
</SectionCard>
</div>
<SectionCard title="Benefit Plans" icon={Gift} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Plan</TableHead>
<TableHead>Category</TableHead>
<TableHead>Enrolled</TableHead>
<TableHead>Eligible</TableHead>
<TableHead>Take-up</TableHead>
<TableHead>Monthly cost</TableHead>
<TableHead>Renewal</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{BENEFITS.map((b) => {
const pct = Math.round((b.enrolled / b.eligible) * 100)
return (
<TableRow key={b.id}>
<TableCell className="font-medium text-ink">{b.name}</TableCell>
<TableCell>
<Badge tone="neutral" size="sm">
{b.category}
</Badge>
</TableCell>
<TableCell className="font-semibold text-ink">{b.enrolled}</TableCell>
<TableCell>{b.eligible}</TableCell>
<TableCell>
<div className="w-[84px]">
<div className="mb-1 text-[12px] font-semibold text-ink">{pct}%</div>
<Progress value={pct} className="h-1" />
</div>
</TableCell>
<TableCell className="font-semibold text-ink">
{money(b.monthlyCost)}
</TableCell>
<TableCell className="whitespace-nowrap text-ink-3">{b.renewal}</TableCell>
<TableCell>
<StatusBadge value={b.status} size="sm" />
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</SectionCard>
</Page>
)
}

215
src/pages/ClientsPage.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { AlertTriangle, Banknote, DollarSign, Store, Users } from "lucide-react"
import { useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CLIENTS } from "@/data/mockData"
import { clientsAtRisk, money } from "@/data/selectors"
import type { Client } from "@/data/types"
const RATE_CARD = [
{ role: "Event Steward", standard: 24.5, overtime: 32.0, night: 28.5 },
{ role: "Bar Supervisor", standard: 27.0, overtime: 35.5, night: 31.0 },
{ role: "Housekeeper", standard: 22.0, overtime: 29.0, night: 25.5 },
{ role: "Cleaning Operative", standard: 21.5, overtime: 28.0, night: 24.5 },
{ role: "Forklift Driver", standard: 26.0, overtime: 34.0, night: 30.0 },
{ role: "Warehouse Operative", standard: 23.0, overtime: 30.0, night: 26.5 },
{ role: "Shift Supervisor", standard: 29.5, overtime: 38.5, night: 33.5 },
]
export function ClientsPage() {
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "accounts"
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const filtered = useMemo(
() => CLIENTS.filter((c) => status === "all" || c.accountStatus === status),
[status]
)
const totalRevenue = CLIENTS.reduce((s, c) => s + c.revenueYtd, 0)
const totalDemand = CLIENTS.reduce((s, c) => s + c.staffDemand, 0)
const activeOrders = CLIENTS.reduce((s, c) => s + c.activeOrders, 0)
const columns = useMemo<DemoColumnDef<Client>[]>(
() => [
{
accessorKey: "name",
header: "Client",
cell: (c) => (
<div className="min-w-0">
<div className="truncate font-semibold text-ink">{c.row.original.name}</div>
<div className="truncate text-[12px] text-ink-3">
{c.row.original.id} · since {c.row.original.since}
</div>
</div>
),
},
{
accessorKey: "venues",
header: "Venues",
cell: (c) => (
<div className="flex w-[176px] flex-wrap gap-1">
{c.row.original.venues.map((v) => (
<Badge key={v} tone="neutral" size="sm">
{v}
</Badge>
))}
</div>
),
},
{
accessorKey: "activeOrders",
header: "Active orders",
cell: (c) => (
<span className="font-semibold text-ink">{c.getValue() as number}</span>
),
},
{
accessorKey: "staffDemand",
header: "Staff demand",
cell: (c) => `${c.getValue() as number} places`,
},
{
accessorKey: "revenueYtd",
header: "Revenue YTD",
cell: (c) => (
<span className="font-semibold text-ink">{money(c.getValue() as number)}</span>
),
},
{
accessorKey: "accountStatus",
header: "Status",
cell: (c) => <StatusBadge value={c.getValue() as string} size="sm" />,
},
{ accessorKey: "owner", header: "Account owner" },
],
[]
)
return (
<Page>
<PageIntro
title="Clients"
description="Account health, venues, staffing demand and revenue for every client."
actions={<Button variant="outline">Add client</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Client Accounts"
value={String(CLIENTS.length)}
sub={`${CLIENTS.reduce((s, c) => s + c.venues.length, 0)} venues served`}
icon={Store}
tone="primary"
/>
<MetricCard
label="Revenue YTD"
value={money(totalRevenue)}
sub="Across all accounts"
icon={Banknote}
badge="Gross"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Staffing Demand"
value={String(totalDemand)}
sub={`${activeOrders} active orders`}
icon={Users}
tone="primary"
/>
<MetricCard
label="Accounts at Risk"
value={String(clientsAtRisk().length)}
sub="At risk or on hold"
icon={AlertTriangle}
badge="Review"
badgeTone="warn"
tone={clientsAtRisk().length ? "warn" : "primary"}
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="accounts">
<Store className="size-3.5" />
Client Accounts
</TabsTrigger>
<TabsTrigger value="rates">
<DollarSign className="size-3.5" />
Service Rates
</TabsTrigger>
</TabsList>
<TabsContent value="accounts">
<SectionCard title="Client Directory" icon={Store} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search clients, venues or owners…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Active", "At Risk", "On Hold", "Prospect"],
},
]}
/>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</TabsContent>
<TabsContent value="rates">
<SectionCard title="Service Rate Card" icon={DollarSign} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Role</TableHead>
<TableHead className="text-right">Standard</TableHead>
<TableHead className="text-right">Night</TableHead>
<TableHead className="text-right">Overtime</TableHead>
<TableHead className="text-right">Uplift</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{RATE_CARD.map((r) => (
<TableRow key={r.role}>
<TableCell className="font-medium text-ink">{r.role}</TableCell>
<TableCell className="text-right font-semibold text-ink">
${r.standard.toFixed(2)}
</TableCell>
<TableCell className="text-right">${r.night.toFixed(2)}</TableCell>
<TableCell className="text-right">${r.overtime.toFixed(2)}</TableCell>
<TableCell className="text-right">
<Badge tone="primary" size="sm">
+{Math.round((r.overtime / r.standard - 1) * 100)}%
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

View File

@@ -0,0 +1,294 @@
import {
AlertTriangle,
FileStack,
FolderKanban,
ShieldAlert,
ShieldCheck,
Shirt,
} from "lucide-react"
import { useSearchParams } from "react-router-dom"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { COMPLIANCE_RISKS, STAFF } from "@/data/mockData"
import { complianceFlagCount, staffWithComplianceIssues } from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
const CATALOG = [
{ name: "Right to Work", required: "All staff", holders: 44, of: 46, renewal: "On hire" },
{ name: "Manual Handling", required: "Warehouse & Logistics", holders: 15, of: 16, renewal: "2 years" },
{ name: "Forklift Licence", required: "Forklift Drivers", holders: 3, of: 4, renewal: "3 years" },
{ name: "Food Hygiene L2", required: "Event & Hospitality", holders: 17, of: 18, renewal: "3 years" },
{ name: "Personal Licence", required: "Bar Supervisors", holders: 2, of: 2, renewal: "10 years" },
{ name: "COSHH Awareness", required: "Facilities & Cleaning", holders: 11, of: 12, renewal: "1 year" },
]
const DOCUMENTS = [
{ staff: "Rosa Delgado", doc: "Right to Work", status: "Expired", updated: "Pending upload" },
{ staff: "Aisha Patel", doc: "Policy Acknowledgement", status: "Warning", updated: "Awaiting signature" },
{ staff: "Samuel Okoro", doc: "Manual Handling Certificate", status: "Expired", updated: "Expired 02 Sep 2025" },
{ staff: "Tomas Ferreira", doc: "Forklift Licence", status: "Warning", updated: "Expires 17 Sep 2025" },
{ staff: "Leila Haddad", doc: "Right to Work", status: "Verified", updated: "Verified 12 Jul 2025" },
{ staff: "Marcus Vance", doc: "Supervisor Training", status: "Verified", updated: "Verified 03 Aug 2025" },
]
const ATTIRE = [
{ staff: "Priya Raman", item: "Event uniform · size exchange", due: "05 Oct 2025", status: "Review" },
{ staff: "Liam O'Connor", item: "Bar tunic · replacement", due: "05 Oct 2025", status: "Review" },
{ staff: "Rosa Delgado", item: "Hi-vis & safety boots", due: "05 Oct 2025", status: "Review" },
{ staff: "Owen Bradley", item: "Hi-vis · annual check", due: "18 Nov 2025", status: "Verified" },
]
export function CompliancePage() {
useWorkforceRevision()
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "certificates"
const high = COMPLIANCE_RISKS.filter((r) => r.severity === "High")
const expiringSoon = STAFF.filter((s) => s.compliance === "Warning")
return (
<Page>
<PageIntro
title="Compliance"
description="Certificates, documents, policies and attire checks across the workforce."
actions={<Button variant="outline">Export register</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Open Flags"
value={String(complianceFlagCount())}
sub={`Across ${COMPLIANCE_RISKS.length} risk categories`}
icon={ShieldAlert}
badge="Review"
badgeTone="warn"
tone="warn"
/>
<MetricCard
label="High Severity"
value={String(high.reduce((s, r) => s + r.staffCount, 0))}
sub="Block assignment until cleared"
icon={AlertTriangle}
badge="Urgent"
badgeTone="bad"
tone="bad"
/>
<MetricCard
label="Staff Affected"
value={String(staffWithComplianceIssues().length)}
sub={`of ${STAFF.length} tracked staff`}
icon={ShieldCheck}
tone="primary"
/>
<MetricCard
label="Expiring Soon"
value={String(expiringSoon.length)}
sub="Within the next 14 days"
icon={FileStack}
badge="Watch"
badgeTone="warn"
tone="primary"
/>
</div>
<SectionCard title="Compliance Risk Overview" icon={ShieldAlert} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Risk</TableHead>
<TableHead>Category</TableHead>
<TableHead>Affected staff</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{COMPLIANCE_RISKS.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium text-ink">{r.risk}</TableCell>
<TableCell>
<Badge tone="neutral" size="sm">
{r.category}
</Badge>
</TableCell>
<TableCell>
<div className="w-[168px]" title={r.staffNames.join(", ")}>
<span className="font-semibold text-ink">{r.staffCount}</span>
<div className="truncate text-[12px] text-ink-3">
{r.staffNames.join(", ")}
</div>
</div>
</TableCell>
<TableCell>
<StatusBadge value={r.severity} size="sm" />
</TableCell>
<TableCell className="whitespace-nowrap">{r.expires.replace(" 2025", "")}</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline">
{r.action}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="certificates">
<ShieldCheck className="size-3.5" />
Staff Certificates
</TabsTrigger>
<TabsTrigger value="documents">
<FolderKanban className="size-3.5" />
Employee Documents
</TabsTrigger>
<TabsTrigger value="attire">
<Shirt className="size-3.5" />
Attire Review
</TabsTrigger>
<TabsTrigger value="catalog">
<FileStack className="size-3.5" />
Compliance Catalog
</TabsTrigger>
</TabsList>
<TabsContent value="certificates">
<SectionCard title="Staff Certificates" icon={ShieldCheck} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Role</TableHead>
<TableHead>Standing</TableHead>
<TableHead>Note</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{STAFF.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium text-ink">{s.name}</TableCell>
<TableCell>{s.role}</TableCell>
<TableCell>
<StatusBadge value={s.compliance} size="sm" />
</TableCell>
<TableCell className="text-ink-3">
{s.complianceNote ?? "All certificates current"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="documents">
<SectionCard title="Employee Documents" icon={FolderKanban} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Document</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last update</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{DOCUMENTS.map((d) => (
<TableRow key={`${d.staff}-${d.doc}`}>
<TableCell className="font-medium text-ink">{d.staff}</TableCell>
<TableCell>{d.doc}</TableCell>
<TableCell>
<StatusBadge value={d.status} size="sm" />
</TableCell>
<TableCell className="text-ink-3">{d.updated}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="attire">
<SectionCard title="Attire Review" icon={Shirt} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Item</TableHead>
<TableHead>Due</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ATTIRE.map((a) => (
<TableRow key={`${a.staff}-${a.item}`}>
<TableCell className="font-medium text-ink">{a.staff}</TableCell>
<TableCell>{a.item}</TableCell>
<TableCell className="whitespace-nowrap">{a.due}</TableCell>
<TableCell>
<StatusBadge value={a.status} size="sm" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="catalog">
<SectionCard title="Compliance Catalog" icon={FileStack} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Requirement</TableHead>
<TableHead>Applies to</TableHead>
<TableHead>Coverage</TableHead>
<TableHead>Renewal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{CATALOG.map((c) => {
const complete = c.holders === c.of
return (
<TableRow key={c.name}>
<TableCell className="font-medium text-ink">{c.name}</TableCell>
<TableCell>{c.required}</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<span className="font-semibold text-ink">
{c.holders}/{c.of}
</span>
<StatusBadge
value={complete ? "Verified" : "Warning"}
size="sm"
/>
</span>
</TableCell>
<TableCell className="text-ink-3">{c.renewal}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

40
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { GensparkCommandCapsule } from "@/components/home/GensparkCommandCapsule"
import { GensparkSuiteBar } from "@/components/home/GensparkSuiteBar"
import { GensparkSuperCard } from "@/components/home/GensparkSuperCard"
import { useWorkforceRevision } from "@/data/store"
export function HomePage() {
useWorkforceRevision()
return (
<div className="relative min-h-[calc(100vh-64px)] w-full overflow-hidden px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
{/* ------------------------------------------------ Center Title */}
<div className="pt-2 pb-6 text-center sm:pt-4 sm:pb-8">
<div className="inline-flex items-center gap-2.5 text-[24px] font-bold tracking-tight text-ink sm:gap-3 sm:text-[32px]">
<img
src="/assets/owliver.png"
alt="Owliver Logo"
className="size-8 rounded-[9px] object-cover shadow-xs border border-line sm:size-10 sm:rounded-[12px]"
/>
<span>Owliver AI</span>
</div>
</div>
{/* ------------------------------------------------ Center Search / Command Capsule (Matching Image 2) */}
<div className="pb-8 sm:pb-10">
<GensparkCommandCapsule />
</div>
{/* ------------------------------------------------ Horizontal Suites Bar (Matching Image 2) */}
<div className="pb-8 sm:pb-12">
<GensparkSuiteBar />
</div>
{/* ------------------------------------------------ Bottom Super App Card (Matching Image 2) */}
<div className="pb-6">
<GensparkSuperCard />
</div>
</div>
)
}

200
src/pages/InvoicesPage.tsx Normal file
View File

@@ -0,0 +1,200 @@
import {
AlertTriangle,
Banknote,
Download,
FileSpreadsheet,
Plus,
Send,
} from "lucide-react"
import { useMemo, useState } from "react"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { INVOICES } from "@/data/mockData"
import { invoiceTotals, money, overdueInvoices } from "@/data/selectors"
import type { Invoice } from "@/data/types"
export function InvoicesPage() {
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const totals = invoiceTotals()
const filtered = useMemo(
() => INVOICES.filter((i) => status === "all" || i.status === status),
[status]
)
const columns = useMemo<DemoColumnDef<Invoice>[]>(
() => [
{
accessorKey: "id",
header: "Invoice",
cell: (c) => (
<span className="font-semibold text-primary">{c.getValue() as string}</span>
),
},
{
accessorKey: "client",
header: "Client",
cell: (c) => (
<div className="min-w-0">
<div className="truncate font-medium text-ink">{c.row.original.client}</div>
<div className="truncate text-[12px] text-ink-3">
{c.row.original.orderRefs.join(", ")}
</div>
</div>
),
},
{ accessorKey: "issued", header: "Issued" },
{
accessorKey: "due",
header: "Due",
cell: (c) => (
<span
className={
c.row.original.status === "Overdue"
? "font-semibold text-bad"
: "text-ink-2"
}
>
{c.getValue() as string}
</span>
),
},
{
accessorKey: "amount",
header: "Amount",
cell: (c) => (
<span className="font-semibold text-ink">{money(c.getValue() as number)}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: (c) => <StatusBadge value={c.getValue() as string} size="sm" />,
},
{
id: "actions",
header: "",
cell: (c) => (
<div className="flex justify-end gap-1.5">
{c.row.original.status === "Draft" ? (
<Button size="sm" variant="soft">
<Send />
Send
</Button>
) : (
<Button size="sm" variant="ghost">
<Download />
PDF
</Button>
)}
</div>
),
},
],
[]
)
return (
<Page>
<PageIntro
title="Invoices"
description="Billing status across every client account, from draft through to settled."
actions={
<>
<Button variant="outline">
<Download />
Export
</Button>
<Button>
<Plus />
Create Invoice
</Button>
</>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Outstanding"
value={money(totals.outstanding)}
sub="Sent and overdue combined"
icon={FileSpreadsheet}
badge="Open"
badgeTone="warn"
tone="primary"
/>
<MetricCard
label="Overdue"
value={money(totals.overdue)}
sub={`${overdueInvoices().length} invoices past due date`}
icon={AlertTriangle}
badge="Chase"
badgeTone="bad"
tone="bad"
/>
<MetricCard
label="Draft"
value={money(totals.draft)}
sub="Ready for billing review"
icon={Send}
badge="Ready"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Collected"
value={money(totals.paid)}
sub="Settled this quarter"
icon={Banknote}
badge="Paid"
badgeTone="good"
tone="primary"
/>
</div>
{overdueInvoices().length > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-[14px] border border-bad-edge bg-bad-soft px-4 py-3">
<AlertTriangle className="size-4 shrink-0 text-bad" />
<p className="min-w-0 flex-1 text-[13px] text-ink-2">
<span className="font-semibold text-bad">
{overdueInvoices().length} invoices are overdue
</span>{" "}
— {overdueInvoices().map((i) => i.client).join(" and ")} owe a combined{" "}
{money(totals.overdue)}.
</p>
<Button size="sm" variant="outline">
Chase payment
</Button>
</div>
)}
<SectionCard title="Invoice Register" icon={FileSpreadsheet} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search invoices, clients or order references…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Draft", "Sent", "Paid", "Overdue"],
},
]}
>
<Badge tone="neutral">
{filtered.length} of {INVOICES.length}
</Badge>
</Toolbar>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</Page>
)
}

200
src/pages/MessagesPage.tsx Normal file
View File

@@ -0,0 +1,200 @@
import { MessageSquare, Search, SendHorizonal } from "lucide-react"
import { useLayoutEffect, useRef, useState } from "react"
import { Page } from "@/components/shared/Page"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { CONVERSATIONS } from "@/data/mockData"
import { cn } from "@/lib/utils"
import type { Conversation } from "@/data/types"
/* ===========================================================================
Messages — a real two-pane inbox. Sending is local state only.
=========================================================================== */
export function MessagesPage() {
const [threads, setThreads] = useState<Conversation[]>(() =>
CONVERSATIONS.map((c) => ({ ...c, messages: [...c.messages] }))
)
const [activeId, setActiveId] = useState(threads[0].id)
const [query, setQuery] = useState("")
const [draft, setDraft] = useState("")
const endRef = useRef<HTMLDivElement>(null)
const active = threads.find((t) => t.id === activeId) ?? threads[0]
const visible = threads.filter((t) =>
`${t.name} ${t.role}`.toLowerCase().includes(query.toLowerCase())
)
useLayoutEffect(() => {
endRef.current?.scrollIntoView({ block: "end" })
}, [active.messages.length, activeId])
/** Opening a thread marks it read. */
function openThread(id: string) {
setActiveId(id)
setThreads((prev) =>
prev.map((t) => (t.id === id && t.unread > 0 ? { ...t, unread: 0 } : t))
)
}
function send() {
const text = draft.trim()
if (!text) return
setThreads((prev) =>
prev.map((t) =>
t.id === activeId
? {
...t,
lastAt: "Now",
messages: [
...t.messages,
{ id: `local-${t.messages.length}`, from: "me" as const, text, at: "Now" },
],
}
: t
)
)
setDraft("")
}
return (
<Page className="h-full">
<div className="grid h-[calc(100dvh-8rem)] min-h-[420px] grid-cols-1 overflow-hidden rounded-[14px] border border-line bg-surface shadow-xs md:grid-cols-[280px_minmax(0,1fr)]">
{/* ------------------------------------------------- conversations */}
<aside className="flex min-h-0 flex-col border-b border-line md:border-b-0 md:border-r">
<div className="shrink-0 border-b border-line-soft p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-3" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search conversations…"
className="pl-8"
/>
</div>
</div>
<div className="scroll-slim min-h-0 flex-1 overflow-y-auto">
{visible.map((t) => (
<button
key={t.id}
type="button"
onClick={() => openThread(t.id)}
className={cn(
"flex w-full items-start gap-2.5 border-b border-line-soft px-3 py-2.5 text-left transition-colors",
t.id === activeId ? "bg-primary-soft" : "hover:bg-surface-soft"
)}
>
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-surface-sunk text-[11px] font-bold text-ink-2">
{t.initials}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-ink">
{t.name}
</span>
<span className="shrink-0 text-[11px] text-ink-3">{t.lastAt}</span>
</div>
<div className="truncate text-[11.5px] text-ink-3">{t.role}</div>
<div className="mt-0.5 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">
{t.messages[t.messages.length - 1].text}
</span>
{t.unread > 0 && (
<span className="grid size-[17px] shrink-0 place-items-center rounded-full bg-primary text-[10px] font-bold text-white">
{t.unread}
</span>
)}
</div>
</div>
</button>
))}
{visible.length === 0 && (
<p className="p-6 text-center text-[12.5px] text-ink-3">
No conversations match that search.
</p>
)}
</div>
</aside>
{/* ------------------------------------------------- active thread */}
<section className="flex min-h-0 flex-col">
<header className="flex shrink-0 items-center gap-2.5 border-b border-line px-4 py-3">
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
{active.initials}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-semibold text-ink">{active.name}</div>
<div className="truncate text-[11.5px] text-ink-3">{active.role}</div>
</div>
<Badge tone="good" size="sm">
<MessageSquare className="size-2.5" />
Active
</Badge>
</header>
<div className="scroll-slim min-h-0 flex-1 space-y-2.5 overflow-y-auto p-4">
{active.messages.map((m) => (
<div
key={m.id}
className={cn("flex", m.from === "me" ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[76%] rounded-[14px] px-3 py-2 text-[13px] leading-snug",
m.from === "me"
? "rounded-br-[5px] bg-primary text-white"
: "rounded-bl-[5px] border border-line bg-surface-soft text-ink-2"
)}
>
<p>{m.text}</p>
<p
className={cn(
"mt-1 text-[10.5px]",
m.from === "me" ? "text-white/70" : "text-ink-3"
)}
>
{m.at}
</p>
</div>
</div>
))}
<div ref={endRef} />
</div>
<div className="shrink-0 border-t border-line p-3">
<div className="flex items-end gap-2 rounded-[14px] border border-line bg-surface-soft px-3 py-2 transition-colors focus-within:border-primary focus-within:bg-surface">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send()
}
}}
placeholder={`Message ${active.name.split(" ")[0]}…`}
aria-label="Message"
className="min-w-0 flex-1 bg-transparent text-[13px] text-ink placeholder:text-ink-3 focus:outline-none"
/>
<button
type="button"
onClick={send}
disabled={!draft.trim()}
aria-label="Send message"
className={cn(
"grid size-7 shrink-0 place-items-center rounded-[9px] transition-colors",
draft.trim()
? "bg-primary text-white hover:bg-[#1a45c0]"
: "bg-surface-sunk text-ink-3"
)}
>
<SendHorizonal className="size-3.5" />
</button>
</div>
</div>
</section>
</div>
</Page>
)
}

319
src/pages/OrdersPage.tsx Normal file
View File

@@ -0,0 +1,319 @@
import {
AlertTriangle,
Banknote,
ClipboardList,
FileStack,
Plus,
UserRoundPlus,
} from "lucide-react"
import { useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CLIENTS, ORDERS, ORDER_SUMMARY } from "@/data/mockData"
import {
money,
orderFillRate,
pipelineValue,
totalPlacesShort,
understaffedOrders,
} from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
/* Quotes are shown as a tab on Orders rather than a separate product area. */
const QUOTES = [
{ id: "QTE-311", client: "Grand Hotel & Suites", venue: "Grand Terrace", staff: 6, value: 5400, status: "Sent", expires: "22 Sep 2025" },
{ id: "QTE-309", client: "Seaview Retail Park", venue: "Seaview Centre", staff: 4, value: 3120, status: "Draft", expires: "25 Sep 2025" },
{ id: "QTE-305", client: "Northgate Entertainment", venue: "Northgate Pavilion", staff: 10, value: 9800, status: "Accepted", expires: "—" },
{ id: "QTE-298", client: "Lantern Events UK", venue: "The Lantern Rooms", staff: 3, value: 2850, status: "Expired", expires: "01 Sep 2025" },
]
export function OrdersPage() {
useWorkforceRevision()
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "all"
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const [department, setDepartment] = useState("all")
const filtered = useMemo(
() =>
ORDERS.filter(
(o) =>
(status === "all" || o.status === status) &&
(department === "all" || o.department === department)
),
[status, department]
)
const columns = useMemo<DemoColumnDef<(typeof ORDERS)[number]>[]>(
() => [
{
accessorKey: "id",
header: "Order",
cell: (c) => (
<span className="font-semibold text-primary">{c.getValue() as string}</span>
),
},
{
accessorKey: "client",
header: "Client / Venue",
cell: (c) => (
<div className="min-w-0">
<div className="truncate font-medium text-ink">{c.row.original.client}</div>
<div className="truncate text-[12px] text-ink-3">{c.row.original.venue}</div>
</div>
),
},
{ accessorKey: "shift", header: "Shift" },
{
id: "staffing",
header: "Staffing",
cell: (c) => {
const { required, filled } = c.row.original
const gap = required - filled
return (
<div className="flex items-center gap-2">
<span className="font-semibold text-ink">
{filled}/{required}
</span>
{gap > 0 && (
<Badge tone="warn" size="sm">
<AlertTriangle className="size-2.5" />
{gap} short
</Badge>
)}
</div>
)
},
},
{
accessorKey: "value",
header: "Value",
cell: (c) => (
<span className="font-semibold text-ink">{money(c.getValue() as number)}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: (c) => <StatusBadge value={c.getValue() as string} />,
},
{ accessorKey: "leadContact", header: "Lead" },
],
[]
)
return (
<Page>
<PageIntro
title="Orders"
description="Every staffing order, its fill status and the revenue riding on it."
actions={
<Button onClick={() => setParams({ tab: "create" })}>
<Plus />
One-time Order
</Button>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Orders Today"
value={String(ORDER_SUMMARY.today)}
sub={`${ORDER_SUMMARY.inProgress} in progress`}
icon={ClipboardList}
badge="Live"
badgeTone="primary"
tone="primary"
/>
<MetricCard
label="Understaffed"
value={String(understaffedOrders().length)}
sub={`${totalPlacesShort()} places short today`}
icon={AlertTriangle}
badge={understaffedOrders().length ? "Action" : "Clear"}
badgeTone={understaffedOrders().length ? "warn" : "good"}
tone={understaffedOrders().length ? "warn" : "primary"}
/>
<MetricCard
label="Fill Rate"
value={`${orderFillRate()}%`}
sub="Across live orders"
icon={UserRoundPlus}
tone="primary"
progress={orderFillRate()}
/>
<MetricCard
label="Pipeline Value"
value={money(pipelineValue())}
sub="Open and confirmed orders"
icon={Banknote}
badge="Gross"
badgeTone="neutral"
tone="primary"
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="all">
<ClipboardList className="size-3.5" />
All Orders
</TabsTrigger>
<TabsTrigger value="create">
<UserRoundPlus className="size-3.5" />
Create Order
</TabsTrigger>
<TabsTrigger value="quotes">
<FileStack className="size-3.5" />
Quotes
</TabsTrigger>
</TabsList>
{/* ------------------------------------------------- order directory */}
<TabsContent value="all">
<SectionCard title="Order Directory" icon={ClipboardList} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search orders, clients or venues…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Understaffed", "In Progress", "Confirmed", "Draft", "Completed"],
},
{
label: "All departments",
value: department,
onChange: setDepartment,
options: [
"all",
"Event & Hospitality",
"Warehouse & Logistics",
"Facilities & Cleaning",
],
},
]}
/>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</TabsContent>
{/* ---------------------------------------------------- create order */}
<TabsContent value="create">
<SectionCard title="Create a One-time Order" icon={UserRoundPlus}>
<form
className="grid gap-4 md:grid-cols-2"
onSubmit={(e) => e.preventDefault()}
>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Client</span>
<Select defaultValue={CLIENTS[0].name}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLIENTS.map((c) => (
<SelectItem key={c.id} value={c.name}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Department</span>
<Select defaultValue="Event & Hospitality">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Event & Hospitality", "Warehouse & Logistics", "Facilities & Cleaning"].map(
(d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
)
)}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Venue</span>
<Input defaultValue="Northgate Arena" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Date</span>
<Input defaultValue="16 Sep 2025" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Shift start</span>
<Input defaultValue="17:00" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Shift end</span>
<Input defaultValue="23:30" />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Staff required</span>
<Input type="number" defaultValue={5} />
</label>
<label className="space-y-1.5">
<span className="text-[12.5px] font-semibold text-ink">Charge rate (per hour)</span>
<Input defaultValue="$24.50" />
</label>
<div className="flex gap-2 md:col-span-2">
<Button type="submit">Create order</Button>
<Button type="button" variant="outline" onClick={() => setParams({ tab: "all" })}>
Cancel
</Button>
</div>
<p className="text-[12px] text-ink-3 md:col-span-2">
Demo form — submitting does not persist anything.
</p>
</form>
</SectionCard>
</TabsContent>
{/* --------------------------------------------------------- quotes */}
<TabsContent value="quotes">
<SectionCard title="Quotes" icon={FileStack} bodyClassName="p-0">
<div className="divide-y divide-line-soft">
{QUOTES.map((q) => (
<div key={q.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="w-[82px] shrink-0 font-semibold text-primary">{q.id}</span>
<div className="min-w-[160px] flex-1">
<div className="truncate text-[13px] font-medium text-ink">{q.client}</div>
<div className="truncate text-[12px] text-ink-3">{q.venue}</div>
</div>
<span className="text-[13px] text-ink-2">{q.staff} staff</span>
<span className="text-[13px] font-semibold text-ink">{money(q.value)}</span>
<span className="text-[12px] text-ink-3">Expires {q.expires}</span>
<StatusBadge value={q.status === "Accepted" ? "Confirmed" : q.status} />
</div>
))}
</div>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

269
src/pages/PayrollPage.tsx Normal file
View File

@@ -0,0 +1,269 @@
import { AlertTriangle, Banknote, Clock, Download, TrendingUp } from "lucide-react"
import { useState } from "react"
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { PAYROLL_LINES, PAYROLL_RUNS, PAYROLL_SUMMARY } from "@/data/mockData"
import { averageHourlyCost, money, money2, num, payrollAnomalies } from "@/data/selectors"
export function PayrollPage() {
const [period, setPeriod] = useState(PAYROLL_RUNS[0].id)
const run = PAYROLL_RUNS.find((r) => r.id === period) ?? PAYROLL_RUNS[0]
const anomalies = payrollAnomalies()
const chartData = PAYROLL_SUMMARY.breakdown.map((b) => ({
name: b.group.split(" & ")[0],
cost: b.cost,
hours: b.hours,
}))
return (
<Page>
<PageIntro
title="Payroll"
description="Worked hours, staff cost and approval status for each pay period."
actions={
<>
<Button variant="outline">
<Download />
Export
</Button>
<Button>Approve run</Button>
</>
}
/>
<Tabs value={period} onValueChange={setPeriod}>
<TabsList className="flex w-full flex-wrap">
{PAYROLL_RUNS.map((r) => (
<TabsTrigger key={r.id} value={r.id}>
{r.period.split(" · ")[0]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Payroll Total"
value={money(run.gross)}
sub={run.period.split(" · ")[1]}
icon={Banknote}
badge={run.status}
badgeTone={
run.status === "Paid" ? "good" : run.status === "Approved" ? "primary" : "warn"
}
tone="primary"
/>
<MetricCard
label="Worked Hours"
value={num(run.hours)}
sub={`${run.staffCount} staff paid`}
icon={Clock}
tone="primary"
/>
<MetricCard
label="Cost per Hour"
value={money2(run.gross / run.hours)}
sub="Blended labour rate"
icon={TrendingUp}
badge="Blended"
badgeTone="neutral"
tone="primary"
/>
<MetricCard
label="Anomalies"
value={String(anomalies.length)}
sub="Lines needing review"
icon={AlertTriangle}
badge={anomalies.length ? "Review" : "Clear"}
badgeTone={anomalies.length ? "warn" : "good"}
tone={anomalies.length ? "warn" : "primary"}
/>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<SectionCard title="Cost by Department" icon={Banknote} className="xl:col-span-2">
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -6 }}>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "#8b99b8" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v / 1000}k`}
/>
<Tooltip
cursor={{ fill: "rgba(29,78,216,.06)" }}
contentStyle={{ borderRadius: 10, border: "1px solid #e4ebf6", fontSize: 12 }}
formatter={(v) => money(Number(v))}
/>
<Bar dataKey="cost" radius={[6, 6, 0, 0]} maxBarSize={56}>
{chartData.map((_, i) => (
<Cell key={i} fill={["#1d4ed8", "#4f7ee6", "#94b5f2"][i]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Period Breakdown" icon={Clock} bodyClassName="p-4">
<div className="space-y-3.5">
{PAYROLL_SUMMARY.breakdown.map((b) => {
const pct = Math.round((b.cost / PAYROLL_SUMMARY.total) * 100)
return (
<div key={b.group}>
<div className="mb-1.5 flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate text-[13px] font-semibold text-ink">
{b.group}
</span>
<span className="shrink-0 text-[12px] text-ink-3">
{b.hours}h · {money(b.cost)}
</span>
</div>
<Progress value={pct} />
</div>
)
})}
<div className="space-y-1.5 border-t border-line-soft pt-3 text-[12.5px]">
<div className="flex justify-between">
<span className="text-ink-3">Overtime spend</span>
<span className="font-semibold text-ink">
{money(PAYROLL_SUMMARY.overtimeSpend)}
</span>
</div>
<div className="flex justify-between">
<span className="text-ink-3">Blended cost / hour</span>
<span className="font-semibold text-ink">{money2(averageHourlyCost())}</span>
</div>
</div>
</div>
</SectionCard>
</div>
{anomalies.length > 0 && (
<SectionCard title="Payroll Anomalies" icon={AlertTriangle} bodyClassName="p-3">
<div className="space-y-2">
{anomalies.map((a) => (
<div
key={a.staffId}
className="flex flex-wrap items-center gap-3 rounded-[10px] border border-warn-edge bg-warn-soft px-3 py-2.5"
>
<AlertTriangle className="size-4 shrink-0 text-warn" />
<div className="min-w-[160px] flex-1">
<div className="text-[13px] font-semibold text-ink">{a.name}</div>
<div className="text-[12px] text-ink-2">{a.anomaly}</div>
</div>
<span className="text-[12.5px] text-ink-2">{a.hours}h</span>
<span className="text-[13px] font-semibold text-ink">{money2(a.gross)}</span>
<Button size="sm" variant="outline">
Review
</Button>
</div>
))}
</div>
</SectionCard>
)}
<SectionCard title="Pay Lines" icon={Banknote} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Staff</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Overtime</TableHead>
<TableHead className="text-right">Rate</TableHead>
<TableHead className="text-right">Gross</TableHead>
<TableHead>Flag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{PAYROLL_LINES.map((l) => (
<TableRow key={l.staffId}>
<TableCell className="font-medium text-ink">{l.name}</TableCell>
<TableCell>{l.role}</TableCell>
<TableCell className="text-right">{l.hours}</TableCell>
<TableCell className="text-right">
{l.overtime > 0 ? `${l.overtime}h` : "—"}
</TableCell>
<TableCell className="text-right">${l.rate.toFixed(2)}</TableCell>
<TableCell className="text-right font-semibold text-ink">
{money2(l.gross)}
</TableCell>
<TableCell>
{l.anomaly ? (
<StatusBadge value="Review" size="sm" />
) : (
<StatusBadge value="Verified" size="sm" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
<SectionCard title="Pay Run History" icon={Clock} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Period</TableHead>
<TableHead className="text-right">Staff</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Gross</TableHead>
<TableHead>Status</TableHead>
<TableHead>Paid on</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{PAYROLL_RUNS.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium text-ink">{r.period}</TableCell>
<TableCell className="text-right">{r.staffCount}</TableCell>
<TableCell className="text-right">{num(r.hours)}</TableCell>
<TableCell className="text-right font-semibold text-ink">
{money(r.gross)}
</TableCell>
<TableCell>
<StatusBadge value={r.status} size="sm" />
</TableCell>
<TableCell className="text-ink-3">{r.paidOn}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</Page>
)
}

View File

@@ -0,0 +1,241 @@
import { Activity, BarChart3, Star, TrendingUp, Users } from "lucide-react"
import { useState } from "react"
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Progress } from "@/components/ui/progress"
import { DEPARTMENT_METRICS, STAFF, TRENDS } from "@/data/mockData"
import {
averageRating,
averageUtilisation,
money,
num,
orderFillRate,
} from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
const RANGES = ["7 days", "30 days", "Quarter"] as const
const axis = { fontSize: 11, fill: "#8b99b8" }
const tooltipStyle = {
borderRadius: 10,
border: "1px solid #e4ebf6",
fontSize: 12,
boxShadow: "0 4px 14px rgba(17,28,68,.07)",
}
export function PerformancePage() {
useWorkforceRevision()
const [range, setRange] = useState<string>(RANGES[0])
const attendance = Math.round(
TRENDS.reduce((s, t) => s + t.attendance, 0) / TRENDS.length
)
const hours = TRENDS.reduce((s, t) => s + t.hours, 0)
const topPerformers = [...STAFF].sort((a, b) => b.rating - a.rating).slice(0, 5)
return (
<Page>
<PageIntro
title="Workforce Performance"
description="Utilisation, attendance and fill rate across every pool and department."
actions={
<Tabs value={range} onValueChange={setRange}>
<TabsList>
{RANGES.map((r) => (
<TabsTrigger key={r} value={r}>
{r}
</TabsTrigger>
))}
</TabsList>
</Tabs>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Utilisation"
value={`${averageUtilisation()}%`}
sub="Hours worked vs available"
icon={TrendingUp}
badge="+4.2%"
badgeTone="good"
tone="primary"
progress={averageUtilisation()}
/>
<MetricCard
label="Attendance"
value={`${attendance}%`}
sub="Clock-in accuracy this week"
icon={Activity}
badge="Stable"
badgeTone="good"
tone="primary"
progress={attendance}
/>
<MetricCard
label="Fill Rate"
value={`${orderFillRate()}%`}
sub="Required places filled"
icon={Users}
badge={orderFillRate() < 90 ? "Watch" : "Good"}
badgeTone={orderFillRate() < 90 ? "warn" : "good"}
tone={orderFillRate() < 90 ? "warn" : "primary"}
progress={orderFillRate()}
/>
<MetricCard
label="Avg Rating"
value={averageRating()}
sub={`${num(hours)} hours delivered`}
icon={Star}
badge="of 5.0"
badgeTone="neutral"
tone="primary"
/>
</div>
<div className="grid gap-3 xl:grid-cols-2">
<SectionCard title="Utilisation & Attendance Trend" icon={TrendingUp}>
<div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={TRENDS} margin={{ top: 8, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis dataKey="label" tick={axis} axisLine={false} tickLine={false} />
<YAxis tick={axis} axisLine={false} tickLine={false} domain={[70, 100]} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => `${v}%`} />
<Legend wrapperStyle={{ fontSize: 12 }} iconType="circle" iconSize={7} />
<Line
name="Utilisation"
type="monotone"
dataKey="utilisation"
stroke="#1d4ed8"
strokeWidth={2}
dot={{ r: 3, fill: "#1d4ed8" }}
/>
<Line
name="Attendance"
type="monotone"
dataKey="attendance"
stroke="#22a957"
strokeWidth={2}
dot={{ r: 3, fill: "#22a957" }}
/>
<Line
name="Fill rate"
type="monotone"
dataKey="fillRate"
stroke="#f0b429"
strokeWidth={2}
dot={{ r: 3, fill: "#f0b429" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Staffing Hours Delivered" icon={BarChart3}>
<div className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={TRENDS} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<defs>
<linearGradient id="hoursFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1d4ed8" stopOpacity={0.28} />
<stop offset="100%" stopColor="#1d4ed8" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis dataKey="label" tick={axis} axisLine={false} tickLine={false} />
<YAxis tick={axis} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tooltipStyle} formatter={(v) => `${v} h`} />
<Area
type="monotone"
dataKey="hours"
stroke="#1d4ed8"
strokeWidth={2}
fill="url(#hoursFill)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</SectionCard>
</div>
<div className="grid gap-3 xl:grid-cols-3">
<SectionCard title="Revenue vs Cost by Department" icon={BarChart3} className="xl:col-span-2">
<div className="h-[220px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={DEPARTMENT_METRICS.map((d) => ({ ...d, name: d.name.split(" & ")[0] }))}
margin={{ top: 8, right: 8, bottom: 0, left: -6 }}
>
<CartesianGrid stroke="#eef3fa" vertical={false} />
<XAxis dataKey="name" tick={axis} axisLine={false} tickLine={false} />
<YAxis
tick={axis}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v / 1000}k`}
/>
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Number(v))} />
<Legend wrapperStyle={{ fontSize: 12 }} iconType="circle" iconSize={7} />
<Bar name="Revenue" dataKey="revenue" fill="#1d4ed8" radius={[5, 5, 0, 0]} maxBarSize={44} />
<Bar name="Cost" dataKey="cost" fill="#c5d8fb" radius={[5, 5, 0, 0]} maxBarSize={44} />
</BarChart>
</ResponsiveContainer>
</div>
</SectionCard>
<SectionCard title="Top Performers" icon={Star} bodyClassName="p-3">
<div className="space-y-2">
{topPerformers.map((s) => (
<div key={s.id} className="flex items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
{s.initials}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-semibold text-ink">{s.name}</div>
<div className="truncate text-[11.5px] text-ink-3">{s.role}</div>
</div>
<span className="shrink-0 text-[13px] font-bold text-ink">{s.rating}</span>
</div>
))}
</div>
</SectionCard>
</div>
<SectionCard title="Department Utilisation" icon={Users} bodyClassName="p-4">
<div className="space-y-3.5">
{DEPARTMENT_METRICS.map((d) => {
const util = Math.round((d.hours / 2200) * 100)
return (
<div key={d.name}>
<div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-2">
<span className="text-[13px] font-semibold text-ink">{d.name}</span>
<span className="text-[12px] text-ink-3">
{d.headcount} staff · {num(d.hours)} h · {money(d.revenue)} · {d.margin}% margin
</span>
</div>
<Progress value={util} />
</div>
)
})}
</div>
</SectionCard>
</Page>
)
}

237
src/pages/ReportsPage.tsx Normal file
View File

@@ -0,0 +1,237 @@
import {
Activity,
BarChart3,
Banknote,
Download,
FileSpreadsheet,
TrendingUp,
Users,
} from "lucide-react"
import { useSearchParams } from "react-router-dom"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { DEPARTMENT_METRICS, REPORTS, REPORTS_SUMMARY } from "@/data/mockData"
import { money, num } from "@/data/selectors"
import type { ReportCard as Report } from "@/data/types"
const AUDIT = [
{ at: "Today 14:32", actor: "Alex Rivera", action: "Resolved understaffed shift", target: "ORD-4830" },
{ at: "Today 13:58", actor: "Sarah Jenkins", action: "Updated order requirement", target: "ORD-4821" },
{ at: "Today 12:30", actor: "Finance Lead", action: "Generated payroll report", target: "PR-38" },
{ at: "Today 11:24", actor: "HR & Compliance", action: "Flagged certificate expiry", target: "STF-001" },
{ at: "Today 09:15", actor: "System", action: "Compliance register refreshed", target: "RPT-05" },
{ at: "Today 08:00", actor: "System", action: "Invoice ageing recalculated", target: "RPT-08" },
{ at: "Yesterday 20:00", actor: "Alex Rivera", action: "Exported client margin report", target: "RPT-07" },
{ at: "Yesterday 18:00", actor: "System", action: "Utilisation report generated", target: "RPT-04" },
]
const CATEGORY_ICON = {
Operational: BarChart3,
Workforce: Users,
Financial: Banknote,
} as const
function ReportTile({ report }: { report: Report }) {
const Icon = CATEGORY_ICON[report.category]
return (
<article className="flex flex-col rounded-[14px] border border-line bg-surface p-4 shadow-xs transition-[transform,box-shadow,border-color,background-color] hover:-translate-y-px hover:border-primary-edge hover:shadow-sm">
<div className="flex items-start justify-between gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-[9px] bg-primary-soft text-primary">
<Icon className="size-4" />
</span>
<Badge tone="neutral" size="sm">
{report.category}
</Badge>
</div>
<h3 className="mt-2.5 text-[14px] font-semibold leading-snug text-ink">
{report.title}
</h3>
<p className="mt-1 flex-1 text-[12.5px] leading-snug text-ink-2">
{report.description}
</p>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-line-soft pt-2.5">
<span className="truncate text-[11.5px] text-ink-3">
{report.updated} · {report.format}
</span>
<Button size="sm" variant="ghost">
<Download />
Export
</Button>
</div>
</article>
)
}
export function ReportsPage() {
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "library"
const byCategory = (c: Report["category"]) =>
REPORTS.filter((r) => r.category === c)
return (
<Page>
<PageIntro
title="Reports"
description="Operational, workforce and financial reporting across the whole product."
actions={
<Button variant="outline">
<Download />
Export all
</Button>
}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Monthly Revenue"
value={money(REPORTS_SUMMARY.monthlyRevenue)}
sub="Billable across all clients"
icon={Banknote}
badge="+6.4%"
badgeTone="good"
tone="primary"
/>
<MetricCard
label="Gross Profit"
value={money(REPORTS_SUMMARY.grossProfit)}
sub={`${REPORTS_SUMMARY.marginPct}% margin`}
icon={TrendingUp}
badge="Healthy"
badgeTone="good"
tone="primary"
progress={REPORTS_SUMMARY.marginPct}
/>
<MetricCard
label="Billable Utilisation"
value={`${REPORTS_SUMMARY.billableUtilisation}%`}
sub="Hours billed vs worked"
icon={Activity}
tone="primary"
progress={REPORTS_SUMMARY.billableUtilisation}
/>
<MetricCard
label="Overtime Spend"
value={money(REPORTS_SUMMARY.overtimeSpend)}
sub="This month"
icon={FileSpreadsheet}
badge="Watch"
badgeTone="warn"
tone="warn"
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="library">
<BarChart3 className="size-3.5" />
Report Library
</TabsTrigger>
<TabsTrigger value="audit">
<Activity className="size-3.5" />
Audit Trail
</TabsTrigger>
</TabsList>
<TabsContent value="library" className="space-y-4">
{(["Operational", "Workforce", "Financial"] as const).map((category) => (
<div key={category}>
<h3 className="mb-2.5 text-[11px] font-bold uppercase tracking-[0.12em] text-ink-3">
{category} Reports
</h3>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{byCategory(category).map((r) => (
<ReportTile key={r.id} report={r} />
))}
</div>
</div>
))}
<SectionCard title="Department Summary" icon={BarChart3} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Department</TableHead>
<TableHead className="text-right">Headcount</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Margin</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{DEPARTMENT_METRICS.map((d) => (
<TableRow key={d.name}>
<TableCell className="font-medium text-ink">{d.name}</TableCell>
<TableCell className="text-right">{d.headcount}</TableCell>
<TableCell className="text-right">{num(d.hours)}</TableCell>
<TableCell className="text-right">{money(d.revenue)}</TableCell>
<TableCell className="text-right">{money(d.cost)}</TableCell>
<TableCell className="text-right font-semibold text-ink">
{d.margin}%
</TableCell>
</TableRow>
))}
<TableRow className="bg-surface-soft">
<TableCell className="font-bold text-ink">Total</TableCell>
<TableCell className="text-right font-bold text-ink">
{DEPARTMENT_METRICS.reduce((s, d) => s + d.headcount, 0)}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{num(DEPARTMENT_METRICS.reduce((s, d) => s + d.hours, 0))}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{money(DEPARTMENT_METRICS.reduce((s, d) => s + d.revenue, 0))}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{money(DEPARTMENT_METRICS.reduce((s, d) => s + d.cost, 0))}
</TableCell>
<TableCell className="text-right font-bold text-ink">
{REPORTS_SUMMARY.marginPct}%
</TableCell>
</TableRow>
</TableBody>
</Table>
</SectionCard>
</TabsContent>
<TabsContent value="audit">
<SectionCard title="Audit Trail" icon={Activity} bodyClassName="p-0">
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>When</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{AUDIT.map((a, i) => (
<TableRow key={i}>
<TableCell className="whitespace-nowrap text-ink-3">{a.at}</TableCell>
<TableCell className="font-medium text-ink">{a.actor}</TableCell>
<TableCell>{a.action}</TableCell>
<TableCell className="font-medium text-primary">{a.target}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

470
src/pages/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,470 @@
import {
Bell,
BriefcaseBusiness,
Building2,
CreditCard,
LifeBuoy,
Palette,
SlidersHorizontal,
Store,
Users,
} from "lucide-react"
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ORG, STAFF, TEAMS, USER } from "@/data/mockData"
import { cn } from "@/lib/utils"
const NOTIFICATIONS = [
{ id: "understaffed", label: "Understaffed order alerts", note: "When an order drops below required headcount", on: true },
{ id: "starting", label: "Shift starting soon", note: "Two hours before an unfilled shift begins", on: true },
{ id: "compliance", label: "Compliance expiry warnings", note: "Fourteen days before a certificate lapses", on: true },
{ id: "payroll", label: "Payroll anomalies", note: "Overtime and under-contract hour flags", on: true },
{ id: "invoice", label: "Invoice overdue reminders", note: "When a client invoice passes its due date", on: false },
{ id: "digest", label: "Weekly performance digest", note: "Monday morning summary of the prior week", on: false },
]
const MEMBERS = [
{ name: "Alex Rivera", email: "alex.rivera@legendarystaffing.com", role: "Operations Lead", access: "Admin" },
{ name: "Sarah Jenkins", email: "s.jenkins@legendarystaffing.com", role: "Client Lead", access: "Manager" },
{ name: "Marcus Vance", email: "m.vance@legendarystaffing.com", role: "Shift Supervisor", access: "Manager" },
{ name: "Finance Lead", email: "finance@legendarystaffing.com", role: "Finance", access: "Billing" },
{ name: "HR & Compliance", email: "hr@legendarystaffing.com", role: "Compliance", access: "Manager" },
]
const HUBS = [
{ name: "Northgate Hub", region: "North", venues: 2, staff: 18 },
{ name: "Harbour Hub", region: "Central", venues: 1, staff: 14 },
{ name: "Castleford Hub", region: "East", venues: 1, staff: 14 },
]
const INTEGRATIONS = [
{ name: "Xero", category: "Accounting", status: "Connected" },
{ name: "Slack", category: "Communication", status: "Connected" },
{ name: "Google Calendar", category: "Scheduling", status: "Connected" },
{ name: "BambooHR", category: "People", status: "Not connected" },
{ name: "Stripe", category: "Payments", status: "Not connected" },
]
const TABS = [
{ value: "general", label: "General", icon: SlidersHorizontal },
{ value: "members", label: "Members", icon: Users },
{ value: "hubs", label: "Hubs", icon: Building2 },
{ value: "notifications", label: "Notifications", icon: Bell },
{ value: "billing", label: "Billing", icon: CreditCard },
{ value: "integrations", label: "Integrations", icon: Store },
{ value: "workforce", label: "Workforce", icon: BriefcaseBusiness },
{ value: "support", label: "Support", icon: LifeBuoy },
]
function Field({
label,
children,
hint,
}: {
label: string
children: React.ReactNode
hint?: string
}) {
return (
<label className="space-y-1.5">
<span className="block text-[12.5px] font-semibold text-ink">{label}</span>
{children}
{hint && <span className="block text-[11.5px] text-ink-3">{hint}</span>}
</label>
)
}
function ToggleRow({
label,
note,
defaultOn,
}: {
label: string
note: string
defaultOn: boolean
}) {
const [on, setOn] = useState(defaultOn)
return (
<div className="flex items-start justify-between gap-4 rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2.5">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-ink">{label}</div>
<div className="text-[12px] text-ink-3">{note}</div>
</div>
<Switch checked={on} onCheckedChange={setOn} aria-label={label} />
</div>
)
}
export function SettingsPage() {
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "general"
const [accent, setAccent] = useState("KROW Blue")
return (
<Page>
<PageIntro
title="Settings"
description={`Workspace configuration for ${ORG}.`}
actions={<Button>Save changes</Button>}
/>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList className="max-w-full overflow-x-auto scroll-slim">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
<t.icon className="size-3.5" />
{t.label}
</TabsTrigger>
))}
</TabsList>
{/* -------------------------------------------------------- general */}
<TabsContent value="general" className="space-y-3">
<SectionCard title="Workspace" icon={SlidersHorizontal}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Organisation name">
<Input defaultValue={ORG} />
</Field>
<Field label="Default team">
<Select defaultValue={TEAMS[0].name}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TEAMS.map((t) => (
<SelectItem key={t.id} value={t.name}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Time zone">
<Select defaultValue="Europe/London">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Europe/London", "Europe/Dublin", "America/New_York"].map((z) => (
<SelectItem key={z} value={z}>
{z}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Week starts on">
<Select defaultValue="Monday">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["Monday", "Sunday"].map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</SectionCard>
<SectionCard title="Account" icon={Users}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Full name">
<Input defaultValue={USER.fullName} />
</Field>
<Field label="Email">
<Input defaultValue={USER.email} />
</Field>
<Field label="Role">
<Input defaultValue={USER.role} readOnly className="bg-surface-sunk" />
</Field>
<Field label="Two-factor authentication" hint="Demo only — nothing is stored.">
<div className="flex h-9 items-center gap-2">
<Switch defaultChecked aria-label="Two-factor authentication" />
<span className="text-[12.5px] text-ink-2">Enabled</span>
</div>
</Field>
</div>
</SectionCard>
<SectionCard title="Appearance" icon={Palette}>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{["KROW Blue", "Deep Navy", "Slate"].map((a) => (
<button
key={a}
type="button"
onClick={() => setAccent(a)}
className={cn(
"flex items-center gap-2 rounded-[10px] border px-3 py-2 text-[12.5px] font-medium transition-colors",
accent === a
? "border-primary bg-primary-soft text-primary"
: "border-line text-ink-2 hover:bg-surface-sunk"
)}
>
<span
className="size-3.5 rounded-full"
style={{
background:
a === "KROW Blue" ? "#1d4ed8" : a === "Deep Navy" ? "#111c44" : "#64748b",
}}
/>
{a}
</button>
))}
</div>
<ToggleRow
label="Compact density"
note="Reduce padding across tables and cards"
defaultOn={false}
/>
<ToggleRow
label="Keep Owliver open"
note="Show the intelligence panel by default on desktop"
defaultOn
/>
</div>
</SectionCard>
</TabsContent>
{/* -------------------------------------------------------- members */}
<TabsContent value="members">
<SectionCard
title="Team Members"
icon={Users}
bodyClassName="p-0"
action={<Button size="sm" variant="outline">Invite member</Button>}
>
<Table>
<TableHeader>
<TableRow className="hover:bg-surface-soft">
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Access</TableHead>
<TableHead className="text-right">Manage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{MEMBERS.map((m) => (
<TableRow key={m.email}>
<TableCell>
<div className="min-w-0">
<div className="truncate font-semibold text-ink">{m.name}</div>
<div className="truncate text-[12px] text-ink-3">{m.email}</div>
</div>
</TableCell>
<TableCell>{m.role}</TableCell>
<TableCell>
<Badge tone={m.access === "Admin" ? "primary" : "neutral"} size="sm">
{m.access}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost">
Edit
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionCard>
</TabsContent>
{/* ----------------------------------------------------------- hubs */}
<TabsContent value="hubs">
<div className="grid gap-3 md:grid-cols-3">
{HUBS.map((h) => (
<SectionCard key={h.name} title={h.name} icon={Building2}>
<dl className="space-y-2 text-[13px]">
<div className="flex justify-between">
<dt className="text-ink-3">Region</dt>
<dd className="font-semibold text-ink">{h.region}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-3">Venues</dt>
<dd className="font-semibold text-ink">{h.venues}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink-3">Staff assigned</dt>
<dd className="font-semibold text-ink">{h.staff}</dd>
</div>
</dl>
</SectionCard>
))}
</div>
</TabsContent>
{/* -------------------------------------------------- notifications */}
<TabsContent value="notifications">
<SectionCard title="Notification Preferences" icon={Bell}>
<div className="space-y-2">
{NOTIFICATIONS.map((n) => (
<ToggleRow key={n.id} label={n.label} note={n.note} defaultOn={n.on} />
))}
</div>
</SectionCard>
</TabsContent>
{/* -------------------------------------------------------- billing */}
<TabsContent value="billing" className="space-y-3">
<SectionCard title="Plan" icon={CreditCard}>
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-[18px] font-bold text-ink">Workforce Pro</span>
<Badge tone="good" size="sm">
Active
</Badge>
</div>
<p className="mt-1 text-[12.5px] text-ink-2">
{STAFF.length} tracked staff · billed monthly · renews 01 Oct 2025
</p>
</div>
<div className="text-right">
<div className="text-[22px] font-bold leading-none text-ink">$489</div>
<div className="text-[12px] text-ink-3">per month</div>
</div>
</div>
</SectionCard>
<SectionCard title="Payment Method" icon={CreditCard}>
<div className="flex flex-wrap items-center gap-3">
<span className="grid h-9 w-14 place-items-center rounded-[8px] border border-line bg-surface-sunk text-[11px] font-bold text-ink-2">
VISA
</span>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-ink">•••• •••• •••• 4821</div>
<div className="text-[12px] text-ink-3">Expires 09 / 2027</div>
</div>
<Button size="sm" variant="outline">
Update
</Button>
</div>
</SectionCard>
</TabsContent>
{/* --------------------------------------------------- integrations */}
<TabsContent value="integrations">
<SectionCard title="Integrations" icon={Store} bodyClassName="p-0">
<div className="divide-y divide-line-soft">
{INTEGRATIONS.map((i) => (
<div key={i.name} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="grid size-9 shrink-0 place-items-center rounded-[9px] bg-surface-sunk text-[11px] font-bold text-ink-2">
{i.name.slice(0, 2).toUpperCase()}
</span>
<div className="min-w-[140px] flex-1">
<div className="text-[13px] font-semibold text-ink">{i.name}</div>
<div className="text-[12px] text-ink-3">{i.category}</div>
</div>
<StatusBadge
value={i.status === "Connected" ? "Verified" : "Closed"}
size="sm"
/>
<Button size="sm" variant={i.status === "Connected" ? "ghost" : "outline"}>
{i.status === "Connected" ? "Manage" : "Connect"}
</Button>
</div>
))}
</div>
</SectionCard>
</TabsContent>
{/* ------------------------------------------------------ workforce */}
<TabsContent value="workforce">
<SectionCard title="Workforce Rules" icon={BriefcaseBusiness}>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Maximum weekly hours" hint="Triggers a payroll anomaly when exceeded.">
<Input defaultValue="40" />
</Field>
<Field label="Minimum contracted hours" hint="Flags under-utilised staff.">
<Input defaultValue="16" />
</Field>
<Field label="Overtime multiplier">
<Input defaultValue="1.5×" />
</Field>
<Field label="Compliance warning window">
<Select defaultValue="14 days">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["7 days", "14 days", "30 days"].map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="mt-4 space-y-2">
<ToggleRow
label="Block assignment on expired compliance"
note="Prevent rostering staff with a lapsed certificate"
defaultOn
/>
<ToggleRow
label="Auto-fill from standby pool"
note="Let Owliver propose replacements for unfilled places"
defaultOn
/>
</div>
</SectionCard>
</TabsContent>
{/* -------------------------------------------------------- support */}
<TabsContent value="support">
<SectionCard title="Support" icon={LifeBuoy}>
<div className="space-y-3">
<p className="text-[13px] text-ink-2">
This is a demo prototype. Nothing here connects to a backend, an API or a
database, and no data leaves the browser.
</p>
<div className="grid gap-3 md:grid-cols-3">
{[
{ label: "Documentation", note: "Product guides and release notes" },
{ label: "Contact support", note: "Weekdays, 08:00 – 18:00" },
{ label: "Service status", note: "All systems operational" },
].map((c) => (
<div
key={c.label}
className="rounded-[10px] border border-line-soft bg-surface-soft px-3 py-2.5"
>
<div className="text-[13px] font-semibold text-ink">{c.label}</div>
<div className="text-[12px] text-ink-3">{c.note}</div>
</div>
))}
</div>
</div>
</SectionCard>
</TabsContent>
</Tabs>
</Page>
)
}

245
src/pages/StaffPage.tsx Normal file
View File

@@ -0,0 +1,245 @@
import { BriefcaseBusiness, ShieldCheck, Star, UserCheck, Users } from "lucide-react"
import { useMemo, useState } from "react"
import { useSearchParams } from "react-router-dom"
import { DataTable, type DemoColumnDef } from "@/components/shared/DataTable"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro, SectionCard } from "@/components/shared/Page"
import { StatusBadge, StatusDot } from "@/components/shared/StatusBadge"
import { Toolbar } from "@/components/shared/Toolbar"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { STAFF, STAFF_COUNTS, TEAMS } from "@/data/mockData"
import {
averageRating,
averageUtilisation,
staffCoverage,
staffWithComplianceIssues,
} from "@/data/selectors"
import { useWorkforceRevision } from "@/data/store"
import type { StaffMember } from "@/data/types"
export function StaffPage() {
useWorkforceRevision()
const [params, setParams] = useSearchParams()
const tab = params.get("tab") ?? "staff"
const [query, setQuery] = useState("")
const [status, setStatus] = useState("all")
const [department, setDepartment] = useState("all")
const filtered = useMemo(
() =>
STAFF.filter(
(s) =>
(status === "all" || s.status === status) &&
(department === "all" || s.department === department)
),
[status, department]
)
const columns = useMemo<DemoColumnDef<StaffMember>[]>(
() => [
{
accessorKey: "name",
header: "Name",
cell: (c) => (
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-primary-soft text-[10.5px] font-bold text-primary">
{c.row.original.initials}
</span>
<div className="min-w-0">
<div className="truncate font-semibold text-ink">{c.row.original.name}</div>
<div className="truncate text-[12px] text-ink-3">{c.row.original.email}</div>
</div>
</div>
),
},
{
accessorKey: "role",
header: "Role",
cell: (c) => (
<div className="min-w-0">
<div className="truncate text-ink-2">{c.row.original.role}</div>
<div className="truncate text-[12px] text-ink-3">{c.row.original.department}</div>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: (c) => (
<span className="flex items-center gap-2">
<StatusDot value={c.getValue() as string} />
<StatusBadge value={c.getValue() as string} size="sm" />
</span>
),
},
{
accessorKey: "utilisation",
header: "Utilisation",
cell: (c) => (
<div className="w-[92px]">
<div className="mb-1 text-[12px] font-semibold text-ink">
{c.getValue() as number}%
</div>
<Progress value={c.getValue() as number} className="h-1" />
</div>
),
},
{
accessorKey: "compliance",
header: "Compliance",
cell: (c) => (
<div className="min-w-0">
<StatusBadge value={c.getValue() as string} size="sm" />
{c.row.original.complianceNote && (
<div className="mt-0.5 truncate text-[11.5px] text-ink-3">
{c.row.original.complianceNote}
</div>
)}
</div>
),
},
{
accessorKey: "nextShift",
header: "Next shift",
cell: (c) => (
<span className="whitespace-nowrap text-ink-2">{c.getValue() as string}</span>
),
},
],
[]
)
return (
<Page>
<PageIntro
title="Staff Directory"
description="The full roster with availability, utilisation and compliance standing."
actions={<Button variant="outline">Invite staff</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Total Staff"
value={String(STAFF_COUNTS.total)}
sub={`${TEAMS.length} staffing pools`}
icon={Users}
tone="primary"
/>
<MetricCard
label="Active Today"
value={String(STAFF_COUNTS.active)}
sub={`${staffCoverage()}% coverage`}
icon={UserCheck}
badge={`${staffCoverage()}%`}
badgeTone="good"
tone="primary"
progress={staffCoverage()}
/>
<MetricCard
label="Avg Utilisation"
value={`${averageUtilisation()}%`}
sub="Hours vs availability"
icon={Star}
badge={`${averageRating()} rating`}
badgeTone="neutral"
tone="primary"
progress={averageUtilisation()}
/>
<MetricCard
label="Compliance Issues"
value={String(staffWithComplianceIssues().length)}
sub="Staff needing review"
icon={ShieldCheck}
badge="Review"
badgeTone="warn"
tone={staffWithComplianceIssues().length ? "warn" : "primary"}
/>
</div>
<Tabs value={tab} onValueChange={(v) => setParams({ tab: v })}>
<TabsList>
<TabsTrigger value="staff">
<Users className="size-3.5" />
Staff
</TabsTrigger>
<TabsTrigger value="teams">
<BriefcaseBusiness className="size-3.5" />
Teams
</TabsTrigger>
</TabsList>
<TabsContent value="staff">
<SectionCard title="Staff" icon={Users} bodyClassName="p-0">
<Toolbar
query={query}
onQuery={setQuery}
placeholder="Search by name, role or location…"
filters={[
{
label: "All statuses",
value: status,
onChange: setStatus,
options: ["all", "Active", "On Break", "Unavailable"],
},
{
label: "All departments",
value: department,
onChange: setDepartment,
options: [
"all",
"Event & Hospitality",
"Warehouse & Logistics",
"Facilities & Cleaning",
],
},
]}
/>
<DataTable columns={columns} data={filtered} globalFilter={query} pageSize={8} />
</SectionCard>
</TabsContent>
<TabsContent value="teams">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{TEAMS.map((team) => {
const members = STAFF.filter((s) => s.team === team.name)
const active = members.filter((s) => s.status === "Active").length
return (
<SectionCard key={team.id} title={team.name} icon={BriefcaseBusiness}>
<div className="mb-3 flex items-baseline gap-2">
<span className="text-[24px] font-bold leading-none text-ink">
{team.members}
</span>
<span className="text-[12.5px] text-ink-3">members</span>
</div>
<Progress
value={members.length ? (active / members.length) * 100 : 0}
className="mb-3"
/>
<div className="space-y-1.5">
{members.slice(0, 5).map((m) => (
<div key={m.id} className="flex items-center gap-2">
<StatusDot value={m.status} />
<span className="min-w-0 flex-1 truncate text-[12.5px] text-ink-2">
{m.name}
</span>
<span className="shrink-0 text-[11.5px] text-ink-3">{m.role}</span>
</div>
))}
{members.length === 0 && (
<p className="text-[12.5px] text-ink-3">
No tracked members in this pool.
</p>
)}
</div>
</SectionCard>
)
})}
</div>
</TabsContent>
</Tabs>
</Page>
)
}

249
src/pages/TasksPage.tsx Normal file
View File

@@ -0,0 +1,249 @@
import {
CheckSquare,
CircleDot,
GripVertical,
ListTodo,
MoreHorizontal,
OctagonAlert,
} from "lucide-react"
import { useEffect, useState } from "react"
import { MetricCard } from "@/components/shared/MetricCard"
import { Page, PageIntro } from "@/components/shared/Page"
import { StatusBadge } from "@/components/shared/StatusBadge"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { TASKS } from "@/data/mockData"
import { useWorkforceRevision } from "@/data/store"
import type { Task, TaskStatus } from "@/data/types"
import { cn } from "@/lib/utils"
const COLUMNS: { status: TaskStatus; label: string; accent: string }[] = [
{ status: "To Do", label: "To Do", accent: "bg-ink-3" },
{ status: "In Progress", label: "In Progress", accent: "bg-primary" },
{ status: "Blocked", label: "Blocked", accent: "bg-bad-dot" },
{ status: "Completed", label: "Completed", accent: "bg-good-dot" },
]
function TaskCard({
task,
onDragStart,
onDragEnd,
onMove,
dragging,
}: {
task: Task
onDragStart: (id: string) => void
onDragEnd: () => void
onMove: (id: string, status: TaskStatus) => void
dragging: boolean
}) {
return (
<article
draggable
onDragStart={(e) => {
// Required for the drag to register in Firefox and to set a drag image.
e.dataTransfer.setData("text/plain", task.id)
e.dataTransfer.effectAllowed = "move"
onDragStart(task.id)
}}
onDragEnd={onDragEnd}
className={cn(
"group cursor-grab rounded-[12px] border border-line bg-surface p-3 shadow-xs transition-[transform,box-shadow,border-color,background-color] active:cursor-grabbing",
"hover:-translate-y-px hover:border-primary-edge hover:shadow-sm",
dragging && "opacity-40"
)}
>
<div className="flex items-start gap-1.5">
<GripVertical className="mt-0.5 size-3.5 shrink-0 text-ink-3 opacity-0 transition-opacity group-hover:opacity-100" />
<div className="min-w-0 flex-1">
<p className="text-[12.5px] font-semibold leading-snug text-ink">
{task.title}
</p>
<p className="mt-1 truncate text-[11px] text-ink-3">{task.orderRef}</p>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
<StatusBadge value={task.priority} size="sm" />
<Badge tone="neutral" size="sm">
{task.category}
</Badge>
</div>
<div className="mt-2 flex items-center justify-between gap-1.5 border-t border-line-soft pt-2">
<span className="min-w-0 flex-1 truncate text-[11px] text-ink-3">{task.due}</span>
<span
title={task.assignee}
className="grid size-6 shrink-0 place-items-center rounded-full bg-primary-soft text-[9.5px] font-bold text-primary"
>
{task.assigneeInitials}
</span>
{/* Keyboard- and click-accessible equivalent of the drag interaction. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Move ${task.title}`}
className="grid size-6 shrink-0 place-items-center rounded-[7px] text-ink-3 transition-colors hover:bg-surface-sunk hover:text-ink"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Move to</DropdownMenuLabel>
{COLUMNS.filter((c) => c.status !== task.status).map((c) => (
<DropdownMenuItem key={c.status} onSelect={() => onMove(task.id, c.status)}>
<span className={cn("size-2 shrink-0 rounded-full", c.accent)} />
{c.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</article>
)
}
export function TasksPage() {
const revision = useWorkforceRevision()
// Seeded from the shared data, then owned locally so cards can be moved.
const [tasks, setTasks] = useState<Task[]>(() => TASKS.map((t) => ({ ...t })))
const [dragId, setDragId] = useState<string | null>(null)
const [overColumn, setOverColumn] = useState<TaskStatus | null>(null)
// Owliver's resolve action completes the staffing tasks — reflect that here.
useEffect(() => {
setTasks(TASKS.map((t) => ({ ...t })))
}, [revision])
function move(id: string, status: TaskStatus) {
setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t)))
}
function drop(status: TaskStatus) {
if (dragId) move(dragId, status)
setDragId(null)
setOverColumn(null)
}
/** Always clear the drag state, even when the card is dropped nowhere. */
function endDrag() {
setDragId(null)
setOverColumn(null)
}
const open = tasks.filter((t) => t.status !== "Completed")
const blocked = tasks.filter((t) => t.status === "Blocked")
const high = open.filter((t) => t.priority === "High")
const done = tasks.filter((t) => t.status === "Completed")
return (
<Page>
<PageIntro
title="Task Board"
description="Operational work in flight. Drag a card between columns to change its status."
actions={<Button variant="outline">New task</Button>}
/>
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Open Tasks"
value={String(open.length)}
sub="Across all categories"
icon={ListTodo}
tone="primary"
/>
<MetricCard
label="High Priority"
value={String(high.length)}
sub="Need action today"
icon={CircleDot}
badge={high.length ? "Urgent" : "Clear"}
badgeTone={high.length ? "bad" : "good"}
tone={high.length ? "warn" : "primary"}
/>
<MetricCard
label="Blocked"
value={String(blocked.length)}
sub="Waiting on a dependency"
icon={OctagonAlert}
badge={blocked.length ? "Review" : "Clear"}
badgeTone={blocked.length ? "warn" : "good"}
tone={blocked.length ? "warn" : "primary"}
/>
<MetricCard
label="Completed"
value={String(done.length)}
sub="Closed today"
icon={CheckSquare}
badge="Done"
badgeTone="good"
tone="primary"
/>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((col) => {
const items = tasks.filter((t) => t.status === col.status)
return (
<section
key={col.status}
onDragOver={(e) => {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
setOverColumn(col.status)
}}
onDragLeave={() => setOverColumn((c) => (c === col.status ? null : c))}
onDrop={(e) => {
e.preventDefault()
drop(col.status)
}}
className={cn(
"flex min-h-[220px] flex-col rounded-[14px] border bg-surface-soft p-2.5 transition-colors",
overColumn === col.status
? "border-primary bg-primary-soft"
: "border-line"
)}
>
<header className="mb-2.5 flex items-center gap-2 px-1">
<span className={cn("size-2 shrink-0 rounded-full", col.accent)} />
<h3 className="min-w-0 flex-1 truncate text-[12.5px] font-bold uppercase tracking-[0.06em] text-ink-2">
{col.label}
</h3>
<span className="grid h-[19px] min-w-[19px] shrink-0 place-items-center rounded-full border border-line bg-surface px-1 text-[11px] font-bold text-ink-2">
{items.length}
</span>
</header>
<div className="flex-1 space-y-2">
{items.map((t) => (
<TaskCard
key={t.id}
task={t}
dragging={dragId === t.id}
onDragStart={setDragId}
onDragEnd={endDrag}
onMove={move}
/>
))}
{items.length === 0 && (
<p className="rounded-[10px] border border-dashed border-line px-3 py-6 text-center text-[12px] text-ink-3">
Drop a task here
</p>
)}
</div>
</section>
)
})}
</div>
</Page>
)
}