@lotics/ui 14.1.0 → 14.3.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 +5 -0
- package/docs/ai_patterns.md +24 -9
- package/docs/catalog.md +36 -9
- package/docs/composition.md +8 -0
- package/docs/templates.md +32 -20
- package/examples/tpl_item_list.tsx +303 -58
- package/examples/tpl_record.tsx +24 -15
- package/package.json +2 -1
- package/src/file_badge.tsx +6 -3
- package/src/file_badge_fit.test.ts +49 -0
- package/src/file_badge_fit.ts +34 -0
- package/src/file_thumbnail.tsx +31 -8
- package/src/follow_scroll.tsx +32 -37
- package/src/linked_record_box.tsx +5 -23
- package/src/press_door.tsx +68 -0
- package/src/pressable_highlight.tsx +7 -0
- package/src/table.tsx +7 -28
- package/examples/tpl_documents.tsx +0 -808
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState, useRef } from "react";
|
|
1
|
+
import { useEffect, useState, useRef } from "react";
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
3
4
|
import { Text } from "@lotics/ui/text";
|
|
4
5
|
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
6
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
@@ -50,7 +51,14 @@ import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
|
50
51
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
51
52
|
import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
|
|
52
53
|
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
53
|
-
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
|
|
54
|
+
import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
|
|
55
|
+
import { AgentRun } from "@lotics/ui/agent_run";
|
|
56
|
+
import { FollowScroll } from "@lotics/ui/follow_scroll";
|
|
57
|
+
import { ClarifyWizard, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
|
|
58
|
+
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
59
|
+
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
60
|
+
import { ChangeField, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeValueInput, type ChangeStatus } from "@lotics/ui/change_review";
|
|
61
|
+
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
54
62
|
import { MemberSelect } from "@lotics/ui/member_select";
|
|
55
63
|
import { Callout, CalloutText } from "@lotics/ui/callout";
|
|
56
64
|
import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
|
|
@@ -1025,71 +1033,294 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
|
|
|
1025
1033
|
);
|
|
1026
1034
|
}
|
|
1027
1035
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1036
|
+
// ─── Enter data — the INTAKE fork ────────────────────────────────────────────
|
|
1037
|
+
// AI FIRST, form as fallback. The one "Enter data" CTA opens a phased dialog:
|
|
1038
|
+
// drop files (the hero) → a short ANALYZE stream reads them → `ClarifyWizard`
|
|
1039
|
+
// asks the genuine ambiguities the analysis surfaced → the IMPORT stream drafts
|
|
1040
|
+
// the records → `ChangeRecord` cards to review + apply (rows really land).
|
|
1041
|
+
// Below the dropzone, the MANUAL variants (each flavors the same 3-field
|
|
1042
|
+
// create-then-refine gate). The mock plays the clarify step as two phases; a
|
|
1043
|
+
// real app can run it as ONE agent run — every app agent carries
|
|
1044
|
+
// `ask_user_choice`, so the run parks on the agent's own question and
|
|
1045
|
+
// `useAgentRun().pendingChoice`/`answerChoice` drive this same wizard.
|
|
1046
|
+
|
|
1047
|
+
type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
1048
|
+
type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "review" | "form";
|
|
1049
|
+
type ManualVariant = "export" | "import";
|
|
1050
|
+
|
|
1051
|
+
type Proposal = { id: string; khach: string; dienThoai: string; phi: number; orders: string };
|
|
1052
|
+
|
|
1053
|
+
// What the (mock) import drafts, per the wizard's grouping answer. A real app
|
|
1054
|
+
// takes these from the import run's structured output.
|
|
1055
|
+
const PROPOSED_BY_CUSTOMER: Proposal[] = [
|
|
1056
|
+
{ id: "pc1", khach: "Meridian Trading Co.", dienThoai: "(555) 014-2288", phi: 240, orders: "PO-7311 · PO-7314" },
|
|
1057
|
+
{ id: "pc2", khach: "Northgate Textiles", dienThoai: "(555) 019-4471", phi: 180, orders: "PO-7312 · PO-7316" },
|
|
1058
|
+
{ id: "pc3", khach: "Blue Harbor Foods", dienThoai: "", phi: 210, orders: "PO-7313 · PO-7315" },
|
|
1059
|
+
];
|
|
1060
|
+
const PROPOSED_BY_ORDER: Proposal[] = PROPOSED_BY_CUSTOMER.flatMap((c) =>
|
|
1061
|
+
c.orders.split(" · ").map((po, i) => ({ id: `${c.id}-${i}`, khach: c.khach, dienThoai: c.dienThoai, phi: Math.round(c.phi / 2), orders: po })),
|
|
1062
|
+
);
|
|
1063
|
+
|
|
1064
|
+
// The wizard's questions come FROM the analysis (a real app renders them off the
|
|
1065
|
+
// analyze run's structured output) — informed, described, one custom-answer slot.
|
|
1066
|
+
const INTAKE_QUESTIONS: ClarifyWizardQuestion[] = [
|
|
1067
|
+
{
|
|
1068
|
+
question: "6 orders across 3 customers. How should they become records?",
|
|
1069
|
+
answers: [
|
|
1070
|
+
{ value: "customer", label: "One record per customer", description: "3 records — each customer's orders grouped into one workspace and checklist." },
|
|
1071
|
+
{ value: "order", label: "One record per order", description: "6 records — every order tracked on its own; more rows, finer-grained status." },
|
|
1072
|
+
],
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
question: "Blue Harbor Foods isn't in the customer book yet. What should happen?",
|
|
1076
|
+
allowCustom: true,
|
|
1077
|
+
answers: [
|
|
1078
|
+
{ value: "create", label: "Create the customer", description: "A new customer record is added and linked as the records land." },
|
|
1079
|
+
{ value: "skip", label: "Leave unassigned", description: "The records are created without a customer — link one later from each record." },
|
|
1080
|
+
],
|
|
1081
|
+
},
|
|
1082
|
+
];
|
|
1083
|
+
|
|
1084
|
+
const doneTool = (id: string, name: string): Part => ({ type: "dynamic-tool", toolName: name, toolCallId: id, state: "output-available", input: undefined, output: undefined });
|
|
1085
|
+
|
|
1086
|
+
const analyzeScript = (fileNames: string[]): Part[] => [
|
|
1087
|
+
{ type: "reasoning", text: "Before drafting anything I need to know what these files contain and whether the parties already exist in the book." },
|
|
1088
|
+
...fileNames.map((n, i) => doneTool(`a${i}`, `Read ${n}`)),
|
|
1089
|
+
doneTool("a-match", "Match customers"),
|
|
1090
|
+
{ type: "text", text: "Found **6 order confirmations** across **3 customers** — one customer isn't in the book yet. Two quick questions before I draft the records." },
|
|
1091
|
+
];
|
|
1092
|
+
|
|
1093
|
+
const importScript = (grouping: "customer" | "order"): Part[] => [
|
|
1094
|
+
{ type: "reasoning", text: grouping === "customer" ? "Group the six orders under their three customers, one record each, fees summed per customer." : "One record per order — six records, fees split from the order totals." },
|
|
1095
|
+
doneTool("i1", "Extract order lines"),
|
|
1096
|
+
doneTool("i2", "Match customers"),
|
|
1097
|
+
doneTool("i3", grouping === "customer" ? "Draft 3 records" : "Draft 6 records"),
|
|
1098
|
+
{ type: "text", text: `Drafted **${grouping === "customer" ? 3 : 6} records** — review below, edit any value, then apply.` },
|
|
1099
|
+
];
|
|
1100
|
+
|
|
1101
|
+
/** The manual gate stays the THREE-field create-then-refine form — past the
|
|
1102
|
+
* popover threshold (1–2 fields), so a Dialog pane; NOT a wizard: the record's
|
|
1103
|
+
* own workspace (the drawer it opens into) is where everything else is refined. */
|
|
1104
|
+
function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
1105
|
+
onCreate: (khach: string, dienThoai: string, phi: number) => void;
|
|
1106
|
+
onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
|
|
1107
|
+
}) {
|
|
1108
|
+
const { small } = useScreenSize();
|
|
1033
1109
|
const [open, setOpen] = useState(false);
|
|
1110
|
+
const [phase, setPhase] = useState<IntakePhase>("intake");
|
|
1111
|
+
const [fileNames, setFileNames] = useState<string[]>([]);
|
|
1112
|
+
const [variant, setVariant] = useState<ManualVariant>("export");
|
|
1113
|
+
const [grouping, setGrouping] = useState<"customer" | "order">("customer");
|
|
1114
|
+
// review state — per-card verdicts + the one editable value (the fee)
|
|
1115
|
+
const [cardStatus, setCardStatus] = useState<Record<string, ChangeStatus>>({});
|
|
1116
|
+
const [fees, setFees] = useState<Record<string, string>>({});
|
|
1117
|
+
// form state (the manual gate)
|
|
1034
1118
|
const [khach, setKhach] = useState("");
|
|
1035
1119
|
const [dienThoai, setDienThoai] = useState("");
|
|
1036
1120
|
const [phi, setPhi] = useState<number | null>(null);
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1121
|
+
|
|
1122
|
+
// The stream reveal — one revealer serves both the analyze and import phases.
|
|
1123
|
+
const [revealed, setRevealed] = useState(0);
|
|
1124
|
+
const script = phase === "analyze" ? analyzeScript(fileNames) : phase === "running" ? importScript(grouping) : [];
|
|
1125
|
+
useEffect(() => {
|
|
1126
|
+
if (phase !== "analyze" && phase !== "running") return;
|
|
1127
|
+
setRevealed(0);
|
|
1128
|
+
const total = phase === "analyze" ? analyzeScript(fileNames).length : importScript(grouping).length;
|
|
1129
|
+
let n = 0;
|
|
1130
|
+
const t = setInterval(() => {
|
|
1131
|
+
n += 1;
|
|
1132
|
+
setRevealed(n);
|
|
1133
|
+
if (n >= total) {
|
|
1134
|
+
clearInterval(t);
|
|
1135
|
+
// Auto-advance: analysis flows into the wizard; the import into review.
|
|
1136
|
+
setTimeout(() => setPhase(phase === "analyze" ? "clarify" : "review"), 500);
|
|
1137
|
+
}
|
|
1138
|
+
}, 650);
|
|
1139
|
+
return () => clearInterval(t);
|
|
1140
|
+
// Deliberately keyed on `phase` alone — the file list and grouping are
|
|
1141
|
+
// settled before their stream phase begins.
|
|
1142
|
+
}, [phase]);
|
|
1143
|
+
|
|
1144
|
+
const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
|
|
1145
|
+
// Kept = explicitly ACCEPTED (the kit's review contract): Apply stays disabled
|
|
1146
|
+
// at 0 kept, and the bar's own Keep-all presses every pending card's Keep.
|
|
1147
|
+
const keptCount = proposals.filter((p) => cardStatus[p.id] === "accepted").length;
|
|
1148
|
+
|
|
1149
|
+
const reset = () => {
|
|
1150
|
+
setPhase("intake");
|
|
1151
|
+
setFileNames([]);
|
|
1152
|
+
setCardStatus({});
|
|
1153
|
+
setFees({});
|
|
1041
1154
|
setKhach("");
|
|
1042
1155
|
setDienThoai("");
|
|
1043
1156
|
setPhi(null);
|
|
1044
1157
|
};
|
|
1158
|
+
const close = () => { setOpen(false); reset(); };
|
|
1159
|
+
|
|
1160
|
+
const applyProposals = () => {
|
|
1161
|
+
const kept = proposals.filter((p) => cardStatus[p.id] === "accepted");
|
|
1162
|
+
onCreateMany(kept.map((p) => ({ khach: p.khach, dienThoai: p.dienThoai, phi: Number(fees[p.id] ?? p.phi) || 0 })));
|
|
1163
|
+
close();
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
const createManual = () => {
|
|
1167
|
+
if (khach.trim() === "") return;
|
|
1168
|
+
onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
|
|
1169
|
+
close();
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const title =
|
|
1173
|
+
phase === "form" ? (variant === "export" ? "New export case" : "New import case")
|
|
1174
|
+
: phase === "intake" ? "Enter data"
|
|
1175
|
+
: "Import from files";
|
|
1176
|
+
|
|
1045
1177
|
return (
|
|
1046
1178
|
<>
|
|
1047
|
-
<Button title="
|
|
1048
|
-
<
|
|
1179
|
+
<Button title="Enter data" color="primary" onPress={() => setOpen(true)} />
|
|
1180
|
+
<ChangeReview>
|
|
1181
|
+
<Dialog width={phase === "review" ? 560 : 480} open={open} onOpenChange={(o) => { if (!o) close(); }}>
|
|
1049
1182
|
<DialogHeader>
|
|
1050
|
-
<DialogHeaderTitle>
|
|
1183
|
+
<DialogHeaderTitle>{title}</DialogHeaderTitle>
|
|
1051
1184
|
</DialogHeader>
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
<
|
|
1055
|
-
{
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
>
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
<
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1185
|
+
|
|
1186
|
+
{phase === "intake" ? (
|
|
1187
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
|
|
1188
|
+
{/* the hero: AI reads the files — no manual step */}
|
|
1189
|
+
<FileDropzone
|
|
1190
|
+
label="Drop the customer's files"
|
|
1191
|
+
hint="Orders, invoices, spreadsheets — AI reads them and drafts the records"
|
|
1192
|
+
onFiles={(fs) => {
|
|
1193
|
+
setFileNames(fs.length > 0 ? fs.map((f) => f.name) : ["orders-june.pdf", "manifest.xlsx"]);
|
|
1194
|
+
setPhase("analyze");
|
|
1195
|
+
}}
|
|
1196
|
+
/>
|
|
1197
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1198
|
+
<View style={{ flex: 1 }}><Divider /></View>
|
|
1199
|
+
<Text size="xs" color="muted">or enter manually</Text>
|
|
1200
|
+
<View style={{ flex: 1 }}><Divider /></View>
|
|
1201
|
+
</View>
|
|
1202
|
+
<View style={{ gap: 8 }}>
|
|
1203
|
+
<CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1204
|
+
<Icon name="arrow-up" size={18} color={colors.zinc[700]} />
|
|
1205
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
1206
|
+
<Text size="sm" weight="semibold">Export case</Text>
|
|
1207
|
+
<Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
|
|
1208
|
+
</View>
|
|
1209
|
+
</CardSelectItem>
|
|
1210
|
+
<CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
1211
|
+
<Icon name="arrow-down" size={18} color={colors.zinc[700]} />
|
|
1212
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
1213
|
+
<Text size="sm" weight="semibold">Import case</Text>
|
|
1214
|
+
<Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
|
|
1215
|
+
</View>
|
|
1216
|
+
</CardSelectItem>
|
|
1217
|
+
</View>
|
|
1218
|
+
</View>
|
|
1219
|
+
) : null}
|
|
1220
|
+
|
|
1221
|
+
{phase === "analyze" || phase === "running" ? (
|
|
1222
|
+
// Streams follow in a FollowScroll (padding mirrors DialogScrollArea);
|
|
1223
|
+
// the phase flip swaps the scroller out, so the next pane opens at the top.
|
|
1224
|
+
<FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
|
|
1225
|
+
<AgentRun parts={script.slice(0, revealed)} state={revealed >= script.length ? "done" : "streaming"} />
|
|
1226
|
+
</FollowScroll>
|
|
1227
|
+
) : null}
|
|
1228
|
+
|
|
1229
|
+
{phase === "clarify" ? (
|
|
1230
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 20 }}>
|
|
1231
|
+
<ClarifyWizard
|
|
1232
|
+
questions={INTAKE_QUESTIONS}
|
|
1233
|
+
onCancel={() => setPhase("intake")}
|
|
1234
|
+
onSubmit={(answers: ClarifyWizardAnswer[]) => {
|
|
1235
|
+
setGrouping(answers[0]?.value === "order" ? "order" : "customer");
|
|
1236
|
+
setPhase("running");
|
|
1237
|
+
}}
|
|
1238
|
+
/>
|
|
1239
|
+
</View>
|
|
1240
|
+
) : null}
|
|
1241
|
+
|
|
1242
|
+
{phase === "review" ? (
|
|
1243
|
+
<DialogScrollArea>
|
|
1244
|
+
<View style={{ gap: 8 }}>
|
|
1245
|
+
<ChangeReviewHeader title={`${proposals.length} records drafted`} />
|
|
1246
|
+
{proposals.map((p) => (
|
|
1247
|
+
<ChangeRecord
|
|
1248
|
+
key={p.id}
|
|
1249
|
+
id={p.id}
|
|
1250
|
+
tone="add"
|
|
1251
|
+
title={grouping === "customer" ? p.khach : `${p.orders} — ${p.khach}`}
|
|
1252
|
+
summary={`${p.orders}${p.dienThoai ? ` · ${p.dienThoai}` : ""}`}
|
|
1253
|
+
status={cardStatus[p.id] ?? "pending"}
|
|
1254
|
+
onAccept={() => setCardStatus((s) => ({ ...s, [p.id]: "accepted" }))}
|
|
1255
|
+
onReject={() => setCardStatus((s) => ({ ...s, [p.id]: "rejected" }))}
|
|
1256
|
+
onUndo={() => setCardStatus((s) => ({ ...s, [p.id]: "pending" }))}
|
|
1257
|
+
>
|
|
1258
|
+
<ChangeField label="Customer" value={p.khach} summary={p.khach} />
|
|
1259
|
+
{p.dienThoai ? <ChangeField label="Phone" value={p.dienThoai} summary={p.dienThoai} /> : null}
|
|
1260
|
+
<ChangeField label="Service fee" value={fees[p.id] ?? String(p.phi)} summary={formatMoney(Number(fees[p.id] ?? p.phi) || 0)}>
|
|
1261
|
+
<ChangeValueInput value={fees[p.id] ?? String(p.phi)} onChangeText={(v) => setFees((s) => ({ ...s, [p.id]: v }))} accessibilityLabel={`Service fee for ${p.khach}`} />
|
|
1262
|
+
</ChangeField>
|
|
1263
|
+
</ChangeRecord>
|
|
1264
|
+
))}
|
|
1265
|
+
</View>
|
|
1266
|
+
</DialogScrollArea>
|
|
1267
|
+
) : null}
|
|
1268
|
+
|
|
1269
|
+
{phase === "form" ? (
|
|
1270
|
+
<View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
|
|
1271
|
+
<Text size="xs" color="muted">
|
|
1272
|
+
{variant === "export"
|
|
1273
|
+
? "The export case starts in Processing — details, tasks, and payment live on the record itself."
|
|
1274
|
+
: "The import case starts in Processing — details, tasks, and payment live on the record itself."}
|
|
1275
|
+
</Text>
|
|
1276
|
+
<FormField label="Customer">
|
|
1277
|
+
{khach === "" ? (
|
|
1278
|
+
/* find-or-create: the input stays a pure search (reflectSelection
|
|
1279
|
+
off — a custom "create" pick has no option to reflect anyway);
|
|
1280
|
+
the pick renders BELOW as the attached line with Change */
|
|
1281
|
+
<Combobox
|
|
1282
|
+
options={CUSTOMER_OPTIONS}
|
|
1283
|
+
onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
|
|
1284
|
+
reflectSelection={false}
|
|
1285
|
+
allowCustom
|
|
1286
|
+
customOptionPlacement="top"
|
|
1287
|
+
customOptionLabel={(q) => `Create new customer “${q}”`}
|
|
1288
|
+
>
|
|
1289
|
+
<ComboboxInput icon="search" placeholder="Search customers, or add a new one…" accessibilityLabel="Customer" />
|
|
1290
|
+
<ComboboxContent />
|
|
1291
|
+
</Combobox>
|
|
1292
|
+
) : (
|
|
1293
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 40 }}>
|
|
1294
|
+
<Icon name={CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "building-2" : "plus"} size={16} color={colors.zinc[400]} />
|
|
1295
|
+
<Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>
|
|
1296
|
+
{khach}
|
|
1297
|
+
{CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " · new customer"}
|
|
1298
|
+
</Text>
|
|
1299
|
+
<Link size="sm" onPress={() => setKhach("")} accessibilityLabel="Change customer">Change</Link>
|
|
1300
|
+
</View>
|
|
1301
|
+
)}
|
|
1302
|
+
</FormField>
|
|
1303
|
+
<FormField label="Phone" optional optionalLabel="Optional">
|
|
1304
|
+
<TextInputField value={dienThoai} onChangeText={setDienThoai} placeholder="(555) 000-0000" accessibilityLabel="Phone" />
|
|
1305
|
+
</FormField>
|
|
1306
|
+
<FormField label="Service fee">
|
|
1307
|
+
<NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Service fee" />
|
|
1308
|
+
</FormField>
|
|
1309
|
+
</View>
|
|
1310
|
+
) : null}
|
|
1311
|
+
|
|
1312
|
+
{phase === "form" ? (
|
|
1313
|
+
<DialogFooter>
|
|
1314
|
+
<Button title="Back" color="secondary" onPress={() => { setKhach(""); setDienThoai(""); setPhi(null); setPhase("intake"); }} />
|
|
1315
|
+
<Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={createManual} />
|
|
1316
|
+
</DialogFooter>
|
|
1317
|
+
) : phase === "review" ? (
|
|
1318
|
+
<DialogFooter>
|
|
1319
|
+
<ChangeReviewActions onDiscard={close} onApply={applyProposals} applyLabel={`Add ${keptCount} ${keptCount === 1 ? "record" : "records"}`} />
|
|
1320
|
+
</DialogFooter>
|
|
1321
|
+
) : null}
|
|
1092
1322
|
</Dialog>
|
|
1323
|
+
</ChangeReview>
|
|
1093
1324
|
</>
|
|
1094
1325
|
);
|
|
1095
1326
|
}
|
|
@@ -1119,7 +1350,7 @@ export function TplItemList() {
|
|
|
1119
1350
|
// EMPTY; the commons wait as ghost suggestions).
|
|
1120
1351
|
const [rows, setRows] = useState<HoSo[]>(HO_SO);
|
|
1121
1352
|
const nextMa = useRef(64);
|
|
1122
|
-
const
|
|
1353
|
+
const addRow = (khach: string, dienThoai: string, phi: number): string => {
|
|
1123
1354
|
const now = new Date();
|
|
1124
1355
|
const ma = `RC-2026-${String(nextMa.current++).padStart(4, "0")}`;
|
|
1125
1356
|
const row: HoSo = {
|
|
@@ -1134,16 +1365,30 @@ export function TplItemList() {
|
|
|
1134
1365
|
};
|
|
1135
1366
|
setRows((prev) => [row, ...prev]);
|
|
1136
1367
|
setTaskMap((prev) => ({ ...prev, [ma]: [] }));
|
|
1137
|
-
|
|
1138
|
-
|
|
1368
|
+
return ma;
|
|
1369
|
+
};
|
|
1370
|
+
// Reveal what was just created: clear every filter that could hide the new
|
|
1371
|
+
// row(s) and land on page 1.
|
|
1372
|
+
const revealNew = () => {
|
|
1139
1373
|
setTab("all");
|
|
1140
1374
|
setSearch("");
|
|
1141
1375
|
setAssignee([]);
|
|
1142
1376
|
setFeeStatus(null);
|
|
1143
1377
|
setSort(null);
|
|
1144
1378
|
setPage(0);
|
|
1379
|
+
};
|
|
1380
|
+
const createRecord = (khach: string, dienThoai: string, phi: number) => {
|
|
1381
|
+
const ma = addRow(khach, dienThoai, phi);
|
|
1382
|
+
revealNew();
|
|
1383
|
+
// A single manual create opens its drawer for refinement (create-then-refine).
|
|
1145
1384
|
setOpenMa(ma);
|
|
1146
1385
|
};
|
|
1386
|
+
// The AI-import apply — rows land at the top of the register; no drawer (a
|
|
1387
|
+
// bulk landing is reviewed IN the register, refined record by record later).
|
|
1388
|
+
const createRecordsBulk = (records: { khach: string; dienThoai: string; phi: number }[]) => {
|
|
1389
|
+
records.forEach((r) => addRow(r.khach, r.dienThoai, r.phi));
|
|
1390
|
+
revealNew();
|
|
1391
|
+
};
|
|
1147
1392
|
const [sort, setSort] = useState<SortState | null>(null);
|
|
1148
1393
|
const [assignee, setAssignee] = useState<string[]>([]);
|
|
1149
1394
|
const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
|
|
@@ -1285,7 +1530,7 @@ export function TplItemList() {
|
|
|
1285
1530
|
onChange={(v) => { setFeeAmount(v); setPage(0); }}
|
|
1286
1531
|
/>
|
|
1287
1532
|
</View>
|
|
1288
|
-
<
|
|
1533
|
+
<EnterDataDialog onCreate={createRecord} onCreateMany={createRecordsBulk} />
|
|
1289
1534
|
</View>
|
|
1290
1535
|
|
|
1291
1536
|
{/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -66,6 +66,8 @@ import { useSelection } from "@lotics/ui/use_selection";
|
|
|
66
66
|
import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
|
|
67
67
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
68
68
|
import { AgentRun } from "@lotics/ui/agent_run";
|
|
69
|
+
import { FollowScroll } from "@lotics/ui/follow_scroll";
|
|
70
|
+
import { useScreenSize } from "@lotics/ui/use_screen_size";
|
|
69
71
|
import { type SourceRef } from "@lotics/ui/sources";
|
|
70
72
|
import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
|
|
71
73
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
@@ -92,9 +94,8 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
|
|
|
92
94
|
// · Comments — the discussion thread. When people collaborate here.
|
|
93
95
|
// · Tasks — the working checklist. Multi-step records with owners.
|
|
94
96
|
// · Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
|
|
95
|
-
// fork (
|
|
96
|
-
//
|
|
97
|
-
// here, in the toolbar dialog there).
|
|
97
|
+
// fork (the Agents "Document desk" pattern — this template
|
|
98
|
+
// is its worked example).
|
|
98
99
|
// · Customer — the LINKED PARTY: another record referenced, never
|
|
99
100
|
// edited here (the linked-record box + drawer).
|
|
100
101
|
// · Fees — the MONEY LEDGER: cost/charge lines, both directions.
|
|
@@ -429,11 +430,10 @@ const GUTTER = RAIL_W + RAIL_GAP;
|
|
|
429
430
|
/** The reading column's ceiling. */
|
|
430
431
|
const CONTENT_MAX = 720;
|
|
431
432
|
|
|
432
|
-
// ── the DOCUMENT DESK — the Agents "Document desk" pattern
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
// in this page's output sections, in tpl_documents' toolbar dialog).
|
|
433
|
+
// ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
|
|
434
|
+
// record's documents surface: files feed ONE "Use AI" entry that forks into
|
|
435
|
+
// extract / cross-check / edit-with-AI; generation lives in this page's
|
|
436
|
+
// output sections (the desk itself is intake-only).
|
|
437
437
|
type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown };
|
|
438
438
|
type Task = "extract" | "check";
|
|
439
439
|
type Phase = "fork" | "running" | "review" | "done";
|
|
@@ -701,6 +701,7 @@ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
|
|
|
701
701
|
export function TplRecord() {
|
|
702
702
|
const id = useRef(1);
|
|
703
703
|
const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
|
|
704
|
+
const { small } = useScreenSize();
|
|
704
705
|
|
|
705
706
|
// ── first paint = Skeleton MIRRORING the final layout, never a spinner.
|
|
706
707
|
// The 700ms mock stands in for the record query a real app awaits.
|
|
@@ -835,8 +836,8 @@ export function TplRecord() {
|
|
|
835
836
|
const taxId = customer?.taxId ?? "";
|
|
836
837
|
const taxIdValid = TAX_ID_RE.test(taxId);
|
|
837
838
|
|
|
838
|
-
// ── the document desk (
|
|
839
|
-
//
|
|
839
|
+
// ── the document desk (see the module banner above; the record's files ARE
|
|
840
|
+
// the desk's register)
|
|
840
841
|
const [files, setFiles] = useState<Doc[]>(DOCS);
|
|
841
842
|
const sel = useSelection();
|
|
842
843
|
|
|
@@ -1885,8 +1886,7 @@ export function TplRecord() {
|
|
|
1885
1886
|
</View>
|
|
1886
1887
|
<View style={{ flex: 1 }} />
|
|
1887
1888
|
{/* no Create here — GENERATION lives in the Document set section
|
|
1888
|
-
below (this desk = intake)
|
|
1889
|
-
output section, keeps the dialog flavor in this slot */}
|
|
1889
|
+
below (this desk = intake) */}
|
|
1890
1890
|
<Button
|
|
1891
1891
|
title="Add files"
|
|
1892
1892
|
color="secondary"
|
|
@@ -2719,7 +2719,7 @@ export function TplRecord() {
|
|
|
2719
2719
|
</View>
|
|
2720
2720
|
</ScrollView>
|
|
2721
2721
|
|
|
2722
|
-
{/* ── the document desk's overlays
|
|
2722
|
+
{/* ── the document desk's overlays */}
|
|
2723
2723
|
<FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
|
|
2724
2724
|
<Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
|
|
2725
2725
|
<Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
|
|
@@ -2734,6 +2734,16 @@ export function TplRecord() {
|
|
|
2734
2734
|
<DialogHeader>
|
|
2735
2735
|
<DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI · ${picked.length} ${picked.length === 1 ? "file" : "files"}`}</DialogHeaderTitle>
|
|
2736
2736
|
</DialogHeader>
|
|
2737
|
+
{phase === "running" ? (
|
|
2738
|
+
// The streaming feed gets its OWN scroller: FollowScroll (the chat list's
|
|
2739
|
+
// inverted mechanism) keeps the newest part in view as the run grows — a
|
|
2740
|
+
// plain DialogScrollArea would let it stream below the fold. The swap back
|
|
2741
|
+
// on the phase flip REMOUNTS the scroll area, so review opens at the TOP.
|
|
2742
|
+
// Padding mirrors DialogScrollArea.
|
|
2743
|
+
<FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
|
|
2744
|
+
<AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} />
|
|
2745
|
+
</FollowScroll>
|
|
2746
|
+
) : (
|
|
2737
2747
|
<DialogScrollArea>
|
|
2738
2748
|
{phase === "fork" ? (
|
|
2739
2749
|
<View style={{ gap: 16 }}>
|
|
@@ -2776,8 +2786,6 @@ export function TplRecord() {
|
|
|
2776
2786
|
</View>
|
|
2777
2787
|
) : null}
|
|
2778
2788
|
|
|
2779
|
-
{phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
|
|
2780
|
-
|
|
2781
2789
|
{phase === "review" && task === "extract" ? (
|
|
2782
2790
|
<View style={{ gap: 16 }}>
|
|
2783
2791
|
<View style={{ gap: 8 }}>
|
|
@@ -2904,6 +2912,7 @@ export function TplRecord() {
|
|
|
2904
2912
|
<CompletionState title={`${applyCount} ${applyCount === 1 ? "change" : "changes"} applied to ${code}`} summary="The files stay on the record — each updated field keeps its source document." />
|
|
2905
2913
|
) : null}
|
|
2906
2914
|
</DialogScrollArea>
|
|
2915
|
+
)}
|
|
2907
2916
|
{phase === "fork" ? (
|
|
2908
2917
|
<DialogFooter>
|
|
2909
2918
|
{uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -113,6 +113,7 @@
|
|
|
113
113
|
"./menu_list_item": "./src/menu_list_item.tsx",
|
|
114
114
|
"./pressable_highlight": "./src/pressable_highlight.tsx",
|
|
115
115
|
"./pressable_row": "./src/pressable_row.tsx",
|
|
116
|
+
"./press_door": "./src/press_door.tsx",
|
|
116
117
|
"./floating_action_bar": "./src/floating_action_bar.tsx",
|
|
117
118
|
"./icon_button": "./src/icon_button.tsx",
|
|
118
119
|
"./info_popover": "./src/info_popover.tsx",
|
package/src/file_badge.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Text, View } from "react-native";
|
|
|
2
2
|
import { fontFamilySemiBold } from "./text_utils";
|
|
3
3
|
import { Icon, type IconName } from "./icon";
|
|
4
4
|
import { isVideoMimeType, isAudioMimeType } from "./mime";
|
|
5
|
+
import { FILE_BADGE_DEFAULT_WIDTH, fileBadgeHeight } from "./file_badge_fit";
|
|
5
6
|
|
|
6
7
|
const VIDEO_COLOR = "#ea580c";
|
|
7
8
|
const AUDIO_COLOR = "#db2777";
|
|
@@ -32,12 +33,14 @@ export function resolveMime(mimeType: string): { label: string; color: string }
|
|
|
32
33
|
return MIME_MAP[mimeType] ?? DEFAULT_BADGE;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
const DEFAULT_SIZE =
|
|
36
|
+
const DEFAULT_SIZE = FILE_BADGE_DEFAULT_WIDTH;
|
|
36
37
|
|
|
37
38
|
interface FileBadgeProps {
|
|
38
39
|
/** Omit only with `placeholder` — a ghost slot has no resolved type yet. */
|
|
39
40
|
mimeType?: string;
|
|
40
|
-
/** Base
|
|
41
|
+
/** Base WIDTH in pixels. Height, radii, font, and padding scale proportionally — the
|
|
42
|
+
* badge is TALLER than it is wide (26 × 32 at the default), so a SQUARE slot takes the
|
|
43
|
+
* fitted width, not the slot side (`DocumentBadge` does that conversion). Default: 26 */
|
|
41
44
|
size?: number;
|
|
42
45
|
/** Show a "TMPL" overlay to distinguish templates from regular files. */
|
|
43
46
|
isTemplate?: boolean;
|
|
@@ -58,7 +61,7 @@ function getMediaIcon(mimeType: string): IconName | undefined {
|
|
|
58
61
|
|
|
59
62
|
export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate, placeholder }: FileBadgeProps) {
|
|
60
63
|
const scale = size / DEFAULT_SIZE;
|
|
61
|
-
const height =
|
|
64
|
+
const height = fileBadgeHeight(size);
|
|
62
65
|
const radius = Math.round(4 * scale);
|
|
63
66
|
|
|
64
67
|
if (placeholder) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
FILE_BADGE_BOX,
|
|
4
|
+
FILE_BADGE_DEFAULT_WIDTH,
|
|
5
|
+
fileBadgeHeight,
|
|
6
|
+
fileBadgeWidthForBox,
|
|
7
|
+
} from "./file_badge_fit";
|
|
8
|
+
|
|
9
|
+
describe("FileBadge geometry", () => {
|
|
10
|
+
it("scales the badge with the requested width instead of pinning the default", () => {
|
|
11
|
+
// The bug this covers: a caller's `size` was dropped and every badge rendered at
|
|
12
|
+
// the 26x32 default. A requested width must produce a proportionally smaller badge.
|
|
13
|
+
expect(fileBadgeHeight(FILE_BADGE_DEFAULT_WIDTH)).toBe(FILE_BADGE_BOX);
|
|
14
|
+
expect(fileBadgeHeight(20)).toBe(25);
|
|
15
|
+
expect(fileBadgeHeight(13)).toBe(16);
|
|
16
|
+
expect(fileBadgeHeight(52)).toBe(64);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("fills the compact thumbnail slot exactly at the default width", () => {
|
|
20
|
+
// Why DEFAULT_WIDTH is 26: a default badge is exactly as tall as the 32px compact
|
|
21
|
+
// tile it sits in. A slot of 32 must therefore resolve to the default, unchanged.
|
|
22
|
+
expect(fileBadgeWidthForBox(FILE_BADGE_BOX)).toBe(FILE_BADGE_DEFAULT_WIDTH);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("resolves a SMALLER slot to a smaller badge, not the default", () => {
|
|
26
|
+
// The reported bug: a sub-32 tile still rendered the 26x32 default — wider AND taller
|
|
27
|
+
// than its own tile. The requested slot now drives the badge.
|
|
28
|
+
expect(fileBadgeWidthForBox(30)).toBe(24);
|
|
29
|
+
expect(fileBadgeHeight(fileBadgeWidthForBox(30))).toBe(30);
|
|
30
|
+
expect(fileBadgeWidthForBox(24)).toBe(19);
|
|
31
|
+
expect(fileBadgeHeight(fileBadgeWidthForBox(24))).toBe(23);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("never overflows its square slot, at any slot size", () => {
|
|
35
|
+
// The badge is taller than it is wide, so height is the binding dimension and the
|
|
36
|
+
// rounding in `fileBadgeHeight` can push a naive floor over the edge.
|
|
37
|
+
for (let box = 8; box <= 200; box++) {
|
|
38
|
+
const width = fileBadgeWidthForBox(box);
|
|
39
|
+
expect(width).toBeLessThanOrEqual(box);
|
|
40
|
+
expect(fileBadgeHeight(width)).toBeLessThanOrEqual(box);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("grows monotonically with the slot", () => {
|
|
45
|
+
for (let box = 9; box <= 200; box++) {
|
|
46
|
+
expect(fileBadgeWidthForBox(box)).toBeGreaterThanOrEqual(fileBadgeWidthForBox(box - 1));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|