2135 lines
82 KiB
TypeScript
2135 lines
82 KiB
TypeScript
import {
|
|
BENEFITS,
|
|
CLIENTS,
|
|
COMPLIANCE_RISKS,
|
|
CONVERSATIONS,
|
|
DEPARTMENT_METRICS,
|
|
INVOICES,
|
|
ORDERS,
|
|
ORDER_SUMMARY,
|
|
PAYROLL_LINES,
|
|
PAYROLL_SUMMARY,
|
|
REPORTS,
|
|
REPORTS_SUMMARY,
|
|
SCHEDULE,
|
|
SETTINGS_SUMMARY,
|
|
STAFF,
|
|
STAFF_COUNTS,
|
|
TASKS,
|
|
TEAMS,
|
|
TODAY,
|
|
TOMORROW,
|
|
TRENDS,
|
|
UPCOMING_SHIFTS,
|
|
USER,
|
|
} from "@/data/mockData"
|
|
import {
|
|
averageHourlyCost,
|
|
averageRating,
|
|
averageUtilisation,
|
|
clientsAtRisk,
|
|
complianceFlagCount,
|
|
filledShifts,
|
|
grossMargin,
|
|
highPriorityTasks,
|
|
invoiceTotals,
|
|
money,
|
|
money2,
|
|
num,
|
|
openTasks,
|
|
orderFillRate,
|
|
overdueInvoices,
|
|
payrollAnomalies,
|
|
percentOfStaff,
|
|
pipelineValue,
|
|
staffCoverage,
|
|
staffWithComplianceIssues,
|
|
totalPlacesShort,
|
|
unavailableStaff,
|
|
understaffedOrders,
|
|
understaffedShifts,
|
|
unreadMessages,
|
|
} from "@/data/selectors"
|
|
import type { Tone } from "@/data/types"
|
|
|
|
import type { ActionId, Block, OwliverContext, OwliverMessage } from "./types"
|
|
|
|
/* ===========================================================================
|
|
Owliver's simulated intelligence.
|
|
|
|
There is no model and no network here. Every reply is assembled from the
|
|
shared mock data, so Owliver can never quote a figure the dashboard
|
|
contradicts. Replies are deterministic: the same question always answers the
|
|
same way.
|
|
=========================================================================== */
|
|
|
|
const severityTone: Record<string, Tone> = {
|
|
High: "bad",
|
|
Medium: "warn",
|
|
Low: "primary",
|
|
}
|
|
|
|
/* ============================ shared builders ============================ */
|
|
|
|
function coverageTable(): Block {
|
|
const s = STAFF_COUNTS
|
|
return {
|
|
type: "table",
|
|
columns: ["Status", "Count", "Share"],
|
|
rows: [
|
|
{ cells: [{ dot: "good", label: "Active" }, s.active, `${percentOfStaff(s.active)}%`] },
|
|
{ cells: [{ dot: "warn", label: "On Break" }, s.onBreak, `${percentOfStaff(s.onBreak)}%`] },
|
|
{ cells: [{ dot: "bad", label: "Unavailable" }, s.unavailable, `${percentOfStaff(s.unavailable)}%`] },
|
|
{ cells: ["Total", s.total, "100%"], summary: true },
|
|
],
|
|
}
|
|
}
|
|
|
|
function understaffedTable(): Block {
|
|
const rows = understaffedShifts().map((r) => ({
|
|
cells: [
|
|
r.ref.replace("ORD-", ""),
|
|
r.location,
|
|
`${r.filled}/${r.required}`,
|
|
{ badge: `-${r.required - r.filled}`, tone: "bad" as Tone },
|
|
],
|
|
}))
|
|
return {
|
|
type: "table",
|
|
columns: ["Order", "Location", "Filled", "Short"],
|
|
rows,
|
|
}
|
|
}
|
|
|
|
function complianceTable(): Block {
|
|
return {
|
|
type: "table",
|
|
columns: ["Risk", "Staff", "Severity", "Expires"],
|
|
rows: COMPLIANCE_RISKS.map((c) => ({
|
|
cells: [
|
|
c.category,
|
|
c.staffCount,
|
|
{ badge: c.severity, tone: severityTone[c.severity] },
|
|
c.expires.replace(" 2025", ""),
|
|
],
|
|
})),
|
|
}
|
|
}
|
|
|
|
/* ========================== the opening exchange ========================== */
|
|
|
|
export function buildInitialConversation(): OwliverMessage[] {
|
|
const short = totalPlacesShort()
|
|
return [
|
|
{
|
|
id: "seed-0",
|
|
author: "owliver",
|
|
blocks: [
|
|
{
|
|
type: "say",
|
|
lines: [
|
|
`Good ${TODAY.partOfDay}, ${USER.name} 👋`,
|
|
"I've analysed today's workforce activity.",
|
|
],
|
|
},
|
|
{
|
|
type: "alerts",
|
|
items: [
|
|
{
|
|
icon: "orders",
|
|
tone: "warn",
|
|
text: `${understaffedShifts().length} understaffed orders`,
|
|
note: `${short} places short today`,
|
|
},
|
|
{
|
|
icon: "clock",
|
|
tone: "bad",
|
|
text: `${ORDER_SUMMARY.startingSoon} shifts starting soon`,
|
|
note: "Within the next two hours",
|
|
},
|
|
{
|
|
icon: "shield",
|
|
tone: "primary",
|
|
text: `${complianceFlagCount()} compliance flags`,
|
|
note: "Review before assignment",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{ id: "seed-1", author: "user", text: "Which orders are understaffed?" },
|
|
{
|
|
id: "seed-2",
|
|
author: "owliver",
|
|
blocks: [
|
|
{
|
|
type: "say",
|
|
lines: [`**${understaffedShifts().length} of ${SCHEDULE.length} live orders are understaffed.**`],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "orders",
|
|
title: "Understaffed Orders",
|
|
body: [understaffedTable()],
|
|
},
|
|
{
|
|
type: "recommend",
|
|
text: "Castleford Logistics has the largest staffing gap — a 22:00 night shift with only 1 of 4 places filled. Owen Bradley and 2 Pool B operatives are available.",
|
|
},
|
|
{
|
|
type: "actions",
|
|
items: [
|
|
{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" },
|
|
{ label: "Open Orders", action: "open-orders", variant: "outline" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
}
|
|
|
|
/** The first line Owliver opens with on each page (section 6). */
|
|
export const CONTEXT_OPENING: Record<OwliverContext, string> = {
|
|
home: "I've analysed today's workforce activity.",
|
|
performance: "I've reviewed this week's workforce performance.",
|
|
orders: "I've reviewed today's orders.",
|
|
availability: "I've checked who's available right now.",
|
|
tasks: "I've reviewed the task board.",
|
|
staff: "I've reviewed the staff directory.",
|
|
compliance: "I've checked your compliance register.",
|
|
benefits: "I've reviewed benefit enrolment.",
|
|
clients: "I've reviewed your client accounts.",
|
|
invoices: "I've reviewed your billing position.",
|
|
payroll: "I've reviewed this week's payroll.",
|
|
reports: "I've reviewed the reporting pack.",
|
|
messages: "I've reviewed your conversations.",
|
|
settings: "I've checked your workspace configuration.",
|
|
}
|
|
|
|
/* ======================= page-aware suggested prompts ==================== */
|
|
|
|
export const QUICK_PROMPTS: Record<OwliverContext, string[]> = {
|
|
home: [
|
|
"Which orders are understaffed?",
|
|
"How is workforce coverage?",
|
|
"What needs attention today?",
|
|
"Give me a workforce summary",
|
|
],
|
|
performance: [
|
|
"What changed this week?",
|
|
"Show utilization trend",
|
|
"Explain the performance change",
|
|
"Which area needs attention?",
|
|
],
|
|
orders: [
|
|
"Which orders are understaffed?",
|
|
"Which order has the biggest staffing gap?",
|
|
"Which orders need attention?",
|
|
],
|
|
availability: [
|
|
"Who is available today?",
|
|
"Where do we need shift coverage?",
|
|
"Show upcoming availability",
|
|
],
|
|
tasks: [
|
|
"Which tasks are overdue?",
|
|
"Which tasks are unassigned?",
|
|
"What needs attention?",
|
|
],
|
|
staff: [
|
|
"Find available staff",
|
|
"Show staff by role",
|
|
"Who is working today?",
|
|
"Show staff profiles",
|
|
],
|
|
compliance: [
|
|
"What compliance risks need attention?",
|
|
"Show expiring certificates",
|
|
"Which staff are affected?",
|
|
"Show high-severity issues",
|
|
],
|
|
benefits: [
|
|
"Show benefits overview",
|
|
"Which benefits need attention?",
|
|
"Show staff benefit status",
|
|
"What needs review?",
|
|
],
|
|
clients: [
|
|
"Show client overview",
|
|
"Which clients need attention?",
|
|
"Show active clients",
|
|
"Show client activity",
|
|
],
|
|
invoices: [
|
|
"Which invoices are overdue?",
|
|
"Show invoice summary",
|
|
"What is ready for review?",
|
|
"Show billing signals",
|
|
],
|
|
payroll: [
|
|
"Summarise payroll",
|
|
"Which staff cost the most?",
|
|
"Are there any payroll anomalies?",
|
|
],
|
|
reports: [
|
|
"Summarise the latest report",
|
|
"Show important findings",
|
|
"What changed?",
|
|
"What should I review?",
|
|
],
|
|
messages: [
|
|
"Show unread messages",
|
|
"What needs a response?",
|
|
"Show recent messages",
|
|
"Which conversations need attention?",
|
|
],
|
|
settings: [
|
|
"Show workspace settings",
|
|
"Review notifications",
|
|
"Review integrations",
|
|
"Show account settings",
|
|
],
|
|
}
|
|
|
|
/* ====================== page-aware opening context ======================= */
|
|
|
|
export function contextGreeting(context: OwliverContext): Block[] {
|
|
switch (context) {
|
|
case "performance":
|
|
return [
|
|
{ type: "say", lines: [`Workforce performance for ${TODAY.short}.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Utilisation", value: `${averageUtilisation()}%`, sub: "Across 16 tracked staff", tone: "primary" },
|
|
{ label: "Fill rate", value: `${orderFillRate()}%`, sub: "Live orders", tone: "warn" },
|
|
{ label: "Avg rating", value: averageRating(), sub: "Out of 5.0", tone: "good" },
|
|
],
|
|
},
|
|
]
|
|
case "orders":
|
|
return [
|
|
{ type: "say", lines: [`${ORDER_SUMMARY.today} orders today — ${understaffedShifts().length} understaffed.`] },
|
|
{ type: "card", icon: "orders", title: "Understaffed Orders", body: [understaffedTable()] },
|
|
{ type: "actions", items: [{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }] },
|
|
]
|
|
case "availability":
|
|
return [
|
|
{ type: "say", lines: [`${STAFF_COUNTS.active} of ${STAFF_COUNTS.total} staff are active right now.`] },
|
|
{ type: "card", icon: "flow", title: "Staff Allocation Flow", body: [{ type: "flow" }] },
|
|
]
|
|
case "compliance":
|
|
return [
|
|
{ type: "say", lines: [`${complianceFlagCount()} compliance flags across ${COMPLIANCE_RISKS.length} risk categories.`] },
|
|
{ type: "card", icon: "shield", title: "Open Compliance Risks", body: [complianceTable()] },
|
|
{ type: "actions", items: [{ label: "Open Compliance", action: "open-compliance", variant: "outline" }] },
|
|
]
|
|
case "payroll":
|
|
return [
|
|
{ type: "say", lines: [`Week 38 payroll is ${money(PAYROLL_SUMMARY.total)} across ${num(PAYROLL_SUMMARY.workedHours)} hours.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Gross", value: money(PAYROLL_SUMMARY.total), sub: "Pending approval", tone: "primary" },
|
|
{ label: "Hours", value: num(PAYROLL_SUMMARY.workedHours), sub: "Worked this week" },
|
|
{ label: "Anomalies", value: String(payrollAnomalies().length), sub: "Need review", tone: "warn" },
|
|
],
|
|
},
|
|
]
|
|
case "reports":
|
|
return [
|
|
{ type: "say", lines: [`${REPORTS.length} reports available. Margin is holding at ${REPORTS_SUMMARY.marginPct}%.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Revenue", value: money(REPORTS_SUMMARY.monthlyRevenue), sub: "This month", tone: "good" },
|
|
{ label: "Payroll", value: money(REPORTS_SUMMARY.monthlyPayroll), sub: "This month" },
|
|
{ label: "Margin", value: `${REPORTS_SUMMARY.marginPct}%`, sub: "Gross", tone: "primary" },
|
|
],
|
|
},
|
|
]
|
|
case "invoices": {
|
|
const t = invoiceTotals()
|
|
return [
|
|
{ type: "say", lines: [`${money(t.outstanding)} outstanding across ${INVOICES.filter((i) => i.status !== "Paid" && i.status !== "Draft").length} invoices.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Outstanding", value: money(t.outstanding), sub: "Sent + overdue", tone: "warn" },
|
|
{ label: "Overdue", value: money(t.overdue), sub: `${overdueInvoices().length} invoices`, tone: "bad" },
|
|
{ label: "Draft", value: money(t.draft), sub: "Ready to send", tone: "primary" },
|
|
],
|
|
},
|
|
]
|
|
}
|
|
case "clients":
|
|
return [
|
|
{ type: "say", lines: [`${CLIENTS.length} client accounts — ${clientsAtRisk().length} need attention.`] },
|
|
{
|
|
type: "card",
|
|
icon: "building",
|
|
title: "Accounts Needing Attention",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Client", "Status", "Orders"],
|
|
rows: clientsAtRisk().map((c) => ({
|
|
cells: [c.name, { badge: c.accountStatus, tone: c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) }, c.activeOrders],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
]
|
|
case "tasks": {
|
|
const blocked = TASKS.filter((t) => t.status === "Blocked")
|
|
const dueToday = openTasks().filter((t) => /Today/.test(t.due))
|
|
return [
|
|
{ type: "say", lines: [`${openTasks().length} open tasks — ${highPriorityTasks().length} high priority, ${blocked.length} blocked.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Due today", value: String(dueToday.length), sub: "Across all owners", tone: "warn" },
|
|
{ label: "Blocked", value: String(blocked.length), sub: "Waiting on a dependency", tone: "bad" },
|
|
{ label: "High", value: String(highPriorityTasks().length), sub: "Priority tasks", tone: "primary" },
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "check",
|
|
title: "Workload by Owner",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Owner", "Open", "High"],
|
|
rows: Array.from(
|
|
openTasks().reduce((m, t) => {
|
|
const cur = m.get(t.assignee) ?? { open: 0, high: 0 }
|
|
cur.open += 1
|
|
if (t.priority === "High") cur.high += 1
|
|
m.set(t.assignee, cur)
|
|
return m
|
|
}, new Map<string, { open: number; high: number }>())
|
|
).map(([owner, v]) => ({ cells: [owner, v.open, v.high] })),
|
|
},
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] },
|
|
]
|
|
}
|
|
case "staff":
|
|
return [
|
|
{ type: "say", lines: [`${STAFF_COUNTS.total} staff tracked — ${staffCoverage()}% active.`] },
|
|
{ type: "card", icon: "users", title: "Today's Staff Coverage", body: [coverageTable()] },
|
|
{
|
|
type: "card",
|
|
icon: "shield",
|
|
title: "Certification Watch",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Staff", "Standing", "Note"],
|
|
rows: STAFF.filter((m) => m.compliance !== "Verified").map((m) => ({
|
|
cells: [
|
|
m.name,
|
|
{ badge: m.compliance, tone: m.compliance === "Expired" ? ("bad" as Tone) : ("warn" as Tone) },
|
|
m.complianceNote ?? "",
|
|
],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "recommend",
|
|
text: "Owen Bradley and Henry Adeyemi are the least-utilised verified staff with logistics cover — the best candidates for tonight's Castleford gap.",
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] },
|
|
]
|
|
case "benefits": {
|
|
const enrolled = BENEFITS.reduce((s, b) => s + b.enrolled, 0)
|
|
const cost = BENEFITS.reduce((s, b) => s + b.monthlyCost, 0)
|
|
return [
|
|
{ type: "say", lines: [`${BENEFITS.length} benefit plans, ${enrolled} total enrolments.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Enrolments", value: String(enrolled), sub: "Across all plans", tone: "primary" },
|
|
{ label: "Monthly cost", value: money(cost), sub: "Employer funded" },
|
|
{ label: "In review", value: String(BENEFITS.filter((b) => b.status === "Review").length), sub: "Plans", tone: "warn" },
|
|
],
|
|
},
|
|
]
|
|
}
|
|
case "messages":
|
|
return [
|
|
{ type: "say", lines: [`${unreadMessages()} unread messages across ${CONVERSATIONS.length} conversations.`] },
|
|
{
|
|
type: "alerts",
|
|
items: CONVERSATIONS.filter((c) => c.unread > 0).map((c) => ({
|
|
icon: "sparkles" as const,
|
|
tone: "warn" as Tone,
|
|
text: `${c.name} — ${c.unread} unread`,
|
|
note: c.messages[c.messages.length - 1].text,
|
|
})),
|
|
},
|
|
]
|
|
case "settings":
|
|
return [
|
|
{ type: "say", lines: ["Workspace configuration for Legendary Staffing Pool A."] },
|
|
{
|
|
type: "alerts",
|
|
items: [
|
|
{ icon: "check", tone: "good", text: "Workforce rules configured", note: "40h cap, 1.5x overtime, 14-day compliance window" },
|
|
{ icon: "shield", tone: "warn", text: "2 integrations not connected", note: "BambooHR and Stripe are still available" },
|
|
{ icon: "users", tone: "primary", text: "5 workspace members", note: "1 admin, 3 managers, 1 billing" },
|
|
],
|
|
},
|
|
]
|
|
case "home":
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
/* ============================ the reply engine =========================== */
|
|
|
|
interface Rule {
|
|
match: RegExp
|
|
/** The page this topic belongs to. Asked elsewhere, Owliver hands it back. */
|
|
owner: OwliverContext
|
|
/** Extra pages where the topic is genuinely in-scope. */
|
|
scope?: OwliverContext[]
|
|
reply: () => Block[]
|
|
}
|
|
|
|
const RULES: Rule[] = [
|
|
{
|
|
match: /understaff|short|gap|uncovered order|fill.*order/i,
|
|
owner: "orders",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${understaffedShifts().length} of ${SCHEDULE.length} live orders are understaffed**, ${totalPlacesShort()} places short in total.`] },
|
|
{ type: "card", icon: "orders", title: "Understaffed Orders", body: [understaffedTable()] },
|
|
{
|
|
type: "recommend",
|
|
text: `${understaffedOrders()[1]?.venue ?? "Castleford Logistics Hub"} has the largest staffing gap. Filling all three shifts protects ${money(understaffedOrders().reduce((s, o) => s + o.value, 0))} of order value.`,
|
|
},
|
|
{
|
|
type: "actions",
|
|
items: [
|
|
{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" },
|
|
{ label: "Open Orders", action: "open-orders", variant: "outline" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: /tomorrow|uncovered/i,
|
|
owner: "availability",
|
|
reply: () => [
|
|
{ type: "say", lines: ["**Tomorrow's cover** — 4 staff scheduled, 2 need action."] },
|
|
{
|
|
type: "card",
|
|
icon: "clock",
|
|
title: "Tomorrow's Roster",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Staff", "Role", "Start", "Status"],
|
|
rows: TOMORROW.map((t) => ({
|
|
cells: [
|
|
t.name,
|
|
t.role,
|
|
t.shift,
|
|
{
|
|
badge: t.status,
|
|
tone: t.status === "Confirmed" ? ("good" as Tone) : t.status === "Unavailable" ? ("bad" as Tone) : ("warn" as Tone),
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Jonah Whitfield is unavailable for the 08:00 driver slot and Priya Raman is still unconfirmed for 16:00. Both need replacing before tonight." },
|
|
{ type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /compliance|certificat|document|expir|risk signal/i,
|
|
owner: "compliance",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${complianceFlagCount()} compliance flags** across ${COMPLIANCE_RISKS.length} categories.`] },
|
|
{ type: "card", icon: "shield", title: "Open Compliance Risks", body: [complianceTable()] },
|
|
{
|
|
type: "details",
|
|
summary: "Who is affected?",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Staff", "Issue"],
|
|
rows: COMPLIANCE_RISKS.flatMap((c) => c.staffNames.map((n) => ({ cells: [n, c.risk] }))),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Tomas Ferreira's forklift certification expires on 17 Sep — he is rostered to Castleford tomorrow at 06:00. Reassign or fast-track the renewal." },
|
|
{ type: "actions", items: [{ label: "Open Compliance", action: "open-compliance" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /payroll|wage|labour cost|labor cost|cost per hour|gross pay/i,
|
|
owner: "payroll",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**Week 38 payroll** is ${money(PAYROLL_SUMMARY.total)} across ${num(PAYROLL_SUMMARY.workedHours)} worked hours.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Gross pay", value: money(PAYROLL_SUMMARY.total), sub: "Pending approval", tone: "primary" },
|
|
{ label: "Cost / hour", value: money2(averageHourlyCost()), sub: "Blended rate" },
|
|
{ label: "Gross margin", value: `${grossMargin()}%`, sub: `On ${money(PAYROLL_SUMMARY.revenue)} revenue`, tone: "good" },
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "money",
|
|
title: "Cost by Department",
|
|
body: [
|
|
{
|
|
type: "chart",
|
|
kind: "bar",
|
|
unit: "$",
|
|
data: PAYROLL_SUMMARY.breakdown.map((b) => ({ label: b.group.split(" & ")[0], value: b.cost })),
|
|
},
|
|
{
|
|
type: "table",
|
|
columns: ["Department", "Hours", "Cost"],
|
|
rows: [
|
|
...PAYROLL_SUMMARY.breakdown.map((b) => ({ cells: [b.group, b.hours, money(b.cost)] })),
|
|
{ cells: ["Total", PAYROLL_SUMMARY.workedHours, money(PAYROLL_SUMMARY.total)], summary: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "details",
|
|
summary: `${payrollAnomalies().length} anomalies detected`,
|
|
body: [
|
|
{
|
|
type: "alerts",
|
|
items: payrollAnomalies().map((a) => ({
|
|
icon: "money" as const,
|
|
tone: "warn" as Tone,
|
|
text: a.name,
|
|
note: a.anomaly ?? "",
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Payroll", action: "open-payroll", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /anomal/i,
|
|
owner: "payroll",
|
|
reply: () => [
|
|
{ type: "say", lines: [`I found **${payrollAnomalies().length} payroll anomalies** in week 38.`] },
|
|
{
|
|
type: "card",
|
|
icon: "money",
|
|
title: "Payroll Anomalies",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Staff", "Hours", "Gross", "Flag"],
|
|
rows: payrollAnomalies().map((a) => ({
|
|
cells: [a.name, a.hours, money2(a.gross), { badge: "Review", tone: "warn" as Tone }],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Marcus Vance exceeded 40 hours and needs overtime sign-off before the run can be approved." },
|
|
{ type: "actions", items: [{ label: "Open Payroll", action: "open-payroll" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /available right now|who.*available|availability|allocation/i,
|
|
owner: "availability",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${STAFF_COUNTS.active} staff are active**, ${STAFF_COUNTS.onBreak} on break and ${STAFF_COUNTS.unavailable} unavailable.`] },
|
|
{ type: "card", icon: "flow", title: "Staff Allocation Flow", body: [{ type: "flow" }] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Unavailable Staff",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Staff", "Role", "Next available"],
|
|
rows: unavailableStaff().map((s) => ({ cells: [s.name, s.role, s.nextShift] })),
|
|
},
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /unavailable/i,
|
|
owner: "availability",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${unavailableStaff().length} staff are unavailable** in the tracked roster.`] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Unavailable Staff",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Staff", "Role", "Next available"],
|
|
rows: unavailableStaff().map((s) => ({ cells: [s.name, s.role, s.nextShift] })),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Samuel Okoro has missed 3 consecutive shifts and his manual handling certificate has expired — worth a welfare check before re-rostering." },
|
|
],
|
|
},
|
|
{
|
|
match: /utilisation|utilization|trend|performance/i,
|
|
owner: "performance",
|
|
reply: () => [
|
|
{ type: "say", lines: [`Utilisation is averaging **${averageUtilisation()}%** across the tracked roster.`] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Utilisation This Week",
|
|
body: [
|
|
{
|
|
type: "chart",
|
|
kind: "line",
|
|
unit: "%",
|
|
data: TRENDS.map((t) => ({ label: t.label, value: t.utilisation })),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Department Performance",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Department", "Hours", "Margin"],
|
|
rows: DEPARTMENT_METRICS.map((d) => ({
|
|
cells: [d.name.split(" & ")[0], num(d.hours), `${d.margin}%`],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Saturday peaked at 96% utilisation but fill rate dropped to 86% — demand is outrunning available supply at weekends." },
|
|
{ type: "actions", items: [{ label: "Open Reports", action: "open-reports", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /attendance/i,
|
|
owner: "performance",
|
|
reply: () => [
|
|
{ type: "say", lines: ["**Attendance this week** is averaging 95%."] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Attendance Trend",
|
|
body: [
|
|
{
|
|
type: "chart",
|
|
kind: "line",
|
|
unit: "%",
|
|
data: TRENDS.map((t) => ({ label: t.label, value: t.attendance })),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Saturday dipped to 92%, the lowest of the week, against the highest rostered hours." },
|
|
],
|
|
},
|
|
{
|
|
match: /fill rate/i,
|
|
owner: "performance",
|
|
scope: ["orders"],
|
|
reply: () => [
|
|
{ type: "say", lines: [`Live order fill rate is **${orderFillRate()}%**.`] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Fill Rate This Week",
|
|
body: [
|
|
{
|
|
type: "chart",
|
|
kind: "line",
|
|
unit: "%",
|
|
data: TRENDS.map((t) => ({ label: t.label, value: t.fillRate })),
|
|
},
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Orders", action: "open-orders", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /department.*best|best.*department/i,
|
|
owner: "performance",
|
|
reply: () => [
|
|
{ type: "say", lines: ["**Event & Hospitality** leads on margin at 43.1%."] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Margin by Department",
|
|
body: [
|
|
{
|
|
type: "chart",
|
|
kind: "bar",
|
|
unit: "%",
|
|
data: DEPARTMENT_METRICS.map((d) => ({ label: d.name.split(" & ")[0], value: d.margin })),
|
|
},
|
|
{
|
|
type: "table",
|
|
columns: ["Department", "Revenue", "Cost", "Margin"],
|
|
rows: DEPARTMENT_METRICS.map((d) => ({
|
|
cells: [d.name.split(" & ")[0], money(d.revenue), money(d.cost), `${d.margin}%`],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: /highest value|order value|revenue.*order|pipeline/i,
|
|
owner: "orders",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${money(pipelineValue())}** of order value is in flight.`] },
|
|
{
|
|
type: "card",
|
|
icon: "money",
|
|
title: "Highest Value Orders",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Order", "Client", "Value", "Status"],
|
|
rows: [...ORDERS]
|
|
.filter((o) => o.status !== "Completed")
|
|
.sort((a, b) => b.value - a.value)
|
|
.slice(0, 5)
|
|
.map((o) => ({
|
|
cells: [
|
|
o.id,
|
|
o.client.split(" ")[0],
|
|
money(o.value),
|
|
{ badge: o.status, tone: o.status === "Understaffed" ? ("bad" as Tone) : ("good" as Tone) },
|
|
],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: /invoice|outstanding|overdue|billing/i,
|
|
owner: "invoices",
|
|
reply: () => {
|
|
const t = invoiceTotals()
|
|
return [
|
|
{ type: "say", lines: [`**${money(t.outstanding)} outstanding**, of which ${money(t.overdue)} is overdue.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Draft", value: money(t.draft), sub: "Ready to send", tone: "primary" },
|
|
{ label: "Sent", value: money(t.sent), sub: "Awaiting payment" },
|
|
{ label: "Overdue", value: money(t.overdue), sub: `${overdueInvoices().length} invoices`, tone: "bad" },
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "file",
|
|
title: "Overdue Invoices",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Invoice", "Client", "Due", "Amount"],
|
|
rows: overdueInvoices().map((i) => ({
|
|
cells: [i.id, i.client.split(" ")[0], i.due, money(i.amount)],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Castleford Freight is both an at-risk account and the largest overdue balance at $5,940. Worth a call before the next night shift is confirmed." },
|
|
{ type: "actions", items: [{ label: "Open Invoices", action: "open-invoices" }] },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /client.*risk|risk.*client|at risk/i,
|
|
owner: "clients",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${clientsAtRisk().length} client accounts** need attention.`] },
|
|
{
|
|
type: "card",
|
|
icon: "building",
|
|
title: "Accounts at Risk",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Client", "Status", "Revenue YTD", "Owner"],
|
|
rows: clientsAtRisk().map((c) => ({
|
|
cells: [
|
|
c.name.split(" ")[0],
|
|
{ badge: c.accountStatus, tone: c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) },
|
|
money(c.revenueYtd),
|
|
c.owner.split(" ")[0],
|
|
],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Castleford Freight combines an at-risk status, an overdue invoice and tonight's most understaffed shift. Highest priority account this week." },
|
|
{ type: "actions", items: [{ label: "Open Clients", action: "open-clients", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /client revenue|revenue.*client|top client/i,
|
|
owner: "clients",
|
|
reply: () => [
|
|
{ type: "say", lines: ["**Revenue by client**, year to date."] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Top Client Accounts",
|
|
body: [
|
|
{
|
|
type: "chart",
|
|
kind: "bar",
|
|
unit: "$",
|
|
data: [...CLIENTS]
|
|
.sort((a, b) => b.revenueYtd - a.revenueYtd)
|
|
.slice(0, 5)
|
|
.map((c) => ({ label: c.name.split(" ")[0], value: c.revenueYtd })),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: /client.*staff|demand/i,
|
|
owner: "clients",
|
|
reply: () => [
|
|
{ type: "say", lines: ["**Staffing demand by client**, across live orders."] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Staff Demand",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Client", "Orders", "Staff needed"],
|
|
rows: [...CLIENTS]
|
|
.filter((c) => c.staffDemand > 0)
|
|
.sort((a, b) => b.staffDemand - a.staffDemand)
|
|
.map((c) => ({ cells: [c.name.split(" ")[0], c.activeOrders, c.staffDemand] })),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: /benefit|enrol|enroll/i,
|
|
owner: "benefits",
|
|
reply: () => {
|
|
const enrolled = BENEFITS.reduce((s, b) => s + b.enrolled, 0)
|
|
const cost = BENEFITS.reduce((s, b) => s + b.monthlyCost, 0)
|
|
return [
|
|
{ type: "say", lines: [`**${enrolled} enrolments** across ${BENEFITS.length} plans, costing ${money(cost)} a month.`] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Enrolment by Plan",
|
|
body: [
|
|
{
|
|
type: "chart",
|
|
kind: "bar",
|
|
data: BENEFITS.map((b) => ({ label: b.name.split(" ")[0], value: b.enrolled })),
|
|
},
|
|
{
|
|
type: "table",
|
|
columns: ["Plan", "Enrolled", "Eligible", "Status"],
|
|
rows: BENEFITS.map((b) => ({
|
|
cells: [
|
|
b.name,
|
|
b.enrolled,
|
|
b.eligible,
|
|
{ badge: b.status, tone: b.status === "Review" ? ("warn" as Tone) : b.status === "Closed" ? ("neutral" as Tone) : ("good" as Tone) },
|
|
],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Enhanced Holiday Accrual is in review with only 19 of 32 eligible staff enrolled — the lowest take-up of any open plan." },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /high priority|priority task|my task/i,
|
|
owner: "tasks",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${highPriorityTasks().length} high priority tasks** are open.`] },
|
|
{
|
|
type: "card",
|
|
icon: "check",
|
|
title: "High Priority Tasks",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Task", "Category", "Due"],
|
|
rows: highPriorityTasks().map((t) => ({
|
|
cells: [t.title.slice(0, 34) + (t.title.length > 34 ? "…" : ""), t.category, t.due],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /blocked/i,
|
|
owner: "tasks",
|
|
reply: () => {
|
|
const blocked = TASKS.filter((t) => t.status === "Blocked")
|
|
return [
|
|
{ type: "say", lines: [`**${blocked.length} tasks are blocked.**`] },
|
|
{
|
|
type: "card",
|
|
icon: "alert",
|
|
title: "Blocked Tasks",
|
|
body: [
|
|
{
|
|
type: "alerts",
|
|
items: blocked.map((t) => ({
|
|
icon: "check" as const,
|
|
tone: "warn" as Tone,
|
|
text: t.title,
|
|
note: `${t.assignee} · due ${t.due}`,
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /report|finding/i,
|
|
owner: "reports",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${REPORTS.length} reports** are available across three categories.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Revenue", value: money(REPORTS_SUMMARY.monthlyRevenue), sub: "This month", tone: "good" },
|
|
{ label: "Profit", value: money(REPORTS_SUMMARY.grossProfit), sub: `${REPORTS_SUMMARY.marginPct}% margin`, tone: "primary" },
|
|
{ label: "Overtime", value: money(REPORTS_SUMMARY.overtimeSpend), sub: "This month", tone: "warn" },
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Revenue vs Cost by Department",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Department", "Revenue", "Cost", "Margin"],
|
|
rows: [
|
|
...DEPARTMENT_METRICS.map((d) => ({
|
|
cells: [d.name.split(" & ")[0], money(d.revenue), money(d.cost), `${d.margin}%`],
|
|
})),
|
|
{
|
|
cells: [
|
|
"Total",
|
|
money(DEPARTMENT_METRICS.reduce((s, d) => s + d.revenue, 0)),
|
|
money(DEPARTMENT_METRICS.reduce((s, d) => s + d.cost, 0)),
|
|
`${REPORTS_SUMMARY.marginPct}%`,
|
|
],
|
|
summary: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "recommend",
|
|
text: "Facilities & Cleaning is the weakest margin at 39.8% while carrying the fewest hours — a rate review is the clearest lever.",
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Reports", action: "open-reports" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /schedule|roster today|today's shifts|shifts today/i,
|
|
owner: "orders",
|
|
scope: ["availability"],
|
|
reply: () => [
|
|
{
|
|
type: "say",
|
|
lines: [
|
|
`**Today's schedule** — ${SCHEDULE.length} shifts, ${filledShifts().length} fully staffed.`,
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "clock",
|
|
title: "Today's Schedule",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Time", "Location", "Staffing", "Status"],
|
|
rows: [...SCHEDULE]
|
|
.sort((a, b) => a.time.localeCompare(b.time))
|
|
.map((r) => ({
|
|
cells: [
|
|
r.time,
|
|
r.location,
|
|
`${r.filled}/${r.required}`,
|
|
r.filled >= r.required
|
|
? { badge: "Filled", tone: "good" as Tone }
|
|
: { badge: `${r.required - r.filled} short`, tone: "warn" as Tone },
|
|
],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
...(understaffedShifts().length > 0
|
|
? ([
|
|
{
|
|
type: "recommend",
|
|
text: `${understaffedShifts().length} of today's shifts still need cover — ${totalPlacesShort()} places in total. The 22:00 Castleford night shift is the tightest.`,
|
|
},
|
|
{
|
|
type: "actions",
|
|
items: [
|
|
{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" },
|
|
{ label: "Open Orders", action: "open-orders", variant: "outline" },
|
|
],
|
|
},
|
|
] as Block[])
|
|
: ([
|
|
{
|
|
type: "say",
|
|
lines: ["Every shift today is fully staffed — nothing needs cover."],
|
|
},
|
|
] as Block[])),
|
|
],
|
|
},
|
|
{
|
|
match: /attention|today|summary|status|what.*need/i,
|
|
owner: "home",
|
|
reply: () => [
|
|
{ type: "say", lines: [`Here's what needs attention on ${TODAY.label}.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Attention", value: String(understaffedShifts().length + ORDER_SUMMARY.startingSoon), sub: "Items to action", tone: "warn" },
|
|
{ label: "Coverage", value: `${staffCoverage()}%`, sub: `${STAFF_COUNTS.active} of ${STAFF_COUNTS.total} active`, tone: "good" },
|
|
{ label: "Compliance", value: String(complianceFlagCount()), sub: "Open flags", tone: "bad" },
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "alert",
|
|
title: "Priority Alerts",
|
|
body: [
|
|
{
|
|
type: "alerts",
|
|
items: [
|
|
{ icon: "orders", tone: "warn", text: `${understaffedShifts().length} understaffed orders`, note: `${totalPlacesShort()} places short today` },
|
|
{ icon: "clock", tone: "bad", text: `${ORDER_SUMMARY.startingSoon} shifts starting soon`, note: UPCOMING_SHIFTS[0].location },
|
|
{ icon: "shield", tone: "primary", text: `${complianceFlagCount()} compliance flags`, note: "Review before assignment" },
|
|
{ icon: "file", tone: "warn", text: `${overdueInvoices().length} overdue invoices`, note: money(invoiceTotals().overdue) },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "actions",
|
|
items: [
|
|
{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" },
|
|
{ label: "Open Task Board", action: "open-tasks", variant: "outline" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
/* ===========================================================================
|
|
Page-specific rules.
|
|
|
|
These win over the shared topic rules, so a phrase like "what needs
|
|
attention?" answers about tasks on the Task Board and about the whole
|
|
workforce on Home.
|
|
=========================================================================== */
|
|
|
|
const CONTEXT_RULES: Partial<Record<OwliverContext, Rule[]>> = {
|
|
home: [
|
|
{
|
|
match: /coverage|how is workforce|workforce coverage/i,
|
|
owner: "home",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${staffCoverage()}% of the workforce is active today** — ${STAFF_COUNTS.active} of ${STAFF_COUNTS.total}.`] },
|
|
{ type: "card", icon: "flow", title: "Staff Allocation Flow", body: [{ type: "flow" }] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Coverage Detail",
|
|
body: [
|
|
{ type: "table", columns: ["Status", "Count", "Share"], rows: [
|
|
{ cells: [{ dot: "good", label: "Active" }, STAFF_COUNTS.active, `${percentOfStaff(STAFF_COUNTS.active)}%`] },
|
|
{ cells: [{ dot: "warn", label: "On Break" }, STAFF_COUNTS.onBreak, `${percentOfStaff(STAFF_COUNTS.onBreak)}%`] },
|
|
{ cells: [{ dot: "bad", label: "Unavailable" }, STAFF_COUNTS.unavailable, `${percentOfStaff(STAFF_COUNTS.unavailable)}%`] },
|
|
{ cells: ["Total", STAFF_COUNTS.total, "100%"], summary: true },
|
|
] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
performance: [
|
|
{
|
|
match: /biggest change|caused|daily breakdown|affected area/i,
|
|
owner: "performance",
|
|
reply: () => {
|
|
const swing = TRENDS.map((t, i) => ({ t, delta: i === 0 ? 0 : t.utilisation - TRENDS[i - 1].utilisation }))
|
|
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))[0]
|
|
const weakest = [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin)[0]
|
|
return [
|
|
{ type: "say", lines: [`The biggest single move was **${swing.t.label}**, ${swing.delta > 0 ? "up" : "down"} ${Math.abs(swing.delta)} points to ${swing.t.utilisation}%.`] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Daily Breakdown",
|
|
body: [
|
|
{ type: "table", columns: ["Day", "Utilisation", "Attendance", "Fill rate"], rows: TRENDS.map((t) => ({ cells: [t.label, `${t.utilisation}%`, `${t.attendance}%`, `${t.fillRate}%`] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `${weakest.name} carries the weakest margin at ${weakest.margin}% and is the area most exposed to this swing.` },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /compare|last week|previous week/i,
|
|
owner: "performance",
|
|
reply: () => {
|
|
const avg = Math.round(TRENDS.reduce((a, t) => a + t.utilisation, 0) / TRENDS.length)
|
|
return [
|
|
{ type: "say", lines: [`This week averaged **${avg}% utilisation** against ${averageUtilisation()}% for the tracked roster overall.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "This week", value: `${avg}%`, sub: "Average utilisation", tone: "primary" },
|
|
{ label: "Roster", value: `${averageUtilisation()}%`, sub: "All tracked staff", tone: "good" },
|
|
{ label: "Fill rate", value: `${orderFillRate()}%`, sub: "Live orders", tone: "warn" },
|
|
],
|
|
},
|
|
{ type: "card", icon: "chart", title: "Utilisation This Week", body: [{ type: "chart", kind: "line", unit: "%", data: TRENDS.map((t) => ({ label: t.label, value: t.utilisation })) }] },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /review next|what should we review/i,
|
|
owner: "performance",
|
|
reply: () => {
|
|
const weakest = [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin)[0]
|
|
return [
|
|
{ type: "say", lines: [`Review **${weakest.name}** first — lowest margin and fewest hours.`] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Review Order",
|
|
body: [
|
|
{ type: "table", columns: ["Department", "Hours", "Margin"], rows: [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin).map((d) => ({ cells: [d.name, num(d.hours), `${d.margin}%`] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `Weekend fill rate is the second item — Saturday ran ${TRENDS[5].utilisation}% utilisation against a ${TRENDS[5].fillRate}% fill rate.` },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /what changed|explain|why|change this week/i,
|
|
owner: "performance",
|
|
reply: () => {
|
|
const first = TRENDS[0], last = TRENDS[TRENDS.length - 1]
|
|
const peak = [...TRENDS].sort((a, b) => b.utilisation - a.utilisation)[0]
|
|
return [
|
|
{ type: "say", lines: [`**Utilisation moved from ${first.utilisation}% on ${first.label} to ${last.utilisation}% on ${last.label}.**`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Peak", value: `${peak.utilisation}%`, sub: peak.label, tone: "primary" },
|
|
{ label: "Attendance", value: `${Math.round(TRENDS.reduce((a, t) => a + t.attendance, 0) / TRENDS.length)}%`, sub: "Weekly average", tone: "good" },
|
|
{ label: "Fill rate", value: `${Math.round(TRENDS.reduce((a, t) => a + t.fillRate, 0) / TRENDS.length)}%`, sub: "Weekly average", tone: "warn" },
|
|
],
|
|
},
|
|
{ type: "card", icon: "chart", title: "Utilisation This Week", body: [{ type: "chart", kind: "line", unit: "%", data: TRENDS.map((t) => ({ label: t.label, value: t.utilisation })) }] },
|
|
{ type: "recommend", text: `${peak.label} peaked at ${peak.utilisation}% utilisation while fill rate fell to ${peak.fillRate}% — demand is outrunning available supply at the end of the week.` },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /area needs attention|which area|needs attention|attention/i,
|
|
owner: "performance",
|
|
reply: () => {
|
|
const weakest = [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin)[0]
|
|
return [
|
|
{ type: "say", lines: [`**${weakest.name}** is the weakest area this week.`] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Department Performance",
|
|
body: [
|
|
{ type: "table", columns: ["Department", "Hours", "Margin"], rows: DEPARTMENT_METRICS.map((d) => ({ cells: [d.name.split(" & ")[0], num(d.hours), `${d.margin}%`] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `${weakest.name} carries the fewest hours (${num(weakest.hours)}) at the lowest margin (${weakest.margin}%) — a rate review is the clearest lever.` },
|
|
]
|
|
},
|
|
},
|
|
],
|
|
|
|
orders: [
|
|
{
|
|
match: /attention|need/i,
|
|
owner: "orders",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${understaffedOrders().length} orders need attention** — ${totalPlacesShort()} places short in total.`] },
|
|
{
|
|
type: "card",
|
|
icon: "orders",
|
|
title: "Orders Needing Attention",
|
|
body: [
|
|
{ type: "table", columns: ["Order", "Client", "Staffing", "Value"], rows: understaffedOrders().map((o) => ({ cells: [o.id, o.client.split(" ")[0], `${o.filled}/${o.required}`, money(o.value)] })) },
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }, { label: "Open Orders", action: "open-orders", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /soonest|starts first|next order|earliest/i,
|
|
owner: "orders",
|
|
reply: () => {
|
|
const next = [...SCHEDULE].sort((a, b) => a.time.localeCompare(b.time))[0]
|
|
return [
|
|
{ type: "say", lines: [`The next order to start is **${next.ref}** at ${next.time}, ${next.location}.`] },
|
|
{
|
|
type: "card",
|
|
icon: "clock",
|
|
title: "Starting Order",
|
|
body: [
|
|
{ type: "table", columns: ["Time", "Order", "Location", "Staffing"], rows: [{ cells: [next.time, next.ref, next.location, `${next.filled}/${next.required}`] }] },
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Orders", action: "open-orders", variant: "outline" }] },
|
|
]
|
|
},
|
|
},
|
|
],
|
|
|
|
availability: [
|
|
{
|
|
match: /cover|next shift|who can/i,
|
|
owner: "availability",
|
|
reply: () => {
|
|
const candidates = [...STAFF]
|
|
.filter((m) => m.status !== "Unavailable" && m.compliance === "Verified")
|
|
.sort((a, b) => a.utilisation - b.utilisation)
|
|
.slice(0, 4)
|
|
return [
|
|
{ type: "say", lines: ["**Best cover for the next shift**, by lowest current utilisation."] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Cover Candidates",
|
|
body: [
|
|
{ type: "table", columns: ["Staff", "Role", "Utilisation"], rows: candidates.map((m) => ({ cells: [m.name, m.role, `${m.utilisation}%`] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `${candidates[0]?.name} has the most headroom at ${candidates[0]?.utilisation}% utilisation and is compliance-verified.` },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /upcoming|later today|next few hours/i,
|
|
owner: "availability",
|
|
reply: () => [
|
|
{ type: "say", lines: ["**Upcoming shifts** across today."] },
|
|
{
|
|
type: "card",
|
|
icon: "clock",
|
|
title: "Upcoming Shifts",
|
|
body: [
|
|
{ type: "table", columns: ["When", "Location", "Staff"], rows: UPCOMING_SHIFTS.map((u) => ({ cells: [u.when.replace("Starting ", ""), u.location, u.count] })) },
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] },
|
|
],
|
|
},
|
|
],
|
|
|
|
tasks: [
|
|
{
|
|
match: /overdue|due today|late/i,
|
|
owner: "tasks",
|
|
reply: () => {
|
|
const due = openTasks().filter((t) => /Today/.test(t.due))
|
|
return [
|
|
{ type: "say", lines: [`**${due.length} tasks are due today.**`] },
|
|
{
|
|
type: "card",
|
|
icon: "check",
|
|
title: "Due Today",
|
|
body: [
|
|
{ type: "table", columns: ["Task", "Due", "Owner"], rows: due.map((t) => ({ cells: [t.title.slice(0, 30) + (t.title.length > 30 ? "…" : ""), t.due.replace(" Today", ""), t.assigneeInitials] })) },
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /unassigned|no owner|nobody/i,
|
|
owner: "tasks",
|
|
reply: () => {
|
|
const byOwner = new Map<string, number>()
|
|
openTasks().forEach((t) => byOwner.set(t.assignee, (byOwner.get(t.assignee) ?? 0) + 1))
|
|
return [
|
|
{ type: "say", lines: ["Every open task has an owner — nothing is unassigned."] },
|
|
{
|
|
type: "card",
|
|
icon: "check",
|
|
title: "Open Tasks by Owner",
|
|
body: [
|
|
{ type: "table", columns: ["Owner", "Open tasks"], rows: [...byOwner].map(([o, n]) => ({ cells: [o, n] })) },
|
|
],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /attention|what needs/i,
|
|
owner: "tasks",
|
|
reply: () => {
|
|
const blocked = TASKS.filter((t) => t.status === "Blocked")
|
|
return [
|
|
{ type: "say", lines: [`**${highPriorityTasks().length} high priority** and **${blocked.length} blocked** tasks need attention.`] },
|
|
{
|
|
type: "alerts",
|
|
items: [
|
|
...highPriorityTasks().map((t) => ({ icon: "check" as const, tone: "bad" as Tone, text: t.title, note: `${t.assignee} · due ${t.due}` })),
|
|
...blocked.map((t) => ({ icon: "alert" as const, tone: "warn" as Tone, text: t.title, note: `Blocked · ${t.assignee}` })),
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] },
|
|
]
|
|
},
|
|
},
|
|
],
|
|
|
|
staff: [
|
|
{
|
|
match: /find available|available staff|who is working|working today|on shift/i,
|
|
owner: "staff",
|
|
reply: () => {
|
|
const active = STAFF.filter((m) => m.status === "Active")
|
|
return [
|
|
{ type: "say", lines: [`**${active.length} of ${STAFF.length} tracked staff are working today.**`] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Working Today",
|
|
body: [
|
|
{ type: "table", columns: ["Staff", "Role", "Shift"], rows: active.map((m) => ({ cells: [m.name, m.role, m.shift] })) },
|
|
],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /by role|roles|department/i,
|
|
owner: "staff",
|
|
reply: () => {
|
|
const byDept = new Map<string, number>()
|
|
STAFF.forEach((m) => byDept.set(m.department, (byDept.get(m.department) ?? 0) + 1))
|
|
return [
|
|
{ type: "say", lines: ["**Roster by department.**"] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Staff by Department",
|
|
body: [
|
|
{ type: "chart", kind: "bar", data: [...byDept].map(([d, n]) => ({ label: d.split(" & ")[0], value: n })) },
|
|
{ type: "table", columns: ["Department", "Staff"], rows: [...byDept].map(([d, n]) => ({ cells: [d, n] })) },
|
|
],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /profile|records|directory|show staff/i,
|
|
owner: "staff",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${STAFF.length} staff records** tracked in this pool.`] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Staff Records",
|
|
body: [
|
|
{ type: "table", columns: ["Staff", "Role", "Rating", "Standing"], rows: STAFF.slice(0, 8).map((m) => ({ cells: [m.name, m.role, m.rating, { badge: m.compliance, tone: m.compliance === "Verified" ? ("good" as Tone) : m.compliance === "Expired" ? ("bad" as Tone) : ("warn" as Tone) }] })) },
|
|
],
|
|
},
|
|
{ type: "details", summary: `${staffWithComplianceIssues().length} records need a compliance review`, body: [{ type: "table", columns: ["Staff", "Note"], rows: staffWithComplianceIssues().map((m) => ({ cells: [m.name, m.complianceNote ?? ""] })) }] },
|
|
],
|
|
},
|
|
],
|
|
|
|
compliance: [
|
|
{
|
|
match: /affected|which staff|who is affected/i,
|
|
owner: "compliance",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${complianceFlagCount()} staff flags** across ${COMPLIANCE_RISKS.length} categories.`] },
|
|
{
|
|
type: "card",
|
|
icon: "shield",
|
|
title: "Affected Staff",
|
|
body: [
|
|
{ type: "table", columns: ["Staff", "Issue", "Severity"], rows: COMPLIANCE_RISKS.flatMap((c) => c.staffNames.map((n) => ({ cells: [n, c.category, { badge: c.severity, tone: severityTone[c.severity] }] }))) },
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Compliance", action: "open-compliance", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /high.?severity|urgent|critical/i,
|
|
owner: "compliance",
|
|
reply: () => {
|
|
const high = COMPLIANCE_RISKS.filter((c) => c.severity === "High")
|
|
return [
|
|
{ type: "say", lines: [`**${high.reduce((n, c) => n + c.staffCount, 0)} staff** carry a high-severity flag.`] },
|
|
{
|
|
type: "alerts",
|
|
items: high.map((c) => ({ icon: "shield" as const, tone: "bad" as Tone, text: c.risk, note: `${c.staffNames.join(", ")} · expires ${c.expires}` })),
|
|
},
|
|
{ type: "recommend", text: "High-severity flags block assignment until cleared — resolve these before tonight's rosters are confirmed." },
|
|
{ type: "actions", items: [{ label: "Open Compliance", action: "open-compliance" }] },
|
|
]
|
|
},
|
|
},
|
|
],
|
|
|
|
benefits: [
|
|
{
|
|
match: /attention|review|needs/i,
|
|
owner: "benefits",
|
|
reply: () => {
|
|
const review = BENEFITS.filter((b) => b.status === "Review")
|
|
const lowest = [...BENEFITS].sort((a, b) => a.enrolled / a.eligible - b.enrolled / b.eligible)[0]
|
|
return [
|
|
{ type: "say", lines: [`**${review.length} plan${review.length === 1 ? "" : "s"} in review**, and take-up is lowest on ${lowest.name}.`] },
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Plans Needing Attention",
|
|
body: [
|
|
{ type: "table", columns: ["Plan", "Enrolled", "Eligible", "Status"], rows: [...review, lowest].filter((b, i, a) => a.indexOf(b) === i).map((b) => ({ cells: [b.name, b.enrolled, b.eligible, { badge: b.status, tone: b.status === "Review" ? ("warn" as Tone) : ("good" as Tone) }] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `${lowest.name} has only ${lowest.enrolled} of ${lowest.eligible} eligible staff enrolled — the lowest take-up of any plan.` },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /staff benefit|benefit status|tier/i,
|
|
owner: "benefits",
|
|
reply: () => {
|
|
const byTier = new Map<string, number>()
|
|
STAFF.forEach((m) => byTier.set(m.benefitsTier, (byTier.get(m.benefitsTier) ?? 0) + 1))
|
|
return [
|
|
{ type: "say", lines: ["**Benefit tier spread** across the tracked roster."] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Staff Benefit Status",
|
|
body: [
|
|
{ type: "table", columns: ["Tier", "Staff", "Share"], rows: [...byTier].map(([t, n]) => ({ cells: [t, n, `${Math.round((n / STAFF.length) * 100)}%`] })) },
|
|
],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
],
|
|
|
|
clients: [
|
|
{
|
|
match: /overview|summary|active client|show client|client activity/i,
|
|
owner: "clients",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${CLIENTS.length} client accounts**, ${CLIENTS.filter((c) => c.accountStatus === "Active").length} currently active.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Revenue YTD", value: money(CLIENTS.reduce((n, c) => n + c.revenueYtd, 0)), sub: "All accounts", tone: "good" },
|
|
{ label: "Live orders", value: String(CLIENTS.reduce((n, c) => n + c.activeOrders, 0)), sub: "Across clients", tone: "primary" },
|
|
{ label: "At risk", value: String(clientsAtRisk().length), sub: "Need attention", tone: "warn" },
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "building",
|
|
title: "Client Accounts",
|
|
body: [
|
|
{ type: "table", columns: ["Client", "Orders", "Status"], rows: CLIENTS.map((c) => ({ cells: [c.name.split(" ")[0], c.activeOrders, { badge: c.accountStatus, tone: c.accountStatus === "Active" ? ("good" as Tone) : c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) }] })) },
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Clients", action: "open-clients", variant: "outline" }] },
|
|
],
|
|
},
|
|
{
|
|
match: /attention|need/i,
|
|
owner: "clients",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${clientsAtRisk().length} accounts need attention.**`] },
|
|
{
|
|
type: "card",
|
|
icon: "building",
|
|
title: "Accounts at Risk",
|
|
body: [
|
|
{ type: "table", columns: ["Client", "Status", "Revenue YTD", "Owner"], rows: clientsAtRisk().map((c) => ({ cells: [c.name.split(" ")[0], { badge: c.accountStatus, tone: c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) }, money(c.revenueYtd), c.owner.split(" ")[0]] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Castleford Freight is at risk and also carries the largest overdue balance — the highest priority account this week." },
|
|
],
|
|
},
|
|
],
|
|
|
|
invoices: [
|
|
{
|
|
match: /ready for review|ready|draft/i,
|
|
owner: "invoices",
|
|
reply: () => {
|
|
const drafts = INVOICES.filter((i) => i.status === "Draft")
|
|
return [
|
|
{ type: "say", lines: [`**${money(invoiceTotals().draft)} is ready for billing review** across ${drafts.length} draft invoices.`] },
|
|
{
|
|
type: "card",
|
|
icon: "file",
|
|
title: "Ready for Review",
|
|
body: [
|
|
{ type: "table", columns: ["Invoice", "Client", "Amount"], rows: drafts.map((i) => ({ cells: [i.id, i.client.split(" ")[0], money(i.amount)] })) },
|
|
],
|
|
},
|
|
{ type: "actions", items: [{ label: "Open Invoices", action: "open-invoices", variant: "outline" }] },
|
|
]
|
|
},
|
|
},
|
|
],
|
|
|
|
payroll: [
|
|
{
|
|
match: /which staff|staff cost the most|highest paid|who cost/i,
|
|
owner: "payroll",
|
|
reply: () => {
|
|
const top = [...PAYROLL_LINES].sort((a, b) => b.gross - a.gross).slice(0, 6)
|
|
return [
|
|
{ type: "say", lines: [`**${top[0].name}** is the highest single cost this week at ${money2(top[0].gross)}.`] },
|
|
{
|
|
type: "card",
|
|
icon: "money",
|
|
title: "Highest Cost Staff",
|
|
body: [
|
|
{ type: "table", columns: ["Staff", "Hours", "Gross"], rows: top.map((l) => ({ cells: [l.name, l.hours, money2(l.gross)] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `${payrollAnomalies().length} pay lines carry an anomaly flag and should be cleared before the run is approved.` },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /worked hours|hours/i,
|
|
owner: "payroll",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${num(PAYROLL_SUMMARY.workedHours)} hours worked** this week across ${STAFF_COUNTS.total} staff.`] },
|
|
{
|
|
type: "card",
|
|
icon: "clock",
|
|
title: "Hours by Department",
|
|
body: [
|
|
{ type: "chart", kind: "bar", data: PAYROLL_SUMMARY.breakdown.map((b) => ({ label: b.group.split(" & ")[0], value: b.hours })) },
|
|
{ type: "table", columns: ["Department", "Hours"], rows: [...PAYROLL_SUMMARY.breakdown.map((b) => ({ cells: [b.group, b.hours] })), { cells: ["Total", PAYROLL_SUMMARY.workedHours], summary: true }] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: /staff cost|cost/i,
|
|
owner: "payroll",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${money(PAYROLL_SUMMARY.total)} staff cost** this week, ${money2(averageHourlyCost())} per worked hour.`] },
|
|
{
|
|
type: "card",
|
|
icon: "money",
|
|
title: "Cost by Department",
|
|
body: [
|
|
{ type: "chart", kind: "bar", unit: "$", data: PAYROLL_SUMMARY.breakdown.map((b) => ({ label: b.group.split(" & ")[0], value: b.cost })) },
|
|
{ type: "table", columns: ["Department", "Hours", "Cost"], rows: [...PAYROLL_SUMMARY.breakdown.map((b) => ({ cells: [b.group, b.hours, money(b.cost)] })), { cells: ["Total", PAYROLL_SUMMARY.workedHours, money(PAYROLL_SUMMARY.total)], summary: true }] },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `Overtime accounts for ${money(PAYROLL_SUMMARY.overtimeSpend)} of the total — Event & Hospitality carries the largest share of hours.` },
|
|
],
|
|
},
|
|
],
|
|
|
|
reports: [
|
|
{
|
|
match: /what changed|should i review|review|latest/i,
|
|
owner: "reports",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**Margin is holding at ${REPORTS_SUMMARY.marginPct}%** on ${money(REPORTS_SUMMARY.monthlyRevenue)} of monthly revenue.`] },
|
|
{
|
|
type: "metrics",
|
|
items: [
|
|
{ label: "Revenue", value: money(REPORTS_SUMMARY.monthlyRevenue), sub: "This month", tone: "good" },
|
|
{ label: "Profit", value: money(REPORTS_SUMMARY.grossProfit), sub: `${REPORTS_SUMMARY.marginPct}% margin`, tone: "primary" },
|
|
{ label: "Overtime", value: money(REPORTS_SUMMARY.overtimeSpend), sub: "Worth review", tone: "warn" },
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "chart",
|
|
title: "Most Recently Updated",
|
|
body: [
|
|
{ type: "table", columns: ["Report", "Category", "Updated"], rows: REPORTS.slice(0, 4).map((r) => ({ cells: [r.title, r.category, r.updated] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `Billable utilisation is ${REPORTS_SUMMARY.billableUtilisation}% — review the Payroll Cost Breakdown and Invoice Ageing reports first.` },
|
|
{ type: "actions", items: [{ label: "Open Reports", action: "open-reports", variant: "outline" }] },
|
|
],
|
|
},
|
|
],
|
|
|
|
messages: [
|
|
{
|
|
match: /unread|response|reply|recent|conversation|attention|message/i,
|
|
owner: "messages",
|
|
reply: () => {
|
|
const needsReply = CONVERSATIONS.filter((c) => c.unread > 0)
|
|
return [
|
|
{ type: "say", lines: [`**${unreadMessages()} unread messages** across ${needsReply.length} conversations.`] },
|
|
{
|
|
type: "alerts",
|
|
items: needsReply.map((c) => ({ icon: "sparkles" as const, tone: "warn" as Tone, text: `${c.name} — ${c.unread} unread`, note: c.messages[c.messages.length - 1].text })),
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "sparkles",
|
|
title: "Recent Conversations",
|
|
body: [
|
|
{ type: "table", columns: ["From", "Last", "Unread"], rows: CONVERSATIONS.map((c) => ({ cells: [c.name, c.lastAt, c.unread === 0 ? "—" : { badge: String(c.unread), tone: "primary" as Tone }] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: `${needsReply[0]?.name ?? "Marcus Vance"} is waiting on an overtime approval for tonight's night shift — the most time-sensitive reply.` },
|
|
]
|
|
},
|
|
},
|
|
],
|
|
|
|
settings: [
|
|
{
|
|
match: /integration/i,
|
|
owner: "settings",
|
|
reply: () => {
|
|
const c = SETTINGS_SUMMARY.integrations.filter((i) => i.connected)
|
|
return [
|
|
{ type: "say", lines: [`**${c.length} of ${SETTINGS_SUMMARY.integrations.length} integrations connected.**`] },
|
|
{
|
|
type: "card",
|
|
icon: "building",
|
|
title: "Integrations",
|
|
body: [
|
|
{ type: "table", columns: ["Integration", "Category", "Status"], rows: SETTINGS_SUMMARY.integrations.map((i) => ({ cells: [i.name, i.category, { badge: i.connected ? "Connected" : "Not connected", tone: i.connected ? ("good" as Tone) : ("neutral" as Tone) }] })) },
|
|
],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /notification/i,
|
|
owner: "settings",
|
|
reply: () => {
|
|
const on = SETTINGS_SUMMARY.notifications.filter((n) => n.on)
|
|
return [
|
|
{ type: "say", lines: [`**${on.length} of ${SETTINGS_SUMMARY.notifications.length} notifications enabled.**`] },
|
|
{
|
|
type: "card",
|
|
icon: "alert",
|
|
title: "Notification Preferences",
|
|
body: [
|
|
{ type: "table", columns: ["Alert", "Status"], rows: SETTINGS_SUMMARY.notifications.map((n) => ({ cells: [n.label, { badge: n.on ? "On" : "Off", tone: n.on ? ("good" as Tone) : ("neutral" as Tone) }] })) },
|
|
],
|
|
},
|
|
{ type: "recommend", text: "Invoice overdue reminders are off while two invoices are past due — worth enabling." },
|
|
]
|
|
},
|
|
},
|
|
{
|
|
match: /account|member|team/i,
|
|
owner: "settings",
|
|
reply: () => [
|
|
{ type: "say", lines: [`**${SETTINGS_SUMMARY.members.length} workspace members**, signed in as ${USER.fullName}.`] },
|
|
{
|
|
type: "card",
|
|
icon: "users",
|
|
title: "Members & Access",
|
|
body: [
|
|
{ type: "table", columns: ["Member", "Role", "Access"], rows: SETTINGS_SUMMARY.members.map((m) => ({ cells: [m.name, m.role, { badge: m.access, tone: m.access === "Admin" ? ("primary" as Tone) : ("neutral" as Tone) }] })) },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: /workspace|setting|configuration|setup/i,
|
|
owner: "settings",
|
|
reply: () => {
|
|
const w = SETTINGS_SUMMARY.workspace
|
|
return [
|
|
{ type: "say", lines: [`Workspace configuration for **${TEAMS[0].name}**.`] },
|
|
{
|
|
type: "card",
|
|
icon: "building",
|
|
title: "Workspace Settings",
|
|
body: [
|
|
{ type: "table", columns: ["Setting", "Value"], rows: [
|
|
{ cells: ["Plan", w.plan] },
|
|
{ cells: ["Time zone", w.timeZone] },
|
|
{ cells: ["Week starts on", w.weekStartsOn] },
|
|
{ cells: ["Max weekly hours", String(w.maxWeeklyHours)] },
|
|
{ cells: ["Overtime multiplier", w.overtimeMultiplier] },
|
|
{ cells: ["Compliance window", w.complianceWindow] },
|
|
] },
|
|
],
|
|
},
|
|
{ type: "alerts", items: [{ icon: "shield", tone: "warn", text: `${SETTINGS_SUMMARY.integrations.filter((i) => !i.connected).length} integrations not connected`, note: SETTINGS_SUMMARY.integrations.filter((i) => !i.connected).map((i) => i.name).join(" and ") }] },
|
|
]
|
|
},
|
|
},
|
|
],
|
|
}
|
|
|
|
/* ===========================================================================
|
|
Page scoping.
|
|
|
|
Owliver uses one component and one dataset everywhere; only the context
|
|
changes. A question whose topic belongs to another page is handed back
|
|
rather than answered with that page's data.
|
|
=========================================================================== */
|
|
|
|
const CONTEXT_META: Record<
|
|
OwliverContext,
|
|
{ label: string; helpsWith: string; action?: ActionId }
|
|
> = {
|
|
home: {
|
|
label: "Home",
|
|
helpsWith: "anything across the workforce — orders, staffing, compliance, payroll and billing",
|
|
},
|
|
performance: {
|
|
label: "Performance",
|
|
helpsWith: "performance, utilisation and workforce trends",
|
|
},
|
|
orders: {
|
|
label: "Orders",
|
|
helpsWith: "orders, staffing gaps and order value",
|
|
action: "open-orders",
|
|
},
|
|
availability: {
|
|
label: "Staff Availability",
|
|
helpsWith: "who is available, who is unavailable and shift cover",
|
|
action: "open-availability",
|
|
},
|
|
tasks: {
|
|
label: "Task Board",
|
|
helpsWith: "tasks, priorities and workload",
|
|
action: "open-tasks",
|
|
},
|
|
staff: {
|
|
label: "Staff Directory",
|
|
helpsWith: "the roster, roles and staff records",
|
|
action: "open-staff",
|
|
},
|
|
compliance: {
|
|
label: "Compliance",
|
|
helpsWith: "compliance flags, certificates and affected staff",
|
|
action: "open-compliance",
|
|
},
|
|
benefits: {
|
|
label: "Benefits",
|
|
helpsWith: "benefit plans, enrolment and eligibility",
|
|
},
|
|
clients: {
|
|
label: "Clients",
|
|
helpsWith: "client accounts, revenue and account health",
|
|
action: "open-clients",
|
|
},
|
|
invoices: {
|
|
label: "Invoices",
|
|
helpsWith: "invoices, outstanding balances and billing",
|
|
action: "open-invoices",
|
|
},
|
|
payroll: {
|
|
label: "Payroll",
|
|
helpsWith: "payroll totals, worked hours, cost and anomalies",
|
|
action: "open-payroll",
|
|
},
|
|
reports: {
|
|
label: "Reports",
|
|
helpsWith: "reports, findings and recommended actions",
|
|
action: "open-reports",
|
|
},
|
|
messages: {
|
|
label: "Messages",
|
|
helpsWith: "conversations, unread messages and what needs a reply",
|
|
},
|
|
settings: {
|
|
label: "Settings",
|
|
helpsWith: "workspace configuration, members, notifications and integrations",
|
|
},
|
|
}
|
|
|
|
/** Hands an out-of-scope question back to the page that owns it. */
|
|
function deflect(owner: OwliverContext, context: OwliverContext): Block[] {
|
|
const target = CONTEXT_META[owner]
|
|
const here = CONTEXT_META[context]
|
|
const blocks: Block[] = [
|
|
{
|
|
type: "say",
|
|
lines: [
|
|
`That information belongs to **${target.label}**. I can help with ${here.helpsWith} here.`,
|
|
],
|
|
},
|
|
]
|
|
if (target.action) {
|
|
blocks.push({
|
|
type: "actions",
|
|
items: [{ label: `Open ${target.label}`, action: target.action }],
|
|
})
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
/** Nothing matched — say what this page can answer. */
|
|
function pageFallback(context: OwliverContext): Block[] {
|
|
return [
|
|
{
|
|
type: "say",
|
|
lines: [
|
|
"I can't answer that from the demo dataset.",
|
|
`On this page I can help with ${CONTEXT_META[context].helpsWith}.`,
|
|
],
|
|
},
|
|
{
|
|
type: "card",
|
|
icon: "sparkles",
|
|
title: "Try asking",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Question"],
|
|
rows: QUICK_PROMPTS[context].map((q) => ({ cells: [q] })),
|
|
},
|
|
],
|
|
},
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Builds Owliver's reply, scoped to the active page.
|
|
*
|
|
* Page-specific rules win first, then the shared topic rules — but a shared
|
|
* rule only answers if the active page owns it (Home owns everything, being
|
|
* the overall workforce view). Always deterministic.
|
|
*/
|
|
export function respond(prompt: string, context: OwliverContext = "home"): Block[] {
|
|
const local = CONTEXT_RULES[context]?.find((r) => r.match.test(prompt))
|
|
if (local) return local.reply()
|
|
|
|
const rule = RULES.find((r) => r.match.test(prompt))
|
|
if (rule) {
|
|
const inScope =
|
|
context === "home" ||
|
|
rule.owner === context ||
|
|
rule.owner === "home" ||
|
|
(rule.scope?.includes(context) ?? false)
|
|
return inScope ? rule.reply() : deflect(rule.owner, context)
|
|
}
|
|
|
|
return pageFallback(context)
|
|
}
|
|
|
|
|
|
/* ===========================================================================
|
|
Follow-up suggestions.
|
|
|
|
After every answer Owliver offers the most useful next questions for what was
|
|
just asked — never the same fixed list, and never outside the active page.
|
|
A deflection deliberately falls back to the page's own suggestions so the
|
|
user is not led further off-scope.
|
|
=========================================================================== */
|
|
|
|
interface FollowUpBranch {
|
|
match: RegExp
|
|
next: string[]
|
|
}
|
|
|
|
const FOLLOW_UPS: Partial<Record<OwliverContext, FollowUpBranch[]>> = {
|
|
home: [
|
|
{ match: /understaff|gap|short/i, next: ["How is workforce coverage?", "What needs attention today?", "Summarise payroll this week"] },
|
|
{ match: /coverage|workforce/i, next: ["Who's uncovered tomorrow?", "Which orders are understaffed?", "Show compliance risks"] },
|
|
{ match: /attention|summary|today/i, next: ["Which orders are understaffed?", "How is workforce coverage?", "Show compliance risks"] },
|
|
{ match: /compliance/i, next: ["Which staff are affected?", "What needs attention today?", "How is workforce coverage?"] },
|
|
{ match: /payroll|cost|hours/i, next: ["Are there payroll anomalies?", "What needs attention today?", "Give me a workforce summary"] },
|
|
],
|
|
performance: [
|
|
{ match: /what changed|this week/i, next: ["Explain the performance change", "Show utilization trend", "Which area needs attention?"] },
|
|
{ match: /explain/i, next: ["What caused the biggest change?", "Show the daily breakdown", "Which area needs attention?"] },
|
|
{ match: /biggest change|caused|daily breakdown/i, next: ["Show the affected area", "Compare with last week", "What should we review next?"] },
|
|
{ match: /affected area|which area|attention/i, next: ["Compare with last week", "Show utilization trend", "What should we review next?"] },
|
|
{ match: /compare|last week/i, next: ["What should we review next?", "Show the daily breakdown", "Which area needs attention?"] },
|
|
{ match: /trend|utilisation|utilization/i, next: ["What changed this week?", "Which area needs attention?", "Show the daily breakdown"] },
|
|
],
|
|
orders: [
|
|
{ match: /understaff|gap|short/i, next: ["Which order has the biggest staffing gap?", "Which order starts soonest?", "Which orders need attention?"] },
|
|
{ match: /biggest|highest value|value/i, next: ["Which orders are understaffed?", "Which order starts soonest?", "Which orders need attention?"] },
|
|
{ match: /soonest|starts/i, next: ["Which orders are understaffed?", "Which orders need attention?", "Which order has the highest value?"] },
|
|
{ match: /attention|need/i, next: ["Which order has the biggest staffing gap?", "Which order starts soonest?", "Which order has the highest value?"] },
|
|
],
|
|
availability: [
|
|
{ match: /available/i, next: ["Where do we need shift coverage?", "Who is unavailable?", "Show upcoming availability"] },
|
|
{ match: /unavailable/i, next: ["Who is available today?", "Where do we need shift coverage?", "Show upcoming availability"] },
|
|
{ match: /cover|coverage/i, next: ["Who is available today?", "Show upcoming availability", "Who is unavailable?"] },
|
|
{ match: /upcoming/i, next: ["Where do we need shift coverage?", "Who is available today?", "Who is unavailable?"] },
|
|
],
|
|
tasks: [
|
|
{ match: /overdue|due/i, next: ["Show high-priority tasks", "What needs attention?", "Which tasks are unassigned?"] },
|
|
{ match: /priority/i, next: ["Which tasks are overdue?", "What's blocked right now?", "Which tasks are unassigned?"] },
|
|
{ match: /attention|blocked/i, next: ["Which tasks are overdue?", "Show high-priority tasks", "Which tasks are unassigned?"] },
|
|
{ match: /unassigned/i, next: ["Which tasks are overdue?", "Show high-priority tasks", "What needs attention?"] },
|
|
],
|
|
staff: [
|
|
{ match: /available|working/i, next: ["Show staff by role", "Show staff profiles", "Who is working today?"] },
|
|
{ match: /role|department/i, next: ["Find available staff", "Show staff profiles", "Who is working today?"] },
|
|
{ match: /profile|record/i, next: ["Show staff by role", "Find available staff", "Who is working today?"] },
|
|
],
|
|
compliance: [
|
|
{ match: /high.?severity|urgent/i, next: ["Which staff are affected?", "Show expiring certificates", "What compliance risks need attention?"] },
|
|
{ match: /affected|who/i, next: ["Show high-severity issues", "Show expiring certificates", "What compliance risks need attention?"] },
|
|
{ match: /expir|certificat/i, next: ["Which staff are affected?", "Show high-severity issues", "What compliance risks need attention?"] },
|
|
{ match: /risk|attention/i, next: ["Show high-severity issues", "Which staff are affected?", "Show expiring certificates"] },
|
|
],
|
|
benefits: [
|
|
{ match: /overview|enrol/i, next: ["Which benefits need attention?", "Show staff benefit status", "What needs review?"] },
|
|
{ match: /attention|review/i, next: ["Show benefits overview", "Show staff benefit status", "What needs review?"] },
|
|
{ match: /status|tier/i, next: ["Show benefits overview", "Which benefits need attention?", "What needs review?"] },
|
|
],
|
|
clients: [
|
|
{ match: /overview|active|activity/i, next: ["Which clients need attention?", "Show client revenue", "Which clients demand most staff?"] },
|
|
{ match: /attention|risk/i, next: ["Show client overview", "Show client revenue", "Show active clients"] },
|
|
{ match: /revenue|demand/i, next: ["Which clients need attention?", "Show client overview", "Show active clients"] },
|
|
],
|
|
invoices: [
|
|
{ match: /overdue/i, next: ["Show invoice summary", "What is ready for review?", "Show billing signals"] },
|
|
{ match: /ready|draft|review/i, next: ["Which invoices are overdue?", "Show invoice summary", "Show billing signals"] },
|
|
{ match: /summary|billing|outstanding/i, next: ["Which invoices are overdue?", "What is ready for review?", "Show billing signals"] },
|
|
],
|
|
payroll: [
|
|
{ match: /summarise|summary|payroll/i, next: ["Which staff cost the most?", "Are there any payroll anomalies?", "Show worked hours"] },
|
|
{ match: /which staff|cost the most/i, next: ["Are there any payroll anomalies?", "Show worked hours", "Summarise payroll"] },
|
|
{ match: /anomal/i, next: ["Which staff cost the most?", "Show worked hours", "Summarise payroll"] },
|
|
{ match: /hours|cost/i, next: ["Which staff cost the most?", "Are there any payroll anomalies?", "Summarise payroll"] },
|
|
],
|
|
reports: [
|
|
{ match: /latest|summarise/i, next: ["Show important findings", "What changed?", "What should I review?"] },
|
|
{ match: /finding/i, next: ["What should I review?", "Summarise the latest report", "What changed?"] },
|
|
{ match: /changed|review/i, next: ["Show important findings", "Summarise the latest report", "What changed?"] },
|
|
],
|
|
messages: [
|
|
{ match: /unread/i, next: ["What needs a response?", "Which conversations need attention?", "Show recent messages"] },
|
|
{ match: /response|reply/i, next: ["Show unread messages", "Show recent messages", "Which conversations need attention?"] },
|
|
{ match: /recent|conversation|attention/i, next: ["Show unread messages", "What needs a response?", "Show recent messages"] },
|
|
],
|
|
settings: [
|
|
{ match: /workspace|configuration/i, next: ["Review notifications", "Review integrations", "Show account settings"] },
|
|
{ match: /notification/i, next: ["Review integrations", "Show workspace settings", "Show account settings"] },
|
|
{ match: /integration/i, next: ["Review notifications", "Show workspace settings", "Show account settings"] },
|
|
{ match: /account|member/i, next: ["Show workspace settings", "Review notifications", "Review integrations"] },
|
|
],
|
|
}
|
|
|
|
/** Whether a question is answerable on this page (rather than handed back). */
|
|
export function isInScope(prompt: string, context: OwliverContext): boolean {
|
|
if (CONTEXT_RULES[context]?.some((r) => r.match.test(prompt))) return true
|
|
const rule = RULES.find((r) => r.match.test(prompt))
|
|
if (!rule) return true
|
|
return (
|
|
context === "home" ||
|
|
rule.owner === context ||
|
|
rule.owner === "home" ||
|
|
(rule.scope?.includes(context) ?? false)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The next questions to offer after answering `prompt` on `context`.
|
|
* Always page-scoped, never repeats the question just asked.
|
|
*/
|
|
export function followUpsFor(prompt: string, context: OwliverContext): string[] {
|
|
const pool = QUICK_PROMPTS[context]
|
|
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
|
|
|
|
// Off-scope answers lead back to this page, not further away.
|
|
const branch = isInScope(prompt, context)
|
|
? FOLLOW_UPS[context]?.find((b) => b.match.test(prompt))
|
|
: undefined
|
|
|
|
const picked = (branch?.next ?? pool).filter((q) => !same(q, prompt))
|
|
const topped = [...picked, ...pool.filter((q) => !same(q, prompt) && !picked.includes(q))]
|
|
return topped.slice(0, 3)
|
|
}
|
|
|
|
/** One call for the panel: the answer plus the next questions to offer. */
|
|
export function answer(
|
|
prompt: string,
|
|
context: OwliverContext
|
|
): { blocks: Block[]; followUps: string[] } {
|
|
return { blocks: respond(prompt, context), followUps: followUpsFor(prompt, context) }
|
|
}
|
|
|
|
/** Confirmation Owliver gives after the resolve action runs. */
|
|
export function resolvedReply(
|
|
resolved: { ref: string; location: string; added: number; total: number }[]
|
|
): Block[] {
|
|
const added = resolved.reduce((s, r) => s + r.added, 0)
|
|
return [
|
|
{ type: "say", lines: [`Done — I've filled **${added} places** across ${resolved.length} shifts.`] },
|
|
{
|
|
type: "card",
|
|
icon: "check",
|
|
title: "Shifts Resolved",
|
|
body: [
|
|
{
|
|
type: "table",
|
|
columns: ["Order", "Location", "Added", "Now"],
|
|
rows: resolved.map((r) => ({
|
|
cells: [r.ref, r.location, `+${r.added}`, { badge: `${r.total}/${r.total}`, tone: "good" as Tone }],
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
{ type: "say", lines: ["Every live order is now fully staffed. The dashboard has updated."] },
|
|
{ type: "actions", items: [{ label: "Open Orders", action: "open-orders", variant: "outline" }] },
|
|
]
|
|
}
|