@lotics/ui 4.4.0 → 4.6.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 +108 -31
- package/examples/tpl_assistant.tsx +6 -6
- package/examples/tpl_briefing.tsx +121 -0
- package/examples/tpl_compare.tsx +133 -0
- package/examples/tpl_crosscheck.tsx +120 -0
- package/examples/tpl_dieline.tsx +218 -55
- package/examples/tpl_draft.tsx +7 -9
- package/examples/tpl_extract.tsx +91 -92
- package/examples/tpl_lookup.tsx +288 -0
- package/examples/tpl_match.tsx +123 -0
- package/examples/tpl_triage.tsx +112 -0
- package/package.json +9 -2
- package/src/agent_progress.tsx +35 -23
- package/src/agent_run.tsx +60 -80
- package/src/change_review.tsx +190 -110
- package/src/choice_list.tsx +63 -0
- package/src/clarify.tsx +19 -39
- package/src/confidence.tsx +30 -8
- package/src/date_filter.tsx +3 -11
- package/src/date_range_filter_field.tsx +38 -27
- package/src/discrepancy.tsx +114 -0
- package/src/finding.tsx +104 -0
- package/src/format_date.test.ts +32 -4
- package/src/format_date.ts +61 -23
- package/src/inline_date_picker.tsx +3 -1
- package/src/match_row.tsx +133 -0
- package/src/prompt_field.tsx +47 -89
- package/src/record_review.tsx +149 -0
- package/src/scored_option.tsx +139 -0
- package/src/sources.tsx +38 -21
- package/src/spec_list.tsx +81 -0
- package/src/suggestion.tsx +35 -45
- package/src/time_field.tsx +4 -5
- package/src/triage_row.tsx +99 -0
- package/src/assisted_field.tsx +0 -93
|
@@ -7,6 +7,7 @@ import { Button } from "./button";
|
|
|
7
7
|
import { PressableHighlight } from "./pressable_highlight";
|
|
8
8
|
import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
|
|
9
9
|
import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
|
|
10
|
+
import { formatDate } from "./format_date";
|
|
10
11
|
|
|
11
12
|
// =============================================================================
|
|
12
13
|
// DateRangeFilterField — the common filter composition over DateFilter:
|
|
@@ -33,7 +34,8 @@ export interface DateRangeFilterFieldProps {
|
|
|
33
34
|
includeTime?: boolean;
|
|
34
35
|
/** Translated labels (presets + footer + placeholder). Defaults to English. */
|
|
35
36
|
labels?: Partial<DateRangeFilterFieldLabels>;
|
|
36
|
-
/** BCP-47 locale for the calendar + trigger date display.
|
|
37
|
+
/** BCP-47 locale for the calendar + trigger date display. Date display defaults to
|
|
38
|
+
* the kit's home market (vi-VN, via `formatDate`) when unset. */
|
|
37
39
|
locale?: string;
|
|
38
40
|
testID?: string;
|
|
39
41
|
}
|
|
@@ -43,17 +45,18 @@ const EMPTY_VALUE: DateFilterValue = {
|
|
|
43
45
|
end: { date: null, time: null },
|
|
44
46
|
};
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* A bound's date + its separate "HH:mm" time → one localized string via the kit's
|
|
50
|
+
* canonical `formatDate` (time-first "HH:mm dd/MM/yyyy"). Date-only when there is no
|
|
51
|
+
* time; "" when there is no date.
|
|
52
|
+
*/
|
|
53
|
+
function formatBound(date: Date | null, time: string | null, locale: string | undefined): string {
|
|
47
54
|
if (!date) return "";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}).format(date);
|
|
54
|
-
} catch {
|
|
55
|
-
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
56
|
-
}
|
|
55
|
+
const m = time ? /^(\d{1,2}):(\d{2})/.exec(time) : null;
|
|
56
|
+
if (!m) return formatDate(date, { locale });
|
|
57
|
+
const dt = new Date(date);
|
|
58
|
+
dt.setHours(Number(m[1]), Number(m[2]), 0, 0);
|
|
59
|
+
return formatDate(dt, { time: true, locale });
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/**
|
|
@@ -64,21 +67,14 @@ function formatDate(date: Date | null, locale: string | undefined): string {
|
|
|
64
67
|
* period rhythm, not date pairs.
|
|
65
68
|
*/
|
|
66
69
|
function formatRangeDisplay(start: Date, end: Date, locale: string | undefined): string {
|
|
67
|
-
if (start.toDateString() === end.toDateString()) return formatDate(start, locale);
|
|
70
|
+
if (start.toDateString() === end.toDateString()) return formatDate(start, { locale });
|
|
68
71
|
|
|
69
72
|
const wholeMonth =
|
|
70
73
|
start.getDate() === 1 &&
|
|
71
74
|
start.getMonth() === end.getMonth() &&
|
|
72
75
|
start.getFullYear() === end.getFullYear() &&
|
|
73
76
|
end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate();
|
|
74
|
-
if (wholeMonth) {
|
|
75
|
-
try {
|
|
76
|
-
const label = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(start);
|
|
77
|
-
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
78
|
-
} catch {
|
|
79
|
-
return `${start.getMonth() + 1}/${start.getFullYear()}`;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
77
|
+
if (wholeMonth) return formatDate(start, { format: "monthYear", locale });
|
|
82
78
|
|
|
83
79
|
const wholeYear =
|
|
84
80
|
start.getFullYear() === end.getFullYear() &&
|
|
@@ -88,7 +84,27 @@ function formatRangeDisplay(start: Date, end: Date, locale: string | undefined):
|
|
|
88
84
|
end.getDate() === 31;
|
|
89
85
|
if (wholeYear) return String(start.getFullYear());
|
|
90
86
|
|
|
91
|
-
return `${formatDate(start, locale)} – ${formatDate(end, locale)}`;
|
|
87
|
+
return `${formatDate(start, { locale })} – ${formatDate(end, { locale })}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The trigger text. An untimed range folds to a compact period (month / year /
|
|
92
|
+
* day) via `formatRangeDisplay`. Once a time is set (and `includeTime`) a timed
|
|
93
|
+
* window isn't a clean period, so it shows "time date – time date". "" when there
|
|
94
|
+
* is no value (caller shows the placeholder).
|
|
95
|
+
*/
|
|
96
|
+
function formatTrigger(value: DateFilterValue, includeTime: boolean, locale: string | undefined): string {
|
|
97
|
+
const { start, end } = value;
|
|
98
|
+
const sTime = includeTime ? start.time : null;
|
|
99
|
+
const eTime = includeTime ? end.time : null;
|
|
100
|
+
|
|
101
|
+
if (start.date && end.date && !sTime && !eTime) {
|
|
102
|
+
return formatRangeDisplay(start.date, end.date, locale);
|
|
103
|
+
}
|
|
104
|
+
if (start.date || end.date) {
|
|
105
|
+
return `${formatBound(start.date, sTime, locale)} – ${formatBound(end.date, eTime, locale)}`;
|
|
106
|
+
}
|
|
107
|
+
return "";
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
@@ -97,12 +113,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
|
97
113
|
const [open, setOpen] = useState(false);
|
|
98
114
|
|
|
99
115
|
const hasValue = Boolean(value.start.date || value.end.date);
|
|
100
|
-
const display =
|
|
101
|
-
value.start.date && value.end.date
|
|
102
|
-
? formatRangeDisplay(value.start.date, value.end.date, locale)
|
|
103
|
-
: hasValue
|
|
104
|
-
? `${formatDate(value.start.date, locale)} – ${formatDate(value.end.date, locale)}`
|
|
105
|
-
: labels.placeholder;
|
|
116
|
+
const display = formatTrigger(value, Boolean(includeTime), locale) || labels.placeholder;
|
|
106
117
|
|
|
107
118
|
return (
|
|
108
119
|
<Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, solid } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Badge } from "./badge";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { CardSelectItem } from "./card_select_item";
|
|
8
|
+
|
|
9
|
+
export interface DiscrepancyValue {
|
|
10
|
+
/** Where this value came from — the document / record / system of record. */
|
|
11
|
+
source: string;
|
|
12
|
+
value: string;
|
|
13
|
+
/** The agent's recommended truth among the conflicting values. */
|
|
14
|
+
recommended?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiscrepancyProps {
|
|
18
|
+
/** The field that disagrees across sources. */
|
|
19
|
+
field: string;
|
|
20
|
+
/** The agent's explanation of the conflict — shown above the options so the
|
|
21
|
+
* human reads WHY before picking. */
|
|
22
|
+
note?: string;
|
|
23
|
+
values: DiscrepancyValue[];
|
|
24
|
+
/** Resolve the conflict by picking a value (index into `values`) — pressing a
|
|
25
|
+
* card commits it; there is no separate confirm button. */
|
|
26
|
+
onResolve?: (index: number) => void;
|
|
27
|
+
/** Send for manual handling instead of picking. */
|
|
28
|
+
onFlag?: () => void;
|
|
29
|
+
/** Resolved → the chosen index settles the card. */
|
|
30
|
+
resolvedIndex?: number;
|
|
31
|
+
flagged?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A field whose value DISAGREES across sources — the unit of an AI cross-check
|
|
36
|
+
* / audit. The agent's explanation sits up top; below it the conflicting values
|
|
37
|
+
* are identical selectable cards (the global press/hover ring), the one the
|
|
38
|
+
* agent believes carrying a neutral "Agent's pick" tag — NOT a pre-selected
|
|
39
|
+
* highlight. Pressing a card resolves the conflict to it — no confirm step.
|
|
40
|
+
* Symmetric, unlike `ChangeReview`'s before→after.
|
|
41
|
+
*/
|
|
42
|
+
export function Discrepancy(props: DiscrepancyProps) {
|
|
43
|
+
const { field, values, note, onResolve, onFlag, resolvedIndex, flagged } = props;
|
|
44
|
+
const resolved = resolvedIndex != null || flagged === true;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={[styles.card, resolved ? styles.resolved : null]}>
|
|
48
|
+
<Text size="sm" weight="semibold" numberOfLines={1}>
|
|
49
|
+
{field}
|
|
50
|
+
</Text>
|
|
51
|
+
|
|
52
|
+
{resolved ? (
|
|
53
|
+
<View style={styles.outcome}>
|
|
54
|
+
<Icon name={flagged ? "triangle-alert" : "circle-check"} size={15} color={flagged ? solid("amber") : solid("emerald")} />
|
|
55
|
+
<Text size="sm" color="muted">
|
|
56
|
+
{flagged
|
|
57
|
+
? "Flagged for manual review"
|
|
58
|
+
: `Resolved to ${values[resolvedIndex as number].source} — ${values[resolvedIndex as number].value}`}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
) : (
|
|
62
|
+
<>
|
|
63
|
+
{note ? (
|
|
64
|
+
<Text size="sm" color="muted">
|
|
65
|
+
{note}
|
|
66
|
+
</Text>
|
|
67
|
+
) : null}
|
|
68
|
+
|
|
69
|
+
<View style={styles.values}>
|
|
70
|
+
{values.map((v, i) => (
|
|
71
|
+
<CardSelectItem
|
|
72
|
+
key={`${v.source}-${i}`}
|
|
73
|
+
accessibilityLabel={`Resolve ${field} to ${v.source}: ${v.value}`}
|
|
74
|
+
onPress={() => onResolve?.(i)}
|
|
75
|
+
style={styles.valueBox}
|
|
76
|
+
>
|
|
77
|
+
<Text size="sm" color="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
78
|
+
{v.source}
|
|
79
|
+
</Text>
|
|
80
|
+
<View style={{ flex: 1 }} />
|
|
81
|
+
{v.recommended ? <Badge label="Agent's pick" /> : null}
|
|
82
|
+
<Text size="md" weight="semibold" tabular>
|
|
83
|
+
{v.value}
|
|
84
|
+
</Text>
|
|
85
|
+
</CardSelectItem>
|
|
86
|
+
))}
|
|
87
|
+
</View>
|
|
88
|
+
|
|
89
|
+
{onFlag ? (
|
|
90
|
+
<View style={styles.footer}>
|
|
91
|
+
<Button title="Flag for review" color="muted" shape="rounded" onPress={onFlag} />
|
|
92
|
+
</View>
|
|
93
|
+
) : null}
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const styles = StyleSheet.create({
|
|
101
|
+
card: {
|
|
102
|
+
borderWidth: 1,
|
|
103
|
+
borderColor: colors.border,
|
|
104
|
+
backgroundColor: colors.white,
|
|
105
|
+
borderRadius: 12,
|
|
106
|
+
padding: 16,
|
|
107
|
+
gap: 12,
|
|
108
|
+
},
|
|
109
|
+
resolved: { backgroundColor: colors.zinc[50] },
|
|
110
|
+
outcome: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
111
|
+
values: { gap: 8 },
|
|
112
|
+
valueBox: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 12, paddingHorizontal: 14 },
|
|
113
|
+
footer: { flexDirection: "row", justifyContent: "flex-end" },
|
|
114
|
+
});
|
package/src/finding.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, solid, type ColorName } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Badge } from "./badge";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { Sources, type SourceRef } from "./sources";
|
|
7
|
+
|
|
8
|
+
export type FindingSeverity = "critical" | "warning" | "info" | "positive";
|
|
9
|
+
|
|
10
|
+
export interface FindingProps {
|
|
11
|
+
severity?: FindingSeverity;
|
|
12
|
+
/** The headline — what the agent found. */
|
|
13
|
+
title: string;
|
|
14
|
+
/** One or two lines explaining it. */
|
|
15
|
+
detail?: string;
|
|
16
|
+
/** A headline figure tied to the finding (a value, a delta, a count), shown in
|
|
17
|
+
* the header row in the severity colour. */
|
|
18
|
+
metric?: string;
|
|
19
|
+
/** A short caption beside the metric giving it context ("exposure", "overdue",
|
|
20
|
+
* "vs target"). */
|
|
21
|
+
metricCaption?: string;
|
|
22
|
+
/** Provenance — the records / documents the finding rests on. */
|
|
23
|
+
sources?: SourceRef[];
|
|
24
|
+
onOpenSource?: (s: SourceRef) => void;
|
|
25
|
+
/** The single action the finding suggests. */
|
|
26
|
+
action?: { label: string; onPress: () => void };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Severity reads through a coloured dot badge + the order it's stacked in (most
|
|
30
|
+
// severe first): red critical, amber warning, blue note, emerald on-track. The
|
|
31
|
+
// metric takes the same colour.
|
|
32
|
+
const SEV: Record<FindingSeverity, { word: string; color: ColorName }> = {
|
|
33
|
+
critical: { word: "Critical", color: "red" },
|
|
34
|
+
warning: { word: "Warning", color: "amber" },
|
|
35
|
+
info: { word: "Note", color: "blue" },
|
|
36
|
+
positive: { word: "On track", color: "emerald" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One ranked insight from an AI briefing / audit / anomaly scan — a severity dot
|
|
41
|
+
* badge and a headline figure share the top row, the headline + explanation read
|
|
42
|
+
* below, then provenance and the single action it suggests. Severity reads
|
|
43
|
+
* through the coloured badge + metric and the stacking order (most severe first).
|
|
44
|
+
* Unlike `Callout` (a flat inline status), a Finding is ranked, sourced, and
|
|
45
|
+
* carries its own next action.
|
|
46
|
+
*/
|
|
47
|
+
export function Finding(props: FindingProps) {
|
|
48
|
+
const sev = SEV[props.severity ?? "info"];
|
|
49
|
+
return (
|
|
50
|
+
<View style={styles.card}>
|
|
51
|
+
<View style={styles.header}>
|
|
52
|
+
<Badge variant="dot" color={sev.color} label={sev.word} />
|
|
53
|
+
<View style={{ flex: 1 }} />
|
|
54
|
+
{props.metric ? (
|
|
55
|
+
<View style={styles.metric}>
|
|
56
|
+
<Text size="lg" weight="semibold" tabular style={{ color: solid(sev.color), letterSpacing: -0.3 }}>
|
|
57
|
+
{props.metric}
|
|
58
|
+
</Text>
|
|
59
|
+
{props.metricCaption ? (
|
|
60
|
+
<Text size="xs" color="muted">
|
|
61
|
+
{props.metricCaption}
|
|
62
|
+
</Text>
|
|
63
|
+
) : null}
|
|
64
|
+
</View>
|
|
65
|
+
) : null}
|
|
66
|
+
</View>
|
|
67
|
+
|
|
68
|
+
<View style={styles.body}>
|
|
69
|
+
<Text size="md" weight="semibold" numberOfLines={2}>
|
|
70
|
+
{props.title}
|
|
71
|
+
</Text>
|
|
72
|
+
{props.detail ? (
|
|
73
|
+
<Text size="sm" color="muted">
|
|
74
|
+
{props.detail}
|
|
75
|
+
</Text>
|
|
76
|
+
) : null}
|
|
77
|
+
</View>
|
|
78
|
+
|
|
79
|
+
{props.sources && props.sources.length > 0 ? (
|
|
80
|
+
<Sources sources={props.sources} onOpen={props.onOpenSource} />
|
|
81
|
+
) : null}
|
|
82
|
+
{props.action ? (
|
|
83
|
+
<View style={styles.actionRow}>
|
|
84
|
+
<Button title={props.action.label} color="secondary" shape="rounded" onPress={props.action.onPress} />
|
|
85
|
+
</View>
|
|
86
|
+
) : null}
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = StyleSheet.create({
|
|
92
|
+
card: {
|
|
93
|
+
borderWidth: 1,
|
|
94
|
+
borderColor: colors.border,
|
|
95
|
+
backgroundColor: colors.white,
|
|
96
|
+
borderRadius: 12,
|
|
97
|
+
padding: 16,
|
|
98
|
+
gap: 12,
|
|
99
|
+
},
|
|
100
|
+
header: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
101
|
+
metric: { flexDirection: "row", alignItems: "baseline", gap: 5 },
|
|
102
|
+
body: { gap: 6 },
|
|
103
|
+
actionRow: { flexDirection: "row", justifyContent: "flex-end" },
|
|
104
|
+
});
|
package/src/format_date.test.ts
CHANGED
|
@@ -14,12 +14,40 @@ describe("formatDate", () => {
|
|
|
14
14
|
expect(formatDate("2026-05-22", { locale: "vi-VN", compact: true })).toBe("22/05");
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
test("
|
|
18
|
-
expect(formatDate("2026-05-22T14:30", { locale: "vi-VN",
|
|
17
|
+
test("time prepends the 24h time, then the locale date", () => {
|
|
18
|
+
expect(formatDate("2026-05-22T14:30", { locale: "vi-VN", time: true })).toBe("14:30 22/05/2026");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("compact
|
|
22
|
-
expect(formatDate("2026-05-22T09:05", { locale: "vi-VN",
|
|
21
|
+
test("time + compact is time-first and drops the year", () => {
|
|
22
|
+
expect(formatDate("2026-05-22T09:05", { locale: "vi-VN", time: true, compact: true })).toBe("09:05 22/05");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("time composes with a readable style — the orthogonal axis", () => {
|
|
26
|
+
expect(formatDate("2026-09-22T14:30", { format: "long", time: true, locale: "en-US" })).toBe("14:30 September 22, 2026");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("medium — readable, abbreviated month", () => {
|
|
30
|
+
expect(formatDate("2026-05-22", { format: "medium", locale: "en-US" })).toBe("May 22, 2026");
|
|
31
|
+
// vi word output is ICU-dependent; assert the day + year are present (day-first locale).
|
|
32
|
+
expect(formatDate("2026-05-22", { format: "medium", locale: "vi-VN" })).toMatch(/22.*2026/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("long — readable, full month name", () => {
|
|
36
|
+
expect(formatDate("2026-09-22", { format: "long", locale: "en-US" })).toBe("September 22, 2026");
|
|
37
|
+
expect(formatDate("2026-09-22", { format: "long", locale: "vi-VN" })).toMatch(/22.*2026/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("dayMonth — day + abbreviated month, no year", () => {
|
|
41
|
+
expect(formatDate("2026-09-22", { format: "dayMonth", locale: "en-US" })).toBe("Sep 22");
|
|
42
|
+
expect(formatDate("2026-09-22", { format: "dayMonth", locale: "vi-VN" })).not.toMatch(/2026/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("monthYear — a period label, sentence-cased", () => {
|
|
46
|
+
expect(formatDate("2026-05-22", { format: "monthYear", locale: "en-US" })).toBe("May 2026");
|
|
47
|
+
// Leading character is upper-cased even where the locale lowercases the month name.
|
|
48
|
+
const vi = formatDate("2026-05-22", { format: "monthYear", locale: "vi-VN" });
|
|
49
|
+
expect(vi.charAt(0)).toBe(vi.charAt(0).toUpperCase());
|
|
50
|
+
expect(vi).toMatch(/2026/);
|
|
23
51
|
});
|
|
24
52
|
|
|
25
53
|
test("date-only ISO does not drift across local timezone (wall-clock parse)", () => {
|
package/src/format_date.ts
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The date STYLE — orthogonal to time (pass `time: true` to prepend a 24h time to any of these).
|
|
3
|
+
* `date` reassembles dd/MM with a stable "/"; the readable styles use word months in locale order.
|
|
4
|
+
* NOT for a component's own internal chrome — a calendar's header / weekday / a11y labels, a gantt
|
|
5
|
+
* axis — which render their own internally-consistent label set with `Intl` directly.
|
|
6
|
+
*/
|
|
7
|
+
export type DateFormatStyle =
|
|
8
|
+
| "date" // 22/05/2026 — numeric
|
|
9
|
+
| "medium" // 22 thg 5, 2026 / Sep 22, 2026 — readable, abbreviated month
|
|
10
|
+
| "long" // 22 tháng 5, 2026 / September 22, 2026 — readable, full month
|
|
11
|
+
| "dayMonth" // 22 thg 5 / Sep 22 — day + abbreviated month, no year
|
|
12
|
+
| "monthYear"; // Tháng 5 2026 / May 2026 — a period label (sentence-cased)
|
|
2
13
|
|
|
3
14
|
export interface FormatDateOptions {
|
|
4
|
-
/**
|
|
15
|
+
/** The date style. Default "date". */
|
|
5
16
|
format?: DateFormatStyle;
|
|
17
|
+
/** Prepend the 24h time — "14:30 <date>". Composes with ANY `format`. Default false. */
|
|
18
|
+
time?: boolean;
|
|
6
19
|
/** BCP-47 locale. Defaults to the product's home market, "vi-VN". */
|
|
7
20
|
locale?: string;
|
|
8
|
-
/** Drop the year
|
|
21
|
+
/** Drop the year ("22/05") on the numeric `date` style. */
|
|
9
22
|
compact?: boolean;
|
|
10
23
|
/** Rendered for null / empty / unparseable input. Default "". */
|
|
11
24
|
emptyLabel?: string;
|
|
12
25
|
}
|
|
13
26
|
|
|
27
|
+
const READABLE_OPTS: Record<"medium" | "long" | "dayMonth" | "monthYear", Intl.DateTimeFormatOptions> = {
|
|
28
|
+
medium: { day: "numeric", month: "short", year: "numeric" },
|
|
29
|
+
long: { day: "numeric", month: "long", year: "numeric" },
|
|
30
|
+
dayMonth: { day: "numeric", month: "short" },
|
|
31
|
+
monthYear: { month: "long", year: "numeric" },
|
|
32
|
+
};
|
|
33
|
+
|
|
14
34
|
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
15
35
|
|
|
16
36
|
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
@@ -43,29 +63,47 @@ export function toISODate(value: Date | string | null | undefined): string {
|
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
/**
|
|
46
|
-
* THE date formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string
|
|
47
|
-
* returns a localized display string; defaults to the home market
|
|
48
|
-
* `22/05/2026
|
|
49
|
-
*
|
|
66
|
+
* THE date-value formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string
|
|
67
|
+
* and returns a localized display string; defaults to the home market. `format` picks the date
|
|
68
|
+
* style: `date` → `22/05/2026` · `medium` → `22 thg 5, 2026` · `long` → `22 tháng 5, 2026` ·
|
|
69
|
+
* `dayMonth` → `22 thg 5` · `monthYear` → `Tháng 5 2026`. **`time: true` prepends the 24h time to
|
|
70
|
+
* ANY style** — `14:30 22/05/2026`, `14:30 22 tháng 5, 2026` (time-first). `compact` drops the
|
|
71
|
+
* year on the numeric `date` style. Never hand-roll a date with `padStart` / `getMonth` / a raw
|
|
72
|
+
* `Intl.DateTimeFormat` for VALUE display — call this. (Component-internal chrome is exempt — see
|
|
73
|
+
* {@link DateFormatStyle}.)
|
|
50
74
|
*/
|
|
51
75
|
export function formatDate(value: Date | string | null | undefined, options: FormatDateOptions = {}): string {
|
|
52
|
-
const { format = "date", locale = "vi-VN", compact = false, emptyLabel = "" } = options;
|
|
76
|
+
const { format = "date", time = false, locale = "vi-VN", compact = false, emptyLabel = "" } = options;
|
|
53
77
|
const date = parseDate(value);
|
|
54
78
|
if (!date) return emptyLabel;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
parts
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
|
|
80
|
+
let dateStr: string;
|
|
81
|
+
if (format === "date") {
|
|
82
|
+
// Numeric: Intl only for the locale-aware part ORDER (dd/MM for vi-VN, MM/dd for en-US), then
|
|
83
|
+
// reassemble with a consistent "/" — Intl's own separator is inconsistent across CLDR (vi-VN
|
|
84
|
+
// uses "/" with a year but "-" without). `compact` drops the year.
|
|
85
|
+
let parts: Intl.DateTimeFormatPart[];
|
|
86
|
+
try {
|
|
87
|
+
parts = new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" }).formatToParts(date);
|
|
88
|
+
} catch {
|
|
89
|
+
return emptyLabel;
|
|
90
|
+
}
|
|
91
|
+
dateStr = parts
|
|
92
|
+
.filter((p) => p.type === "day" || p.type === "month" || (!compact && p.type === "year"))
|
|
93
|
+
.map((p) => p.value)
|
|
94
|
+
.join("/");
|
|
95
|
+
} else {
|
|
96
|
+
// Readable styles: word months in locale order — Intl's output is correct here (the separator
|
|
97
|
+
// inconsistency that forces the numeric reassembly is specific to the all-numeric form).
|
|
98
|
+
try {
|
|
99
|
+
dateStr = new Intl.DateTimeFormat(locale, READABLE_OPTS[format]).format(date);
|
|
100
|
+
} catch {
|
|
101
|
+
return emptyLabel;
|
|
102
|
+
}
|
|
103
|
+
// A period label leads a line → sentence-case (a no-op where the locale already capitalizes).
|
|
104
|
+
if (format === "monthYear") dateStr = dateStr.charAt(0).toUpperCase() + dateStr.slice(1);
|
|
64
105
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.join("/");
|
|
69
|
-
if (format === "datetime") out += ` ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
|
70
|
-
return out;
|
|
106
|
+
|
|
107
|
+
// Time-first: a stable 24h "HH:mm " prepended (no locale comma, no AM/PM), per the product convention.
|
|
108
|
+
return time ? `${pad2(date.getHours())}:${pad2(date.getMinutes())} ${dateStr}` : dateStr;
|
|
71
109
|
}
|
|
@@ -67,7 +67,9 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
67
67
|
[value, commit],
|
|
68
68
|
);
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// `format` here is the field config (date vs datetime); the display formatter only needs
|
|
71
|
+
// whether to show a time → map it to the orthogonal `time` flag.
|
|
72
|
+
const display = formatDate(value, { time: format === "datetime", locale, emptyLabel: "" });
|
|
71
73
|
|
|
72
74
|
return (
|
|
73
75
|
<View>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { Confidence, type ConfidenceLevel } from "./confidence";
|
|
7
|
+
|
|
8
|
+
export interface MatchSide {
|
|
9
|
+
/** Primary line — the record name / id. */
|
|
10
|
+
title: string;
|
|
11
|
+
/** Secondary line — amount, date, customer. */
|
|
12
|
+
detail?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MatchRowProps {
|
|
16
|
+
/** The known item we're finding a counterpart for. */
|
|
17
|
+
source: MatchSide;
|
|
18
|
+
/** The agent's proposed counterpart. Omit → the "no confident match" state. */
|
|
19
|
+
match?: MatchSide;
|
|
20
|
+
/** One line of WHY the agent paired them. */
|
|
21
|
+
rationale?: string;
|
|
22
|
+
confidence?: ConfidenceLevel;
|
|
23
|
+
confidenceScore?: number;
|
|
24
|
+
onAccept?: () => void;
|
|
25
|
+
/** Pick a different counterpart — the host opens its candidate list. */
|
|
26
|
+
onReassign?: () => void;
|
|
27
|
+
onDismiss?: () => void;
|
|
28
|
+
acceptLabel?: string;
|
|
29
|
+
/** Resolved → settles to a quiet outcome line. */
|
|
30
|
+
status?: "open" | "accepted" | "dismissed";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An AI-proposed PAIRING — a known item, an arrow, the agent's proposed
|
|
35
|
+
* counterpart; a confidence meter and one muted line of why. The human accepts,
|
|
36
|
+
* reassigns, or rejects; nothing links on its own. Deliberately spare: two
|
|
37
|
+
* sides, an arrow, the reason. The unit of an AI reconciliation / dedup /
|
|
38
|
+
* correlation queue. Unlike `Suggestion` (a single proposed value) this is
|
|
39
|
+
* two-sided; unlike the deterministic reconcile recipe, the agent reasons it.
|
|
40
|
+
*/
|
|
41
|
+
export function MatchRow(props: MatchRowProps) {
|
|
42
|
+
const status = props.status ?? "open";
|
|
43
|
+
const resolved = status !== "open";
|
|
44
|
+
const hasMatch = props.match != null;
|
|
45
|
+
const hasConfidence = props.confidence != null || props.confidenceScore != null;
|
|
46
|
+
|
|
47
|
+
if (resolved) {
|
|
48
|
+
return (
|
|
49
|
+
<View style={[styles.card, styles.resolved]}>
|
|
50
|
+
<View style={styles.pair}>
|
|
51
|
+
<Side side={props.source} />
|
|
52
|
+
</View>
|
|
53
|
+
<Text size="xs" color="muted" weight="medium">
|
|
54
|
+
{status === "accepted" ? (hasMatch ? `Matched · ${props.match!.title}` : "Matched") : "Dismissed"}
|
|
55
|
+
</Text>
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View style={styles.card}>
|
|
62
|
+
<View style={styles.pair}>
|
|
63
|
+
<Side side={props.source} />
|
|
64
|
+
<Icon name="arrow-right" size={16} color={colors.zinc[400]} />
|
|
65
|
+
{hasMatch ? (
|
|
66
|
+
<Side side={props.match!} right />
|
|
67
|
+
) : (
|
|
68
|
+
<View style={[styles.side, styles.sideRight]}>
|
|
69
|
+
<Text size="sm" weight="medium" color="muted" align="right" numberOfLines={1}>
|
|
70
|
+
No confident match
|
|
71
|
+
</Text>
|
|
72
|
+
<Text size="xs" color="muted" align="right" numberOfLines={1}>
|
|
73
|
+
Pick a counterpart
|
|
74
|
+
</Text>
|
|
75
|
+
</View>
|
|
76
|
+
)}
|
|
77
|
+
</View>
|
|
78
|
+
|
|
79
|
+
{hasConfidence || props.rationale ? (
|
|
80
|
+
<View style={styles.meta}>
|
|
81
|
+
{hasConfidence ? <Confidence level={props.confidence} score={props.confidenceScore} /> : null}
|
|
82
|
+
{props.rationale ? (
|
|
83
|
+
<Text size="xs" color="muted" style={{ flex: 1 }} numberOfLines={2}>
|
|
84
|
+
{props.rationale}
|
|
85
|
+
</Text>
|
|
86
|
+
) : null}
|
|
87
|
+
</View>
|
|
88
|
+
) : null}
|
|
89
|
+
|
|
90
|
+
<View style={styles.footer}>
|
|
91
|
+
{props.onDismiss ? <Button title="Not a match" color="muted" shape="rounded" onPress={props.onDismiss} /> : null}
|
|
92
|
+
{props.onReassign ? (
|
|
93
|
+
<Button title={hasMatch ? "Reassign" : "Find match"} color="secondary" shape="rounded" onPress={props.onReassign} />
|
|
94
|
+
) : null}
|
|
95
|
+
{hasMatch && props.onAccept ? (
|
|
96
|
+
<Button title={props.acceptLabel ?? "Accept"} color="primary" shape="rounded" onPress={props.onAccept} />
|
|
97
|
+
) : null}
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Side({ side, right }: { side: MatchSide; right?: boolean }) {
|
|
104
|
+
return (
|
|
105
|
+
<View style={[styles.side, right ? styles.sideRight : null]}>
|
|
106
|
+
<Text size="sm" weight="medium" numberOfLines={1} align={right ? "right" : undefined}>
|
|
107
|
+
{side.title}
|
|
108
|
+
</Text>
|
|
109
|
+
{side.detail ? (
|
|
110
|
+
<Text size="xs" color="muted" numberOfLines={1} align={right ? "right" : undefined}>
|
|
111
|
+
{side.detail}
|
|
112
|
+
</Text>
|
|
113
|
+
) : null}
|
|
114
|
+
</View>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const styles = StyleSheet.create({
|
|
119
|
+
card: {
|
|
120
|
+
borderWidth: 1,
|
|
121
|
+
borderColor: colors.border,
|
|
122
|
+
backgroundColor: colors.white,
|
|
123
|
+
borderRadius: 12,
|
|
124
|
+
padding: 16,
|
|
125
|
+
gap: 12,
|
|
126
|
+
},
|
|
127
|
+
resolved: { backgroundColor: colors.zinc[50] },
|
|
128
|
+
pair: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
129
|
+
side: { flex: 1, gap: 2 },
|
|
130
|
+
sideRight: { alignItems: "flex-end" },
|
|
131
|
+
meta: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
132
|
+
footer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
|
|
133
|
+
});
|