@lotics/ui 3.6.0 → 4.1.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 +352 -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_billing.tsx +344 -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 +12 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +33 -8
- package/src/control_surface.ts +8 -0
- 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/index.css +6 -3
- package/src/inline_date_picker.tsx +111 -0
- package/src/inline_edit.tsx +238 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +92 -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/link.tsx +32 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/picker.tsx +4 -1
- package/src/popover.tsx +10 -1
- package/src/pressable_row.tsx +4 -1
- package/src/radio_picker.tsx +3 -1
- package/src/section_heading.tsx +43 -29
- package/src/segmented_control.tsx +3 -2
- package/src/tabs.tsx +4 -2
- package/src/tag_input.tsx +202 -0
- package/src/text.tsx +1 -1
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, ScrollView, View } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors, solid, withAlpha } from "@lotics/ui/colors";
|
|
5
|
+
import { ActionMenu } from "@lotics/ui/action_menu";
|
|
6
|
+
import { Avatar } from "@lotics/ui/avatar";
|
|
7
|
+
import { Badge } from "@lotics/ui/badge";
|
|
8
|
+
import { Button } from "@lotics/ui/button";
|
|
9
|
+
import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
10
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
11
|
+
import { Divider } from "@lotics/ui/divider";
|
|
12
|
+
import { Drawer } from "@lotics/ui/drawer";
|
|
13
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
14
|
+
import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
15
|
+
import { MenuListItem } from "@lotics/ui/menu_list_item";
|
|
16
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@lotics/ui/popover";
|
|
17
|
+
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
18
|
+
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
19
|
+
import { Screen } from "@lotics/ui/screen_router";
|
|
20
|
+
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
// Template · Shift staffing — two-sided allocation: members SIGN UP, the
|
|
23
|
+
// manager DECIDES and FILLS. Three bands reuse the execution grammar:
|
|
24
|
+
// the coverage board is the overview (shifts × days, each cell its
|
|
25
|
+
// filled/required state — press to open the slot), the signup rail is the
|
|
26
|
+
// APPROVALS pattern (in-hours = one-click Accept; overtime = consequence
|
|
27
|
+
// Dialog; decline = ⋯), and the slot drawer fills gaps with the DISPATCH
|
|
28
|
+
// fit-gate (every candidate names its hours; same-slot members excluded,
|
|
29
|
+
// double-shift days flagged). Accepting and assigning move the board live.
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const DAYS = [
|
|
33
|
+
{ key: "mon", label: "Mon", date: "15/06" },
|
|
34
|
+
{ key: "tue", label: "Tue", date: "16/06" },
|
|
35
|
+
{ key: "wed", label: "Wed", date: "17/06" },
|
|
36
|
+
{ key: "thu", label: "Thu", date: "18/06" },
|
|
37
|
+
{ key: "fri", label: "Fri", date: "19/06" },
|
|
38
|
+
{ key: "sat", label: "Sat", date: "20/06" },
|
|
39
|
+
{ key: "sun", label: "Sun", date: "21/06" },
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
const SHIFTS = [
|
|
43
|
+
{ key: "morning", label: "Morning", hours: "06–14" },
|
|
44
|
+
{ key: "afternoon", label: "Afternoon", hours: "14–22" },
|
|
45
|
+
{ key: "night", label: "Night", hours: "22–06" },
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
type DayKey = (typeof DAYS)[number]["key"];
|
|
49
|
+
type ShiftKey = (typeof SHIFTS)[number]["key"];
|
|
50
|
+
type SlotId = `${DayKey}-${ShiftKey}`;
|
|
51
|
+
|
|
52
|
+
const MEMBERS = [
|
|
53
|
+
"Sarah Chen", "David Park", "Maria Lopez", "James Wu", "Emma Davis",
|
|
54
|
+
"Daniel Reed", "Olivia Grant", "Victor Lane", "Nora Walsh", "Tom Becker",
|
|
55
|
+
"Ray Santos", "Ken Adachi",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Required headcount per slot — weekends run lighter, nights always 2. */
|
|
59
|
+
const required = (d: DayKey, s: ShiftKey): number =>
|
|
60
|
+
s === "night" ? 2 : d === "sat" || d === "sun" ? 2 : 3;
|
|
61
|
+
|
|
62
|
+
const slotId = (d: DayKey, s: ShiftKey): SlotId => `${d}-${s}`;
|
|
63
|
+
const SHIFT_HOURS = 8;
|
|
64
|
+
const WEEK_LIMIT = 40;
|
|
65
|
+
|
|
66
|
+
// The week as the manager finds it: mornings mostly staffed, the back half
|
|
67
|
+
// of the week and nights still open.
|
|
68
|
+
const INITIAL_ASSIGNED: Record<SlotId, string[]> = {
|
|
69
|
+
"mon-morning": ["Sarah Chen", "David Park", "Maria Lopez"],
|
|
70
|
+
"mon-afternoon": ["James Wu", "Emma Davis", "Daniel Reed"],
|
|
71
|
+
"mon-night": ["Tom Becker", "Ray Santos"],
|
|
72
|
+
"tue-morning": ["Sarah Chen", "Olivia Grant", "Victor Lane"],
|
|
73
|
+
"tue-afternoon": ["James Wu", "Nora Walsh"],
|
|
74
|
+
"tue-night": ["Ken Adachi"],
|
|
75
|
+
"wed-morning": ["David Park", "Maria Lopez", "Olivia Grant"],
|
|
76
|
+
"wed-afternoon": ["Emma Davis", "Daniel Reed", "Sarah Chen"],
|
|
77
|
+
"wed-night": ["Tom Becker", "Ray Santos"],
|
|
78
|
+
"thu-morning": ["Sarah Chen", "Victor Lane", "James Wu"],
|
|
79
|
+
"thu-afternoon": ["Nora Walsh"],
|
|
80
|
+
"thu-night": [],
|
|
81
|
+
"fri-morning": ["David Park", "Maria Lopez"],
|
|
82
|
+
"fri-afternoon": ["James Wu"],
|
|
83
|
+
"fri-night": ["Ken Adachi"],
|
|
84
|
+
"sat-morning": ["Olivia Grant", "Victor Lane"],
|
|
85
|
+
"sat-afternoon": ["Emma Davis"],
|
|
86
|
+
"sat-night": [],
|
|
87
|
+
"sun-morning": ["Nora Walsh", "Sarah Chen"],
|
|
88
|
+
"sun-afternoon": ["James Wu"],
|
|
89
|
+
"sun-night": ["Tom Becker"],
|
|
90
|
+
} as Record<SlotId, string[]>;
|
|
91
|
+
|
|
92
|
+
interface Signup {
|
|
93
|
+
id: string;
|
|
94
|
+
member: string;
|
|
95
|
+
day: DayKey;
|
|
96
|
+
shift: ShiftKey;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const INITIAL_SIGNUPS: Signup[] = [
|
|
100
|
+
{ id: "su1", member: "Daniel Reed", day: "thu", shift: "afternoon" },
|
|
101
|
+
{ id: "su2", member: "Ken Adachi", day: "thu", shift: "night" },
|
|
102
|
+
{ id: "su3", member: "Ray Santos", day: "thu", shift: "night" },
|
|
103
|
+
{ id: "su4", member: "Sarah Chen", day: "fri", shift: "afternoon" },
|
|
104
|
+
{ id: "su5", member: "Tom Becker", day: "sat", shift: "night" },
|
|
105
|
+
{ id: "su6", member: "Maria Lopez", day: "sun", shift: "afternoon" },
|
|
106
|
+
{ id: "su7", member: "James Wu", day: "sat", shift: "night" },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const dayOf = (k: DayKey) => DAYS.find((d) => d.key === k)!;
|
|
110
|
+
const shiftOf = (k: ShiftKey) => SHIFTS.find((s) => s.key === k)!;
|
|
111
|
+
const slotLabel = (d: DayKey, s: ShiftKey) => `${dayOf(d).label} ${dayOf(d).date} · ${shiftOf(s).label} ${shiftOf(s).hours}`;
|
|
112
|
+
|
|
113
|
+
function KVRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
114
|
+
return (
|
|
115
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 28 }}>
|
|
116
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>{label}</Text>
|
|
117
|
+
{children}
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Overtime acceptance is a consequence gate, not a click. */
|
|
123
|
+
function OvertimeGate({ label, hoursAfter, onConfirm }: { label: string; hoursAfter: number; onConfirm: () => void }) {
|
|
124
|
+
const [open, setOpen] = useState(false);
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<Button title="Accept overtime" color="primary" onPress={() => setOpen(true)} />
|
|
128
|
+
<Dialog width={440} open={open} onOpenChange={setOpen}>
|
|
129
|
+
<Screen route="">
|
|
130
|
+
<DialogHeader>
|
|
131
|
+
<DialogHeaderTitle>Accept into overtime</DialogHeaderTitle>
|
|
132
|
+
</DialogHeader>
|
|
133
|
+
<View style={{ padding: 24 }}>
|
|
134
|
+
<Text size="sm" color="muted">
|
|
135
|
+
{`${label} would be at ${hoursAfter}h this week — over the ${WEEK_LIMIT}h limit. Accepting books the extra hours as overtime and is recorded in the weekly staffing report.`}
|
|
136
|
+
</Text>
|
|
137
|
+
</View>
|
|
138
|
+
<DialogFooter>
|
|
139
|
+
<Button title="Cancel" color="secondary" onPress={() => setOpen(false)} />
|
|
140
|
+
<Button title="Accept and book overtime" color="primary" onPress={() => { setOpen(false); onConfirm(); }} />
|
|
141
|
+
</DialogFooter>
|
|
142
|
+
</Screen>
|
|
143
|
+
</Dialog>
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function TplShifts() {
|
|
149
|
+
const [assigned, setAssigned] = useState<Record<SlotId, string[]>>(INITIAL_ASSIGNED);
|
|
150
|
+
const [signups, setSignups] = useState<Signup[]>(INITIAL_SIGNUPS);
|
|
151
|
+
const [openSlot, setOpenSlot] = useState<SlotId | null>(null);
|
|
152
|
+
|
|
153
|
+
const hoursOf = (member: string) =>
|
|
154
|
+
Object.values(assigned).reduce((h, list) => h + (list.includes(member) ? SHIFT_HOURS : 0), 0);
|
|
155
|
+
|
|
156
|
+
const accept = (su: Signup) => {
|
|
157
|
+
setAssigned((prev) => ({ ...prev, [slotId(su.day, su.shift)]: [...prev[slotId(su.day, su.shift)], su.member] }));
|
|
158
|
+
setSignups((prev) => prev.filter((s) => s.id !== su.id));
|
|
159
|
+
};
|
|
160
|
+
const decline = (su: Signup) => setSignups((prev) => prev.filter((s) => s.id !== su.id));
|
|
161
|
+
const assign = (slot: SlotId, member: string) =>
|
|
162
|
+
setAssigned((prev) => ({ ...prev, [slot]: [...prev[slot], member] }));
|
|
163
|
+
const unassign = (slot: SlotId, member: string) =>
|
|
164
|
+
setAssigned((prev) => ({ ...prev, [slot]: prev[slot].filter((m) => m !== member) }));
|
|
165
|
+
|
|
166
|
+
const allSlots: { day: DayKey; shift: ShiftKey }[] = SHIFTS.flatMap((s) => DAYS.map((d) => ({ day: d.key, shift: s.key })));
|
|
167
|
+
const totalRequired = allSlots.reduce((n, x) => n + required(x.day, x.shift), 0);
|
|
168
|
+
const totalFilled = allSlots.reduce((n, x) => n + Math.min(assigned[slotId(x.day, x.shift)].length, required(x.day, x.shift)), 0);
|
|
169
|
+
const unfilled = allSlots.filter((x) => assigned[slotId(x.day, x.shift)].length < required(x.day, x.shift)).length;
|
|
170
|
+
const scheduled = new Set(Object.values(assigned).flat()).size;
|
|
171
|
+
|
|
172
|
+
// The drawer walks the board in reading order (shift rows × days).
|
|
173
|
+
const openIdx = openSlot ? allSlots.findIndex((x) => slotId(x.day, x.shift) === openSlot) : -1;
|
|
174
|
+
const open = openIdx >= 0 ? allSlots[openIdx] : null;
|
|
175
|
+
const openAssigned = openSlot ? assigned[openSlot] : [];
|
|
176
|
+
const openRequired = open ? required(open.day, open.shift) : 0;
|
|
177
|
+
const openSignups = open ? signups.filter((s) => s.day === open.day && s.shift === open.shift) : [];
|
|
178
|
+
|
|
179
|
+
const coverageColor = (filled: number, req: number) =>
|
|
180
|
+
filled >= req ? solid("emerald") : filled === 0 ? solid("red") : solid("amber");
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
184
|
+
<View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
|
|
185
|
+
{/* header band */}
|
|
186
|
+
<View style={{ gap: 2 }}>
|
|
187
|
+
<Text size="xl" weight="semibold">Shift staffing · week 15–21/06</Text>
|
|
188
|
+
<Text size="sm" color="muted">Members sign up, you allocate — fill every slot without burning anyone out</Text>
|
|
189
|
+
</View>
|
|
190
|
+
|
|
191
|
+
<KPIStrip
|
|
192
|
+
items={[
|
|
193
|
+
{ label: "Coverage", value: Math.round((totalFilled / totalRequired) * 100), format: "percentage", info: "Filled positions vs required across every slot this week. The board's job is 100% with nobody over 40h." },
|
|
194
|
+
{ label: "Unfilled slots", value: unfilled, format: "number", tone: unfilled > 0 ? "danger" : "default", caption: "red and amber cells below" },
|
|
195
|
+
{ label: "Pending signups", value: signups.length, format: "number", tone: signups.length > 0 ? "warning" : "default", info: "Members asking for shifts — accept in one click when it keeps them inside 40h; overtime needs the consequence dialog." },
|
|
196
|
+
{ label: "Scheduled members", value: scheduled, format: "number", caption: `of ${MEMBERS.length} on the roster` },
|
|
197
|
+
]}
|
|
198
|
+
/>
|
|
199
|
+
|
|
200
|
+
{/* the coverage board — shifts × days, every cell a door */}
|
|
201
|
+
<Card style={{ padding: 0 }}>
|
|
202
|
+
<CardHeader>
|
|
203
|
+
<CardHeaderTitle info="Filled / required per slot. Green is covered, amber is short, red is empty — press any cell to work that slot.">
|
|
204
|
+
Coverage board
|
|
205
|
+
</CardHeaderTitle>
|
|
206
|
+
</CardHeader>
|
|
207
|
+
<View style={{ paddingHorizontal: 20, paddingVertical: 16, gap: 6 }}>
|
|
208
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
209
|
+
<View style={{ width: 110 }} />
|
|
210
|
+
{DAYS.map((d) => (
|
|
211
|
+
<View key={d.key} style={{ flex: 1, alignItems: "center" }}>
|
|
212
|
+
<Text size="xs" color="muted" transform="uppercase">{d.label}</Text>
|
|
213
|
+
<Text size="xs" color="muted" tabular>{d.date}</Text>
|
|
214
|
+
</View>
|
|
215
|
+
))}
|
|
216
|
+
</View>
|
|
217
|
+
{SHIFTS.map((s) => (
|
|
218
|
+
<View key={s.key} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
219
|
+
<View style={{ width: 110 }}>
|
|
220
|
+
<Text size="sm">{s.label}</Text>
|
|
221
|
+
<Text size="xs" color="muted" tabular>{s.hours}</Text>
|
|
222
|
+
</View>
|
|
223
|
+
{DAYS.map((d) => {
|
|
224
|
+
const id = slotId(d.key, s.key);
|
|
225
|
+
const filled = assigned[id].length;
|
|
226
|
+
const req = required(d.key, s.key);
|
|
227
|
+
const color = coverageColor(filled, req);
|
|
228
|
+
const isOpen = openSlot === id;
|
|
229
|
+
return (
|
|
230
|
+
<Pressable
|
|
231
|
+
key={d.key}
|
|
232
|
+
accessibilityRole="button"
|
|
233
|
+
accessibilityLabel={`${slotLabel(d.key, s.key)}: ${filled} of ${req} filled`}
|
|
234
|
+
onPress={() => setOpenSlot(id)}
|
|
235
|
+
style={{
|
|
236
|
+
flex: 1,
|
|
237
|
+
minHeight: 44,
|
|
238
|
+
borderRadius: 8,
|
|
239
|
+
alignItems: "center",
|
|
240
|
+
justifyContent: "center",
|
|
241
|
+
backgroundColor: withAlpha(color, filled >= req ? 0.12 : 0.16),
|
|
242
|
+
borderWidth: 2,
|
|
243
|
+
borderColor: isOpen ? colors.zinc[900] : "transparent",
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<Text size="sm" weight="medium" tabular>{`${filled}/${req}`}</Text>
|
|
247
|
+
</Pressable>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
</View>
|
|
251
|
+
))}
|
|
252
|
+
</View>
|
|
253
|
+
<CardFooter>
|
|
254
|
+
<Text size="xs" color="muted" tabular style={{ flex: 1 }}>
|
|
255
|
+
{`${totalFilled} of ${totalRequired} positions filled · accepting a signup or adding a member updates the board live`}
|
|
256
|
+
</Text>
|
|
257
|
+
</CardFooter>
|
|
258
|
+
</Card>
|
|
259
|
+
|
|
260
|
+
{/* the signup inbox — verdicts, with hours as the policy band */}
|
|
261
|
+
<Card style={{ padding: 0 }}>
|
|
262
|
+
<CardHeader>
|
|
263
|
+
<CardHeaderTitle info="Members asking for shifts, oldest first. The hours column shows the week AFTER accepting — inside 40h is one click; overtime gets a consequence dialog; ⋯ declines.">
|
|
264
|
+
Pending signups
|
|
265
|
+
</CardHeaderTitle>
|
|
266
|
+
</CardHeader>
|
|
267
|
+
{signups.length === 0 ? (
|
|
268
|
+
<EmptyState message="No pending signups" hint="New requests appear here the moment members ask for a shift" />
|
|
269
|
+
) : (
|
|
270
|
+
signups.map((su, i) => {
|
|
271
|
+
const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
|
|
272
|
+
const overtime = hoursAfter > WEEK_LIMIT;
|
|
273
|
+
const slotFull = assigned[slotId(su.day, su.shift)].length >= required(su.day, su.shift);
|
|
274
|
+
return (
|
|
275
|
+
<View key={su.id}>
|
|
276
|
+
{i > 0 ? <Divider /> : null}
|
|
277
|
+
<PressableRow onPress={() => setOpenSlot(slotId(su.day, su.shift))} selected={openSlot === slotId(su.day, su.shift)}>
|
|
278
|
+
<Pressable
|
|
279
|
+
accessibilityRole="button"
|
|
280
|
+
accessibilityLabel={`Open slot for ${su.member}'s signup`}
|
|
281
|
+
onPress={() => setOpenSlot(slotId(su.day, su.shift))}
|
|
282
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 52 }}
|
|
283
|
+
>
|
|
284
|
+
<Avatar name={su.member} size={28} />
|
|
285
|
+
<View style={{ flex: 1, gap: 0 }}>
|
|
286
|
+
<Text size="sm" numberOfLines={1}>{su.member}</Text>
|
|
287
|
+
<Text size="xs" color="muted">{slotLabel(su.day, su.shift)}</Text>
|
|
288
|
+
</View>
|
|
289
|
+
{slotFull ? <Badge variant="dot" label="Slot already full" color="zinc" /> : null}
|
|
290
|
+
<Text size="sm" tabular color={overtime ? "danger" : "muted"} style={{ width: 96 }} align="right">
|
|
291
|
+
{`${hoursOf(su.member)}h → ${hoursAfter}h`}
|
|
292
|
+
</Text>
|
|
293
|
+
<View style={{ width: 92 }}>
|
|
294
|
+
{overtime ? <Badge variant="dot" label="Overtime" color="red" /> : <Badge variant="dot" label="In hours" color="emerald" />}
|
|
295
|
+
</View>
|
|
296
|
+
</Pressable>
|
|
297
|
+
{overtime ? (
|
|
298
|
+
<OvertimeGate label={su.member} hoursAfter={hoursAfter} onConfirm={() => accept(su)} />
|
|
299
|
+
) : (
|
|
300
|
+
<Button title="Accept" color="secondary" onPress={() => accept(su)} />
|
|
301
|
+
)}
|
|
302
|
+
<ActionMenu
|
|
303
|
+
accessibilityLabel={`Actions for ${su.member}'s signup`}
|
|
304
|
+
items={[{ key: "decline", label: "Decline signup", icon: "ban", danger: true, onPress: () => decline(su) }]}
|
|
305
|
+
/>
|
|
306
|
+
</PressableRow>
|
|
307
|
+
</View>
|
|
308
|
+
);
|
|
309
|
+
})
|
|
310
|
+
)}
|
|
311
|
+
<CardFooter>
|
|
312
|
+
<Text size="xs" color="muted" tabular style={{ flex: 1 }}>
|
|
313
|
+
{`${signups.length} pending · first come, first served unless coverage says otherwise`}
|
|
314
|
+
</Text>
|
|
315
|
+
</CardFooter>
|
|
316
|
+
</Card>
|
|
317
|
+
</View>
|
|
318
|
+
|
|
319
|
+
{open !== null && openSlot !== null ? (
|
|
320
|
+
<Drawer
|
|
321
|
+
open
|
|
322
|
+
onOpenChange={(o) => !o && setOpenSlot(null)}
|
|
323
|
+
title={slotLabel(open.day, open.shift)}
|
|
324
|
+
width={460}
|
|
325
|
+
onPrev={openIdx > 0 ? () => setOpenSlot(slotId(allSlots[openIdx - 1].day, allSlots[openIdx - 1].shift)) : undefined}
|
|
326
|
+
onNext={openIdx < allSlots.length - 1 ? () => setOpenSlot(slotId(allSlots[openIdx + 1].day, allSlots[openIdx + 1].shift)) : undefined}
|
|
327
|
+
position={`${openIdx + 1}/${allSlots.length}`}
|
|
328
|
+
>
|
|
329
|
+
<ScrollView key={openSlot} style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 16 }}>
|
|
330
|
+
<ProgressBar
|
|
331
|
+
title="Coverage"
|
|
332
|
+
value={openAssigned.length}
|
|
333
|
+
max={openRequired}
|
|
334
|
+
format="fraction"
|
|
335
|
+
color={solid("amber")}
|
|
336
|
+
completeColor={solid("emerald")}
|
|
337
|
+
/>
|
|
338
|
+
<View style={{ gap: 8 }}>
|
|
339
|
+
<Text size="sm" weight="semibold">On this shift</Text>
|
|
340
|
+
{openAssigned.length === 0 ? (
|
|
341
|
+
<Text size="sm" color="muted">Nobody yet — accept a signup or add a member below.</Text>
|
|
342
|
+
) : (
|
|
343
|
+
openAssigned.map((m) => (
|
|
344
|
+
<View key={m} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
|
|
345
|
+
<Avatar name={m} size={28} />
|
|
346
|
+
<Text size="sm" style={{ flex: 1 }}>{m}</Text>
|
|
347
|
+
<Text size="xs" color="muted" tabular>{`${hoursOf(m)}h this week`}</Text>
|
|
348
|
+
<ActionMenu
|
|
349
|
+
accessibilityLabel={`Actions for ${m}`}
|
|
350
|
+
items={[{ key: "remove", label: "Remove from shift", icon: "ban", danger: true, onPress: () => unassign(openSlot, m) }]}
|
|
351
|
+
/>
|
|
352
|
+
</View>
|
|
353
|
+
))
|
|
354
|
+
)}
|
|
355
|
+
</View>
|
|
356
|
+
{openSignups.length > 0 ? (
|
|
357
|
+
<View style={{ gap: 8 }}>
|
|
358
|
+
<Text size="sm" weight="semibold">Asked for this shift</Text>
|
|
359
|
+
{openSignups.map((su) => {
|
|
360
|
+
const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
|
|
361
|
+
return (
|
|
362
|
+
<View key={su.id} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
|
|
363
|
+
<Avatar name={su.member} size={28} />
|
|
364
|
+
<Text size="sm" style={{ flex: 1 }}>{su.member}</Text>
|
|
365
|
+
<Text size="xs" color={hoursAfter > WEEK_LIMIT ? "danger" : "muted"} tabular>{`→ ${hoursAfter}h`}</Text>
|
|
366
|
+
{hoursAfter > WEEK_LIMIT ? (
|
|
367
|
+
<OvertimeGate label={su.member} hoursAfter={hoursAfter} onConfirm={() => accept(su)} />
|
|
368
|
+
) : (
|
|
369
|
+
<Button title="Accept" color="secondary" onPress={() => accept(su)} />
|
|
370
|
+
)}
|
|
371
|
+
</View>
|
|
372
|
+
);
|
|
373
|
+
})}
|
|
374
|
+
</View>
|
|
375
|
+
) : null}
|
|
376
|
+
<Divider />
|
|
377
|
+
<KVRow label="Required">
|
|
378
|
+
<Text size="sm" tabular>{`${openRequired} members`}</Text>
|
|
379
|
+
</KVRow>
|
|
380
|
+
</ScrollView>
|
|
381
|
+
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: 20, paddingVertical: 14, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 12 }}>
|
|
382
|
+
{/* the dispatch fit-gate: every candidate names its week; the
|
|
383
|
+
slot's own members are excluded, overtime is visible */}
|
|
384
|
+
<Popover side="top" align="end">
|
|
385
|
+
<PopoverTrigger>
|
|
386
|
+
<Button title="Add member" color="secondary" />
|
|
387
|
+
</PopoverTrigger>
|
|
388
|
+
<PopoverContent style={{ width: 320 }}>
|
|
389
|
+
<View style={{ paddingHorizontal: 12, paddingTop: 10, paddingBottom: 6 }}>
|
|
390
|
+
<Text size="xs" color="muted" transform="uppercase">{`Fill ${slotLabel(open.day, open.shift)}`}</Text>
|
|
391
|
+
</View>
|
|
392
|
+
<ScrollView style={{ maxHeight: 280 }} contentContainerStyle={{ paddingHorizontal: 6, paddingBottom: 8, gap: 2 }}>
|
|
393
|
+
{MEMBERS.filter((m) => !openAssigned.includes(m)).map((m) => {
|
|
394
|
+
const after = hoursOf(m) + SHIFT_HOURS;
|
|
395
|
+
const sameDay = SHIFTS.some((s) => s.key !== open.shift && assigned[slotId(open.day, s.key)].includes(m));
|
|
396
|
+
return (
|
|
397
|
+
<MenuListItem
|
|
398
|
+
key={m}
|
|
399
|
+
icon={<Avatar name={m} size={28} />}
|
|
400
|
+
title={m}
|
|
401
|
+
description={
|
|
402
|
+
after > WEEK_LIMIT
|
|
403
|
+
? `${hoursOf(m)}h → ${after}h — overtime`
|
|
404
|
+
: sameDay
|
|
405
|
+
? `${hoursOf(m)}h this week — already works ${dayOf(open.day).label}`
|
|
406
|
+
: `${hoursOf(m)}h this week`
|
|
407
|
+
}
|
|
408
|
+
onPress={() => assign(openSlot, m)}
|
|
409
|
+
/>
|
|
410
|
+
);
|
|
411
|
+
})}
|
|
412
|
+
</ScrollView>
|
|
413
|
+
</PopoverContent>
|
|
414
|
+
</Popover>
|
|
415
|
+
<Button title="Open shift record" color="primary" onPress={() => {}} />
|
|
416
|
+
</View>
|
|
417
|
+
</Drawer>
|
|
418
|
+
) : null}
|
|
419
|
+
</ScrollView>
|
|
420
|
+
);
|
|
421
|
+
}
|