@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,240 @@
|
|
|
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 { Badge } from "@lotics/ui/badge";
|
|
6
|
+
import { Button } from "@lotics/ui/button";
|
|
7
|
+
import { Card } from "@lotics/ui/card";
|
|
8
|
+
import { ChipGroup } from "@lotics/ui/chip_group";
|
|
9
|
+
import { Counter } from "@lotics/ui/counter";
|
|
10
|
+
import { Divider } from "@lotics/ui/divider";
|
|
11
|
+
import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
|
|
12
|
+
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
13
|
+
import { ScanField } from "@lotics/ui/scan_field";
|
|
14
|
+
import { StepList, type StepListItem } from "@lotics/ui/step_list";
|
|
15
|
+
import { CompletionState } from "@lotics/ui/completion_state";
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
// Template · Guided execution — a warehouse PICK RUN. The work is a SEQUENCE of
|
|
19
|
+
// physical tasks, not a list to browse: the run hands the operator the next bin,
|
|
20
|
+
// they scan to verify they're at the right shelf, confirm the count, and the
|
|
21
|
+
// next task takes its place. Two panes use the screen the way the work wants —
|
|
22
|
+
// the CURRENT task large on the left (where to walk, what to take, how many), the
|
|
23
|
+
// whole PICK PATH on the right (done · now · up next) so position is always in
|
|
24
|
+
// view. Scan-to-verify, can't-skip ordering, and a short-pick exception that
|
|
25
|
+
// flags-and-advances without stalling the run.
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
interface PickLine {
|
|
29
|
+
id: string;
|
|
30
|
+
zone: string;
|
|
31
|
+
bin: string;
|
|
32
|
+
sku: string;
|
|
33
|
+
item: string;
|
|
34
|
+
qty: number;
|
|
35
|
+
status: "pending" | "picked" | "short";
|
|
36
|
+
/** Actual units taken — = qty when picked, < qty when short. */
|
|
37
|
+
picked?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Ordered by pick path — the run walks the operator down the aisles in sequence.
|
|
41
|
+
const WAVE: PickLine[] = [
|
|
42
|
+
{ id: "L1", zone: "Aisle A", bin: "A-12-03", sku: "RM-0012", item: "Kraft paper 175gsm, 1.6m", qty: 8, status: "pending" },
|
|
43
|
+
{ id: "L2", zone: "Aisle A", bin: "A-14-08", sku: "RM-0018", item: "Medium paper 115gsm, 1.4m", qty: 12, status: "pending" },
|
|
44
|
+
{ id: "L3", zone: "Aisle B", bin: "B-03-01", sku: "FG-0203", item: "Carton box 600×400×400, 5-ply", qty: 50, status: "pending" },
|
|
45
|
+
{ id: "L4", zone: "Aisle B", bin: "B-05-07", sku: "FG-0218", item: "Carton box 350×250×200, 3-ply", qty: 40, status: "pending" },
|
|
46
|
+
{ id: "L5", zone: "Aisle C", bin: "C-01-02", sku: "SUP-0007", item: "Clear packing tape 48mm", qty: 24, status: "pending" },
|
|
47
|
+
{ id: "L6", zone: "Aisle C", bin: "C-02-09", sku: "SUP-0011", item: "PP strapping 12mm, 10kg roll", qty: 6, status: "pending" },
|
|
48
|
+
{ id: "L7", zone: "Aisle C", bin: "C-04-01", sku: "FG-0226", item: "Offset box 250×180×90, 4-color", qty: 30, status: "pending" },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const SHORT_REASONS = [
|
|
52
|
+
{ label: "Out of stock", value: "oos" },
|
|
53
|
+
{ label: "Damaged", value: "damaged" },
|
|
54
|
+
{ label: "Location empty", value: "empty" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// The exception branch — record fewer units than asked + a reason, then advance.
|
|
58
|
+
// A short never blocks the run; the shortfall is flagged for backfill.
|
|
59
|
+
function ShortGate({ line, onShort }: { line: PickLine; onShort: (actual: number) => void }) {
|
|
60
|
+
const [open, setOpen] = useState(false);
|
|
61
|
+
const [actual, setActual] = useState(0);
|
|
62
|
+
const [reason, setReason] = useState("oos");
|
|
63
|
+
return (
|
|
64
|
+
<Popover open={open} onOpenChange={setOpen} side="top" align="start">
|
|
65
|
+
<PopoverTrigger>
|
|
66
|
+
<Button title="Can't pick all" color="muted" />
|
|
67
|
+
</PopoverTrigger>
|
|
68
|
+
<PopoverContent style={{ width: 300 }} disableBodyScroll>
|
|
69
|
+
<View style={{ gap: 14 }}>
|
|
70
|
+
<View style={{ gap: 2 }}>
|
|
71
|
+
<Text size="sm" weight="semibold">{`Short pick · ${line.bin}`}</Text>
|
|
72
|
+
<Text size="xs" color="muted">Record what you actually took — the rest is flagged for backfill.</Text>
|
|
73
|
+
</View>
|
|
74
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
75
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>{`Picked (of ${line.qty})`}</Text>
|
|
76
|
+
<Counter value={actual} min={0} max={line.qty} onValueChange={setActual} accessibilityLabel="units actually picked" />
|
|
77
|
+
</View>
|
|
78
|
+
<ChipGroup accessibilityLabel="Short reason" options={SHORT_REASONS} value={reason} onValueChange={setReason} />
|
|
79
|
+
</View>
|
|
80
|
+
<PopoverFooter>
|
|
81
|
+
<Button title="Record short" color="danger" onPress={() => { setOpen(false); onShort(actual); }} />
|
|
82
|
+
</PopoverFooter>
|
|
83
|
+
</PopoverContent>
|
|
84
|
+
</Popover>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The hero — the one task in front of the operator right now.
|
|
89
|
+
function CurrentTask({ line, index, total, onConfirm, onShort }: {
|
|
90
|
+
line: PickLine; index: number; total: number; onConfirm: () => void; onShort: (actual: number) => void;
|
|
91
|
+
}) {
|
|
92
|
+
const [scan, setScan] = useState("");
|
|
93
|
+
const matched = scan.trim().toUpperCase() === line.bin.toUpperCase();
|
|
94
|
+
return (
|
|
95
|
+
<Card style={{ padding: 0 }}>
|
|
96
|
+
<View style={{ padding: 24, gap: 18 }}>
|
|
97
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
98
|
+
<Badge label={line.zone} color="blue" />
|
|
99
|
+
<Text size="xs" color="muted" tabular style={{ flex: 1, textAlign: "right" }}>{`Line ${index + 1} of ${total}`}</Text>
|
|
100
|
+
</View>
|
|
101
|
+
{/* the bin is the biggest thing on screen — it's where you walk */}
|
|
102
|
+
<View style={{ gap: 2 }}>
|
|
103
|
+
<Text size="xs" color="muted" transform="uppercase">Go to bin</Text>
|
|
104
|
+
<Text size="xxl" weight="semibold" tabular>{line.bin}</Text>
|
|
105
|
+
</View>
|
|
106
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 16, flexWrap: "wrap" }}>
|
|
107
|
+
<View style={{ gap: 2, flexGrow: 1, flexBasis: 200 }}>
|
|
108
|
+
<Text size="md" weight="medium">{line.item}</Text>
|
|
109
|
+
<Text size="sm" color="muted" tabular>{line.sku}</Text>
|
|
110
|
+
</View>
|
|
111
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
112
|
+
<Text size="sm" color="muted">Pick</Text>
|
|
113
|
+
<Text size="xl" weight="semibold" tabular>{line.qty}</Text>
|
|
114
|
+
<Text size="sm" color="muted">units</Text>
|
|
115
|
+
</View>
|
|
116
|
+
</View>
|
|
117
|
+
{/* scan-to-verify — confirm you're at the right shelf before the count */}
|
|
118
|
+
<ScanField
|
|
119
|
+
value={scan}
|
|
120
|
+
onChangeText={setScan}
|
|
121
|
+
status={matched ? "match" : scan.trim() ? "mismatch" : "idle"}
|
|
122
|
+
matchHint="Location verified — confirm the count"
|
|
123
|
+
mismatchHint={`Wrong bin — walk to ${line.bin}`}
|
|
124
|
+
placeholder={`Scan or type bin ${line.bin}`}
|
|
125
|
+
onScan={() => { if (matched) onConfirm(); }}
|
|
126
|
+
accessibilityLabel="Scan bin to verify location"
|
|
127
|
+
/>
|
|
128
|
+
</View>
|
|
129
|
+
<Divider />
|
|
130
|
+
<View style={{ padding: 16, flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
131
|
+
<ShortGate line={line} onShort={onShort} />
|
|
132
|
+
<View style={{ flex: 1 }} />
|
|
133
|
+
<Button title={`Confirm ${line.qty}`} color="primary" onPress={onConfirm} />
|
|
134
|
+
</View>
|
|
135
|
+
</Card>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Complete({ lines, onReset }: { lines: PickLine[]; onReset: () => void }) {
|
|
140
|
+
const shorts = lines.filter((l) => l.status === "short").length;
|
|
141
|
+
const units = lines.reduce((s, l) => s + (l.status === "short" ? l.picked ?? 0 : l.qty), 0);
|
|
142
|
+
return (
|
|
143
|
+
<Card style={{ padding: 0 }}>
|
|
144
|
+
<CompletionState
|
|
145
|
+
title="Wave picked"
|
|
146
|
+
summary={`${lines.length} lines · ${units} units${shorts > 0 ? ` · ${shorts} short` : ""}`}
|
|
147
|
+
>
|
|
148
|
+
<Button title="Start next wave" color="muted" onPress={onReset} />
|
|
149
|
+
<Button title="Hand to packing" color="primary" onPress={() => {}} />
|
|
150
|
+
</CompletionState>
|
|
151
|
+
</Card>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The right rail — the whole run at a glance: done · now · up next. Read-only;
|
|
156
|
+
// the run is sequential, so position is shown, not chosen.
|
|
157
|
+
function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: number }) {
|
|
158
|
+
const steps: StepListItem[] = lines.map((l, i) => ({
|
|
159
|
+
id: l.id,
|
|
160
|
+
status: l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "pending",
|
|
161
|
+
title: l.bin,
|
|
162
|
+
subtitle: l.item,
|
|
163
|
+
trailing:
|
|
164
|
+
l.status === "short" ? (
|
|
165
|
+
<Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
|
|
166
|
+
) : (
|
|
167
|
+
<Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
|
|
168
|
+
),
|
|
169
|
+
}));
|
|
170
|
+
return (
|
|
171
|
+
<Card style={{ padding: 0 }}>
|
|
172
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
|
|
173
|
+
<Text size="xs" color="muted" transform="uppercase">{`Pick path · ${lines.length} lines`}</Text>
|
|
174
|
+
</View>
|
|
175
|
+
<Divider />
|
|
176
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 14 }}>
|
|
177
|
+
<StepList steps={steps} accessibilityLabel="Pick path" />
|
|
178
|
+
</View>
|
|
179
|
+
</Card>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function TplPick() {
|
|
184
|
+
const [lines, setLines] = useState<PickLine[]>(WAVE);
|
|
185
|
+
|
|
186
|
+
const idx = lines.findIndex((l) => l.status === "pending");
|
|
187
|
+
const current = idx >= 0 ? lines[idx] : null;
|
|
188
|
+
const done = lines.filter((l) => l.status !== "pending");
|
|
189
|
+
|
|
190
|
+
const totalUnits = lines.reduce((s, l) => s + l.qty, 0);
|
|
191
|
+
const pickedUnits = lines.reduce((s, l) => s + (l.status === "picked" ? l.qty : l.status === "short" ? l.picked ?? 0 : 0), 0);
|
|
192
|
+
const shorts = lines.filter((l) => l.status === "short").length;
|
|
193
|
+
|
|
194
|
+
const setStatus = (id: string, status: PickLine["status"], picked: number) =>
|
|
195
|
+
setLines((prev) => prev.map((l) => (l.id === id ? { ...l, status, picked } : l)));
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
199
|
+
<View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
|
|
200
|
+
{/* header — the wave + the cart it's filling */}
|
|
201
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12, flexWrap: "wrap" }}>
|
|
202
|
+
<View style={{ gap: 2, flex: 1 }}>
|
|
203
|
+
<Text size="xl" weight="semibold">Pick wave W-0142</Text>
|
|
204
|
+
<Text size="sm" color="muted">7 lines for 3 outbound orders · Cart C-07</Text>
|
|
205
|
+
</View>
|
|
206
|
+
<Button title="Pause wave" color="muted" onPress={() => {}} />
|
|
207
|
+
</View>
|
|
208
|
+
|
|
209
|
+
{/* run progress — the one number that says how close to done */}
|
|
210
|
+
<View style={{ gap: 6 }}>
|
|
211
|
+
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
|
212
|
+
<Text size="sm" color="muted" style={{ flex: 1 }}>{`${done.length} of ${lines.length} lines · ${pickedUnits} of ${totalUnits} units`}</Text>
|
|
213
|
+
{shorts > 0 ? <Text size="sm" color="danger" tabular>{`${shorts} short`}</Text> : null}
|
|
214
|
+
</View>
|
|
215
|
+
<ProgressBar value={done.length} max={lines.length} format="none" color={solid("blue")} />
|
|
216
|
+
</View>
|
|
217
|
+
|
|
218
|
+
{/* two panes — the current task large, the whole path alongside it */}
|
|
219
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
|
|
220
|
+
<View style={{ flexGrow: 1.6, flexBasis: 440 }}>
|
|
221
|
+
{current ? (
|
|
222
|
+
<CurrentTask
|
|
223
|
+
line={current}
|
|
224
|
+
index={idx}
|
|
225
|
+
total={lines.length}
|
|
226
|
+
onConfirm={() => setStatus(current.id, "picked", current.qty)}
|
|
227
|
+
onShort={(actual) => setStatus(current.id, actual >= current.qty ? "picked" : "short", actual)}
|
|
228
|
+
/>
|
|
229
|
+
) : (
|
|
230
|
+
<Complete lines={lines} onReset={() => setLines(WAVE)} />
|
|
231
|
+
)}
|
|
232
|
+
</View>
|
|
233
|
+
<View style={{ flexGrow: 1, flexBasis: 320 }}>
|
|
234
|
+
<RunPath lines={lines} currentIndex={idx} />
|
|
235
|
+
</View>
|
|
236
|
+
</View>
|
|
237
|
+
</View>
|
|
238
|
+
</ScrollView>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { ScrollView, View, type TextInput as RNTextInput } from "react-native";
|
|
3
|
+
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { colors } from "@lotics/ui/colors";
|
|
5
|
+
import { Button } from "@lotics/ui/button";
|
|
6
|
+
import { Alert } from "@lotics/ui/alert";
|
|
7
|
+
import { Card, CardBody, CardHeader, CardHeaderMeta, CardHeaderTitle } from "@lotics/ui/card";
|
|
8
|
+
import { Divider } from "@lotics/ui/divider";
|
|
9
|
+
import { Icon, type IconName } from "@lotics/ui/icon";
|
|
10
|
+
import { FormField } from "@lotics/ui/form_field";
|
|
11
|
+
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
12
|
+
import { Combobox } from "@lotics/ui/combobox";
|
|
13
|
+
import type { PickerOption } from "@lotics/ui/picker";
|
|
14
|
+
import { SegmentedControl } from "@lotics/ui/segmented_control";
|
|
15
|
+
import { Counter } from "@lotics/ui/counter";
|
|
16
|
+
import { EmptyState } from "@lotics/ui/empty_state";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Template · Quick capture — the SPEED surface, opposite of the heavy form: one
|
|
20
|
+
// compact row of inputs, Enter to add, the field keeps focus and the contact
|
|
21
|
+
// carries over so the next touchpoint is two keystrokes away. Every entry drops
|
|
22
|
+
// into the log below (newest first, undoable). A `SegmentedControl` mode, a
|
|
23
|
+
// `Combobox`-as-select, a `Counter` for the bounded duration. No screen-wide
|
|
24
|
+
// Save — each entry commits on its own.
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
type ActType = "call" | "email" | "meeting" | "note";
|
|
28
|
+
|
|
29
|
+
const ACT: Record<ActType, { label: string; icon: IconName }> = {
|
|
30
|
+
call: { label: "Call", icon: "phone" },
|
|
31
|
+
email: { label: "Email", icon: "mail" },
|
|
32
|
+
meeting: { label: "Meeting", icon: "calendar" },
|
|
33
|
+
note: { label: "Note", icon: "message-square" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const TYPE_OPTIONS = (Object.keys(ACT) as ActType[]).map((v) => ({ label: ACT[v].label, value: v }));
|
|
37
|
+
|
|
38
|
+
interface Contact {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
company: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CONTACTS: Contact[] = [
|
|
45
|
+
{ id: "c1", name: "Mara Lindqvist", company: "Northwind Traders" },
|
|
46
|
+
{ id: "c2", name: "Diego Alvarez", company: "Harbor Freight Lines" },
|
|
47
|
+
{ id: "c3", name: "Priya Nair", company: "Summit Packaging Co." },
|
|
48
|
+
{ id: "c4", name: "Tom Becker", company: "Atlas Distribution" },
|
|
49
|
+
{ id: "c5", name: "Lena Fischer", company: "Bluewater Logistics" },
|
|
50
|
+
{ id: "c6", name: "Sara Cohen", company: "Meridian Retail Group" },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const CONTACT_OPTIONS: PickerOption<string, Contact>[] = CONTACTS.map((c) => ({
|
|
54
|
+
value: c.id,
|
|
55
|
+
label: c.name,
|
|
56
|
+
data: c,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
interface Entry {
|
|
60
|
+
id: string;
|
|
61
|
+
type: ActType;
|
|
62
|
+
contact: Contact;
|
|
63
|
+
note: string;
|
|
64
|
+
minutes: number;
|
|
65
|
+
time: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SEED: Entry[] = [
|
|
69
|
+
{ id: "e1", type: "call", contact: CONTACTS[2], note: "Reviewed Q3 volume, wants a revised quote by Friday", minutes: 15, time: "10:05" },
|
|
70
|
+
{ id: "e2", type: "email", contact: CONTACTS[0], note: "Sent updated pallet spec sheet", minutes: 0, time: "09:40" },
|
|
71
|
+
{ id: "e3", type: "meeting", contact: CONTACTS[4], note: "Warehouse walkthrough — flagged dock height", minutes: 45, time: "09:12" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const fmtDuration = (m: number) => (m === 0 ? "—" : m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}` : `${m}m`);
|
|
75
|
+
|
|
76
|
+
export function TplQuick() {
|
|
77
|
+
const id = useRef(1);
|
|
78
|
+
const noteRef = useRef<RNTextInput>(null);
|
|
79
|
+
|
|
80
|
+
const [type, setType] = useState<ActType>("call");
|
|
81
|
+
const [contact, setContact] = useState<PickerOption<string, Contact> | null>(null);
|
|
82
|
+
const [note, setNote] = useState("");
|
|
83
|
+
const [minutes, setMinutes] = useState(15);
|
|
84
|
+
const [entries, setEntries] = useState<Entry[]>(SEED);
|
|
85
|
+
|
|
86
|
+
const canAdd = contact !== null && note.trim().length > 0;
|
|
87
|
+
|
|
88
|
+
const add = () => {
|
|
89
|
+
if (!canAdd || !contact?.data) return;
|
|
90
|
+
const entry: Entry = {
|
|
91
|
+
id: `e_${(id.current += 1)}`,
|
|
92
|
+
type,
|
|
93
|
+
contact: contact.data,
|
|
94
|
+
note: note.trim(),
|
|
95
|
+
minutes,
|
|
96
|
+
time: "Just now",
|
|
97
|
+
};
|
|
98
|
+
setEntries((prev) => [entry, ...prev]);
|
|
99
|
+
// Keep type + contact for the next touchpoint; clear only the note + duration
|
|
100
|
+
// and re-focus the note so logging again is immediate.
|
|
101
|
+
setNote("");
|
|
102
|
+
setMinutes(type === "email" ? 0 : 15);
|
|
103
|
+
noteRef.current?.focus();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
|
|
108
|
+
<View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
|
|
109
|
+
<View style={{ gap: 2 }}>
|
|
110
|
+
<Text size="xl" weight="semibold">Log activity</Text>
|
|
111
|
+
<Text size="sm" color="muted">Capture a touchpoint in seconds — pick the type, the contact, type the note, press Enter</Text>
|
|
112
|
+
</View>
|
|
113
|
+
|
|
114
|
+
{/* the capture row */}
|
|
115
|
+
<Card style={{ padding: 0 }}>
|
|
116
|
+
<CardBody>
|
|
117
|
+
<SegmentedControl accessibilityLabel="Activity type" options={TYPE_OPTIONS} value={type} onValueChange={setType} style={{ marginBottom: 16 }} />
|
|
118
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 16 }}>
|
|
119
|
+
<FormField label="Contact" style={{ flexGrow: 1, flexBasis: 240 }}>
|
|
120
|
+
<Combobox
|
|
121
|
+
options={CONTACT_OPTIONS}
|
|
122
|
+
value={contact}
|
|
123
|
+
onValueChange={setContact}
|
|
124
|
+
getOptionDescription={(o) => o.data?.company}
|
|
125
|
+
placeholder="Select a contact"
|
|
126
|
+
accessibilityLabel="Contact"
|
|
127
|
+
/>
|
|
128
|
+
</FormField>
|
|
129
|
+
<FormField label="Duration" style={{ flexGrow: 0, flexBasis: 180 }}>
|
|
130
|
+
<View style={{ minHeight: 40, justifyContent: "center" }}>
|
|
131
|
+
<Counter value={minutes} onValueChange={setMinutes} min={0} max={240} step={15} accessibilityLabel="Duration" format={fmtDuration} />
|
|
132
|
+
</View>
|
|
133
|
+
</FormField>
|
|
134
|
+
</View>
|
|
135
|
+
<FormField label="Note">
|
|
136
|
+
<TextInputField
|
|
137
|
+
ref={noteRef}
|
|
138
|
+
value={note}
|
|
139
|
+
onChangeText={setNote}
|
|
140
|
+
placeholder="What happened? (press Enter to log)"
|
|
141
|
+
autoFocus
|
|
142
|
+
onKeyPress={(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
143
|
+
if (e.nativeEvent.key === "Enter") {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
add();
|
|
146
|
+
}
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
</FormField>
|
|
150
|
+
</CardBody>
|
|
151
|
+
<Divider />
|
|
152
|
+
<View style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 20, paddingVertical: 12, gap: 8 }}>
|
|
153
|
+
<Text size="xs" color="muted" style={{ flex: 1 }}>
|
|
154
|
+
{contact?.data ? `${ACT[type].label} · ${contact.data.name}` : "Pick a contact to log against"}
|
|
155
|
+
</Text>
|
|
156
|
+
<Button title="Log activity" color="primary" icon="plus" disabled={!canAdd} onPress={add} />
|
|
157
|
+
</View>
|
|
158
|
+
</Card>
|
|
159
|
+
|
|
160
|
+
{/* the running log */}
|
|
161
|
+
<Card style={{ padding: 0 }}>
|
|
162
|
+
<CardHeader>
|
|
163
|
+
<CardHeaderTitle>Today</CardHeaderTitle>
|
|
164
|
+
<CardHeaderMeta>{entries.length} logged</CardHeaderMeta>
|
|
165
|
+
</CardHeader>
|
|
166
|
+
{entries.length === 0 ? (
|
|
167
|
+
<EmptyState icon="message-square" message="Nothing logged yet" hint="Your activity for the day appears here" />
|
|
168
|
+
) : (
|
|
169
|
+
entries.map((e, i) => (
|
|
170
|
+
<View key={e.id}>
|
|
171
|
+
{i > 0 ? <Divider /> : null}
|
|
172
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12 }}>
|
|
173
|
+
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
|
|
174
|
+
<Icon name={ACT[e.type].icon} size={16} color={colors.zinc[600]} />
|
|
175
|
+
</View>
|
|
176
|
+
<View style={{ flex: 1, gap: 1 }}>
|
|
177
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
178
|
+
<Text weight="medium" numberOfLines={1}>{e.contact.name}</Text>
|
|
179
|
+
<Text size="xs" color="muted">· {e.contact.company}</Text>
|
|
180
|
+
</View>
|
|
181
|
+
<Text size="sm" color="muted" numberOfLines={1}>{e.note}</Text>
|
|
182
|
+
</View>
|
|
183
|
+
<View style={{ alignItems: "flex-end", width: 76 }}>
|
|
184
|
+
<Text size="xs" color="muted" tabular>{e.time}</Text>
|
|
185
|
+
{e.minutes > 0 ? <Text size="xs" color="muted" tabular>{fmtDuration(e.minutes)}</Text> : null}
|
|
186
|
+
</View>
|
|
187
|
+
<Button
|
|
188
|
+
title="Delete"
|
|
189
|
+
color="secondary"
|
|
190
|
+
accessibilityLabel={`Delete ${e.contact.name} ${ACT[e.type].label.toLowerCase()}`}
|
|
191
|
+
onPress={() =>
|
|
192
|
+
Alert.alert(
|
|
193
|
+
"Delete activity?",
|
|
194
|
+
`Remove the ${ACT[e.type].label.toLowerCase()} logged for ${e.contact.name}. This can't be undone.`,
|
|
195
|
+
[
|
|
196
|
+
{ text: "Cancel", style: "cancel" },
|
|
197
|
+
{ text: "Delete", style: "destructive", onPress: () => setEntries((prev) => prev.filter((x) => x.id !== e.id)) },
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
/>
|
|
202
|
+
</View>
|
|
203
|
+
</View>
|
|
204
|
+
))
|
|
205
|
+
)}
|
|
206
|
+
</Card>
|
|
207
|
+
</View>
|
|
208
|
+
</ScrollView>
|
|
209
|
+
);
|
|
210
|
+
}
|