first commit
This commit is contained in:
191
src/pages/AvailabilityPage.tsx
Normal file
191
src/pages/AvailabilityPage.tsx
Normal 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
204
src/pages/BenefitsPage.tsx
Normal 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
215
src/pages/ClientsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
294
src/pages/CompliancePage.tsx
Normal file
294
src/pages/CompliancePage.tsx
Normal 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
40
src/pages/HomePage.tsx
Normal 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
200
src/pages/InvoicesPage.tsx
Normal 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
200
src/pages/MessagesPage.tsx
Normal 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
319
src/pages/OrdersPage.tsx
Normal 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
269
src/pages/PayrollPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
src/pages/PerformancePage.tsx
Normal file
241
src/pages/PerformancePage.tsx
Normal 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
237
src/pages/ReportsPage.tsx
Normal 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
470
src/pages/SettingsPage.tsx
Normal 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
245
src/pages/StaffPage.tsx
Normal 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
249
src/pages/TasksPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user