wf-support-sla
wf-support-sla — the SUPPORT-TICKET workflow demo.
13 source files3 test files

Get it
// not one of the apps the toolchain ships: this one lives in the // repository. Clone it, then: $ cd demo/wf-support-sla $ osy launch
The app
app.osy7 lines
// wf-support-sla — the SUPPORT-TICKET workflow demo. app WfSupportSla { model "model/**/*.osy"; tests "tests/**/*.test.osy"; data "data/**/*.json"; }
model/auth.osy51 lines
// Sign-in for the support desk. // TWO ROLES, and they answer different questions. `Authenticator` is about the credential; `SupportManager` is // AUTHORITY OVER WORK — who may move a commitment somebody else made. [Role] enum AppRole { Authenticator, SupportManager } entity RoleGrant { Agent Grantee; [Required] AppRole Level; security { allow read when IsAuthenticated; allow read when IsAuthenticator; allow create when IsAuthenticator; } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsSupportManager => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.SupportManager); [AuthMethod] string Signup(string email, string password) { var local = Text.Split(email, "@")[0]; var name = Text.Upper(Text.Substring(local, 0, 1)) + Text.Substring(local, 1, Text.Length(local) - 1); var a = new Agent { Name = name, Email = email, PasswordHash = Security.HashPassword(password), Team = Team.Support, OnShift = true }; if (!RoleGrant.Any(g => g.Level == AppRole.SupportManager)) { new RoleGrant { Grantee = a, Level = AppRole.SupportManager }; } return Security.IssueJwt(a.Id, a.Email); } [AuthMethod] string Login(string email, string password) { var a = Agent.Where(x => x.Email == email).FirstOrDefault(); if (a == null) { Security.VerifyPassword(password); return ""; } if (a.PasswordHash == null) { return ""; } if (Security.VerifyPassword(password, a.PasswordHash)) { return Security.IssueJwt(a.Id, a.Email); } return ""; } app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, // where an unauthenticated visit to the board is sent };
model/board.osy481 lines
// THE SLA BOARD — the screen the whole SLA layer exists for. // A duration a human reads at a glance: "4h", "1h32m", "45m". Not a raw TimeSpan — "01:32:00" is a machine's format // and this is a surface people scan. string Duration(TimeSpan t) { var mins = Convert.ToInt(Math.Truncate(t.TotalMinutes)); var neg = mins < 0; if (neg) { mins = -mins; } var h = mins / 60; var m = mins % 60; var text = ""; if (h > 0 && m > 0) { text = h + "h" + m + "m"; } else if (h > 0) { text = h + "h"; } else if (m > 0) { text = m + "m"; } else { text = Convert.ToInt(Math.Truncate(t.TotalSeconds)) + "s"; if (neg) { text = Convert.ToInt(-Math.Truncate(t.TotalSeconds)) + "s"; } } if (neg) { return "-" + text; } return text; } int SlaPercent(TimeSpan elapsed, TimeSpan budget) { if (budget.TotalSeconds <= 0.0) { return 0; } var pct = Convert.ToInt(Math.Floor(elapsed.TotalSeconds / budget.TotalSeconds * 100.0)); if (pct > 100) { return 100; } if (pct < 0) { return 0; } return pct; } string SlaText(TimeSpan elapsed, TimeSpan budget) => Duration(elapsed) + " of " + Duration(budget); string Countdown(TimeSpan t) { var secs = Convert.ToInt(Math.Truncate(t.TotalSeconds)); var neg = secs < 0; if (neg) { secs = -secs; } if (secs >= 3600) { return Duration(t); } var m = secs / 60; var s = secs % 60; var text = m + "m " + (s < 10 ? "0" + s : "" + s) + "s"; if (neg) { return "-" + text; } return text; } string SlaRemainingText(TimeSpan breachesIn) { if (breachesIn.TotalSeconds < 0.0) { return "breached " + Countdown(breachesIn) + " ago"; } return "breaches in " + Countdown(breachesIn); } string SlaWord(TimeSpan breachesIn, TimeSpan budget) { if (breachesIn.TotalSeconds < 0.0) { return "breached"; } if (budget.TotalSeconds > 0.0 && breachesIn.TotalSeconds < budget.TotalSeconds * 0.25) { return "at risk"; } return "on time"; } /// One ticket as the board reads it: the ticket's own facts, plus the promise that governs it right now. A `class`, so /// it is an in-memory projection with no table behind it. class DeskRow { public Guid TicketId; public string Subject; public string CustomerName; public Severity Severity; public TicketStatus Status; /// A ticket with no live clock (closed, cancelled, or parked with everything paused) is a legitimate row — it just /// has no countdown. Absence, not a zero that would draw as a full bar. public bool HasPromise; public string Promise; public TimeSpan Budget; public TimeSpan Elapsed; /// Only meaningful when `HasPromise` — it is set at the create site to `now` (a zero countdown) so the class has no /// invented default, and every reader gates on `HasPromise` rather than on a sentinel date. public DateTime BreachesAt; /// A promise that was MISSED stays missed, even if the ticket is then answered and closed well — so this is the /// RECORD, not the current state, and the two are deliberately different things on the row. public bool EverBreached; /// WHO HOLDS THE GOVERNING SLOT — read off the same `Workflow.Work` row the promise came from, never off a field on /// the ticket. Empty when nobody has picked it up, which is a real state and not a missing value. public bool HasAssignee; public string AssigneeName; public string AssigneeInitials; /// Is the signed-in agent holding it? The personal queue every desk has, answered on the server where the identity is. public bool IsMine; /// When it was raised. A board with no ages cannot tell a ticket that arrived a minute ago from one that has been /// sitting all week — and "how long has this been here" is the second question anyone asks after "is it late". public DateTime RaisedAt; /// THE URGENCY BAND, decided ONCE on the server and read by the ordering, the desk summary and the card alike. public Band Band; /// Kept for the card, which needs to know whether the number beside it is still moving. public bool BreachedNow; public bool AtRiskNow; } /// What the current promise IS, in this desk's words. The platform's own `SlaKind` labels are generic ("state /// expiry"), and the same clock means different things here: a state `Expire` is the FIRST RESPONSE promise in `Open` /// and the NEXT REPLY promise in `Working`. The app knows which; the platform cannot. string PromiseName(TicketStatus s) { if (s == TicketStatus.Open) { return "first response"; } if (s == TicketStatus.Working) { return "next reply"; } if (s == TicketStatus.AwaitingCustomer) { return "customer reply"; } if (s == TicketStatus.AwaitingVendor) { return "vendor reply"; } if (s == TicketStatus.Resolved) { return "confirmation"; } return ""; } /// THE DESK, on the server. One row per ticket, carrying the promise that governs across every slot open on its run — /// which is what `Workflow.WorkByItem<Ticket>()` answers, so this function no longer folds it by hand. It stays a /// server read for a real reason: `Elapsed` is ACCRUED, walked on the run's own ServiceHours calendar, and no client /// can derive it. What is left here is the app's own half — the customer, the severity, the band, the roster names. List<DeskRow> Desk() { var rows = new List<DeskRow>(); var work = Workflow.WorkByItem<Ticket>().Include(r => r.Item).ToList(); var roster = Agent.ToList(); var me = Session.CurrentUser; foreach (var t in Ticket.Include(x => x.Customer).OrderBy(x => x.Subject)) { var row = new DeskRow { TicketId = t.Id, Subject = t.Subject, CustomerName = t.Customer.Name, Severity = t.Severity, Status = t.Status, HasPromise = false, Promise = PromiseName(t.Status), BreachesAt = DateTime.UtcNow, // a zero countdown; every reader gates on HasPromise, never on this date EverBreached = t.ResponseBreached || t.ResolutionBreached || t.NextReplyBreached, HasAssignee = false, AssigneeName = "", AssigneeInitials = "", IsMine = false, RaisedAt = t.CreatedAt, BreachedNow = false, AtRiskNow = false, Band = Band.Idle, }; foreach (var w in work) { if (w.Item.Id != t.Id) { continue; } if (w.BreachesAt != null) { row.HasPromise = true; row.Budget = w.Budget ?? TimeSpan.Zero; row.Elapsed = w.Elapsed ?? TimeSpan.Zero; row.BreachesAt = w.BreachesAt; var left = w.Remaining ?? TimeSpan.Zero; var budget = w.Budget ?? TimeSpan.Zero; row.BreachedNow = left.TotalSeconds < 0.0; row.AtRiskNow = !row.BreachedNow && budget.TotalSeconds > 0.0 && left.TotalSeconds < budget.TotalSeconds * 0.25; } if (w.Assignee != null) { foreach (var a in roster) { if (w.Assignee == a.Id) { row.HasAssignee = true; row.AssigneeName = a.Name; row.AssigneeInitials = Initials(a.Name); row.IsMine = me != null && a.Id == me.Id; } } } } var live = t.Status != TicketStatus.Closed && t.Status != TicketStatus.Cancelled; if (row.BreachedNow || (row.EverBreached && live)) { row.Band = Band.Breached; } else if (row.AtRiskNow) { row.Band = Band.AtRisk; } else if (row.HasPromise) { row.Band = Band.Ok; } else { row.Band = Band.Idle; } rows.Add(row); } return rows.OrderBy(r => r.Band == Band.Breached ? 0 : (r.Band == Band.AtRisk ? 1 : (r.Band == Band.Ok ? 2 : 3))) .ThenBy(r => r.BreachesAt) .ThenBy(r => r.RaisedAt) .ToList(); } /// THE SAME DESK, as a PURE projection over values the page already holds — the live shape. List<DeskRow> DeskRows(Ticket[] tickets, Osyrin.WorkItemRow_Ticket[] work, Agent[] roster, Agent? me) { var rows = new List<DeskRow>(); foreach (var t in tickets) { var row = new DeskRow { TicketId = t.Id, Subject = t.Subject, CustomerName = t.Customer.Name, Severity = t.Severity, Status = t.Status, HasPromise = false, Promise = PromiseName(t.Status), BreachesAt = DateTime.UtcNow, EverBreached = t.ResponseBreached || t.ResolutionBreached || t.NextReplyBreached, HasAssignee = false, AssigneeName = "", AssigneeInitials = "", IsMine = false, RaisedAt = t.CreatedAt, BreachedNow = false, AtRiskNow = false, Band = Band.Idle, }; foreach (var w in work) { if (w.Item.Id != t.Id) { continue; } if (w.BreachesAt != null) { row.HasPromise = true; row.Budget = w.Budget ?? TimeSpan.Zero; row.Elapsed = w.Elapsed ?? TimeSpan.Zero; row.BreachesAt = w.BreachesAt; var left = w.Remaining ?? TimeSpan.Zero; var budget = w.Budget ?? TimeSpan.Zero; row.BreachedNow = left.TotalSeconds < 0.0; row.AtRiskNow = !row.BreachedNow && budget.TotalSeconds > 0.0 && left.TotalSeconds < budget.TotalSeconds * 0.25; } if (w.Assignee != null) { foreach (var a in roster) { if (w.Assignee == a.Id) { row.HasAssignee = true; row.AssigneeName = a.Name; row.AssigneeInitials = Initials(a.Name); row.IsMine = me != null && a.Id == me.Id; } } } } var live = t.Status != TicketStatus.Closed && t.Status != TicketStatus.Cancelled; if (row.BreachedNow || (row.EverBreached && live)) { row.Band = Band.Breached; } else if (row.AtRiskNow) { row.Band = Band.AtRisk; } else if (row.HasPromise) { row.Band = Band.Ok; } else { row.Band = Band.Idle; } rows.Add(row); } return rows.OrderBy(r => r.Band == Band.Breached ? 0 : (r.Band == Band.AtRisk ? 1 : (r.Band == Band.Ok ? 2 : 3))) .ThenBy(r => r.BreachesAt) .ThenBy(r => r.RaisedAt) .ToList(); } /// How long ago, in the coarsest unit that is still true — "3d", "4h", "12m". A board is scanned, and a timestamp /// makes every reader do the subtraction themselves. string Ago(TimeSpan since) { if (since.TotalDays >= 1.0) { return Convert.ToInt(Math.Floor(since.TotalDays)) + "d"; } if (since.TotalHours >= 1.0) { return Convert.ToInt(Math.Floor(since.TotalHours)) + "h"; } if (since.TotalMinutes >= 1.0) { return Convert.ToInt(Math.Floor(since.TotalMinutes)) + "m"; } return "just now"; } /// Does this row survive the board's filters? A pure function over the projection and the three controls, so the /// filtering is one testable expression rather than three nested `if`s in a render body. bool Matches(DeskRow r, string search, string queue, string severity) { if (queue == "mine" && !r.IsMine) { return false; } if (queue == "unassigned" && r.HasAssignee) { return false; } if (severity == "gold" && r.Severity != Severity.Gold) { return false; } if (severity == "basic" && r.Severity != Severity.Basic) { return false; } if (Text.IsEmpty(search)) { return true; } var q = Text.Lower(search); return Text.Contains(Text.Lower(r.Subject), q) || Text.Contains(Text.Lower(r.CustomerName), q); } /// The board's rows after its three controls. A plain client function over the projection — no entity reads, so it is /// safe in a computed — and it keeps `Matches` a single testable expression rather than a predicate inlined per lane. List<DeskRow> Filtered(DeskRow[] rows, string search, string queue, string severity) { var kept = new List<DeskRow>(); foreach (var r in rows) { if (Matches(r, search, queue, severity)) { kept.Add(r); } } return kept; } [Page("/")] [Layout(DeskShell)] [Render(CSR)] [Title("Support board")] component SlaBoard() { live var tickets = Ticket.Include(x => x.Customer).OrderBy(x => x.Subject).ToList(); live var work = Workflow.WorkByItem<Ticket>().Include(r => r.Item).ToList(); live var roster = Agent.ToList(); live var me = Session.CurrentUser; live var rows = DeskRows(tickets, work, roster, me); string search = ""; string queue = "all"; // all · mine · unassigned string severity = "any"; // any · gold · basic live var visible = Filtered(rows, search, queue, severity); action ShowAll() { queue = "all"; } action ShowMine() { queue = "mine"; } action ShowUnassigned() { queue = "unassigned"; } action AnySeverity() { severity = "any"; } action OnlyGold() { severity = "gold"; } action OnlyBasic() { severity = "basic"; } action ClearSearch() { search = ""; } render { Stack(gap: 5) { PageHead("Open work", "Every ticket by where it is — and how each one is doing against its promise."); if (rows.Count() == 0) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 2, align: Align.Center) { Text("Nothing is open", fontSize: FontSize.Section, fontWeight: FontWeight.Medium, color: Colors.OnBg); Link(href: "/desk") { Text("Raise the first ticket from the desk", fontSize: FontSize.Body, color: Colors.Primary); } } } } else { Row(gap: 3, align: Align.Center, wrap: "wrap") { Row(gap: 1, align: Align.Center) { Chip("All", "Show all tickets", queue == "all", ShowAll); Chip("Mine", "Show only mine", queue == "mine", ShowMine); Chip("Unassigned", "Show only unassigned", queue == "unassigned", ShowUnassigned); } Row(gap: 1, align: Align.Center) { Chip("Any severity", "Any severity", severity == "any", AnySeverity); Chip("Gold", "Filter to Gold", severity == "gold", OnlyGold); Chip("Basic", "Filter to Basic", severity == "basic", OnlyBasic); } Row(gap: 2, align: Align.Center, grow: 1, minW: "0") { Input(value: search, label: "Search subject or customer", placeholder: "Search subject or customer", grow: 1); if (search != "") { Pressable(onClick: ClearSearch, label: "Clear search") { Text("Clear", fontSize: FontSize.Caption, color: Colors.TextMuted); } } } } Row(gap: 3, align: Align.Start, overflowX: "auto", pb: 3, role: UiRole.Region, label: "Board") { foreach (var s in TicketStatus.Members) { Lane(s, visible.Where(r => r.Status == s).ToList()); } } } } } } /// Which of the desk's four buckets a tally counts. A `variants` block keyed on an enum parameter is the shape the /// platform means, and it is the better one: the CALL SITE names the meaning and the component owns the palette, so /// there is one place to change what "at risk" looks like. enum Band { Breached, AtRisk, Ok, Idle } /// One number and what it counts. Four of these are the whole desk-health read: a person checks them before reading a /// single card, and a zero is as informative as a five. [Composable] component Tally(int count, string label, Band band) { variants { base { Display = Display.InlineFlex; Px = "12px"; Py = "8px"; Rounded = Radius.Control; } band { Breached { Bg = Colors.TintBreached; Color = Colors.Breached; } AtRisk { Bg = Colors.TintAtRisk; Color = Colors.AtRisk; } Ok { Bg = Colors.TintOk; Color = Colors.Ok; } Idle { Bg = Colors.Sunken; Color = Colors.TextMuted; } } } render { Row(align: Align.Center, gap: 2) { Text(count + " " + label, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium); } } } /// A filter chip. Selected is the BRAND tint, not a border — a board has three of these rows and outline-only /// selection makes the reader hunt for which one is on. [Composable] component Chip(string label, string name, bool on, Action onPick) { render { Pressable(onClick: onPick, label: name, selected: on) { Row(align: Align.Center, h: "30px", px: 3, rounded: Radius.Pill, bg: on ? Colors.TintPrimary : Colors.Sunken, color: on ? Colors.Primary : Colors.TextMuted, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium) { Text(label); } } } } /// RAISE A TICKET. Chips rather than a dropdown for the two small choice sets: there are three customers and two /// severities, and a select for three options is a click to open plus a click to choose where one would have done. [Composable] component RaiseForm(Customer[] customers, Binding<string> subject, Guid picked, Severity severity, string problem, Action<Guid> onPickCustomer, Action onGold, Action onBasic, Action onRaise) { render { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Text("Raise a ticket", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); Stack(gap: 1) { Text("What is wrong", fontSize: FontSize.Caption, color: Colors.TextMuted); TextArea(value: subject, label: "Ticket subject", rows: 3, fontSize: FontSize.Body, placeholder: "Checkout returns 500 on card payment — started after the 14:00 deploy, card and Apple Pay both fail"); } Row(gap: 3, align: Align.End, wrap: "wrap") { Stack(gap: 1) { Text("For whom", fontSize: FontSize.Caption, color: Colors.TextMuted); Row(gap: 1, align: Align.Center) { foreach (var c in customers) { Chip(c.Name, "For " + c.Name, c.Id == picked, () => onPickCustomer(c.Id)); } } } Stack(gap: 1) { Text("Promise", fontSize: FontSize.Caption, color: Colors.TextMuted); Row(gap: 1, align: Align.Center) { Chip("Gold", "Promise Gold", severity == Severity.Gold, onGold); Chip("Basic", "Promise Basic", severity == Severity.Basic, onBasic); } } Osyrin.Button("Raise ticket", onClick: onRaise, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium); } if (problem != "") { Text(problem, fontSize: FontSize.Caption, color: Colors.Breached); } } } } } /// One lane: a state, its count, and its tickets. The lane is a SUNKEN panel and the cards on it are surfaces — which /// is what lets a card read as a card without a shadow doing that work. [Composable] component Lane(TicketStatus state, DeskRow[] tickets) { render { Stack(gap: 2, p: 2, w: Length.LaneW, shrink: 0, rounded: Radius.Card, bg: Colors.Sunken) { Row(gap: 2, align: Align.Center, px: 1, py: 1) { Text(state, fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); Text(tickets.Count(), fontSize: FontSize.Caption, color: Colors.TextMuted, px: 2, py: 1, bg: Colors.Surface, rounded: Radius.Pill); } if (tickets.Count() == 0) { Text("nothing here", fontSize: FontSize.Caption, color: Colors.TextMuted, px: 1, pb: 2); } Stack(gap: 2) { foreach (var t in tickets) { TicketCard(t); } } } } } /// One ticket, one promise. [Composable] component TicketCard(DeskRow r) { live var breachesIn = r.BreachesAt - DateTime.UtcNow; render { Link(href: "/ticket/" + r.TicketId) { Stack(gap: 3, p: 3, rounded: Radius.Control, borderW: 1, bg: r.Band == Band.Breached ? Colors.TintBreached : Colors.Surface, border: r.Band == Band.Breached ? Colors.Breached : Colors.Border) { Stack(gap: 1) { Text(r.Subject, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg); Text(r.CustomerName + " · raised " + Ago(DateTime.UtcNow - r.RaisedAt) + " ago", fontSize: FontSize.Caption, color: Colors.TextMuted); } Row(align: Align.Center, gap: 2) { SeverityChip(r.Severity); if (r.HasAssignee) { Holder(r.AssigneeName, r.AssigneeInitials); } else { Unheld(); } Spacer(); if (r.EverBreached) { Text("missed a promise", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Breached); } } if (r.HasPromise) { Stack(gap: 1) { Box(h: "6px", w: "100%", rounded: Radius.Pill, bg: Colors.Sunken) { Box(h: "6px", rounded: Radius.Pill, w: SlaPercent(r.Elapsed, r.Budget) + "%", bg: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok)); } Row(align: Align.Center, gap: 2) { Text(SlaWord(breachesIn, r.Budget), fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok)); Text("· " + SlaRemainingText(breachesIn), fontSize: FontSize.Caption, fontVariant: FontVariant.TabularNums, color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : Colors.TextMuted); } Text(r.Promise + " · " + SlaText(r.Elapsed, r.Budget), fontSize: FontSize.Caption, color: Colors.TextMuted); } } else { Text(r.Status == TicketStatus.Closed || r.Status == TicketStatus.Cancelled ? "no promise left to keep" : r.EverBreached ? "no promise still running" : "promise paused", fontSize: FontSize.Caption, color: Colors.TextMuted); } } } } } [Composable] component EmptyDesk(Action onSeed) { render { Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 3, align: Align.Center) { Text("Nothing is waiting", fontSize: FontSize.Section, fontWeight: FontWeight.Medium, color: Colors.OnBg); Text("No ticket currently has a live promise. On a fresh install there is no desk to look at yet.", fontSize: FontSize.Body, color: Colors.TextMuted, textAlign: TextAlign.Center); Osyrin.Button("Create a sample desk", onClick: onSeed, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium); } } } } /// One person on the hand-over list: who, whether they are at the desk, when they work, and what they are carrying. /// ⚑ THE LOAD IS THE NUMBER THAT CHANGES A DECISION. "On shift" alone routes work to whoever is awake and already /// drowning; the count beside it is what makes this a choice rather than a list. [Composable] component PersonRow(Person p) { render { Row(align: Align.Center, gap: 3, px: 2, py: 2, rounded: Radius.Control, bg: p.IsMe ? Colors.TintPrimary : Colors.Surface) { Avatar(p.Initials, !p.OnShift); Stack(gap: 0, grow: 1, minW: "0") { Row(align: Align.Center, gap: 2) { Text(p.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg); if (p.IsMe) { Text("you", fontSize: FontSize.Caption, color: Colors.Primary, fontWeight: FontWeight.Medium); } } Text(p.Team + " · " + p.Hours, fontSize: FontSize.Caption, color: Colors.TextMuted); } Text(p.OnShift ? "on shift" : "off shift", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: p.OnShift ? Colors.Ok : Colors.TextMuted); Text(p.OpenTickets == 1 ? "1 open" : p.OpenTickets + " open", fontSize: FontSize.Caption, color: Colors.TextMuted, px: 2, py: 1, rounded: Radius.Pill, bg: Colors.Sunken); } } }
model/people.osy103 lines
// WHO HOLDS THIS, AND WHO COULD. /// Initials for an avatar — one letter for a single name, two for a full one. Nothing about a person's identity, just /// a legible stand-in for a photograph this app has no pipeline for. string Initials(string name) { if (Text.IsEmpty(name)) { return "?"; } var parts = Text.Split(name, " "); if (parts.Count() == 1) { return Text.Upper(Text.Substring(parts[0], 0, 1)); } return Text.Upper(Text.Substring(parts[0], 0, 1)) + Text.Upper(Text.Substring(parts[parts.Count() - 1], 0, 1)); } /// "09:00" from a TimeSpan. A shift is read as a wall clock, not as a duration. string Clock(TimeSpan t) { var mins = Convert.ToInt(Math.Truncate(t.TotalMinutes)); var h = mins / 60; var m = mins % 60; return (h < 10 ? "0" + h : "" + h) + ":" + (m < 10 ? "0" + m : "" + m); } string TeamName(Team t) { if (t == Team.Support) { return "Support"; } return "Engineering"; } /// One person as a hand-over screen needs them: who they are, whether they are AT the desk, when they work, and how /// much they are already carrying. The last is the one that changes a decision — "Sue is on shift" and "Sue is on /// shift holding nine tickets" are different answers to "who should take this". class Person { public Guid AgentId; public string Name; public string Initials; public string Team; public bool OnShift; public string Hours; /// How many live promises this person is currently holding — counted off the same board read, so it cannot drift /// from what the lanes show. public int OpenTickets; /// Is this the signed-in agent? The one row that can act on itself today, since `Workflow.Claim` takes a slot FOR /// the caller and there is no verb for handing one to somebody else (see the ticket page). public bool IsMe; } /// THE ROSTER, with each person's current load. A server read: the load is counted from `Workflow.Work`, whose SLA /// numbers are accrued on each run's own calendar and are not derivable anywhere else. List<Person> Roster() { var work = Workflow.Work<Ticket>().ToList(); var me = Session.CurrentUser; var people = new List<Person>(); foreach (var a in Agent.OrderBy(x => x.Name)) { var held = 0; foreach (var w in work) { if (w.Assignee == a.Id) { held = held + 1; } } people.Add(new Person { AgentId = a.Id, Name = a.Name, Initials = Initials(a.Name), Team = TeamName(a.Team), OnShift = a.OnShift, OpenTickets = held, IsMe = me != null && a.Id == me.Id, Hours = Clock(a.ShiftStart) + "–" + Clock(a.ShiftEnd) + " " + a.ShiftZone, }); } return people; } /// A round avatar. INITIALS, not an image: this app ships no upload path and no CDN, and a broken <img> is worse than /// a letter. The moment there is a photo the shape does not change — one `Image` inside the same circle. [Composable] component Avatar(string initials, bool dim) { render { Row(align: Align.Center, justify: Justify.Center, w: "28px", h: "28px", rounded: Radius.Pill, bg: dim ? Colors.Sunken : Colors.TintPrimary, decorative: true) { Text(initials, fontSize: FontSize.Caption, fontWeight: FontWeight.Semibold, color: dim ? Colors.TextMuted : Colors.Primary); } } } /// Who holds a thing, inline: the avatar and the name. `decorative` on the avatar means a reader hears the NAME once /// rather than hearing two letters and then the name it stands for. [Composable] component Holder(string name, string initials) { render { Row(align: Align.Center, gap: 2) { Avatar(initials, false); Text(name, fontSize: FontSize.Caption, color: Colors.TextMuted); } } } /// Nobody holds it. Said plainly rather than left blank — an empty space where an owner goes reads as a page that did /// not finish loading, and "unassigned" is a real and actionable state on a support desk. [Composable] component Unheld() { render { Row(align: Align.Center, gap: 2) { Avatar("–", true); Text("unassigned", fontSize: FontSize.Caption, color: Colors.TextMuted); } } } /// The name behind an actor id, or "the platform" when there is none. An engine-fired event (a reminder, a breach, a /// deadline) genuinely has no actor, and naming the last human who touched the ticket would be the one thing a /// timeline must never do. string ActorName(Guid? actor, Person[] roster) { if (actor == null) { return "the platform"; } foreach (var p in roster) { if (p.AgentId == actor) { return p.Name; } } return "someone who has left"; }
model/seed.osy74 lines
// A sample desk, so the board has something to be a board OF on first run. /// ⚑ IT COMMITS, AND THAT IS NOT DECORATION. Every `new` here would otherwise land in the CALLING PAGE's unit of /// work, which nothing on this page ever commits: the board filled in optimistically and the rows were discarded /// with no error the moment you reloaded. And a workflow run can only be started for a row that EXISTS, so the /// create has to land before the settle below. void SeedDemoDesk() { if (SlaTarget.Any()) { return; } // already seeded — the board just has nothing open right now var aroundTheClock = new ServiceHours { Name = "AroundTheClock", Zone = "UTC" }; var bizHours = new ServiceHours { Name = "BusinessHours", Zone = "Europe/Stockholm" }; foreach (var d in [DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday]) { new ServiceWindow { ServiceHours = bizHours, Day = d, Start = TimeSpan.FromHours(9), End = TimeSpan.FromHours(17) }; } new SlaTarget { Severity = Severity.Gold, RespondWithin = TimeSpan.FromMinutes(30), NextReplyWithin = TimeSpan.FromHours(1), ResolveWithin = TimeSpan.FromHours(2), ServiceHours = aroundTheClock }; new SlaTarget { Severity = Severity.Basic, RespondWithin = TimeSpan.FromHours(4), NextReplyWithin = TimeSpan.FromHours(8), ResolveWithin = TimeSpan.FromDays(5), ServiceHours = bizHours }; new Agent { Name = "Nina Alvarez", Team = Team.Support, OnShift = true, ShiftStart = TimeSpan.FromHours(8), ShiftEnd = TimeSpan.FromHours(16), ShiftZone = "Europe/Stockholm" }; new Agent { Name = "Sue Lindqvist", Team = Team.Support, OnShift = true, ShiftStart = TimeSpan.FromHours(14), ShiftEnd = TimeSpan.FromHours(22), ShiftZone = "Europe/Stockholm" }; new Agent { Name = "Ravi Mehta", Team = Team.Support, OnShift = false, ShiftStart = TimeSpan.FromHours(22), ShiftEnd = TimeSpan.FromHours(6), ShiftZone = "Asia/Kolkata" }; new Agent { Name = "Ed Barnes", Team = Team.Engineering, OnShift = true, ShiftStart = TimeSpan.FromHours(9), ShiftEnd = TimeSpan.FromHours(17), ShiftZone = "Europe/London" }; var acme = new Customer { Name = "Acme", Email = "ops@acme.test" }; var globex = new Customer { Name = "Globex", Email = "help@globex.test" }; var initech = new Customer { Name = "Initech", Email = "support@initech.test" }; new Ticket { Subject = "Checkout returns 500 on card payment", Customer = acme, Severity = Severity.Gold }; new Ticket { Subject = "Export job stuck since this morning", Customer = globex, Severity = Severity.Gold }; new Ticket { Subject = "Invoice PDF has the wrong VAT line", Customer = initech, Severity = Severity.Basic }; new Ticket { Subject = "Add a second admin seat", Customer = acme, Severity = Severity.Basic }; new Ticket { Subject = "SSO login loops after password reset", Customer = globex, Severity = Severity.Basic }; UnitOfWork.Commit(); } /// A ticket with a DELIBERATELY SHORT promise, so a breach can be SEEN in real time — in a browser, by a person, /// with no test clock. Everything else here needs `TestClock`, which is a test-surface verb; this is the same fact /// reached through the ordinary engine, and it is the difference between photographing a breach and mocking one. void SeedBreachingTicket(int seconds) { var gold = SlaTarget.Single(t => t.Severity == Severity.Gold); var respond = gold.RespondWithin; var resolve = gold.ResolveWithin; var reply = gold.NextReplyWithin; gold.RespondWithin = TimeSpan.FromSeconds(seconds); gold.NextReplyWithin = TimeSpan.FromSeconds(seconds); gold.ResolveWithin = TimeSpan.FromSeconds(seconds); UnitOfWork.Commit(); var acme = Customer.Single(c => c.Name == "Acme"); new Ticket { Subject = "Payment webhooks stopped arriving", Customer = acme, Severity = Severity.Gold }; UnitOfWork.Commit(); gold.RespondWithin = respond; gold.NextReplyWithin = reply; gold.ResolveWithin = resolve; UnitOfWork.Commit(); } /// RAISE A TICKET. A create + a commit, in that order and for that reason: a run can only be started for a row that /// EXISTS, so the commit is what makes it exist — and the board can then answer "what does this owe" the moment the /// card appears rather than whenever a background worker gets to it. void RaiseTicket(string subject, Guid customerId, Severity severity) { var customer = Customer.Single(c => c.Id == customerId); new Ticket { Subject = subject, Customer = customer, Severity = severity }; UnitOfWork.Commit(); }
model/service_hours_security.osy5 lines
// The SLA calendar's SECURITY, and it needs a file of its own. partial entity ServiceHours { security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } }
model/shell.osy99 lines
// THE SHELL — the chrome every signed-in page sits in, and the app's identity in one place. [Layout] component DeskShell() { action SignOut() { Session.SignOut(); } variants { base { Display = Display.Flex; MinH = "100vh"; Bg = Colors.Bg; Color = Colors.OnBg; } } render { Stack(gap: 0, grow: 1, minW: "0") { TopBar(Navigation.CurrentPath, SignOut); Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") { Stack(gap: 0, grow: 1, minW: "0", maxW: "1240px", w: "100%", p: 5) { Outlet(); } } } } } /// The bar. Separated from the canvas by a hairline rather than a shadow — see the file header. [Composable] component TopBar(string currentPath, Action onSignOut) { variants { base { Position = Position.Sticky; Top = "0"; Z = 30; Bg = Colors.Surface; BorderW = "0 0 1px 0"; Border = Colors.Border; } } render { Row(justify: Justify.Center, w: "100%") { Row(align: Align.Center, gap: 5, w: "100%", maxW: "1240px", px: 5, h: "60px") { Wordmark(); Nav(currentPath); Spacer(); Pressable(onClick: onSignOut, label: "Sign out") { Text("Sign out", fontSize: FontSize.Body, color: Colors.TextMuted); } } } } } /// THE ONE PLACE THE DISPLAY FACE IS USED. Fraunces at 900 is a variable display serif with real character, which is /// exactly why it is confined to one word — a display face in a data table is why apps look amateur. The mark beside /// it is the brand green; everything else on the screen earns its colour by being late. [Composable] component Wordmark() { render { Row(gap: 2, align: Align.Center) { Row(align: Align.Center, justify: Justify.Center, w: "26px", h: "26px", rounded: Radius.Control, bg: Colors.Primary) { Text("W", fontFamily: Font.Serif, fontSize: FontSize.Body, fontWeight: FontWeight.Black, color: Colors.OnPrimary); } Text("Watchdesk", fontFamily: Font.Serif, fontSize: FontSize.Wordmark, fontWeight: FontWeight.Black, color: Colors.OnBg, letterSpacing: "-0.02em"); } } } [Composable] component Nav(string currentPath) { render { Row(gap: 1, align: Align.Center) { NavItem("Board", "/", currentPath == "/"); NavItem("The desk", "/desk", currentPath == "/desk"); NavItem("On shift", "/on-shift", currentPath == "/on-shift"); } } } /// One pill. Current = the brand tint; idle = muted text. [Composable] component NavItem(string label, string href, bool current) { render { Link(href: href) { Row(align: Align.Center, h: "32px", px: 3, rounded: Radius.Pill, bg: current ? Colors.TintPrimary : "transparent", color: current ? Colors.Primary : Colors.TextMuted, fontSize: FontSize.Body, fontWeight: FontWeight.Medium) { Text(label); } } } } [Composable] component Spacer() { variants { base { Grow = 1; } } render { Box(); } } /// A page heading block: the title, and a line of context under it. [Composable] component PageHead(string title, string subtitle) { render { Stack(gap: 1) { Text(title, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, color: Colors.OnBg, letterSpacing: "-0.02em"); if (subtitle != "") { Text(subtitle, fontSize: FontSize.Body, color: Colors.TextMuted); } } } } /// THE SEVERITY CHIP. Gold is a 24/7 promise and Basic a business-hours one, which is a fact about the CONTRACT rather /// than about how a ticket is doing — so it is drawn in the brand tint and never in the SLA hues. Confusing "this /// customer pays for 24/7" with "this ticket is late" is exactly the mistake the three-hue rule exists to prevent. [Composable] component SeverityChip(Severity severity) { render { Row(align: Align.Center, h: "22px", px: 2, rounded: Radius.Pill, bg: Colors.TintPrimary) { Text(severity, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Primary); } } }
model/support.osy265 lines
// SUPPORT TICKET — the HITL ticket flow + a resolve-gate + the contractual RESPONSE SLA + a clean follow-the-sun // reassignment over an app `Available()` function. Faithful to workflow_design/support.osy. enum Team { Support, Engineering } enum Severity { Gold, Basic } // Gold = 24/7 promise; Basic = business-hours promise /// WHERE A TICKET IS. The member NAME is what the code and the workflow compare against; the `[Label]` label is /// what a person reads — and a board is read far more often than it is programmed against. enum TicketStatus { Open, Working, [Label("Awaiting customer")] AwaitingCustomer, [Label("Awaiting vendor")] AwaitingVendor, Resolved, Closed, Cancelled, } [Principal] entity Agent { [Required, MaxLength(100)] string Name; Team Team = Team.Support; bool OnShift; // availability — a roster stand-in the follow-the-sun reminder polls TimeSpan ShiftStart = TimeSpan.FromHours(9); TimeSpan ShiftEnd = TimeSpan.FromHours(17); [MaxLength(60)] string ShiftZone = "Europe/Stockholm"; [MaxLength(200), Unique] string? Email; [MaxLength(200)] string? PasswordHash; security { deny read PasswordHash when !IsAuthenticator; allow read, create, update when IsAuthenticator; allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Customer { [Required, MaxLength(200)] string Name; [Required, MaxLength(255)] string Email; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } enum MessageDirection { Outbound, Inbound } /// ONE TURN OF THE CONVERSATION on a ticket. entity Message { [Required] Ticket Ticket; Agent? Author; [Required, MaxLength(4000)] string Body; [Required] MessageDirection Direction = MessageDirection.Outbound; DateTime At; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity SlaTarget { Severity Severity = Severity.Gold; TimeSpan RespondWithin; /// Every SUBSEQUENT reply, not just the first — a real contract promises the conversation, not the opening line. TimeSpan NextReplyWithin; TimeSpan ResolveWithin; [Required] Osyrin.Workflow.ServiceHours ServiceHours; // a baseline entity — referenced by its qualified name from an app security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Ticket { [Required, MaxLength(200)] string Subject; TicketStatus Status; Severity Severity = Severity.Gold; [Required] Customer Customer; [MaxLength(2000)] string? RootCause; // gated by `Requires` before Resolve [MaxLength(2000)] string? Resolution; TimeSpan SlaRespondWithin; TimeSpan SlaNextReplyWithin; TimeSpan SlaResolveWithin; Osyrin.Workflow.ServiceHours ServiceHours; bool ResponseBreached; DateTime? ResponseBreachedAt; bool ResolutionBreached; DateTime? ResolutionBreachedAt; bool NextReplyBreached; DateTime? NextReplyBreachedAt; /// How many times the customer rejected a fix. A ticket that has come back three times is a different conversation. int ReopenCount; /// Chase messages sent while waiting on the customer — incremented by the `Chase` reminder. int ChasesSent; /// Who we are blocked on outside the company, while `AwaitingVendor`. Null the rest of the time. [MaxLength(120)] string? WaitingOnVendor; /// Chases sent to that vendor — incremented by the `ChaseVendor` reminder. int VendorChasesSent; /// Why the ticket was cancelled (duplicate, spam, withdrawn). Null unless it was. [MaxLength(500)] string? CancelReason; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } bool Available(Agent a) => a.OnShift; Agent? FirstAvailable(Agent[] candidates) { foreach (var a in candidates) { if (Available(a)) return a; } return null; } workflow SupportTicket { Tracks = Ticket.Status; Autostart = true; Initial = Open; ServiceHours = this.Item.ServiceHours; // the SLA-accrual window this instance's clocks walk (W55) Accrues = [Open, Working]; // the clock runs only in these states (a customer-parked state pauses it) Deadline = this.Item.SlaResolveWithin; // the whole-instance RESOLUTION promise — pauses while parked event Respond(string reply); event Resolve(string summary); event AskCustomer(); // park on the customer (stops the resolution clock) event CustomerReplied(); // un-park (the clock resumes) event Close(); event Reopen(string reason); // the fix didn't hold — back to Working event AskVendor(string vendor); // blocked on a third party (stops the resolution clock) event VendorReplied(); // the vendor came back event Escalate(Severity severity); // "this P3 is actually a P1" — re-snapshot the SLA + retarget [Authorize(u => u.Team == Team.Support)] event Cancel(string reason); // duplicate / spam / withdrawn — it should never have existed subscribe Escalate(Severity severity) as Bump { Candidates = u => u.Team == Team.Support; } on Deadline { this.Item.ResolutionBreached = true; this.Item.ResolutionBreachedAt = DurableClock.UtcNow; } on Bump(Severity severity) { this.Item.Severity = severity; var target = SlaTarget.Single(t => t.Severity == severity); this.Item.SlaRespondWithin = target.RespondWithin; this.Item.SlaNextReplyWithin = target.NextReplyWithin; this.Item.SlaResolveWithin = target.ResolveWithin; this.Item.ServiceHours = target.ServiceHours; Workflow.Retarget(); } on Cancel(string reason) { this.Item.CancelReason = reason; goto Cancelled; } Start { var target = SlaTarget.Single(t => t.Severity == this.Item.Severity); this.Item.SlaRespondWithin = target.RespondWithin; this.Item.SlaNextReplyWithin = target.NextReplyWithin; this.Item.SlaResolveWithin = target.ResolveWithin; this.Item.ServiceHours = target.ServiceHours; } state Open { Expire = this.Item.SlaRespondWithin; on Expire { this.Item.ResponseBreached = true; this.Item.ResponseBreachedAt = DurableClock.UtcNow; } subscribe Respond(string reply) as FirstReply { Candidates = u => u.Team == Team.Support; Reassign = a => RoleGrant.Any(g => g.Grantee == a && g.Level == AppRole.SupportManager); Finished { Within = this.Item.SlaRespondWithin; Remind Nudge(After = Within / 2) { if (!Available(slot.Assignee)) { var cover = FirstAvailable(slot.Candidates); if (cover != null) slot.Assign(cover); } } } } on FirstReply(string reply) { goto Working; } } state Working { Expire = this.Item.SlaNextReplyWithin; on Expire { this.Item.NextReplyBreached = true; this.Item.NextReplyBreachedAt = DurableClock.UtcNow; } subscribe Resolve(string summary) as Fix { Candidates = u => u.Team == Team.Support; Requires { RootCause { Must = !Text.IsEmpty(this.Item.RootCause); // recorded = a non-empty root cause (C#-faithful: an unset string reads null) Message = "Record the root cause before resolving."; } } } subscribe AskCustomer() as Park { // park on the customer — pauses the resolution clock Candidates = u => u.Team == Team.Support; } subscribe AskVendor(string vendor) as ParkVendor { // park on a THIRD PARTY — pauses it just the same Candidates = u => u.Team == Team.Support; } on Fix(string summary) { this.Item.Resolution = summary; goto Resolved; } on Park() { goto AwaitingCustomer; } on ParkVendor(string vendor) { this.Item.WaitingOnVendor = vendor; goto AwaitingVendor; } } state AwaitingVendor { subscribe VendorReplied() as VendorBack { Candidates = u => u.Team == Team.Support; Finished { Within = TimeSpan.FromDays(10); Accrues = false; Remind ChaseVendor(After = TimeSpan.FromDays(2), ThenEvery = TimeSpan.FromDays(2), Accrues = false) { this.Item.VendorChasesSent = this.Item.VendorChasesSent + 1; } } } on VendorBack() { this.Item.WaitingOnVendor = null; goto Working; } } state AwaitingCustomer { subscribe CustomerReplied() as Back { Candidates = u => u.Team == Team.Support; // an agent records the reply; a real portal would let the customer Finished { Within = TimeSpan.FromDays(14); Accrues = false; Remind Chase(After = TimeSpan.FromDays(3), ThenEvery = TimeSpan.FromDays(4), Accrues = false) { this.Item.ChasesSent = this.Item.ChasesSent + 1; } Unfinished { goto Closed; } // fourteen days of silence is an answer } } on Back() { goto Working; } } state Resolved { subscribe Close() as Confirm { Candidates = u => u.Team == Team.Support; Finished { Within = TimeSpan.FromDays(7); Accrues = false; Unfinished { goto Closed; } } } subscribe Reopen(string reason) as Reopened { Candidates = u => u.Team == Team.Support; } on Confirm { goto Closed; } on Reopened(string reason) { this.Item.ReopenCount = this.Item.ReopenCount + 1; this.Item.Resolution = null; // the previous summary is no longer the answer goto Working; // → back inside `Accrues`: the resolution clock RESUMES } } terminal success Closed { } terminal cancel Cancelled { Message = "ticket cancelled"; } }
model/theme.osy47 lines
// wf-support-sla — the design tokens. theme SupportDesk { Colors { Bg = Modes.Of(light: "#F7FAF8", dark: "#0B1512"); // the page Surface = Modes.Of(light: "#FFFFFF", dark: "#122019"); // cards, the board's lanes Sunken = Modes.Of(light: "#EDF3EF", dark: "#0E1A15"); OnBg = Modes.Of(light: "#0B1F17", dark: "#E8F2EC"); // body text TextMuted = Modes.Of(light: "#5A6B62", dark: "#93A79C"); // secondary text, timestamps Border = Modes.Of(light: "#DDE7E1", dark: "#22322B"); // hairlines Muted = Modes.Of(light: "#EAF0EC", dark: "#1B2A23"); Primary = Modes.Of(light: Palette.From("#0A7D50"), dark: Palette.From("#43CE92")); // the wordmark, primary actions, the active lane PrimaryHover = Modes.Of(light: "#086340", dark: "#63DCA8"); // pressed/hover OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#04120C"); Ok = Modes.Of(light: "#0E6E4E", dark: "#3FBF88"); // within the promise AtRisk = Modes.Of(light: "#B7791F", dark: "#E0A93F"); // under a quarter of it left Breached = Modes.Of(light: "#B42318", dark: "#F27166"); // over TintOk = Modes.Of(light: "#E4F1EA", dark: "#102E22"); TintAtRisk = Modes.Of(light: "#FBF0DC", dark: "#33260D"); TintBreached = Modes.Of(light: "#FCE9E7", dark: "#3A1512"); TintPrimary = Modes.Of(light: "#DFF3E8", dark: "#0F3324"); } Radius { Control = "8px"; Card = "12px"; Pill = "999px"; } Space { Xs = "4px"; Sm = "8px"; Md = "14px"; Lg = "20px"; Xl = "32px"; } Font { // `Serif`, not `Display`: six demos spell a font SIZE `FontSize.Display`, and token leaf // names are one flat namespace — so no shared theme could dress both spellings. Serif = "Fraunces, Iowan Old Style, Palatino, Georgia, ui-serif, serif"; Sans = "ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif"; } FontSize { Caption = "12px"; Body = "14px"; Section = "16px"; Title = "22px"; Wordmark = "24px"; } FontWeight { Regular = 400; Medium = 500; Semibold = 600; Black = 900; } Length { LaneW = "290px"; } Breakpoints { Cozy = 900; } }
model/pages/desk.osy68 lines
// THE DESK, AT A GLANCE — how the whole queue is doing, and the one form that adds to it. [Page("/desk")] [Layout(DeskShell)] [Render(CSR)] [Title("The desk")] component DeskStatus() { DeskRow[] rows; on mount { rows = Desk(); } action Seed() { SeedDemoDesk(); rows = Desk(); } action SeedBreaching() { SeedBreachingTicket(45); rows = Desk(); } live var customers = Customer.OrderBy(c => c.Name).ToList(); string newSubject = ""; Guid newCustomer = Guid.Empty; // "nobody chosen yet", said explicitly rather than left to a default Severity newSeverity = Severity.Gold; string raiseProblem = ""; action PickCustomer(Guid id) { newCustomer = id; } action PickGold() { newSeverity = Severity.Gold; } action PickBasic() { newSeverity = Severity.Basic; } action Raise() { raiseProblem = ""; if (Text.IsEmpty(newSubject)) { raiseProblem = "A ticket needs a subject."; return; } if (newCustomer == Guid.Empty) { raiseProblem = "Choose which customer it is for."; return; } RaiseTicket(newSubject, newCustomer, newSeverity); newSubject = ""; rows = Desk(); } render { Stack(gap: 5) { Row(align: Align.Center, gap: 4) { PageHead("The desk", "How the queue is doing right now — and the one form that adds to it."); Spacer(); Stack(gap: 2, align: Align.End) { if (rows.Count() > 0) { Text(rows.Count(rw => rw.EverBreached) + (rows.Count(rw => rw.EverBreached) == 1 ? " missed promise on record" : " missed promises on record"), fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: rows.Any(rw => rw.EverBreached) ? Colors.Breached : Colors.TextMuted); Pressable(onClick: SeedBreaching, label: "Raise a 45-second ticket") { Text("Raise a 45-second ticket", fontSize: FontSize.Caption, color: Colors.TextMuted); } } } } if (rows.Count() == 0) { EmptyDesk(Seed); } else { Row(gap: 2, align: Align.Center) { Tally(rows.Count(r => r.Band == Band.Breached), "breached", Band.Breached); Tally(rows.Count(r => r.Band == Band.AtRisk), "at risk", Band.AtRisk); Tally(rows.Count(r => r.Band == Band.Ok), "on time", Band.Ok); Tally(rows.Count(r => r.Band == Band.Idle), "no live promise", Band.Idle); } RaiseForm(customers, newSubject, newCustomer, newSeverity, raiseProblem, PickCustomer, PickGold, PickBasic, Raise); } } } }
model/pages/login.osy63 lines
// Sign-in, prefilled. This demo's subject is the SLA board, not the credential flow, so the fields carry working // values and signing in is one click. "Create this account" doubles as first-run setup. [Page("/login")] [AllowAnonymous] [Render(CSR)] [Title("Support desk — sign in")] component LoginPage() { string email = "sam@support.test"; string password = "demo1234"; string problem = ""; action SignIn() { var ticket = Login(email, password); if (ticket == "") { problem = "That email and password don't match an agent."; } else { Session.SignIn(ticket); } } action Register() { var ticket = Signup(email, password); if (ticket == "") { problem = "Couldn't create that account."; } else { Session.SignIn(ticket); } } render { Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) { Stack(gap: 4, w: "100%", maxW: "380px") { Stack(gap: 2) { Wordmark(); Text("Every open ticket, and how it is doing against its promise.", fontSize: FontSize.Body, color: Colors.TextMuted); } Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border, shadow: "0 1px 2px rgba(11,31,23,0.04), 0 8px 24px rgba(11,31,23,0.06)") { Stack(gap: 3) { Stack(gap: 1) { Text("Email", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextMuted); Input(value: email, label: "Email", placeholder: "you@support.test"); } Stack(gap: 1) { Text("Password", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextMuted); Input(value: password, type: "password", label: "Password", placeholder: "password"); } if (problem != "") { Box(bg: Colors.Sunken, rounded: Radius.Control, px: 3, py: 2) { Text(problem, fontSize: FontSize.Caption, color: Colors.Breached); } } Osyrin.Button("Sign in", onClick: SignIn, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium); Osyrin.Button("Create this account", onClick: Register); } } Text("Prefilled for the demo — sign in, or create the account on first run.", fontSize: FontSize.Caption, color: Colors.TextMuted, textAlign: TextAlign.Center); } } } }
model/pages/on_shift.osy27 lines
// WHO IS ON THE DESK — its own screen, because it is its own question. [Page("/on-shift")] [Layout(DeskShell)] [Render(CSR)] [Title("On shift")] component OnShift() { Person[] roster; on mount { roster = Roster(); } render { Stack(gap: 5) { PageHead("On shift", "Who is at the desk, when they work, and what they are carrying."); Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3, role: UiRole.Region, label: "Who is on the desk") { if (roster.Count() == 0) { Text("Nobody is on the roster yet.", fontSize: FontSize.Caption, color: Colors.TextMuted); } Stack(gap: 1) { foreach (var p in roster) { PersonRow(p); } } } } } } }
model/pages/ticket.osy642 lines
// ONE TICKET — everything a person needs to move it on, without leaving the page. [Page("/ticket/{id}")] [Layout(DeskShell)] [Render(CSR)] [Title("Ticket")] component TicketPage(Guid id) { live var tickets = Ticket.Include(t => t.Customer).Where(t => t.Id == id).ToList(); live var moves = SupportTicket.For(Ticket.Where(t => t.Id == id).Single()).Transitions; live var checklist = SupportTicket.For(Ticket.Where(t => t.Id == id).Single()).Requirements; Osyrin.Workflow.WorkflowAuditEntry[] history; ThreadRow[] conversation; DeskRow[] rows; on mount { rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id); } Person[] roster; string tab = "do"; action ShowDo() { tab = "do"; } action ShowTalk() { tab = "talk"; } action ShowLog() { tab = "log"; } action ShowGive() { tab = "give"; } string reply = ""; string rootCause = ""; string summary = ""; string vendor = ""; string reason = ""; /// Where raising `ev` puts this ticket, as the state's own label. Empty when the engine's list does not carry the /// event at all — which is a real case, not a defect: see `Cancel` below. string DestOf(string ev) { var t = tickets.FirstOrDefault(); foreach (var m in moves) { if (m.Event == ev) { if (t != null && m.TargetLabel == t.Status.Label) { return "Stays here"; } return m.TargetLabel; } } return ""; } /// Why `ev` is shut, in the engine's own words — empty when it is open. string BlockedOf(string ev) { foreach (var m in moves) { if (m.Event == ev && !m.Allowed) { return m.Reason; } } return ""; } action SendReply() { var t = tickets.FirstOrDefault(); if (t == null || reply == "") { return; } DoRespond(t, reply); reply = ""; rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id); } action SaveRootCause() { var t = tickets.FirstOrDefault(); if (t == null) { return; } t.RootCause = rootCause; UnitOfWork.Commit(); rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id); } action Resolve() { var t = tickets.FirstOrDefault(); if (t == null || summary == "") { return; } DoResolve(t, summary); summary = ""; rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id); } action Take() { DoClaimGoverning(id); rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id); } action Give(Guid agentId) { DoAssign(id, agentId); rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id); } action HandBack() { DoReleaseMine(id); rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id); } action AskCustomer() { var t = tickets.FirstOrDefault(); if (t != null) { DoAskCustomer(t); rows = Desk(); roster = Roster(); history = HistoryOf(id); conversation = ConversationOf(id); } } action CustomerReplied() { var t = tickets.FirstOrDefault(); if (t != null) { DoCustomerReplied(t); rows = Desk(); } } action VendorReplied() { var t = tickets.FirstOrDefault(); if (t != null) { DoVendorReplied(t); rows = Desk(); } } action Close() { var t = tickets.FirstOrDefault(); if (t != null) { DoClose(t); rows = Desk(); } } action AskVendor() { var t = tickets.FirstOrDefault(); if (t == null || vendor == "") { return; } DoAskVendor(t, vendor); vendor = ""; rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id); } action Reopen() { var t = tickets.FirstOrDefault(); if (t == null || reason == "") { return; } DoReopen(t, reason); reason = ""; rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id); } action Cancel() { var t = tickets.FirstOrDefault(); if (t == null || reason == "") { return; } DoCancel(t, reason); reason = ""; rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id); } action Escalate() { var t = tickets.FirstOrDefault(); if (t == null) { return; } DoEscalate(t, t.Severity == Severity.Gold ? Severity.Basic : Severity.Gold); rows = Desk(); history = HistoryOf(id); conversation = ConversationOf(id); } render { Stack(gap: 5) { foreach (var t in tickets) { Stack(gap: 4) { Row(align: Align.Center, gap: 3) { Stack(gap: 1) { Row(align: Align.Center, gap: 2) { Link(href: "/") { Text("Board", fontSize: FontSize.Caption, color: Colors.Primary, fontWeight: FontWeight.Medium); } Text("/", fontSize: FontSize.Caption, color: Colors.TextMuted); Text(t.Status, fontSize: FontSize.Caption, color: Colors.TextMuted); } Text(t.Subject, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, color: Colors.OnBg, letterSpacing: "-0.02em"); Text(t.Customer.Name + " · " + t.Customer.Email, fontSize: FontSize.Body, color: Colors.TextMuted); } Spacer(); SeverityChip(t.Severity); } Stack(gap: 4) { foreach (var r in rows.Where(x => x.TicketId == id).Take(1)) { PromisePanel(r); } HandoverPanel(rows.Where(x => x.TicketId == id).Take(1).ToList(), Take, HandBack); } if (t.ResponseBreached || t.NextReplyBreached || t.ResolutionBreached) { Box(bg: Colors.TintBreached, rounded: Radius.Card, p: 4) { Stack(gap: 2) { Text("Missed promises", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.Breached); if (t.ResponseBreached) { Text("First response · " + t.ResponseBreachedAt.ToString("g"), fontSize: FontSize.Caption, color: Colors.Breached); } if (t.NextReplyBreached) { Text("A reply · " + t.NextReplyBreachedAt.ToString("g"), fontSize: FontSize.Caption, color: Colors.Breached); } if (t.ResolutionBreached) { Text("Resolution · " + t.ResolutionBreachedAt.ToString("g"), fontSize: FontSize.Caption, color: Colors.Breached); } Text("Recorded, not fatal — the customer still has their problem, so the ticket carries on.", fontSize: FontSize.Caption, color: Colors.TextMuted); } } } Row(gap: 4, align: Align.End) { Tab("What you can do", "Show what you can do", tab == "do", ShowDo); Tab("Conversation", "Show the conversation", tab == "talk", ShowTalk); Tab("History", "Show the history", tab == "log", ShowLog); Stack(canSee: IsSupportManager) { Tab("Reassign", "Show reassign", tab == "give", ShowGive); } } if (tab == "talk") { Conversation(conversation); } if (tab == "give") { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3, role: UiRole.Region, label: "Reassign") { Text("Hand it to someone", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); Text("The reply promise RESTARTS for whoever receives it — a budget somebody had no chance to meet " + "is a hot potato, not a commitment. They must be eligible to hold it, exactly as if they had " + "taken it themselves.", fontSize: FontSize.Caption, color: Colors.TextMuted); Stack(gap: 1) { foreach (var p in roster) { Row(align: Align.Center, gap: 3, px: 2, py: 2, rounded: Radius.Control, bg: Colors.Surface) { Avatar(p.Initials, !p.OnShift); Stack(gap: 0, grow: 1, minW: "0") { Text(p.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg); Text(p.Team + " · " + (p.OnShift ? "on shift" : "off shift") + " · " + (p.OpenTickets == 1 ? "1 open" : p.OpenTickets + " open"), fontSize: FontSize.Caption, color: Colors.TextMuted); } Osyrin.Button("Give it to " + p.Name, onClick: () => Give(p.AgentId)); } } } } } } if (tab == "log") { Timeline(history, roster); } if (tab == "do") { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 4) { Text("What you can do", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); if (moves.Any(m => m.Event == "Respond")) { Verb("Reply to the customer", "The first reply is what the response promise is measured against.", DestOf("Respond"), BlockedOf("Respond")) { Row(gap: 2, align: Align.Start) { TextArea(value: reply, label: "Your reply", rows: 4, grow: 1, placeholder: "Looking into it now — I can see the failed charges on our side too."); Osyrin.Button("Send reply", onClick: SendReply, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium); } } } if (moves.Any(m => m.Event == "Resolve")) { Verb("Resolve", "A root cause has to be on the record before a fix can be filed.", DestOf("Resolve"), BlockedOf("Resolve")) { Stack(gap: 3) { foreach (var c in checklist) { Row(gap: 2, align: Align.Center) { Text(c.Met ? "done" : "needed", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, px: 2, py: 1, rounded: Radius.Pill, bg: c.Met ? Colors.TintOk : Colors.TintAtRisk, color: c.Met ? Colors.Ok : Colors.AtRisk); Text(c.Message, fontSize: FontSize.Caption, color: Colors.TextMuted); } } Row(gap: 2, align: Align.Center) { TextArea(value: rootCause, label: "Root cause", rows: 3, placeholder: "Expired certificate"); Osyrin.Button("Record root cause", onClick: SaveRootCause); } Row(gap: 2, align: Align.Center) { TextArea(value: summary, label: "What you did", rows: 3, placeholder: "Renewed the cert"); Osyrin.Button("Resolve ticket", onClick: Resolve, disabled: !moves.Any(m => m.Event == "Resolve" && m.Allowed) || checklist.Any(c => !c.Met), whenDenied: checklist.Any(c => !c.Met) ? checklist.Where(c => !c.Met).First().Message : "not available yet"); } } } } if (moves.Any(m => m.Event == "AskCustomer")) { Verb("Wait on the customer", "Parking stops the resolution clock — the delay is not the desk's.", DestOf("AskCustomer"), BlockedOf("AskCustomer")) { Row() { Osyrin.Button("Ask the customer", onClick: AskCustomer); } } } if (moves.Any(m => m.Event == "AskVendor")) { Verb("Wait on a third party", "Same pause, different reason — and we keep chasing them, not the customer.", DestOf("AskVendor"), BlockedOf("AskVendor")) { Row(gap: 2, align: Align.Center) { Input(value: vendor, label: "Who we are waiting on", placeholder: "Northwind"); // Without a name to wait on there is nothing to ask, so the button is unavailable rather // than silently inert (`ui-inert-affordance`). Osyrin.Button("Ask the vendor", onClick: AskVendor, disabled: vendor == "", whenDenied: "name who we are waiting on first"); } } } if (moves.Any(m => m.Event == "CustomerReplied")) { Verb("The customer wrote back", "The clock resumes from where it paused.", DestOf("CustomerReplied"), BlockedOf("CustomerReplied")) { Row() { Osyrin.Button("Customer replied", onClick: CustomerReplied); } } } if (moves.Any(m => m.Event == "VendorReplied")) { Verb("The vendor came back", "Back to work, with the resolution clock running again.", DestOf("VendorReplied"), BlockedOf("VendorReplied")) { Row() { Osyrin.Button("Vendor replied", onClick: VendorReplied); } } } if (moves.Any(m => m.Event == "Close")) { Verb("Close it", "Silence for seven days closes it anyway — this is saying so now.", DestOf("Close"), BlockedOf("Close")) { Row() { Osyrin.Button("Close ticket", onClick: Close); } } } if (moves.Any(m => m.Event == "Reopen")) { Verb("The fix did not hold", "A ticket that comes back keeps its history instead of becoming a new one.", DestOf("Reopen"), BlockedOf("Reopen")) { Row(gap: 2, align: Align.Center) { TextArea(value: reason, label: "What went wrong", rows: 3, placeholder: "Still failing on card payments"); Osyrin.Button("Reopen ticket", onClick: Reopen); } } } if (moves.Any(m => m.Event == "Escalate")) { Verb("Re-grade the severity", "The SLA is re-snapshotted and every live clock re-based, so the new terms start now.", DestOf("Escalate"), BlockedOf("Escalate")) { Row() { Osyrin.Button(t.Severity == Severity.Gold ? "Downgrade to Basic" : "Upgrade to Gold", onClick: Escalate); } } } if (moves.Count() > 0) { Verb("It should never have existed", "A duplicate, spam, or withdrawn — not the same thing as closed.", "", "") { Row(gap: 2, align: Align.Center) { Input(value: reason, label: "Why", placeholder: "Duplicate of #118"); // Same as "Ask the vendor": no reason typed, nothing to do, so say so on the control. Osyrin.Button("Cancel ticket", onClick: Cancel, disabled: reason == "", whenDenied: "give a reason first"); } } } if (moves.Count() == 0) { Text("This ticket is finished — there is nothing left to do to it.", fontSize: FontSize.Body, color: Colors.TextMuted); } } } } if (t.RootCause != null || t.Resolution != null || t.WaitingOnVendor != null || t.ReopenCount > 0 || t.ChasesSent > 0 || t.VendorChasesSent > 0 || t.CancelReason != null) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 2) { Text("On the record", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); if (t.RootCause != null) { Fact("Root cause", t.RootCause); } if (t.Resolution != null) { Fact("Resolution", t.Resolution); } if (t.WaitingOnVendor != null) { Fact("Waiting on", t.WaitingOnVendor); } if (t.CancelReason != null) { Fact("Cancelled because", t.CancelReason); } if (t.ReopenCount > 0) { Fact("Reopened", t.ReopenCount + " time(s)"); } if (t.ChasesSent > 0) { Fact("Customer chased", t.ChasesSent + " time(s)"); } if (t.VendorChasesSent > 0) { Fact("Vendor chased", t.VendorChasesSent + " time(s)"); } } } } } } if (tickets.Count() == 0) { Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 2, align: Align.Center) { Text("No such ticket", fontSize: FontSize.Section, fontWeight: FontWeight.Medium, color: Colors.OnBg); Link(href: "/") { Text("Back to the board", fontSize: FontSize.Body, color: Colors.Primary); } } } } } } } /// THE PROMISE, large. The same three bands and the same word as the board — drawn from the same projection, so the /// two surfaces cannot drift into disagreeing about how a ticket is doing. [Composable] component PromisePanel(DeskRow r) { live var breachesIn = r.BreachesAt - DateTime.UtcNow; render { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Row(align: Align.Center, gap: 3) { Stack(gap: 1) { Text(r.HasPromise ? r.Promise : "no live promise", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); if (r.HasPromise) { Text(SlaText(r.Elapsed, r.Budget), fontSize: FontSize.Caption, color: Colors.TextMuted); } } Spacer(); if (r.HasPromise) { Stack(gap: 1, align: Align.End) { Text(SlaWord(breachesIn, r.Budget), fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok)); Text(SlaRemainingText(breachesIn), fontSize: FontSize.Body, fontWeight: FontWeight.Medium, fontVariant: FontVariant.TabularNums, color: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : Colors.OnBg); } } } if (r.HasPromise) { Box(h: "8px", w: "100%", rounded: Radius.Pill, bg: Colors.Sunken) { Box(h: "8px", rounded: Radius.Pill, w: SlaPercent(r.Elapsed, r.Budget) + "%", bg: breachesIn.TotalSeconds < 0.0 ? Colors.Breached : (breachesIn.TotalSeconds < r.Budget.TotalSeconds * 0.25 ? Colors.AtRisk : Colors.Ok)); } } } } } } /// THE RUN'S OWN TIMELINE, newest first. Nothing here is written by this app: `Audit` is the engine's record of what /// it did, so a reminder that fired at 3am and a breach nobody was watching are both in it — which is exactly the /// artifact ops reads in a dispute. [Composable] component Conversation(ThreadRow[] messages) { render { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3, role: UiRole.Region, label: "Conversation") { Text("Conversation", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); if (messages.Count() == 0) { Text("Nobody has replied to this customer yet.", fontSize: FontSize.Caption, color: Colors.TextMuted); } Stack(gap: 3) { foreach (var m in messages) { MessageRow(m); } } } } } } /// One turn. The AUTHOR and the direction lead; the body is the content. [Composable] component MessageRow(ThreadRow m) { render { Box(bg: m.IsOurs ? Colors.TintPrimary : Colors.Sunken, rounded: Radius.Card, p: 3) { Stack(gap: 2) { Row(align: Align.Center, gap: 2) { Text(m.Way, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.OnBg); if (m.HasAuthor) { Text(m.Author, fontSize: FontSize.Caption, color: Colors.TextMuted); } Spacer(); Text(Ago(DateTime.UtcNow - m.At) + " ago", fontSize: FontSize.Caption, color: Colors.TextMuted); } Markdown(m.Body); } } } } [Composable] component Timeline(Osyrin.Workflow.WorkflowAuditEntry[] entries, Person[] roster) { render { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Text("History", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); if (entries.Count() == 0) { Text("Nothing has happened to this ticket yet.", fontSize: FontSize.Caption, color: Colors.TextMuted); } Stack(gap: 2) { foreach (var e in entries.OrderByDescending(x => x.At)) { TimelineRow(e, roster); } } } } } } /// One event. [Composable] component TimelineRow(Osyrin.Workflow.WorkflowAuditEntry e, Person[] roster) { render { Row(gap: 3, align: Align.Start) { Text(e.Kind, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.OnBg, w: "110px"); Stack(gap: 0, grow: 1, minW: "0") { Text(e.Slot == null ? "the ticket" : e.Slot, fontSize: FontSize.Caption, color: Colors.TextMuted); if (e.Message != null) { Text(e.Message, fontSize: FontSize.Caption, color: Colors.TextMuted); } } Text(ActorName(e.Actor, roster), fontSize: FontSize.Caption, color: Colors.TextMuted, w: "130px"); Text(Ago(DateTime.UtcNow - e.At) + " ago", fontSize: FontSize.Caption, color: Colors.TextMuted, fontVariant: FontVariant.TabularNums); } } } /// WHO HOLDS THIS TICKET, and the roster it could go to. [Composable] component HandoverPanel(DeskRow[] rows, Action onTake, Action onHandBack) { render { Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3, role: UiRole.Region, label: "Who holds this") { Row(align: Align.Center, gap: 3) { Text("Who holds this", fontSize: FontSize.Section, fontWeight: FontWeight.Semibold, color: Colors.OnBg); Spacer(); foreach (var r in rows) { if (r.HasAssignee) { Row(align: Align.Center, gap: 2) { Holder(r.AssigneeName, r.AssigneeInitials); Osyrin.Button("Hand it back", onClick: onHandBack); } } else { Row(align: Align.Center, gap: 2) { Unheld(); Osyrin.Button("Take it", onClick: onTake, bg: Colors.Primary, color: Colors.OnPrimary, fontWeight: FontWeight.Medium); } } } } Text("Anyone on the support desk can pick this up — the roster is on the board.", fontSize: FontSize.Caption, color: Colors.TextMuted); } } } } /// ONE TAB. A pill says "filter"; a tab says "the page continues here, under this heading" — and these switch which /// PANEL is showing rather than narrowing a list, which is the board's job and the board's idiom. [Composable] component Tab(string label, string name, bool on, Action onPick) { render { Pressable(onClick: onPick, label: name, selected: on) { Stack(gap: 2) { Text(label, fontSize: FontSize.Body, fontWeight: on ? FontWeight.Semibold : FontWeight.Medium, color: on ? Colors.OnBg : Colors.TextMuted); Box(h: "2px", rounded: Radius.Pill, bg: on ? Colors.Primary : "transparent"); } } } } /// One verb: what it is, what it means, WHERE IT LANDS, and its own controls. The EXPLANATION is not decoration — a /// support desk's states differ by what happens on silence, and that is invisible from a button label. [Composable] component Verb(string title, string note, string dest, string blocked) { render { Stack(gap: 2, pb: 2) { Stack(gap: 1) { Row(gap: 2, align: Align.Center) { Text(title, fontSize: FontSize.Body, fontWeight: FontWeight.Medium, color: Colors.OnBg); if (dest != "") { Text("→ " + dest, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextMuted, px: 2, py: 1, bg: Colors.Sunken, rounded: Radius.Pill); } } Text(note, fontSize: FontSize.Caption, color: Colors.TextMuted); if (blocked != "") { Text(blocked, fontSize: FontSize.Caption, color: Colors.AtRisk); } } Slot; } } } /// A label and its value, on one line. [Composable] component Fact(string label, string value) { render { Row(gap: 2, align: Align.Start) { Text(label, fontSize: FontSize.Caption, color: Colors.TextMuted, w: "140px"); Text(value, fontSize: FontSize.Caption, color: Colors.OnBg); } } } /// The run's timeline for one ticket. A server read, so the page can re-ask for it after an act that the live read /// does not see (a claim moves a SLOT, not the run). /// ONE TURN, PROJECTED. The page renders a class, not the `Message` entity — the same shape `DeskRow` has and for /// the same reason: a render slot reads plain VALUES, and an entity handed to one carries fields the client cannot /// resolve (the panel rendered its heading and then stopped, on a list that was demonstrably non-empty). class ThreadRow { public string Author; public bool HasAuthor; public string Way; public string Body; public DateTime At; /// Did WE send it. The panel says so in words as well — this only tints, so the thread can be scanned for "what /// did we tell them" without reading every label, and stays legible to someone who cannot use the tint. public bool IsOurs; } /// THE CONVERSATION — what was actually SAID, oldest first. List<ThreadRow> ConversationOf(Guid id) { var rows = new List<ThreadRow>(); foreach (var m in Message.Include(m => m.Author).Where(m => m.Ticket.Id == id).OrderBy(m => m.At)) { rows.Add(new ThreadRow { Way = m.Direction == MessageDirection.Outbound ? "We replied" : "They wrote in", HasAuthor = m.Author != null, Author = m.Author == null ? "" : m.Author.Name, IsOurs = m.Direction == MessageDirection.Outbound, Body = m.Body, At = m.At, }); } return rows; } Osyrin.Workflow.WorkflowAuditEntry[] HistoryOf(Guid id) { var t = Ticket.Single(x => x.Id == id); return SupportTicket.For(t).Audit; } /// THE ROW THE PANEL IS COUNTING DOWN — the engine's GOVERNING slot for this ticket, resolved here so that "who /// holds it" and "take it" can never mean two different rows. void DoClaimGoverning(Guid ticketId) { var row = GoverningRow(ticketId); if (row == null) { return; } Workflow.Claim(row); } /// HAND BACK THE SLOT YOU ARE HOLDING — which is not the same question as "which slot governs", and must not be /// asked that way. void DoAssign(Guid ticketId, Guid agentId) { var held = Workflow.Work<Ticket>().Include(r => r.Item) .Where(r => r.Item.Id == ticketId && r.Assignee != null) .OrderBy(r => r.BreachesAt).FirstOrDefault(); var row = held ?? GoverningRow(ticketId); if (row == null) { return; } var target = Agent.Where(a => a.Id == agentId).FirstOrDefault(); if (target == null) { return; } Workflow.Assign(row, target); } void DoReleaseMine(Guid ticketId) { var me = Session.CurrentUser; if (me == null) { return; } var mine = me.Id; var row = Workflow.Work<Ticket>().Include(r => r.Item) .Where(r => r.Item.Id == ticketId && r.Assignee == mine).FirstOrDefault(); if (row == null) { return; } Workflow.Release(row); } /// The per-slot row for the ticket's GOVERNING slot — the engine's own answer, the same one `Desk()` reads, so "who /// holds this", "take it" and "give it to Sue" can never mean three different slots. Osyrin.WorkRow_Ticket GoverningRow(Guid ticketId) { var item = Workflow.WorkByItem<Ticket>().Include(r => r.Item).Where(r => r.Item.Id == ticketId).FirstOrDefault(); if (item == null) { return null; } var slot = item.GoverningSlot; if (slot == null) { return null; } return Workflow.Work<Ticket>().Include(r => r.Item) .Where(r => r.Item.Id == ticketId && r.SlotAlias == slot).FirstOrDefault(); } /// Raise the reply, and KEEP IT — in that order, so a refused deposit writes no message. A thread showing an answer /// the workflow turned away would be worse than one that lost it. void DoRespond(Ticket t, string reply) { SupportTicket.RaiseRespond(t, reply); new Message { Ticket = t, Body = reply, Direction = MessageDirection.Outbound, At = DateTime.UtcNow, Author = Session.CurrentUser }; } void DoResolve(Ticket t, string summary) { SupportTicket.RaiseResolve(t, summary); } void DoAskCustomer(Ticket t) { SupportTicket.RaiseAskCustomer(t); } void DoCustomerReplied(Ticket t) { SupportTicket.RaiseCustomerReplied(t); } void DoAskVendor(Ticket t, string who) { SupportTicket.RaiseAskVendor(t, who); } void DoVendorReplied(Ticket t) { SupportTicket.RaiseVendorReplied(t); } void DoClose(Ticket t) { SupportTicket.RaiseClose(t); } void DoReopen(Ticket t, string why) { SupportTicket.RaiseReopen(t, why); } void DoCancel(Ticket t, string why) { SupportTicket.RaiseCancel(t, why); } void DoEscalate(Ticket t, Severity s) { SupportTicket.RaiseEscalate(t, s); }