@lotics/ui 3.6.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +323 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +22 -6
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
6
|
+
import { Divider } from "@lotics/ui/divider";
|
|
7
|
+
import { DetailRow } from "@lotics/ui/detail_row";
|
|
8
|
+
import { Badge } from "@lotics/ui/badge";
|
|
9
|
+
import { InlineTextInput } from "@lotics/ui/inline_text_input";
|
|
10
|
+
import { InlineNumberInput } from "@lotics/ui/inline_number_input";
|
|
11
|
+
import { InlineSelect } from "@lotics/ui/inline_select";
|
|
12
|
+
import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
|
|
13
|
+
import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
|
|
14
|
+
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
// Template · Inline record (plain) — the same in-place record edit as the card
|
|
17
|
+
// variant, but CARD-LESS: sections are set off by a clear `SectionHeading` + a
|
|
18
|
+
// hairline and generous vertical rhythm, not card chrome. The lighter look for
|
|
19
|
+
// a single full-screen record where banded cards would be visual noise.
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const PLAN = [
|
|
23
|
+
{ value: "starter", label: "Starter" },
|
|
24
|
+
{ value: "growth", label: "Growth" },
|
|
25
|
+
{ value: "enterprise", label: "Enterprise" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const HEALTH = [
|
|
29
|
+
{ value: "healthy", label: "Healthy" },
|
|
30
|
+
{ value: "at_risk", label: "At risk" },
|
|
31
|
+
{ value: "churning", label: "Churning" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const HEALTH_DOT: Record<string, string> = {
|
|
35
|
+
healthy: colors.emerald[500],
|
|
36
|
+
at_risk: colors.amber[500],
|
|
37
|
+
churning: colors.red[500],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function persist<T>(set: (v: T) => void) {
|
|
41
|
+
return (v: T) =>
|
|
42
|
+
new Promise<void>((resolve) => {
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
set(v);
|
|
45
|
+
resolve();
|
|
46
|
+
}, 350);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
51
|
+
return (
|
|
52
|
+
<DetailRow label={label} labelWidth={130} minHeight={40}>
|
|
53
|
+
<View style={{ flex: 1 }}>{children}</View>
|
|
54
|
+
</DetailRow>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
59
|
+
return (
|
|
60
|
+
<View style={{ gap: 8 }}>
|
|
61
|
+
<SectionHeading>
|
|
62
|
+
<SectionHeadingTitle>{title}</SectionHeadingTitle>
|
|
63
|
+
</SectionHeading>
|
|
64
|
+
<Divider />
|
|
65
|
+
<View style={{ gap: 2 }}>{children}</View>
|
|
66
|
+
</View>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function TplRecordPlain() {
|
|
71
|
+
const [name, setName] = useState("Northwind Traders");
|
|
72
|
+
const [contact, setContact] = useState("Mara Lindqvist");
|
|
73
|
+
const [email, setEmail] = useState("mara@northwind.co");
|
|
74
|
+
const [phone, setPhone] = useState("");
|
|
75
|
+
const [city, setCity] = useState("Gothenburg");
|
|
76
|
+
const [value, setValue] = useState<number | null>(48000);
|
|
77
|
+
const [plan, setPlan] = useState("growth");
|
|
78
|
+
const [health, setHealth] = useState("healthy");
|
|
79
|
+
const [renews, setRenews] = useState("2026-05-22");
|
|
80
|
+
const [review, setReview] = useState("2026-04-10T14:30");
|
|
81
|
+
const [opens, setOpens] = useState("09:00");
|
|
82
|
+
|
|
83
|
+
const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
87
|
+
<View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 28 }}>
|
|
88
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
89
|
+
<Text size="xl" weight="semibold">
|
|
90
|
+
{name}
|
|
91
|
+
</Text>
|
|
92
|
+
<Badge label="Customer" color="blue" />
|
|
93
|
+
<View style={{ flex: 1 }} />
|
|
94
|
+
<Text size="xs" color="muted">
|
|
95
|
+
Click any value to edit
|
|
96
|
+
</Text>
|
|
97
|
+
</View>
|
|
98
|
+
|
|
99
|
+
<Section title="Contact">
|
|
100
|
+
<Field label="Account name">
|
|
101
|
+
<InlineTextInput value={name} onSave={persist(setName)} accessibilityLabel="Account name" />
|
|
102
|
+
</Field>
|
|
103
|
+
<Field label="Primary contact">
|
|
104
|
+
<InlineTextInput value={contact} onSave={persist(setContact)} accessibilityLabel="Primary contact" />
|
|
105
|
+
</Field>
|
|
106
|
+
<Field label="Email">
|
|
107
|
+
<InlineTextInput value={email} onSave={persist(setEmail)} placeholder="Add email…" accessibilityLabel="Email" />
|
|
108
|
+
</Field>
|
|
109
|
+
<Field label="Phone">
|
|
110
|
+
<InlineTextInput value={phone} onSave={persist(setPhone)} placeholder="Add phone…" accessibilityLabel="Phone" />
|
|
111
|
+
</Field>
|
|
112
|
+
<Field label="City">
|
|
113
|
+
<InlineTextInput value={city} onSave={persist(setCity)} accessibilityLabel="City" />
|
|
114
|
+
</Field>
|
|
115
|
+
</Section>
|
|
116
|
+
|
|
117
|
+
<Section title="Commercial">
|
|
118
|
+
<Field label="Annual value">
|
|
119
|
+
<InlineNumberInput value={value} onSave={persist(setValue)} format={money} accessibilityLabel="Annual value" />
|
|
120
|
+
</Field>
|
|
121
|
+
<Field label="Plan">
|
|
122
|
+
<InlineSelect value={plan} onSave={persist(setPlan)} options={PLAN} accessibilityLabel="Plan" />
|
|
123
|
+
</Field>
|
|
124
|
+
<Field label="Health">
|
|
125
|
+
<InlineSelect
|
|
126
|
+
value={health}
|
|
127
|
+
onSave={persist(setHealth)}
|
|
128
|
+
options={HEALTH}
|
|
129
|
+
accessibilityLabel="Health"
|
|
130
|
+
renderOptionContent={(o) => (
|
|
131
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
132
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: HEALTH_DOT[o.value] }} />
|
|
133
|
+
<Text size="sm">{o.label}</Text>
|
|
134
|
+
</View>
|
|
135
|
+
)}
|
|
136
|
+
/>
|
|
137
|
+
</Field>
|
|
138
|
+
</Section>
|
|
139
|
+
|
|
140
|
+
<Section title="Schedule">
|
|
141
|
+
<Field label="Renews on">
|
|
142
|
+
<InlineDatePicker value={renews} onSave={persist(setRenews)} locale="en-US" accessibilityLabel="Renews on" />
|
|
143
|
+
</Field>
|
|
144
|
+
<Field label="Next review">
|
|
145
|
+
<InlineDatePicker value={review} onSave={persist(setReview)} format="datetime" locale="en-US" accessibilityLabel="Next review" />
|
|
146
|
+
</Field>
|
|
147
|
+
<Field label="Support opens">
|
|
148
|
+
<InlineTimePicker value={opens} onSave={persist(setOpens)} accessibilityLabel="Support opens" />
|
|
149
|
+
</Field>
|
|
150
|
+
</Section>
|
|
151
|
+
</View>
|
|
152
|
+
</ScrollView>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, solid } from "@lotics/ui/colors";
|
|
5
|
+
import { Accordion, AccordionContent, AccordionHeader, AccordionTitle } from "@lotics/ui/accordion";
|
|
6
|
+
import { Button } from "@lotics/ui/button";
|
|
7
|
+
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
8
|
+
import { Divider } from "@lotics/ui/divider";
|
|
9
|
+
import { Drawer } from "@lotics/ui/drawer";
|
|
10
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
11
|
+
import { PressableHighlight } from "@lotics/ui/pressable_highlight";
|
|
12
|
+
import { Sparkline } from "@lotics/ui/sparkline";
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Template · Network rollup — hierarchical totals with drill: region →
|
|
16
|
+
// site → lane, every level carrying the SAME aligned metric columns so a
|
|
17
|
+
// bad number can be chased down the tree ("which branch drags the
|
|
18
|
+
// total?"). Pure composition: nested Accordions + a fixed-width column
|
|
19
|
+
// map; under-target branches are flagged at every level so the drill
|
|
20
|
+
// path is visible before expanding. Leaf rows open a sequenced drawer
|
|
21
|
+
// over their site's lanes.
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const ONTIME_TARGET = 88;
|
|
25
|
+
|
|
26
|
+
interface Lane {
|
|
27
|
+
id: string;
|
|
28
|
+
dest: string;
|
|
29
|
+
volume: number;
|
|
30
|
+
ontime: number;
|
|
31
|
+
margin: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Site {
|
|
35
|
+
key: string;
|
|
36
|
+
label: string;
|
|
37
|
+
lanes: Lane[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Region {
|
|
41
|
+
key: string;
|
|
42
|
+
label: string;
|
|
43
|
+
sites: Site[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Deterministic 12-week volume history for the drawer sparkline.
|
|
47
|
+
function hash(i: number, salt: number): number {
|
|
48
|
+
let x = (i + 1) * 2654435761 + salt * 40503;
|
|
49
|
+
x = ((x >>> 16) ^ x) * 0x45d9f3b;
|
|
50
|
+
x = ((x >>> 16) ^ x) * 0x45d9f3b;
|
|
51
|
+
return (x >>> 16) % 1000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lane = (site: string, n: number, dest: string, volume: number, ontime: number, margin: number): Lane => ({
|
|
55
|
+
id: `LN-${site}-${String(n).padStart(2, "0")}`, dest, volume, ontime, margin,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The story: South region drags the network — Southbay's lanes run late.
|
|
59
|
+
const REGIONS: Region[] = [
|
|
60
|
+
{
|
|
61
|
+
key: "north", label: "North region",
|
|
62
|
+
sites: [
|
|
63
|
+
{ key: "EP", label: "Eastport", lanes: [
|
|
64
|
+
lane("EP", 1, "Arden", 4180, 96, 22), lane("EP", 2, "Brookfield", 3460, 94, 19),
|
|
65
|
+
lane("EP", 3, "Centerville", 2210, 91, 24), lane("EP", 4, "Dalton", 1730, 97, 17),
|
|
66
|
+
]},
|
|
67
|
+
{ key: "NG", label: "Northgate", lanes: [
|
|
68
|
+
lane("NG", 1, "Edgewater", 3040, 93, 21), lane("NG", 2, "Fairview", 2380, 95, 18),
|
|
69
|
+
lane("NG", 3, "Hillcrest", 1610, 92, 23),
|
|
70
|
+
]},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: "central", label: "Central region",
|
|
75
|
+
sites: [
|
|
76
|
+
{ key: "RS", label: "Riverside", lanes: [
|
|
77
|
+
lane("RS", 1, "Kingsport", 2870, 90, 16), lane("RS", 2, "Lakeside", 2140, 94, 20),
|
|
78
|
+
lane("RS", 3, "Midvale", 1890, 89, 14),
|
|
79
|
+
]},
|
|
80
|
+
{ key: "MH", label: "Midvale Hub", lanes: [
|
|
81
|
+
lane("MH", 1, "Norwood", 2520, 92, 18), lane("MH", 2, "Arden", 1480, 96, 15),
|
|
82
|
+
lane("MH", 3, "Granton", 1160, 87, 12),
|
|
83
|
+
]},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: "south", label: "South region",
|
|
88
|
+
sites: [
|
|
89
|
+
{ key: "SB", label: "Southbay", lanes: [
|
|
90
|
+
lane("SB", 1, "Fairview", 2640, 81, 11), lane("SB", 2, "Dalton", 2310, 84, 13),
|
|
91
|
+
lane("SB", 3, "Lakeside", 1820, 79, 9),
|
|
92
|
+
]},
|
|
93
|
+
{ key: "GR", label: "Granton", lanes: [
|
|
94
|
+
lane("GR", 1, "Brookfield", 1540, 90, 16), lane("GR", 2, "Norwood", 980, 86, 14),
|
|
95
|
+
]},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// Volume-weighted rollups — a small lane can't mask a big one.
|
|
101
|
+
function rollup(lanes: Lane[]) {
|
|
102
|
+
const volume = lanes.reduce((s, l) => s + l.volume, 0);
|
|
103
|
+
const ontime = Math.round(lanes.reduce((s, l) => s + l.ontime * l.volume, 0) / volume);
|
|
104
|
+
const margin = Math.round(lanes.reduce((s, l) => s + l.margin * l.volume, 0) / volume);
|
|
105
|
+
return { volume, ontime, margin };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ALL_LANES = REGIONS.flatMap((r) => r.sites.flatMap((s) => s.lanes));
|
|
109
|
+
const NETWORK = rollup(ALL_LANES);
|
|
110
|
+
const UNDER_TARGET = ALL_LANES.filter((l) => l.ontime < ONTIME_TARGET).length;
|
|
111
|
+
|
|
112
|
+
/** The shared column map — identical at every level of the tree. */
|
|
113
|
+
const COLS = { volume: 90, ontime: 64, margin: 64 } as const;
|
|
114
|
+
|
|
115
|
+
function MetricCols({ volume, ontime, margin, weight }: { volume: number; ontime: number; margin: number; weight?: "medium" }) {
|
|
116
|
+
return (
|
|
117
|
+
<>
|
|
118
|
+
<Text size="sm" weight={weight} tabular align="right" style={{ width: COLS.volume }}>
|
|
119
|
+
{volume.toLocaleString("en-US")}
|
|
120
|
+
</Text>
|
|
121
|
+
<Text size="sm" weight={weight} tabular align="right" color={ontime < ONTIME_TARGET ? "danger" : "default"} style={{ width: COLS.ontime }}>
|
|
122
|
+
{`${ontime}%`}
|
|
123
|
+
</Text>
|
|
124
|
+
<Text size="sm" weight={weight} tabular align="right" style={{ width: COLS.margin }}>
|
|
125
|
+
{`${margin}%`}
|
|
126
|
+
</Text>
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function KVRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
132
|
+
return (
|
|
133
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 28 }}>
|
|
134
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
|
|
135
|
+
{children}
|
|
136
|
+
</View>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function TplRollup() {
|
|
141
|
+
// Drawer walks the opened lane's site — the natural sibling group.
|
|
142
|
+
const [open, setOpen] = useState<{ site: Site; idx: number } | null>(null);
|
|
143
|
+
const openLane = open ? open.site.lanes[open.idx] : null;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
147
|
+
<View style={{ width: "100%", maxWidth: 980, alignSelf: "center", gap: 16 }}>
|
|
148
|
+
{/* header band */}
|
|
149
|
+
<View style={{ gap: 2 }}>
|
|
150
|
+
<Text size="xl" weight="semibold">Network rollup</Text>
|
|
151
|
+
<Text size="sm" color="muted">This month by region, site and lane — expand the branch that drags the total</Text>
|
|
152
|
+
</View>
|
|
153
|
+
|
|
154
|
+
<KPIStrip
|
|
155
|
+
items={[
|
|
156
|
+
{ label: "Network volume", value: NETWORK.volume, format: "number", caption: `${ALL_LANES.length} lanes` },
|
|
157
|
+
{ label: "On-time", value: NETWORK.ontime, format: "percentage", info: `Volume-weighted across every lane — target ${ONTIME_TARGET}%. Flagged branches are below it.` },
|
|
158
|
+
{ label: "Margin", value: NETWORK.margin, format: "percentage", info: "Volume-weighted lane margin after transport cost." },
|
|
159
|
+
{ label: "Lanes under target", value: UNDER_TARGET, format: "number", tone: "danger", caption: `on-time below ${ONTIME_TARGET}%` },
|
|
160
|
+
]}
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
<Card style={{ padding: 0 }}>
|
|
164
|
+
<CardHeader>
|
|
165
|
+
<CardHeaderTitle info="The same three columns at every level — totals are volume-weighted, so a flagged region always traces to flagged lanes inside it.">
|
|
166
|
+
By region · site · lane
|
|
167
|
+
</CardHeaderTitle>
|
|
168
|
+
</CardHeader>
|
|
169
|
+
|
|
170
|
+
{/* eyebrow columns — match the tree's column map exactly */}
|
|
171
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 10 }}>
|
|
172
|
+
<Text size="xs" color="muted" transform="uppercase" style={{ flex: 1 }}>Branch</Text>
|
|
173
|
+
<Text size="xs" color="muted" transform="uppercase" align="right" style={{ width: COLS.volume }}>Volume</Text>
|
|
174
|
+
<Text size="xs" color="muted" transform="uppercase" align="right" style={{ width: COLS.ontime }}>On-time</Text>
|
|
175
|
+
<Text size="xs" color="muted" transform="uppercase" align="right" style={{ width: COLS.margin }}>Margin</Text>
|
|
176
|
+
<View style={{ width: 16 }} />
|
|
177
|
+
</View>
|
|
178
|
+
<Divider />
|
|
179
|
+
|
|
180
|
+
<View style={{ paddingHorizontal: 20, paddingVertical: 4 }}>
|
|
181
|
+
{REGIONS.map((region, ri) => {
|
|
182
|
+
const regionTotals = rollup(region.sites.flatMap((s) => s.lanes));
|
|
183
|
+
return (
|
|
184
|
+
<View key={region.key}>
|
|
185
|
+
{ri > 0 ? <Divider /> : null}
|
|
186
|
+
<Accordion defaultExpanded={regionTotals.ontime < ONTIME_TARGET}>
|
|
187
|
+
<AccordionHeader accessibilityLabel={`${region.label} rollup`}>
|
|
188
|
+
<AccordionTitle
|
|
189
|
+
icon={regionTotals.ontime < ONTIME_TARGET ? "circle-alert" : undefined}
|
|
190
|
+
iconColor={solid("amber")}
|
|
191
|
+
>
|
|
192
|
+
{region.label}
|
|
193
|
+
</AccordionTitle>
|
|
194
|
+
<MetricCols {...regionTotals} weight="medium" />
|
|
195
|
+
</AccordionHeader>
|
|
196
|
+
<AccordionContent>
|
|
197
|
+
{region.sites.map((site) => {
|
|
198
|
+
const siteTotals = rollup(site.lanes);
|
|
199
|
+
return (
|
|
200
|
+
<Accordion key={site.key} defaultExpanded={siteTotals.ontime < ONTIME_TARGET}>
|
|
201
|
+
<AccordionHeader accessibilityLabel={`${site.label} rollup`}>
|
|
202
|
+
<AccordionTitle
|
|
203
|
+
icon={siteTotals.ontime < ONTIME_TARGET ? "circle-alert" : undefined}
|
|
204
|
+
iconColor={solid("amber")}
|
|
205
|
+
>
|
|
206
|
+
{site.label}
|
|
207
|
+
</AccordionTitle>
|
|
208
|
+
<MetricCols {...siteTotals} />
|
|
209
|
+
</AccordionHeader>
|
|
210
|
+
<AccordionContent>
|
|
211
|
+
{site.lanes.map((l, li) => (
|
|
212
|
+
<PressableHighlight
|
|
213
|
+
key={l.id}
|
|
214
|
+
accessibilityRole="button"
|
|
215
|
+
accessibilityLabel={`Open lane ${l.id} to ${l.dest}`}
|
|
216
|
+
onPress={() => setOpen({ site, idx: li })}
|
|
217
|
+
style={{
|
|
218
|
+
flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40,
|
|
219
|
+
borderRadius: 8, paddingHorizontal: 8, marginHorizontal: -8,
|
|
220
|
+
backgroundColor: openLane?.id === l.id ? colors.zinc[100] : undefined,
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<Text size="sm" tabular style={{ width: 88 }}>{l.id}</Text>
|
|
224
|
+
<Text size="sm" color="muted" numberOfLines={1} style={{ flex: 1 }}>{`→ ${l.dest}`}</Text>
|
|
225
|
+
<MetricCols {...l} />
|
|
226
|
+
<View style={{ width: 16 }} />
|
|
227
|
+
</PressableHighlight>
|
|
228
|
+
))}
|
|
229
|
+
</AccordionContent>
|
|
230
|
+
</Accordion>
|
|
231
|
+
);
|
|
232
|
+
})}
|
|
233
|
+
</AccordionContent>
|
|
234
|
+
</Accordion>
|
|
235
|
+
</View>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
</View>
|
|
239
|
+
|
|
240
|
+
<CardFooter>
|
|
241
|
+
<Text size="xs" color="muted" tabular style={{ flex: 1 }}>
|
|
242
|
+
{`Network · ${NETWORK.volume.toLocaleString("en-US")} units · ${NETWORK.ontime}% on-time · ${NETWORK.margin}% margin`}
|
|
243
|
+
</Text>
|
|
244
|
+
</CardFooter>
|
|
245
|
+
</Card>
|
|
246
|
+
</View>
|
|
247
|
+
|
|
248
|
+
{open !== null && openLane !== null ? (
|
|
249
|
+
<Drawer
|
|
250
|
+
open
|
|
251
|
+
onOpenChange={(o) => !o && setOpen(null)}
|
|
252
|
+
title={openLane.id}
|
|
253
|
+
width={440}
|
|
254
|
+
onPrev={open.idx > 0 ? () => setOpen({ site: open.site, idx: open.idx - 1 }) : undefined}
|
|
255
|
+
onNext={open.idx < open.site.lanes.length - 1 ? () => setOpen({ site: open.site, idx: open.idx + 1 }) : undefined}
|
|
256
|
+
position={`${open.idx + 1}/${open.site.lanes.length}`}
|
|
257
|
+
>
|
|
258
|
+
<View key={openLane.id} style={{ flex: 1, padding: 24, gap: 16 }}>
|
|
259
|
+
<View style={{ gap: 6 }}>
|
|
260
|
+
<KVRow label="Route"><Text size="sm">{`${open.site.label} → ${openLane.dest}`}</Text></KVRow>
|
|
261
|
+
<Divider />
|
|
262
|
+
<KVRow label="Volume this month"><Text size="sm" tabular>{openLane.volume.toLocaleString("en-US")}</Text></KVRow>
|
|
263
|
+
<Divider />
|
|
264
|
+
<KVRow label="On-time">
|
|
265
|
+
<Text size="sm" tabular color={openLane.ontime < ONTIME_TARGET ? "danger" : "default"}>
|
|
266
|
+
{`${openLane.ontime}% · target ${ONTIME_TARGET}%`}
|
|
267
|
+
</Text>
|
|
268
|
+
</KVRow>
|
|
269
|
+
<Divider />
|
|
270
|
+
<KVRow label="Margin"><Text size="sm" tabular>{`${openLane.margin}%`}</Text></KVRow>
|
|
271
|
+
</View>
|
|
272
|
+
<View style={{ gap: 8 }}>
|
|
273
|
+
<Text size="xs" color="muted" transform="uppercase">Volume · last 12 weeks</Text>
|
|
274
|
+
<Sparkline
|
|
275
|
+
data={Array.from({ length: 12 }, (_, w) => openLane.volume / 4 + (hash(w, openLane.volume) - 500) * (openLane.volume / 12000))}
|
|
276
|
+
width={200}
|
|
277
|
+
height={36}
|
|
278
|
+
color={openLane.ontime < ONTIME_TARGET ? solid("amber") : solid("blue")}
|
|
279
|
+
/>
|
|
280
|
+
</View>
|
|
281
|
+
</View>
|
|
282
|
+
<View
|
|
283
|
+
style={{
|
|
284
|
+
borderTopWidth: 1,
|
|
285
|
+
borderTopColor: colors.border,
|
|
286
|
+
paddingHorizontal: 20,
|
|
287
|
+
paddingVertical: 14,
|
|
288
|
+
flexDirection: "row",
|
|
289
|
+
alignItems: "center",
|
|
290
|
+
justifyContent: "flex-end",
|
|
291
|
+
gap: 12,
|
|
292
|
+
}}
|
|
293
|
+
>
|
|
294
|
+
<Button title="Open lane record" color="primary" onPress={() => {}} />
|
|
295
|
+
</View>
|
|
296
|
+
</Drawer>
|
|
297
|
+
) : null}
|
|
298
|
+
</ScrollView>
|
|
299
|
+
);
|
|
300
|
+
}
|