@lotics/ui 7.19.3 → 9.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 +298 -159
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_lookup.tsx +37 -10
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +48 -32
- package/examples/tpl_tasks.tsx +47 -47
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +9 -9
- package/src/agent_run.tsx +1 -1
- package/src/capture_row.tsx +59 -0
- package/src/change_review.tsx +732 -236
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/detail_row.tsx +137 -10
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +43 -16
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/sources.tsx +8 -5
- package/src/suggestion_chip.tsx +47 -0
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_ratedesk.tsx +0 -386
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_triage.tsx +0 -112
- package/examples/tpl_wizard.tsx +0 -223
- package/src/discrepancy.tsx +0 -114
- package/src/match_sides.tsx +0 -79
- package/src/record_fields.tsx +0 -68
- package/src/review_card.tsx +0 -172
- package/src/scored_option.tsx +0 -138
- package/src/spec_list.tsx +0 -81
- package/src/triage_row.tsx +0 -99
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import { Pressable, ScrollView, View } from "react-native";
|
|
3
|
-
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors } 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 { ChipGroup } from "@lotics/ui/chip_group";
|
|
10
|
-
import { FilterChip } from "@lotics/ui/filter_chip";
|
|
11
|
-
import { Counter } from "@lotics/ui/counter";
|
|
12
|
-
import { Card } from "@lotics/ui/card";
|
|
13
|
-
import { Divider } from "@lotics/ui/divider";
|
|
14
|
-
import { Drawer } from "@lotics/ui/drawer";
|
|
15
|
-
import { EmptyState } from "@lotics/ui/empty_state";
|
|
16
|
-
import { formatMoney } from "@lotics/ui/format_money";
|
|
17
|
-
import { StatusBadge } from "@lotics/ui/status_badge";
|
|
18
|
-
import { PressableRow } from "@lotics/ui/pressable_row";
|
|
19
|
-
import { SearchInput } from "@lotics/ui/search_input";
|
|
20
|
-
|
|
21
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
-
// Template · Directory — the partner directory every trading/logistics desk
|
|
23
|
-
// needs: one searchable, filterable register of customers and suppliers with
|
|
24
|
-
// the relationship status and volume at a glance.
|
|
25
|
-
//
|
|
26
|
-
// Grammar: zinc-50 canvas · one header band (count + ONE primary action) ·
|
|
27
|
-
// toolbar (live search + type tabs) · ONE banded card of rows separated by
|
|
28
|
-
// hairlines: Avatar → name + MST → type badge → tabular volume → StatusBadge.
|
|
29
|
-
// Doors: a partner row is the PRIMARY entity here — press opens the partner
|
|
30
|
-
// workspace Drawer (◀ ▶ / ←→ sequenced over the visible filtered ordering);
|
|
31
|
-
// ⋯ holds the row's quick operations (destructive last).
|
|
32
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
type PartnerKind = "khach" | "ncc";
|
|
35
|
-
|
|
36
|
-
interface Partner {
|
|
37
|
-
id: string;
|
|
38
|
-
ten: string;
|
|
39
|
-
mst: string;
|
|
40
|
-
kind: PartnerKind;
|
|
41
|
-
donHang: number;
|
|
42
|
-
doanhSo: number;
|
|
43
|
-
active: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const PARTNERS: Partner[] = [
|
|
47
|
-
{ id: "p1", ten: "Apex Plastics", mst: "0301452786", kind: "khach", donHang: 24, doanhSo: 860_000_000, active: true },
|
|
48
|
-
{ id: "p2", ten: "Eastland Packaging", mst: "0312908455", kind: "ncc", donHang: 18, doanhSo: 412_000_000, active: true },
|
|
49
|
-
{ id: "p3", ten: "Vista Paper Mills", mst: "0309977120", kind: "ncc", donHang: 31, doanhSo: 1_240_000_000, active: true },
|
|
50
|
-
{ id: "p4", ten: "Crestline Foods", mst: "0107643298", kind: "khach", donHang: 12, doanhSo: 340_000_000, active: true },
|
|
51
|
-
{ id: "p5", ten: "Harbor Engineering", mst: "0203318874", kind: "khach", donHang: 7, doanhSo: 156_000_000, active: false },
|
|
52
|
-
{ id: "p6", ten: "Summit Freight", mst: "0314226901", kind: "ncc", donHang: 42, doanhSo: 980_000_000, active: true },
|
|
53
|
-
{ id: "p7", ten: "Northway Appliances", mst: "0108812346", kind: "khach", donHang: 19, doanhSo: 624_000_000, active: true },
|
|
54
|
-
{ id: "p8", ten: "Brightline Inks", mst: "0305570213", kind: "ncc", donHang: 9, doanhSo: 87_000_000, active: false },
|
|
55
|
-
{ id: "p9", ten: "Phoenix Textiles", mst: "0300523847", kind: "khach", donHang: 28, doanhSo: 1_030_000_000, active: true },
|
|
56
|
-
{ id: "p10", ten: "Delta Chemicals", mst: "0204461185", kind: "ncc", donHang: 15, doanhSo: 295_000_000, active: true },
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
const KIND_TABS = [
|
|
60
|
-
{ value: "all", label: "All" },
|
|
61
|
-
{ value: "khach", label: "Customers" },
|
|
62
|
-
{ value: "ncc", label: "Suppliers" },
|
|
63
|
-
] as const;
|
|
64
|
-
|
|
65
|
-
type KindTab = (typeof KIND_TABS)[number]["value"];
|
|
66
|
-
|
|
67
|
-
const kindLabel = (kind: PartnerKind) => (kind === "khach" ? "Customer" : "Supplier");
|
|
68
|
-
|
|
69
|
-
function PartnerRow({ p, selected, onPress }: { p: Partner; selected: boolean; onPress: () => void }) {
|
|
70
|
-
return (
|
|
71
|
-
<PressableRow onPress={onPress} selected={selected} style={{ minHeight: 64 }}>
|
|
72
|
-
<Pressable
|
|
73
|
-
accessibilityRole="button"
|
|
74
|
-
accessibilityLabel={`Open partner ${p.ten}`}
|
|
75
|
-
onPress={onPress}
|
|
76
|
-
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
77
|
-
>
|
|
78
|
-
<Avatar size={36} name={p.ten} />
|
|
79
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
80
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
81
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{p.ten}</Text>
|
|
82
|
-
<Badge label={kindLabel(p.kind)} color={p.kind === "khach" ? "blue" : undefined} />
|
|
83
|
-
</View>
|
|
84
|
-
<Text size="xs" color="muted" tabular>MST {p.mst}</Text>
|
|
85
|
-
</View>
|
|
86
|
-
<Text size="sm" color="muted" tabular>
|
|
87
|
-
{p.donHang} orders · {formatMoney(p.doanhSo)}
|
|
88
|
-
</Text>
|
|
89
|
-
<StatusBadge enabled={p.active} label={p.active ? "Active" : "Paused"} />
|
|
90
|
-
</Pressable>
|
|
91
|
-
<ActionMenu
|
|
92
|
-
accessibilityLabel={`Actions for ${p.ten}`}
|
|
93
|
-
items={[
|
|
94
|
-
{ key: "don", label: "Create order", icon: "file-text", onPress: () => {} },
|
|
95
|
-
{ key: "sua", label: "Edit details", icon: "pencil", onPress: () => {} },
|
|
96
|
-
p.active
|
|
97
|
-
? { key: "dung", label: "Pause partnership", icon: "pause", danger: true, onPress: () => {} }
|
|
98
|
-
: { key: "molai", label: "Resume partnership", icon: "history", onPress: () => {} },
|
|
99
|
-
]}
|
|
100
|
-
/>
|
|
101
|
-
</PressableRow>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function KVRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
106
|
-
return (
|
|
107
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
|
|
108
|
-
<Text size="sm" color="muted" style={{ width: 110 }}>{label}</Text>
|
|
109
|
-
{typeof children === "string" ? <Text size="sm">{children}</Text> : children}
|
|
110
|
-
</View>
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// The drawer body — compact partner workspace: identity, the key facts already
|
|
115
|
-
// on the row, and the two real next steps. Keyed by partner id by the caller
|
|
116
|
-
// so nothing leaks across ◀ ▶ steps.
|
|
117
|
-
function PartnerWorkspace({ p }: { p: Partner }) {
|
|
118
|
-
return (
|
|
119
|
-
<>
|
|
120
|
-
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 20 }}>
|
|
121
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
122
|
-
<Avatar size={44} name={p.ten} />
|
|
123
|
-
<View style={{ gap: 4, flex: 1 }}>
|
|
124
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
125
|
-
<Badge label={kindLabel(p.kind)} color={p.kind === "khach" ? "blue" : undefined} />
|
|
126
|
-
<StatusBadge enabled={p.active} label={p.active ? "Active" : "Paused"} />
|
|
127
|
-
</View>
|
|
128
|
-
<Text size="xs" color="muted" tabular userSelect="auto">MST {p.mst}</Text>
|
|
129
|
-
</View>
|
|
130
|
-
</View>
|
|
131
|
-
|
|
132
|
-
<Divider />
|
|
133
|
-
|
|
134
|
-
<View style={{ gap: 6 }}>
|
|
135
|
-
<KVRow label="Partner type">{kindLabel(p.kind)}</KVRow>
|
|
136
|
-
<KVRow label="Tax ID"><Text size="sm" tabular userSelect="auto">{p.mst}</Text></KVRow>
|
|
137
|
-
<KVRow label="Orders"><Text size="sm" tabular>{`${p.donHang} orders`}</Text></KVRow>
|
|
138
|
-
<KVRow label="Revenue"><Text size="sm" tabular>{formatMoney(p.doanhSo)}</Text></KVRow>
|
|
139
|
-
</View>
|
|
140
|
-
</ScrollView>
|
|
141
|
-
|
|
142
|
-
{/* pinned footer — the workspace's escalation, commit action at the right edge */}
|
|
143
|
-
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: 20, paddingVertical: 14, flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
144
|
-
<Text size="xs" color="muted" style={{ flex: 1 }}>New orders attach to this partner — track them in the Orders module.</Text>
|
|
145
|
-
<Button title="Edit details" color="secondary" onPress={() => {}} />
|
|
146
|
-
<Button title="Create order" color="primary" onPress={() => {}} />
|
|
147
|
-
</View>
|
|
148
|
-
</>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function TplDirectory() {
|
|
153
|
-
const [search, setSearch] = useState("");
|
|
154
|
-
const [tab, setTab] = useState<KindTab>("all");
|
|
155
|
-
const [openId, setOpenId] = useState<string | null>(null);
|
|
156
|
-
const [minOrders, setMinOrders] = useState(0);
|
|
157
|
-
|
|
158
|
-
const q = search.trim().toLowerCase();
|
|
159
|
-
const matches = PARTNERS.filter(
|
|
160
|
-
(p) =>
|
|
161
|
-
(tab === "all" || p.kind === tab) &&
|
|
162
|
-
p.donHang >= minOrders &&
|
|
163
|
-
(q === "" || p.ten.toLowerCase().includes(q) || p.mst.includes(q)),
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
// The drawer sequences over the visible filtered ordering. When the open
|
|
167
|
-
// partner falls out of it (search/tab changed), the chevrons disappear but
|
|
168
|
-
// the drawer stays on the record.
|
|
169
|
-
const openPartner = PARTNERS.find((p) => p.id === openId) ?? null;
|
|
170
|
-
const seqIndex = openPartner ? matches.findIndex((p) => p.id === openPartner.id) : -1;
|
|
171
|
-
|
|
172
|
-
return (
|
|
173
|
-
<View style={{ flex: 1, backgroundColor: colors.white }}>
|
|
174
|
-
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 28 }}>
|
|
175
|
-
<View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
|
|
176
|
-
{/* header band */}
|
|
177
|
-
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
|
|
178
|
-
<View style={{ gap: 2, flex: 1 }}>
|
|
179
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
180
|
-
<Text size="xl" weight="semibold">Directory</Text>
|
|
181
|
-
</View>
|
|
182
|
-
<Text size="sm" color="muted">Customers and suppliers — look anyone up by name or tax ID</Text>
|
|
183
|
-
</View>
|
|
184
|
-
<Button title="Add partner" color="primary" onPress={() => {}} />
|
|
185
|
-
</View>
|
|
186
|
-
|
|
187
|
-
{/* toolbar: live search + a partner-type lens. Wraps when narrow;
|
|
188
|
-
every control is 40px so a wrapped band reads as clean rows. */}
|
|
189
|
-
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
|
190
|
-
<View style={{ flexGrow: 1, flexBasis: 240, minWidth: 200, maxWidth: 360 }}>
|
|
191
|
-
<SearchInput
|
|
192
|
-
placeholder="Search by name or tax ID…"
|
|
193
|
-
accessibilityLabel="Search partners"
|
|
194
|
-
value={search}
|
|
195
|
-
onChangeText={setSearch}
|
|
196
|
-
/>
|
|
197
|
-
</View>
|
|
198
|
-
<ChipGroup
|
|
199
|
-
accessibilityLabel="Partner type"
|
|
200
|
-
options={KIND_TABS.map((t) => ({
|
|
201
|
-
label: `${t.label} · ${PARTNERS.filter((p) => t.value === "all" || p.kind === t.value).length}`,
|
|
202
|
-
value: t.value,
|
|
203
|
-
}))}
|
|
204
|
-
value={tab}
|
|
205
|
-
onValueChange={setTab}
|
|
206
|
-
/>
|
|
207
|
-
<FilterChip
|
|
208
|
-
label="Min orders"
|
|
209
|
-
summary={minOrders > 0 ? `≥ ${minOrders}` : undefined}
|
|
210
|
-
onClear={() => setMinOrders(0)}
|
|
211
|
-
clearLabel="Clear min-orders filter"
|
|
212
|
-
>
|
|
213
|
-
<View style={{ gap: 10 }}>
|
|
214
|
-
<Text size="sm" color="muted">Show partners with at least</Text>
|
|
215
|
-
<Counter
|
|
216
|
-
value={minOrders}
|
|
217
|
-
min={0}
|
|
218
|
-
max={50}
|
|
219
|
-
step={5}
|
|
220
|
-
onValueChange={setMinOrders}
|
|
221
|
-
accessibilityLabel="minimum orders"
|
|
222
|
-
format={(n) => `${n} orders`}
|
|
223
|
-
/>
|
|
224
|
-
</View>
|
|
225
|
-
</FilterChip>
|
|
226
|
-
</View>
|
|
227
|
-
|
|
228
|
-
{/* the register: one card, gap-separated rounded `register` rows (no per-row Divider) */}
|
|
229
|
-
<Card style={{ padding: 0 }}>
|
|
230
|
-
{matches.length === 0 ? (
|
|
231
|
-
<EmptyState
|
|
232
|
-
message="No partners match"
|
|
233
|
-
hint="Try another keyword or switch the type filter"
|
|
234
|
-
/>
|
|
235
|
-
) : (
|
|
236
|
-
<View style={{ paddingVertical: 6, gap: 2 }}>
|
|
237
|
-
{matches.map((p) => (
|
|
238
|
-
<PartnerRow key={p.id} p={p} selected={p.id === openId} onPress={() => setOpenId(p.id)} />
|
|
239
|
-
))}
|
|
240
|
-
</View>
|
|
241
|
-
)}
|
|
242
|
-
</Card>
|
|
243
|
-
</View>
|
|
244
|
-
</ScrollView>
|
|
245
|
-
|
|
246
|
-
{/* the partner workspace — body keyed by partner id so per-record state
|
|
247
|
-
resets when ◀ ▶ (or ←/→) steps through the visible list */}
|
|
248
|
-
{openPartner ? (
|
|
249
|
-
<Drawer
|
|
250
|
-
open onOpenChange={(o) => !o && setOpenId(null)} title={openPartner.ten} width={480}
|
|
251
|
-
onPrev={seqIndex > 0 ? () => setOpenId(matches[seqIndex - 1].id) : undefined}
|
|
252
|
-
onNext={seqIndex >= 0 && seqIndex < matches.length - 1 ? () => setOpenId(matches[seqIndex + 1].id) : undefined}
|
|
253
|
-
position={seqIndex >= 0 ? `${seqIndex + 1}/${matches.length}` : undefined}
|
|
254
|
-
>
|
|
255
|
-
<PartnerWorkspace key={openPartner.id} p={openPartner} />
|
|
256
|
-
</Drawer>
|
|
257
|
-
) : null}
|
|
258
|
-
</View>
|
|
259
|
-
);
|
|
260
|
-
}
|
package/examples/tpl_draft.tsx
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
-
import { Text } from "@lotics/ui/text";
|
|
5
|
-
import { Button } from "@lotics/ui/button";
|
|
6
|
-
import { Card, CardBody, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
|
-
import { SegmentedControl } from "@lotics/ui/segmented_control";
|
|
8
|
-
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
9
|
-
import { DotsIndicator } from "@lotics/ui/dots_indicator";
|
|
10
|
-
import { CompletionState } from "@lotics/ui/completion_state";
|
|
11
|
-
|
|
12
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
-
// Template · AI draft generation — a FOURTH AI workflow shape: the agent writes
|
|
14
|
-
// a first DRAFT from context, and the human refines it. The feedback differs
|
|
15
|
-
// from the others — the OUTPUT itself streams (the reply text fills in live),
|
|
16
|
-
// then becomes an editable surface the person edits before sending. Tone +
|
|
17
|
-
// length steer the generation; Regenerate re-drafts. AI drafts, the human owns
|
|
18
|
-
// the final words. All mock — the recipe, not a model.
|
|
19
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
type Tone = "friendly" | "formal";
|
|
22
|
-
type Length = "short" | "detailed";
|
|
23
|
-
|
|
24
|
-
const INBOUND =
|
|
25
|
-
"Hi — we're launching a new product line and need about 5,000 corrugated mailers " +
|
|
26
|
-
"(FEFCO 0427) per month. Could you share pricing and a lead time?";
|
|
27
|
-
|
|
28
|
-
const OPENING: Record<Tone, string> = {
|
|
29
|
-
friendly: "Hi Maya,\n\nThanks for reaching out — exciting to hear about the new line!",
|
|
30
|
-
formal: "Dear Ms. Tran,\n\nThank you for your enquiry regarding corrugated mailers.",
|
|
31
|
-
};
|
|
32
|
-
const OFFER: Record<Length, string> = {
|
|
33
|
-
short:
|
|
34
|
-
"For 5,000 FEFCO 0427 mailers a month we can offer 8,200₫/unit at that volume, with a 7–10 day lead time after artwork sign-off.",
|
|
35
|
-
detailed:
|
|
36
|
-
"For 5,000 FEFCO 0427 mailers a month, our volume price is 8,200₫/unit (B-flute, 1-colour print). Lead time is 7–10 working days after artwork sign-off, and the first run includes the cutting die at no charge. We can hold this pricing for 30 days, and step it down again past 10,000 units a month.",
|
|
37
|
-
};
|
|
38
|
-
const CLOSE: Record<Tone, string> = {
|
|
39
|
-
friendly: "Happy to send samples whenever you're ready — just say the word.\n\nBest,\nLan",
|
|
40
|
-
formal: "We would be glad to provide samples upon request.\n\nKind regards,\nLan Nguyen\nSunrise Packaging",
|
|
41
|
-
};
|
|
42
|
-
const draftFor = (tone: Tone, length: Length) =>
|
|
43
|
-
`${OPENING[tone]}\n\n${OFFER[length]}\n\n${CLOSE[tone]}`;
|
|
44
|
-
|
|
45
|
-
export function TplDraft() {
|
|
46
|
-
const [phase, setPhase] = useState<"idle" | "drafting" | "ready" | "sent">("idle");
|
|
47
|
-
const [tone, setTone] = useState<Tone>("friendly");
|
|
48
|
-
const [length, setLength] = useState<Length>("short");
|
|
49
|
-
const [draft, setDraft] = useState("");
|
|
50
|
-
const [target, setTarget] = useState("");
|
|
51
|
-
const [revealed, setRevealed] = useState(0);
|
|
52
|
-
|
|
53
|
-
// Stream the draft text in, word by word, then hand it to the editor.
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (phase !== "drafting") return;
|
|
56
|
-
const words = target.split(" ");
|
|
57
|
-
if (revealed >= words.length) {
|
|
58
|
-
setDraft(target);
|
|
59
|
-
setPhase("ready");
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const t = setTimeout(() => setRevealed((r) => r + 2), revealed === 0 ? 200 : 45);
|
|
63
|
-
return () => clearTimeout(t);
|
|
64
|
-
}, [phase, revealed, target]);
|
|
65
|
-
|
|
66
|
-
const startDraft = () => {
|
|
67
|
-
setTarget(draftFor(tone, length));
|
|
68
|
-
setRevealed(0);
|
|
69
|
-
setDraft("");
|
|
70
|
-
setPhase("drafting");
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const streamed = phase === "drafting" ? target.split(" ").slice(0, revealed).join(" ") : "";
|
|
74
|
-
|
|
75
|
-
return (
|
|
76
|
-
<ScrollView style={{ backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 80 }}>
|
|
77
|
-
<View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
|
|
78
|
-
<View style={{ gap: 2 }}>
|
|
79
|
-
<Text size="xl" weight="semibold">Reply to inquiry</Text>
|
|
80
|
-
<Text size="sm" color="muted">The agent drafts a reply from the message; you set the tone, then edit before sending.</Text>
|
|
81
|
-
</View>
|
|
82
|
-
|
|
83
|
-
<Card style={{ padding: 0 }}>
|
|
84
|
-
<CardHeader><CardHeaderTitle>Inbound</CardHeaderTitle></CardHeader>
|
|
85
|
-
<CardBody>
|
|
86
|
-
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
87
|
-
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: colors.zinc[200], alignItems: "center", justifyContent: "center" }}>
|
|
88
|
-
<Text size="xs" weight="semibold" color="muted">MT</Text>
|
|
89
|
-
</View>
|
|
90
|
-
<Text size="sm" style={{ flex: 1 }}>{INBOUND}</Text>
|
|
91
|
-
</View>
|
|
92
|
-
</CardBody>
|
|
93
|
-
</Card>
|
|
94
|
-
|
|
95
|
-
{phase === "sent" ? (
|
|
96
|
-
<Card>
|
|
97
|
-
<CompletionState title="Reply sent" summary="Your edited draft was sent to Maya Tran.">
|
|
98
|
-
<Button title="Draft another" color="secondary" onPress={() => { setPhase("idle"); setDraft(""); }} />
|
|
99
|
-
</CompletionState>
|
|
100
|
-
</Card>
|
|
101
|
-
) : (
|
|
102
|
-
<Card style={{ padding: 0 }}>
|
|
103
|
-
<CardHeader>
|
|
104
|
-
<CardHeaderTitle info="Tone and length steer the draft; you edit the result before it goes out.">Your reply</CardHeaderTitle>
|
|
105
|
-
</CardHeader>
|
|
106
|
-
<CardBody>
|
|
107
|
-
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 16 }}>
|
|
108
|
-
<View style={{ gap: 6 }}>
|
|
109
|
-
<Text size="xs" color="muted" weight="medium">Tone</Text>
|
|
110
|
-
<SegmentedControl
|
|
111
|
-
accessibilityLabel="Tone"
|
|
112
|
-
options={[{ label: "Friendly", value: "friendly" }, { label: "Formal", value: "formal" }]}
|
|
113
|
-
value={tone}
|
|
114
|
-
onValueChange={setTone}
|
|
115
|
-
disabled={phase === "drafting"}
|
|
116
|
-
/>
|
|
117
|
-
</View>
|
|
118
|
-
<View style={{ gap: 6 }}>
|
|
119
|
-
<Text size="xs" color="muted" weight="medium">Length</Text>
|
|
120
|
-
<SegmentedControl
|
|
121
|
-
accessibilityLabel="Length"
|
|
122
|
-
options={[{ label: "Short", value: "short" }, { label: "Detailed", value: "detailed" }]}
|
|
123
|
-
value={length}
|
|
124
|
-
onValueChange={setLength}
|
|
125
|
-
disabled={phase === "drafting"}
|
|
126
|
-
/>
|
|
127
|
-
</View>
|
|
128
|
-
</View>
|
|
129
|
-
|
|
130
|
-
{phase === "idle" ? (
|
|
131
|
-
<View style={{ alignItems: "flex-start", paddingTop: 4 }}>
|
|
132
|
-
<Button title="Draft a reply" color="primary" onPress={startDraft} />
|
|
133
|
-
</View>
|
|
134
|
-
) : null}
|
|
135
|
-
|
|
136
|
-
{phase === "drafting" ? (
|
|
137
|
-
<View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, padding: 14, gap: 10, backgroundColor: colors.white }}>
|
|
138
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
139
|
-
<Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Drafting</Text>
|
|
140
|
-
<DotsIndicator size={5} color={colors.zinc[900]} />
|
|
141
|
-
</View>
|
|
142
|
-
<Text size="sm">{streamed}</Text>
|
|
143
|
-
</View>
|
|
144
|
-
) : null}
|
|
145
|
-
|
|
146
|
-
{phase === "ready" ? (
|
|
147
|
-
<TextInputField value={draft} onChangeText={setDraft} multiline numberOfLines={12} autoGrow />
|
|
148
|
-
) : null}
|
|
149
|
-
</CardBody>
|
|
150
|
-
|
|
151
|
-
{phase === "ready" ? (
|
|
152
|
-
<CardFooter>
|
|
153
|
-
<View style={{ flex: 1 }} />
|
|
154
|
-
<Button title="Regenerate" color="secondary" icon="rotate-ccw" onPress={startDraft} />
|
|
155
|
-
<Button title="Send reply" color="primary" onPress={() => setPhase("sent")} />
|
|
156
|
-
</CardFooter>
|
|
157
|
-
) : null}
|
|
158
|
-
</Card>
|
|
159
|
-
)}
|
|
160
|
-
</View>
|
|
161
|
-
</ScrollView>
|
|
162
|
-
);
|
|
163
|
-
}
|
package/examples/tpl_extract.tsx
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
-
import { Text } from "@lotics/ui/text";
|
|
5
|
-
import { Button } from "@lotics/ui/button";
|
|
6
|
-
import { Card, CardBody, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
|
|
7
|
-
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
8
|
-
import { AgentRun, type AgentRunStep, type AgentRunItem } from "@lotics/ui/agent_run";
|
|
9
|
-
import { ReviewCard } from "@lotics/ui/review_card";
|
|
10
|
-
import { RecordFields, type RecordField } from "@lotics/ui/record_fields";
|
|
11
|
-
import { Sources } from "@lotics/ui/sources";
|
|
12
|
-
import { CompletionState } from "@lotics/ui/completion_state";
|
|
13
|
-
import { type ConfidenceLevel } from "@lotics/ui/confidence";
|
|
14
|
-
|
|
15
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
-
// Template · AI extract & confirm — read an image, then review each extracted
|
|
17
|
-
// RECORD before it's added to a table. Drop a photo → the agent extracts the
|
|
18
|
-
// lines (AgentRun) → each lands as a `ReviewCard` wrapping a `RecordFields` body
|
|
19
|
-
// whose fields are click-to-edit; Confirm collapses it to a tidy checklist row (Undo to re-open),
|
|
20
|
-
// Remove skips it → Save all writes the confirmed lines at once. A one-shot
|
|
21
|
-
// capture pass: nothing is stored until the human has reviewed it. All mock.
|
|
22
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
type ScriptStep = Omit<AgentRunStep, "status">;
|
|
25
|
-
type Phase = "intake" | "extracting" | "review" | "done";
|
|
26
|
-
type Status = "pending" | "confirmed" | "removed";
|
|
27
|
-
|
|
28
|
-
const EXTRACT: ScriptStep[] = [
|
|
29
|
-
{ id: "x1", label: "Reading the packing list", detail: "A photo of a 3-line shipping manifest." },
|
|
30
|
-
{ id: "x2", label: "detect_table", kind: "tool" },
|
|
31
|
-
{ id: "x3", label: "Extracting the line items", detail: "Description, HS code, quantity, weight and value per carton." },
|
|
32
|
-
{ id: "x4", label: "classify_hs_codes", kind: "tool" },
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
interface RecordData {
|
|
36
|
-
id: string;
|
|
37
|
-
title: string;
|
|
38
|
-
confidence: ConfidenceLevel;
|
|
39
|
-
fields: RecordField[];
|
|
40
|
-
}
|
|
41
|
-
const RECORDS: RecordData[] = [
|
|
42
|
-
{ id: "r1", title: "Carton 1 — Frozen shrimp", confidence: "high", fields: [
|
|
43
|
-
{ label: "HS code", value: "0306.17.10" },
|
|
44
|
-
{ label: "Quantity", value: "120", unit: "ctn" },
|
|
45
|
-
{ label: "Net weight", value: "1,200", unit: "kg" },
|
|
46
|
-
{ label: "Value (FOB)", value: "86,200,000", unit: "₫" },
|
|
47
|
-
] },
|
|
48
|
-
{ id: "r2", title: "Carton 2 — Cotton T-shirts", confidence: "medium", fields: [
|
|
49
|
-
{ label: "HS code", value: "6109.10.00", uncertain: true },
|
|
50
|
-
{ label: "Quantity", value: "500", unit: "pcs" },
|
|
51
|
-
{ label: "Net weight", value: "340", unit: "kg" },
|
|
52
|
-
{ label: "Value (FOB)", value: "41,300,000", unit: "₫" },
|
|
53
|
-
] },
|
|
54
|
-
{ id: "r3", title: "Carton 3 — Li-ion batteries", confidence: "low", fields: [
|
|
55
|
-
{ label: "HS code", value: "8507.60.10", uncertain: true },
|
|
56
|
-
{ label: "Quantity", value: "48", unit: "pcs" },
|
|
57
|
-
{ label: "Net weight", value: "96", unit: "kg" },
|
|
58
|
-
{ label: "Value (FOB)", value: "18,700,000", unit: "₫" },
|
|
59
|
-
] },
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
const initialValues = () => Object.fromEntries(RECORDS.map((r) => [r.id, r.fields.map((f) => f.value)]));
|
|
63
|
-
|
|
64
|
-
export function TplExtract() {
|
|
65
|
-
const [phase, setPhase] = useState<Phase>("intake");
|
|
66
|
-
const [statuses, setStatuses] = useState<Record<string, Status>>({});
|
|
67
|
-
const [values, setValues] = useState<Record<string, string[]>>(initialValues);
|
|
68
|
-
|
|
69
|
-
const [revealed, setRevealed] = useState(0);
|
|
70
|
-
const [streaming, setStreaming] = useState(false);
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (!streaming) return;
|
|
73
|
-
if (revealed >= EXTRACT.length) {
|
|
74
|
-
const t = setTimeout(() => {
|
|
75
|
-
setStreaming(false);
|
|
76
|
-
setPhase("review");
|
|
77
|
-
}, 650);
|
|
78
|
-
return () => clearTimeout(t);
|
|
79
|
-
}
|
|
80
|
-
const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 60 : 820);
|
|
81
|
-
return () => clearTimeout(t);
|
|
82
|
-
}, [streaming, revealed]);
|
|
83
|
-
|
|
84
|
-
const items: AgentRunItem[] = EXTRACT.slice(0, revealed).map((s, i) => ({
|
|
85
|
-
type: "step",
|
|
86
|
-
...s,
|
|
87
|
-
status: streaming && revealed < EXTRACT.length && i === revealed - 1 ? "running" : "done",
|
|
88
|
-
}));
|
|
89
|
-
|
|
90
|
-
const start = () => {
|
|
91
|
-
setRevealed(0);
|
|
92
|
-
setStreaming(true);
|
|
93
|
-
setPhase("extracting");
|
|
94
|
-
};
|
|
95
|
-
const reset = () => {
|
|
96
|
-
setStreaming(false);
|
|
97
|
-
setRevealed(0);
|
|
98
|
-
setStatuses({});
|
|
99
|
-
setValues(initialValues());
|
|
100
|
-
setPhase("intake");
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const setStatus = (id: string, status: Status | undefined) =>
|
|
104
|
-
setStatuses((s) => {
|
|
105
|
-
const n = { ...s };
|
|
106
|
-
if (status) n[id] = status;
|
|
107
|
-
else delete n[id];
|
|
108
|
-
return n;
|
|
109
|
-
});
|
|
110
|
-
const editField = (id: string, index: number, value: string) =>
|
|
111
|
-
setValues((v) => ({ ...v, [id]: v[id].map((x, i) => (i === index ? value : x)) }));
|
|
112
|
-
|
|
113
|
-
const confirmedCount = RECORDS.filter((r) => statuses[r.id] === "confirmed").length;
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<ScrollView style={{ backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
|
|
117
|
-
<View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
|
|
118
|
-
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
|
|
119
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
120
|
-
<Text size="xl" weight="semibold">Capture packing list</Text>
|
|
121
|
-
<Text size="sm" color="muted">Drop a photo; the agent extracts each line, you review and edit, then save them all to the table.</Text>
|
|
122
|
-
</View>
|
|
123
|
-
{phase !== "intake" ? <Button title="Start over" color="muted" onPress={reset} /> : null}
|
|
124
|
-
</View>
|
|
125
|
-
|
|
126
|
-
{phase === "intake" ? (
|
|
127
|
-
<Card style={{ padding: 0 }}>
|
|
128
|
-
<CardHeader><CardHeaderTitle info="Each extracted line is reviewable and editable before anything is saved.">Source photo</CardHeaderTitle></CardHeader>
|
|
129
|
-
<CardBody>
|
|
130
|
-
<FileDropzone onFiles={start} accept="image/*,application/pdf" height={200} label="Drop a packing list" hint="or click to choose · JPG, PNG, PDF" dropLabel="Drop to extract" />
|
|
131
|
-
</CardBody>
|
|
132
|
-
</Card>
|
|
133
|
-
) : null}
|
|
134
|
-
|
|
135
|
-
{phase === "extracting" ? (
|
|
136
|
-
<Card>
|
|
137
|
-
<AgentRun items={items} state={streaming ? "streaming" : "done"} />
|
|
138
|
-
</Card>
|
|
139
|
-
) : null}
|
|
140
|
-
|
|
141
|
-
{phase === "review" ? (
|
|
142
|
-
<>
|
|
143
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
144
|
-
<Text size="sm" weight="semibold" style={{ flex: 1 }}>Extracted lines</Text>
|
|
145
|
-
<Text size="xs" color="muted" tabular>{confirmedCount} of {RECORDS.length} confirmed</Text>
|
|
146
|
-
</View>
|
|
147
|
-
<Sources label="Extracted from" onOpen={() => {}} sources={[{ id: "doc", label: "packing-list.jpg", kind: "document", detail: "1 photo" }]} />
|
|
148
|
-
<View style={{ gap: 10 }}>
|
|
149
|
-
{RECORDS.map((r) => (
|
|
150
|
-
<ReviewCard
|
|
151
|
-
key={r.id}
|
|
152
|
-
title={r.title}
|
|
153
|
-
confidence={r.confidence}
|
|
154
|
-
status={statuses[r.id] === "confirmed" ? "accepted" : statuses[r.id] === "removed" ? "dismissed" : "open"}
|
|
155
|
-
acceptedLabel="Confirmed"
|
|
156
|
-
dismissedLabel="Removed"
|
|
157
|
-
actions={[
|
|
158
|
-
{ label: "Remove", onPress: () => setStatus(r.id, "removed"), kind: "muted" },
|
|
159
|
-
{ label: "Confirm", onPress: () => setStatus(r.id, "confirmed"), kind: "secondary" },
|
|
160
|
-
]}
|
|
161
|
-
onUndo={() => setStatus(r.id, undefined)}
|
|
162
|
-
>
|
|
163
|
-
<RecordFields
|
|
164
|
-
fields={r.fields.map((f, i) => ({ ...f, value: values[r.id][i] }))}
|
|
165
|
-
onEditField={(i, value) => editField(r.id, i, value)}
|
|
166
|
-
/>
|
|
167
|
-
</ReviewCard>
|
|
168
|
-
))}
|
|
169
|
-
</View>
|
|
170
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
171
|
-
<Button title="Confirm all" color="muted" onPress={() => setStatuses(Object.fromEntries(RECORDS.map((r) => [r.id, "confirmed" as Status])))} />
|
|
172
|
-
<View style={{ flex: 1 }} />
|
|
173
|
-
<Button
|
|
174
|
-
title={`Save ${confirmedCount} ${confirmedCount === 1 ? "line" : "lines"}`}
|
|
175
|
-
color="primary"
|
|
176
|
-
disabled={confirmedCount === 0}
|
|
177
|
-
onPress={() => setPhase("done")}
|
|
178
|
-
/>
|
|
179
|
-
</View>
|
|
180
|
-
</>
|
|
181
|
-
) : null}
|
|
182
|
-
|
|
183
|
-
{phase === "done" ? (
|
|
184
|
-
<Card>
|
|
185
|
-
<CompletionState title={`${confirmedCount} lines saved`} summary="The confirmed lines were written to the shipment table.">
|
|
186
|
-
<Button title="Capture another" color="secondary" onPress={reset} />
|
|
187
|
-
</CompletionState>
|
|
188
|
-
</Card>
|
|
189
|
-
) : null}
|
|
190
|
-
</View>
|
|
191
|
-
</ScrollView>
|
|
192
|
-
);
|
|
193
|
-
}
|