|
| 1 | +// ----------------------------------------------------------------------- |
| 2 | +// BizDashboard — analytics page builders. |
| 3 | +// |
| 4 | +// Each returns a Fill+Stretch GridControl that becomes the direct root of a |
| 5 | +// NavigationView page (via nav.SetItemContent). The Sales grid's live KPI |
| 6 | +// controls are stored in BizRefs so the deterministic Simulation walk can |
| 7 | +// touch them each tick. |
| 8 | +// |
| 9 | +// NOTE on track sizing: never use GridLength.Auto() for a track whose content |
| 10 | +// might measure 0 (Markup/Dropdown/Checkbox collapse it to blank). Use |
| 11 | +// GridLength.Cells(N) for fixed rows and GridLength.Star(1) for proportional. |
| 12 | +// ----------------------------------------------------------------------- |
| 13 | + |
| 14 | +using SharpConsoleUI; |
| 15 | +using SharpConsoleUI.Builders; |
| 16 | +using SharpConsoleUI.Controls; |
| 17 | +using SharpConsoleUI.Layout; |
| 18 | +using SharpConsoleUI.Themes; |
| 19 | + |
| 20 | +namespace BizDashboard; |
| 21 | + |
| 22 | +/// <summary> |
| 23 | +/// Holds the live Sales-page controls the <see cref="Simulation"/> updates each tick. |
| 24 | +/// </summary> |
| 25 | +internal sealed class BizRefs |
| 26 | +{ |
| 27 | + // Sales KPI tiles (big number + delta line each). |
| 28 | + public MarkupControl RevenueTile { get; set; } = null!; |
| 29 | + public MarkupControl OrdersTile { get; set; } = null!; |
| 30 | + public MarkupControl ChurnTile { get; set; } = null!; |
| 31 | + |
| 32 | + // Sales trend chart. |
| 33 | + public LineGraphControl RevenueGraph { get; set; } = null!; |
| 34 | +} |
| 35 | + |
| 36 | +internal static class Pages |
| 37 | +{ |
| 38 | + private static readonly Color BgSlate = new(28, 32, 48); |
| 39 | + private static readonly Color TileBg = new(34, 40, 60); |
| 40 | + |
| 41 | + /// <summary>Splits a '\n'-joined markup block into per-row markup lines.</summary> |
| 42 | + private static string[] SplitLines(string s) => s.Split('\n'); |
| 43 | + |
| 44 | + private static GridBuilder FillGrid() => |
| 45 | + Controls.Grid() |
| 46 | + .WithColorRole(ColorRole.Primary) |
| 47 | + .WithVerticalAlignment(VerticalAlignment.Fill) |
| 48 | + .WithAlignment(HorizontalAlignment.Stretch); |
| 49 | + |
| 50 | + #region Sales |
| 51 | + |
| 52 | + /// <summary> |
| 53 | + /// Three KPI tiles (Revenue / Orders / Churn) across the top, a wide |
| 54 | + /// revenue trend line graph below. KPIs + chart are driven live by the sim. |
| 55 | + /// </summary> |
| 56 | + public static GridControl BuildSalesGrid(BizRefs refs) |
| 57 | + { |
| 58 | + var grid = FillGrid() |
| 59 | + .Columns(GridLength.Star(1), GridLength.Star(1), GridLength.Star(1)) |
| 60 | + .Rows(GridLength.Cells(8), GridLength.Star(1)) |
| 61 | + .RowGap(1) |
| 62 | + .ColumnGap(2) |
| 63 | + .WithPadding(1, 1, 1, 1) |
| 64 | + .Build(); |
| 65 | + |
| 66 | + // Seed each tile from a SPLIT line list (one markup line per row) so the |
| 67 | + // big-number value is populated and visible immediately on launch — and |
| 68 | + // so the build-time content matches exactly what the sim writes each tick |
| 69 | + // via SetContent(List<string>). A single '\n'-joined string would land as |
| 70 | + // one logical line and hide the value line. |
| 71 | + var revenue = Controls.Markup() |
| 72 | + .AddLines(SplitLines(FormatRevenue(184_200, +4.2))) |
| 73 | + .WithVerticalAlignment(VerticalAlignment.Top) |
| 74 | + .WithMargin(2, 0, 1, 0) |
| 75 | + .Build(); |
| 76 | + var orders = Controls.Markup() |
| 77 | + .AddLines(SplitLines(FormatOrders(1_284, +2.8))) |
| 78 | + .WithVerticalAlignment(VerticalAlignment.Top) |
| 79 | + .WithMargin(2, 0, 1, 0) |
| 80 | + .Build(); |
| 81 | + var churn = Controls.Markup() |
| 82 | + .AddLines(SplitLines(FormatChurn(2.4, -0.3))) |
| 83 | + .WithVerticalAlignment(VerticalAlignment.Top) |
| 84 | + .WithMargin(2, 0, 1, 0) |
| 85 | + .Build(); |
| 86 | + |
| 87 | + grid.Place(revenue, 0, 0); |
| 88 | + grid.Place(orders, 0, 1); |
| 89 | + grid.Place(churn, 0, 2); |
| 90 | + grid.Cell(0, 0).Border = BorderStyle.Rounded; |
| 91 | + grid.Cell(0, 1).Border = BorderStyle.Rounded; |
| 92 | + grid.Cell(0, 2).Border = BorderStyle.Rounded; |
| 93 | + grid.Cell(0, 0).Background = TileBg; |
| 94 | + grid.Cell(0, 1).Background = TileBg; |
| 95 | + grid.Cell(0, 2).Background = TileBg; |
| 96 | + |
| 97 | + var graph = Controls.LineGraph() |
| 98 | + .WithTitle("Revenue (last 60 ticks)") |
| 99 | + .WithMode(LineGraphMode.Braille) |
| 100 | + .WithColorRole(ColorRole.Success) |
| 101 | + .WithMinValue(0) |
| 102 | + .WithMaxValue(100) |
| 103 | + .WithData(new double[] { 0 }) |
| 104 | + .WithVerticalAlignment(VerticalAlignment.Fill) |
| 105 | + .WithMargin(1, 1, 1, 1) |
| 106 | + .Build(); |
| 107 | + graph.MaxDataPoints = Simulation.MaxPoints; |
| 108 | + |
| 109 | + grid.Place(graph, 1, 0, colSpan: 3); |
| 110 | + grid.Cell(1, 0).Border = BorderStyle.Rounded; |
| 111 | + grid.Cell(1, 0).Background = BgSlate; |
| 112 | + |
| 113 | + refs.RevenueTile = revenue; |
| 114 | + refs.OrdersTile = orders; |
| 115 | + refs.ChurnTile = churn; |
| 116 | + refs.RevenueGraph = graph; |
| 117 | + return grid; |
| 118 | + } |
| 119 | + |
| 120 | + internal static string FormatRevenue(int dollars, double deltaPct) |
| 121 | + { |
| 122 | + string arrow = deltaPct >= 0 ? "▲" : "▼"; |
| 123 | + string clr = deltaPct >= 0 ? "rgb(120,220,160)" : "rgb(230,120,110)"; |
| 124 | + return string.Join('\n', new[] |
| 125 | + { |
| 126 | + "[dim]REVENUE (MTD)[/]", |
| 127 | + $"[bold rgb(120,220,160)] ${dollars:N0}[/]", |
| 128 | + $"[{clr}]{arrow} {Math.Abs(deltaPct):0.0}% vs last month[/]", |
| 129 | + }); |
| 130 | + } |
| 131 | + |
| 132 | + internal static string FormatOrders(int count, double deltaPct) |
| 133 | + { |
| 134 | + string arrow = deltaPct >= 0 ? "▲" : "▼"; |
| 135 | + string clr = deltaPct >= 0 ? "rgb(120,220,160)" : "rgb(230,120,110)"; |
| 136 | + return string.Join('\n', new[] |
| 137 | + { |
| 138 | + "[dim]ORDERS (MTD)[/]", |
| 139 | + $"[bold rgb(120,190,255)] {count:N0}[/]", |
| 140 | + $"[{clr}]{arrow} {Math.Abs(deltaPct):0.0}% vs last month[/]", |
| 141 | + }); |
| 142 | + } |
| 143 | + |
| 144 | + internal static string FormatChurn(double pct, double deltaPct) |
| 145 | + { |
| 146 | + // For churn, DOWN is good — invert the color logic. |
| 147 | + string arrow = deltaPct >= 0 ? "▲" : "▼"; |
| 148 | + string clr = deltaPct <= 0 ? "rgb(120,220,160)" : "rgb(230,120,110)"; |
| 149 | + return string.Join('\n', new[] |
| 150 | + { |
| 151 | + "[dim]CHURN (30d)[/]", |
| 152 | + $"[bold rgb(230,180,90)] {pct:0.0}%[/]", |
| 153 | + $"[{clr}]{arrow} {Math.Abs(deltaPct):0.0} pts vs last month[/]", |
| 154 | + }); |
| 155 | + } |
| 156 | + |
| 157 | + #endregion |
| 158 | + |
| 159 | + #region Customers |
| 160 | + |
| 161 | + /// <summary> |
| 162 | + /// Left: a customer table (Name / Plan / MRR). Right: a detail markup that |
| 163 | + /// updates as the selected row changes via SelectedRowChanged. |
| 164 | + /// </summary> |
| 165 | + public static GridControl BuildCustomersGrid() |
| 166 | + { |
| 167 | + var grid = FillGrid() |
| 168 | + .Columns(GridLength.Star(1), GridLength.Star(1)) |
| 169 | + .Rows(GridLength.Star(1)) |
| 170 | + .ColumnGap(2) |
| 171 | + .ColumnSplitterAfter(0) |
| 172 | + .WithPadding(1, 1, 1, 1) |
| 173 | + .Build(); |
| 174 | + |
| 175 | + var table = Controls.Table() |
| 176 | + .WithColorRole(ColorRole.Primary) |
| 177 | + .AddColumn("Customer") |
| 178 | + .AddColumn("Plan") |
| 179 | + .AddColumn("MRR", TextJustification.Right) |
| 180 | + .WithVerticalAlignment(VerticalAlignment.Fill) |
| 181 | + .WithMargin(1, 1, 1, 1) |
| 182 | + .Build(); |
| 183 | + |
| 184 | + (string name, string plan, string mrr, string detail)[] seed = |
| 185 | + { |
| 186 | + ("Acme Corp", "Enterprise", "$4,200", "12 seats · renews Sep · CSM: Dana"), |
| 187 | + ("Globex", "Business", "$1,150", "5 seats · renews Jul · trial→paid"), |
| 188 | + ("Initech", "Business", "$980", "4 seats · at-risk · low usage"), |
| 189 | + ("Umbrella", "Enterprise", "$6,400", "30 seats · expanding · upsell ready"), |
| 190 | + ("Hooli", "Starter", "$290", "2 seats · self-serve · healthy"), |
| 191 | + ("Stark Ind.", "Enterprise", "$8,900", "44 seats · advocate · case study"), |
| 192 | + ("Wayne Ent.", "Business", "$1,320", "6 seats · renews Aug · stable"), |
| 193 | + ("Soylent", "Starter", "$190", "1 seat · churned trial last Q"), |
| 194 | + }; |
| 195 | + |
| 196 | + string[] details = new string[seed.Length]; |
| 197 | + for (int i = 0; i < seed.Length; i++) |
| 198 | + { |
| 199 | + table.AddRow(seed[i].name, seed[i].plan, seed[i].mrr); |
| 200 | + details[i] = seed[i].detail; |
| 201 | + } |
| 202 | + |
| 203 | + var detail = Controls.Markup(BuildCustomerDetail(seed[0].name, seed[0].plan, seed[0].mrr, details[0])) |
| 204 | + .WithVerticalAlignment(VerticalAlignment.Fill) |
| 205 | + .WithMargin(2, 1, 2, 1) |
| 206 | + .Build(); |
| 207 | + |
| 208 | + table.SelectedRowChanged += (s, idx) => |
| 209 | + { |
| 210 | + if (idx >= 0 && idx < seed.Length) |
| 211 | + { |
| 212 | + var c = seed[idx]; |
| 213 | + detail.SetContent(BuildCustomerDetailLines(c.name, c.plan, c.mrr, details[idx])); |
| 214 | + } |
| 215 | + }; |
| 216 | + |
| 217 | + grid.Place(table, 0, 0); |
| 218 | + grid.Place(detail, 0, 1); |
| 219 | + grid.Cell(0, 0).Border = BorderStyle.Rounded; |
| 220 | + grid.Cell(0, 1).Border = BorderStyle.Rounded; |
| 221 | + grid.Cell(0, 0).Background = BgSlate; |
| 222 | + grid.Cell(0, 1).Background = BgSlate; |
| 223 | + return grid; |
| 224 | + } |
| 225 | + |
| 226 | + private static List<string> BuildCustomerDetailLines(string name, string plan, string mrr, string detail) => |
| 227 | + new() |
| 228 | + { |
| 229 | + $"[bold rgb(120,200,255)]{name}[/]", |
| 230 | + "", |
| 231 | + $"[dim]Plan[/] {plan}", |
| 232 | + $"[dim]MRR [/] [bold rgb(120,220,160)]{mrr}[/]", |
| 233 | + "", |
| 234 | + $"[dim]{detail}[/]", |
| 235 | + }; |
| 236 | + |
| 237 | + private static string BuildCustomerDetail(string name, string plan, string mrr, string detail) => |
| 238 | + string.Join('\n', BuildCustomerDetailLines(name, plan, mrr, detail)); |
| 239 | + |
| 240 | + #endregion |
| 241 | + |
| 242 | + #region Inventory |
| 243 | + |
| 244 | + /// <summary>A single Fill table with status colored via inline markup.</summary> |
| 245 | + public static GridControl BuildInventoryGrid() |
| 246 | + { |
| 247 | + var grid = FillGrid() |
| 248 | + .Columns(GridLength.Star(1)) |
| 249 | + .Rows(GridLength.Star(1)) |
| 250 | + .WithPadding(1, 1, 1, 1) |
| 251 | + .Build(); |
| 252 | + |
| 253 | + var table = Controls.Table() |
| 254 | + .WithColorRole(ColorRole.Primary) |
| 255 | + .AddColumn("SKU") |
| 256 | + .AddColumn("Item") |
| 257 | + .AddColumn("Stock", TextJustification.Right) |
| 258 | + .AddColumn("Status") |
| 259 | + .WithVerticalAlignment(VerticalAlignment.Fill) |
| 260 | + .WithMargin(1, 1, 1, 1) |
| 261 | + .Build(); |
| 262 | + |
| 263 | + (string sku, string item, int stock)[] seed = |
| 264 | + { |
| 265 | + ("CFE-001", "House Blend Coffee", 142), |
| 266 | + ("TEA-014", "Green Tea", 6), |
| 267 | + ("BAK-220", "Blueberry Muffin", 0), |
| 268 | + ("BAK-118", "Sesame Bagel", 38), |
| 269 | + ("JUI-007", "Orange Juice", 21), |
| 270 | + ("BAK-330", "Choc Chip Cookie", 4), |
| 271 | + ("CFE-009", "Decaf Beans", 75), |
| 272 | + ("SYR-002", "Vanilla Syrup", 0), |
| 273 | + ("CUP-016", "12oz Paper Cups", 410), |
| 274 | + ("LID-016", "12oz Lids", 12), |
| 275 | + }; |
| 276 | + |
| 277 | + foreach (var (sku, item, stock) in seed) |
| 278 | + table.AddRow(sku, item, stock.ToString(), StatusMarkup(stock)); |
| 279 | + |
| 280 | + grid.Place(table, 0, 0); |
| 281 | + grid.Cell(0, 0).Border = BorderStyle.Rounded; |
| 282 | + grid.Cell(0, 0).Background = BgSlate; |
| 283 | + return grid; |
| 284 | + } |
| 285 | + |
| 286 | + private static string StatusMarkup(int stock) => stock switch |
| 287 | + { |
| 288 | + 0 => "[red]Out[/]", |
| 289 | + <= 10 => "[yellow]Low[/]", |
| 290 | + _ => "[green]In stock[/]", |
| 291 | + }; |
| 292 | + |
| 293 | + #endregion |
| 294 | + |
| 295 | + #region Reports |
| 296 | + |
| 297 | + /// <summary>A single Fill markdown pane — an executive business summary.</summary> |
| 298 | + public static GridControl BuildReportsGrid() |
| 299 | + { |
| 300 | + var grid = FillGrid() |
| 301 | + .Columns(GridLength.Star(1)) |
| 302 | + .Rows(GridLength.Star(1)) |
| 303 | + .WithPadding(1, 1, 1, 1) |
| 304 | + .Build(); |
| 305 | + |
| 306 | + string md = "[markdown]" + string.Join('\n', new[] |
| 307 | + { |
| 308 | + "# Q2 Executive Summary", |
| 309 | + "", |
| 310 | + "Revenue is **up 4.2%** month-over-month, driven by enterprise expansion", |
| 311 | + "and a healthy trial-to-paid conversion rate.", |
| 312 | + "", |
| 313 | + "## Highlights", |
| 314 | + "", |
| 315 | + "- **Revenue (MTD):** $184,200", |
| 316 | + "- **New logos:** 14 (best quarter on record)", |
| 317 | + "- **Churn (30d):** 2.4% — *down* 0.3 pts", |
| 318 | + "- **NRR:** 118%", |
| 319 | + "", |
| 320 | + "## Watch items", |
| 321 | + "", |
| 322 | + "- *Initech* flagged at-risk — low usage, renews this month", |
| 323 | + "- Two SKUs out of stock (Blueberry Muffin, Vanilla Syrup)", |
| 324 | + "- 12oz Lids running low — reorder before weekend", |
| 325 | + "", |
| 326 | + "## Top accounts by MRR", |
| 327 | + "", |
| 328 | + "1. Stark Industries — $8,900", |
| 329 | + "2. Umbrella — $6,400", |
| 330 | + "3. Acme Corp — $4,200", |
| 331 | + "", |
| 332 | + "> Net: a strong quarter. Prioritize the Initech save and clear the", |
| 333 | + "> two stockouts before the weekend rush.", |
| 334 | + }) + "[/]"; |
| 335 | + |
| 336 | + var report = Controls.Markup(md) |
| 337 | + .WithVerticalAlignment(VerticalAlignment.Fill) |
| 338 | + .WithMargin(2, 1, 2, 1) |
| 339 | + .Build(); |
| 340 | + |
| 341 | + grid.Place(report, 0, 0); |
| 342 | + grid.Cell(0, 0).Border = BorderStyle.Rounded; |
| 343 | + grid.Cell(0, 0).Background = BgSlate; |
| 344 | + return grid; |
| 345 | + } |
| 346 | + |
| 347 | + #endregion |
| 348 | +} |
0 commit comments