@agent-native/core 0.124.0 → 0.124.1
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +16 -0
- package/corpus/core/package.json +1 -1
- package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +6 -3
- package/corpus/templates/analytics/server/lib/report-chart-svg.ts +18 -5
- package/corpus/templates/crm/actions/_crm-list-utils.ts +178 -0
- package/corpus/templates/crm/actions/add-crm-record-to-list.ts +23 -2
- package/corpus/templates/crm/actions/create-crm-list.ts +229 -43
- package/corpus/templates/crm/actions/create-crm-record.ts +13 -5
- package/corpus/templates/crm/actions/update-crm-list-entry.ts +1 -1
- package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +22 -2
- package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +143 -32
- package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +190 -75
- package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +267 -115
- package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +265 -131
- package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +79 -100
- package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +12 -8
- package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +27 -31
- package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +16 -13
- package/corpus/templates/crm/app/components/crm/record/attribute-row.tsx +108 -0
- package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +201 -9
- package/corpus/templates/crm/app/components/crm/record/panel-resize.ts +61 -0
- package/corpus/templates/crm/app/components/crm/record/record-data.ts +5 -4
- package/corpus/templates/crm/app/components/crm/shared/README-tokens.md +103 -0
- package/corpus/templates/crm/app/components/crm/shared/RecordReferencePicker.tsx +117 -0
- package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +84 -0
- package/corpus/templates/crm/app/components/crm/shared/ui-tokens.ts +89 -0
- package/corpus/templates/crm/app/global.css +170 -0
- package/corpus/templates/crm/app/i18n/en-US.ts +1 -0
- package/corpus/templates/crm/changelog/2026-07-26-a-half-typed-number-or-date-no-longer-saves-as-empty-the-fie.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-a-new-pipeline-board-now-opens-with-stage-columns-a-summabl.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-record-reference-fields-like-account-can-now-be-linked-from-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-retyping-a-record-field-now-replaces-the-old-value-instead-o.md +6 -0
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Highlights, then the rest of the object's typed attributes, each
|
|
3
|
+
* inline-editable when `update-crm-record` would actually accept it.
|
|
4
|
+
*
|
|
5
|
+
* This renders the *sections*; the resizable pane that holds them (and the
|
|
6
|
+
* list memberships below them) belongs to `RecordWorkspace`.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
import { ExtensionSlot } from "@agent-native/core/client/extensions";
|
|
7
9
|
import { useT } from "@agent-native/core/client/i18n";
|
|
8
10
|
import { IconChevronRight } from "@tabler/icons-react";
|
|
9
11
|
import { useState } from "react";
|
|
10
12
|
|
|
13
|
+
import { overlayProps } from "@/components/crm/shared/ui-tokens";
|
|
14
|
+
|
|
11
15
|
import type {
|
|
12
16
|
CrmAttributeDefinition,
|
|
13
17
|
CrmValue,
|
|
14
18
|
} from "../../../../shared/crm-contract";
|
|
19
|
+
import { AttributeRowShell, PANEL_SECTION_HEADING } from "./attribute-row";
|
|
15
20
|
import { FieldEditor, FieldLockNote, FieldValueDisplay } from "./field-editors";
|
|
16
21
|
import { FieldHistoryButton } from "./FieldHistory";
|
|
17
22
|
import {
|
|
@@ -37,32 +42,31 @@ export function AttributePanel({
|
|
|
37
42
|
values: page.values,
|
|
38
43
|
});
|
|
39
44
|
|
|
45
|
+
const row = (attribute: CrmAttributeDefinition) => (
|
|
46
|
+
<AttributeRow
|
|
47
|
+
key={attribute.id}
|
|
48
|
+
recordId={page.record.id}
|
|
49
|
+
attribute={attribute}
|
|
50
|
+
value={page.values[attribute.apiSlug]}
|
|
51
|
+
meta={page.valueMeta[attribute.apiSlug]}
|
|
52
|
+
isEditing={editingSlug === attribute.apiSlug}
|
|
53
|
+
onStartEdit={() => setEditingSlug(attribute.apiSlug)}
|
|
54
|
+
onDone={() => setEditingSlug(null)}
|
|
55
|
+
onCommit={(value) => onCommit(attribute, value)}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
|
|
40
59
|
return (
|
|
41
|
-
|
|
60
|
+
<>
|
|
42
61
|
<section aria-labelledby="crm-record-highlights">
|
|
43
|
-
<h2
|
|
44
|
-
id="crm-record-highlights"
|
|
45
|
-
className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
|
|
46
|
-
>
|
|
62
|
+
<h2 id="crm-record-highlights" className={PANEL_SECTION_HEADING}>
|
|
47
63
|
{t("record.highlights")}
|
|
48
64
|
</h2>
|
|
49
|
-
<div className="mt-
|
|
65
|
+
<div className="mt-2">
|
|
50
66
|
{highlights.length ? (
|
|
51
|
-
highlights.map(
|
|
52
|
-
<AttributeRow
|
|
53
|
-
key={attribute.id}
|
|
54
|
-
recordId={page.record.id}
|
|
55
|
-
attribute={attribute}
|
|
56
|
-
value={page.values[attribute.apiSlug]}
|
|
57
|
-
meta={page.valueMeta[attribute.apiSlug]}
|
|
58
|
-
isEditing={editingSlug === attribute.apiSlug}
|
|
59
|
-
onStartEdit={() => setEditingSlug(attribute.apiSlug)}
|
|
60
|
-
onDone={() => setEditingSlug(null)}
|
|
61
|
-
onCommit={(value) => onCommit(attribute, value)}
|
|
62
|
-
/>
|
|
63
|
-
))
|
|
67
|
+
highlights.map(row)
|
|
64
68
|
) : (
|
|
65
|
-
<p className="py-3 text-sm text-
|
|
69
|
+
<p className="py-3 text-sm text-content-tertiary">
|
|
66
70
|
{t("record.noAttributes")}
|
|
67
71
|
</p>
|
|
68
72
|
)}
|
|
@@ -71,41 +75,16 @@ export function AttributePanel({
|
|
|
71
75
|
|
|
72
76
|
{rest.length ? (
|
|
73
77
|
<details open className="group/details">
|
|
74
|
-
<summary
|
|
78
|
+
<summary
|
|
79
|
+
className={`flex cursor-pointer list-none items-center gap-1.5 ${PANEL_SECTION_HEADING}`}
|
|
80
|
+
>
|
|
75
81
|
<IconChevronRight className="size-3.5 transition-transform group-open/details:rotate-90" />
|
|
76
82
|
{t("record.allAttributes")}
|
|
77
83
|
</summary>
|
|
78
|
-
<div className="mt-
|
|
79
|
-
{rest.map((attribute) => (
|
|
80
|
-
<AttributeRow
|
|
81
|
-
key={attribute.id}
|
|
82
|
-
recordId={page.record.id}
|
|
83
|
-
attribute={attribute}
|
|
84
|
-
value={page.values[attribute.apiSlug]}
|
|
85
|
-
meta={page.valueMeta[attribute.apiSlug]}
|
|
86
|
-
isEditing={editingSlug === attribute.apiSlug}
|
|
87
|
-
onStartEdit={() => setEditingSlug(attribute.apiSlug)}
|
|
88
|
-
onDone={() => setEditingSlug(null)}
|
|
89
|
-
onCommit={(value) => onCommit(attribute, value)}
|
|
90
|
-
/>
|
|
91
|
-
))}
|
|
92
|
-
</div>
|
|
84
|
+
<div className="mt-2">{rest.map(row)}</div>
|
|
93
85
|
</details>
|
|
94
86
|
) : null}
|
|
95
|
-
|
|
96
|
-
{/* Stable extension contract: changing this slot id or context is a migration. */}
|
|
97
|
-
<ExtensionSlot
|
|
98
|
-
id="crm.record-sidebar.bottom"
|
|
99
|
-
context={{
|
|
100
|
-
recordId: page.record.id,
|
|
101
|
-
objectType: page.record.objectType,
|
|
102
|
-
kind: page.record.kind,
|
|
103
|
-
displayName: page.record.displayName,
|
|
104
|
-
provider: page.record.provider,
|
|
105
|
-
values: page.values,
|
|
106
|
-
}}
|
|
107
|
-
/>
|
|
108
|
-
</aside>
|
|
87
|
+
</>
|
|
109
88
|
);
|
|
110
89
|
}
|
|
111
90
|
|
|
@@ -139,54 +118,54 @@ function AttributeRow({
|
|
|
139
118
|
(attribute.attributeType === "status" ||
|
|
140
119
|
attribute.attributeType === "select")));
|
|
141
120
|
|
|
121
|
+
// Provenance rides on the row's tooltip instead of a hover-revealed caption:
|
|
122
|
+
// a caption either reserves ~15px on every row (killing the 36px rhythm) or
|
|
123
|
+
// grows the row on hover, and animating layout is off the table. The history
|
|
124
|
+
// dialog still carries the full actor-and-when list.
|
|
125
|
+
const title = [
|
|
126
|
+
attribute.description ?? attribute.label,
|
|
127
|
+
meta
|
|
128
|
+
? t("record.lastSetBy", { actor: meta.actorId ?? meta.actorType })
|
|
129
|
+
: "",
|
|
130
|
+
]
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.join(" · ");
|
|
133
|
+
|
|
142
134
|
return (
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
<span
|
|
150
|
-
className="text-sm text-muted-foreground"
|
|
151
|
-
title={attribute.description ?? attribute.label}
|
|
152
|
-
>
|
|
153
|
-
{attribute.label}
|
|
154
|
-
</span>
|
|
155
|
-
{attribute.historyTracked ? (
|
|
135
|
+
<AttributeRowShell
|
|
136
|
+
type={attribute.attributeType}
|
|
137
|
+
label={attribute.label}
|
|
138
|
+
title={title}
|
|
139
|
+
affordance={
|
|
140
|
+
attribute.historyTracked ? (
|
|
156
141
|
<FieldHistoryButton recordId={recordId} attribute={attribute} />
|
|
157
|
-
) : null
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
actor: meta.actorId ?? meta.actorType,
|
|
186
|
-
})}
|
|
187
|
-
</p>
|
|
188
|
-
) : null}
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
142
|
+
) : null
|
|
143
|
+
}
|
|
144
|
+
>
|
|
145
|
+
{alwaysLive || isEditing ? (
|
|
146
|
+
<FieldEditor
|
|
147
|
+
attribute={attribute}
|
|
148
|
+
value={value}
|
|
149
|
+
autoFocus={isEditing}
|
|
150
|
+
onCommit={onCommit}
|
|
151
|
+
onDone={onDone}
|
|
152
|
+
/>
|
|
153
|
+
) : editability.editable ? (
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
{...overlayProps({
|
|
157
|
+
className: "w-full cursor-pointer rounded-lg px-2 py-1 text-left",
|
|
158
|
+
})}
|
|
159
|
+
onClick={onStartEdit}
|
|
160
|
+
>
|
|
161
|
+
<FieldValueDisplay attribute={attribute} value={value} />
|
|
162
|
+
</button>
|
|
163
|
+
) : (
|
|
164
|
+
<div className="grid gap-1 px-2 py-1">
|
|
165
|
+
<FieldValueDisplay attribute={attribute} value={value} />
|
|
166
|
+
<FieldLockNote reason={editability.reason} />
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</AttributeRowShell>
|
|
191
170
|
);
|
|
192
171
|
}
|
|
@@ -31,15 +31,17 @@ export function RecordHeader({
|
|
|
31
31
|
: undefined;
|
|
32
32
|
|
|
33
33
|
return (
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
/* 104px: the record header's fixed band. `min-h` rather than `h` so a
|
|
35
|
+
wrapped title or a second row of badges grows it instead of clipping. */
|
|
36
|
+
<header className="flex min-h-[6.5rem] items-center border-b border-hairline px-5 py-4 sm:px-6">
|
|
37
|
+
<div className="flex w-full flex-wrap items-center justify-between gap-4">
|
|
36
38
|
<div className="flex min-w-0 items-center gap-3">
|
|
37
39
|
<RecordAvatar name={record.displayName} kind={record.kind} />
|
|
38
40
|
<div className="min-w-0">
|
|
39
|
-
<h1 className="truncate text-xl font-semibold
|
|
41
|
+
<h1 className="truncate text-xl font-semibold">
|
|
40
42
|
{record.displayName}
|
|
41
43
|
</h1>
|
|
42
|
-
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-
|
|
44
|
+
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-content-secondary">
|
|
43
45
|
<span className="capitalize">{record.kind}</span>
|
|
44
46
|
{record.stage ? (
|
|
45
47
|
<Badge variant="secondary" className="font-normal">
|
|
@@ -69,7 +71,7 @@ export function RecordHeader({
|
|
|
69
71
|
</Button>
|
|
70
72
|
) : null}
|
|
71
73
|
{providerLabel && !recordUrl && recordUrlUnavailableReason ? (
|
|
72
|
-
<span className="text-xs text-
|
|
74
|
+
<span className="text-xs text-content-tertiary">
|
|
73
75
|
{t("record.upstreamLinkUnavailable", {
|
|
74
76
|
provider: providerLabel,
|
|
75
77
|
reason: recordUrlUnavailableReason,
|
|
@@ -90,12 +92,14 @@ function RecordAvatar({ name, kind }: { name: string; kind: string }) {
|
|
|
90
92
|
.slice(0, 2)
|
|
91
93
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
|
92
94
|
.join("");
|
|
95
|
+
// Person avatars are circles, company avatars a 30% squircle — the shape is
|
|
96
|
+
// how the two record kinds stay distinguishable at a glance.
|
|
93
97
|
return (
|
|
94
98
|
<div
|
|
95
99
|
aria-hidden
|
|
96
|
-
className={`grid size-10 shrink-0 place-items-center text-sm font-
|
|
97
|
-
kind === "person" ? "rounded-full" : "rounded-
|
|
98
|
-
}
|
|
100
|
+
className={`grid size-10 shrink-0 place-items-center bg-muted text-sm font-medium uppercase text-content-secondary ring-1 ring-inset ring-hairline ${
|
|
101
|
+
kind === "person" ? "rounded-full" : "rounded-avatar-company"
|
|
102
|
+
}`}
|
|
99
103
|
>
|
|
100
104
|
{initials || "?"}
|
|
101
105
|
</div>
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from "@/components/ui/select";
|
|
27
27
|
|
|
28
28
|
import type { CrmValue } from "../../../../shared/crm-contract";
|
|
29
|
+
import { AttributeRowShell, PANEL_SECTION_HEADING } from "./attribute-row";
|
|
29
30
|
import { FieldEditor } from "./field-editors";
|
|
30
31
|
import { FieldHistoryButton } from "./FieldHistory";
|
|
31
32
|
import {
|
|
@@ -57,12 +58,9 @@ export function RecordLists({
|
|
|
57
58
|
}) {
|
|
58
59
|
const t = useT();
|
|
59
60
|
return (
|
|
60
|
-
<section className="grid gap-
|
|
61
|
-
<div className="flex items-center justify-between gap-2">
|
|
62
|
-
<h2
|
|
63
|
-
id="crm-record-lists"
|
|
64
|
-
className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
|
|
65
|
-
>
|
|
61
|
+
<section className="grid gap-2" aria-labelledby="crm-record-lists">
|
|
62
|
+
<div className="flex min-h-9 items-center justify-between gap-2">
|
|
63
|
+
<h2 id="crm-record-lists" className={PANEL_SECTION_HEADING}>
|
|
66
64
|
{t("record.lists")}
|
|
67
65
|
</h2>
|
|
68
66
|
<AddToListControl page={page} />
|
|
@@ -77,12 +75,12 @@ export function RecordLists({
|
|
|
77
75
|
/>
|
|
78
76
|
))
|
|
79
77
|
) : (
|
|
80
|
-
<p className="text-sm text-
|
|
78
|
+
<p className="text-sm text-content-tertiary">
|
|
81
79
|
{t("record.listsEmpty")}
|
|
82
80
|
</p>
|
|
83
81
|
)}
|
|
84
82
|
{page.listMembershipsTruncated ? (
|
|
85
|
-
<p className="text-xs text-
|
|
83
|
+
<p className="text-xs text-content-tertiary">
|
|
86
84
|
{t("record.listsTruncated")}
|
|
87
85
|
</p>
|
|
88
86
|
) : null}
|
|
@@ -105,49 +103,47 @@ function ListCard({
|
|
|
105
103
|
}) {
|
|
106
104
|
const t = useT();
|
|
107
105
|
return (
|
|
108
|
-
<div className="rounded-
|
|
109
|
-
<div className="flex items-center justify-between gap-2 border-b border-
|
|
106
|
+
<div className="rounded-card border border-hairline bg-card">
|
|
107
|
+
<div className="flex min-h-9 items-center justify-between gap-2 border-b border-hairline px-3">
|
|
110
108
|
<Link
|
|
111
109
|
to={`/lists/${encodeURIComponent(list.id)}`}
|
|
112
110
|
className="truncate text-sm font-medium hover:underline"
|
|
113
111
|
>
|
|
114
112
|
{list.name}
|
|
115
113
|
</Link>
|
|
116
|
-
<span className="shrink-0 text-xs text-
|
|
114
|
+
<span className="shrink-0 text-xs tabular-nums text-content-tertiary">
|
|
117
115
|
{t("record.entryCount", { count: list.entries.length })}
|
|
118
116
|
</span>
|
|
119
117
|
</div>
|
|
120
|
-
<div className="divide-y divide-
|
|
118
|
+
<div className="divide-y divide-hairline">
|
|
121
119
|
{list.entries.map((entry, index) => (
|
|
122
|
-
<div key={entry.id} className="
|
|
120
|
+
<div key={entry.id} className="px-1 py-1">
|
|
123
121
|
{list.entries.length > 1 ? (
|
|
124
|
-
<p className="
|
|
122
|
+
<p className="px-2 py-1 text-xs text-content-tertiary">
|
|
125
123
|
{t("record.entryOrdinal", { index: index + 1 })}
|
|
126
124
|
</p>
|
|
127
125
|
) : null}
|
|
128
126
|
{list.attributes.length ? (
|
|
129
127
|
list.attributes.map((attribute) => (
|
|
130
|
-
|
|
128
|
+
/* An entry attribute is the list's own workflow value, not the
|
|
129
|
+
record's — an opportunity's `Stage` and a pipeline's `Stage`
|
|
130
|
+
are different fields, and moving one does not move the other.
|
|
131
|
+
This label still reads identically to the object attribute
|
|
132
|
+
above; qualifying it needs a new i18n key, which is reported
|
|
133
|
+
rather than added here. */
|
|
134
|
+
<AttributeRowShell
|
|
131
135
|
key={attribute.id}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
the record's — an opportunity's `Stage` and a pipeline's
|
|
137
|
-
`Stage` are different fields, and moving one does not
|
|
138
|
-
move the other. This label still reads identically to
|
|
139
|
-
the object attribute in the left panel; qualifying it
|
|
140
|
-
needs a new i18n key, which is reported rather than
|
|
141
|
-
added here. */}
|
|
142
|
-
<span className="min-w-0 text-xs text-muted-foreground">
|
|
143
|
-
{attribute.label}
|
|
144
|
-
</span>
|
|
136
|
+
type={attribute.attributeType}
|
|
137
|
+
label={attribute.label}
|
|
138
|
+
title={attribute.description ?? attribute.label}
|
|
139
|
+
affordance={
|
|
145
140
|
<FieldHistoryButton
|
|
146
141
|
recordId={recordId}
|
|
147
142
|
entryId={entry.id}
|
|
148
143
|
attribute={entryAttributeAsEditable(attribute)}
|
|
149
144
|
/>
|
|
150
|
-
|
|
145
|
+
}
|
|
146
|
+
>
|
|
151
147
|
<FieldEditor
|
|
152
148
|
attribute={entryAttributeAsEditable(attribute)}
|
|
153
149
|
value={entry.values[attribute.apiSlug]}
|
|
@@ -155,10 +151,10 @@ function ListCard({
|
|
|
155
151
|
onEntryCommit(entry.id, attribute, value)
|
|
156
152
|
}
|
|
157
153
|
/>
|
|
158
|
-
</
|
|
154
|
+
</AttributeRowShell>
|
|
159
155
|
))
|
|
160
156
|
) : (
|
|
161
|
-
<p className="text-xs text-
|
|
157
|
+
<p className="px-2 py-1 text-xs text-content-tertiary">
|
|
162
158
|
{t("record.listNoAttributes")}
|
|
163
159
|
</p>
|
|
164
160
|
)}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@tabler/icons-react";
|
|
17
17
|
import { Link } from "react-router";
|
|
18
18
|
|
|
19
|
+
import { overlayProps } from "@/components/crm/shared/ui-tokens";
|
|
19
20
|
import { Badge } from "@/components/ui/badge";
|
|
20
21
|
import { Button } from "@/components/ui/button";
|
|
21
22
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
@@ -45,7 +46,7 @@ export function RecordTabs({
|
|
|
45
46
|
<Tabs
|
|
46
47
|
value={tab}
|
|
47
48
|
onValueChange={(next) => onTabChange(next as RecordTab)}
|
|
48
|
-
className="flex
|
|
49
|
+
className="flex flex-col p-5 sm:p-6"
|
|
49
50
|
>
|
|
50
51
|
<TabsList className="h-9 self-start bg-muted/70">
|
|
51
52
|
<TabsTrigger value="activity" className="gap-1.5">
|
|
@@ -73,16 +74,16 @@ export function RecordTabs({
|
|
|
73
74
|
description={t("record.activityNotIngestedDescription")}
|
|
74
75
|
/>
|
|
75
76
|
) : (
|
|
76
|
-
<div className="divide-y divide-
|
|
77
|
+
<div className="divide-y divide-hairline rounded-card border border-hairline bg-card">
|
|
77
78
|
{activity.items.map((item) => (
|
|
78
79
|
<div key={item.id} className="px-4 py-3">
|
|
79
80
|
<p className="text-sm font-medium">{item.title}</p>
|
|
80
81
|
{item.summary ? (
|
|
81
|
-
<p className="mt-1 text-sm text-
|
|
82
|
+
<p className="mt-1 text-sm text-content-secondary">
|
|
82
83
|
{item.summary}
|
|
83
84
|
</p>
|
|
84
85
|
) : null}
|
|
85
|
-
<p className="mt-2 text-xs text-
|
|
86
|
+
<p className="mt-2 text-xs text-content-secondary">
|
|
86
87
|
{[
|
|
87
88
|
item.actor,
|
|
88
89
|
item.occurredAt ? formatDate(item.occurredAt) : undefined,
|
|
@@ -104,13 +105,13 @@ export function RecordTabs({
|
|
|
104
105
|
</TabsContent>
|
|
105
106
|
|
|
106
107
|
<TabsContent value="tasks" className="mt-4 max-w-2xl">
|
|
107
|
-
<div className="divide-y divide-
|
|
108
|
+
<div className="divide-y divide-hairline rounded-card border border-hairline bg-card">
|
|
108
109
|
{tasks.length ? (
|
|
109
110
|
tasks.map((task) => (
|
|
110
111
|
<div key={task.id} className="flex items-center gap-3 px-4 py-3">
|
|
111
112
|
<div className="min-w-0 flex-1">
|
|
112
113
|
<p className="text-sm font-medium">{task.title}</p>
|
|
113
|
-
<p className="mt-0.5 text-xs text-
|
|
114
|
+
<p className="mt-0.5 text-xs text-content-secondary">
|
|
114
115
|
{task.dueAt
|
|
115
116
|
? t("record.taskDue", { when: formatDate(task.dueAt) })
|
|
116
117
|
: task.status}
|
|
@@ -132,7 +133,7 @@ export function RecordTabs({
|
|
|
132
133
|
</div>
|
|
133
134
|
))
|
|
134
135
|
) : (
|
|
135
|
-
<p className="px-4 py-8 text-center text-sm text-
|
|
136
|
+
<p className="px-4 py-8 text-center text-sm text-content-secondary">
|
|
136
137
|
{t("record.tasksEmpty")}
|
|
137
138
|
</p>
|
|
138
139
|
)}
|
|
@@ -140,19 +141,21 @@ export function RecordTabs({
|
|
|
140
141
|
</TabsContent>
|
|
141
142
|
|
|
142
143
|
<TabsContent value="related" className="mt-4 max-w-2xl">
|
|
143
|
-
<div className="divide-y divide-
|
|
144
|
+
<div className="divide-y divide-hairline rounded-card border border-hairline bg-card">
|
|
144
145
|
{record.relatedRecords?.length ? (
|
|
145
146
|
record.relatedRecords.map((related) => (
|
|
146
147
|
<Link
|
|
147
148
|
key={`${related.id}:${related.relationshipType}`}
|
|
148
149
|
to={`/records/${encodeURIComponent(related.id)}`}
|
|
149
|
-
|
|
150
|
+
{...overlayProps({
|
|
151
|
+
className: "flex items-center gap-3 px-4 py-3",
|
|
152
|
+
})}
|
|
150
153
|
>
|
|
151
154
|
<div className="min-w-0 flex-1">
|
|
152
155
|
<p className="truncate text-sm font-medium">
|
|
153
156
|
{related.displayName}
|
|
154
157
|
</p>
|
|
155
|
-
<p className="mt-0.5 truncate text-xs text-
|
|
158
|
+
<p className="mt-0.5 truncate text-xs text-content-secondary">
|
|
156
159
|
{[
|
|
157
160
|
related.relationshipLabel ?? related.relationshipType,
|
|
158
161
|
related.subtitle,
|
|
@@ -167,7 +170,7 @@ export function RecordTabs({
|
|
|
167
170
|
</Link>
|
|
168
171
|
))
|
|
169
172
|
) : (
|
|
170
|
-
<p className="px-4 py-8 text-center text-sm text-
|
|
173
|
+
<p className="px-4 py-8 text-center text-sm text-content-secondary">
|
|
171
174
|
{t("record.relatedEmpty")}
|
|
172
175
|
</p>
|
|
173
176
|
)}
|
|
@@ -185,9 +188,9 @@ function ComingSoon({
|
|
|
185
188
|
description: string;
|
|
186
189
|
}) {
|
|
187
190
|
return (
|
|
188
|
-
<div className="rounded-
|
|
191
|
+
<div className="rounded-card border border-dashed border-hairline bg-card px-5 py-8 text-center">
|
|
189
192
|
<p className="text-sm font-medium">{title}</p>
|
|
190
|
-
<p className="mx-auto mt-1.5 max-w-md text-sm leading-6 text-
|
|
193
|
+
<p className="mx-auto mt-1.5 max-w-md text-sm leading-6 text-content-secondary">
|
|
191
194
|
{description}
|
|
192
195
|
</p>
|
|
193
196
|
</div>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The label/value row shared by the record's object attributes and by a list
|
|
3
|
+
* entry's own attributes. One shell, so a pipeline value and a record value
|
|
4
|
+
* cannot drift into looking like two different products.
|
|
5
|
+
*
|
|
6
|
+
* Geometry: a 30%/1fr split, a 20px icon box with a 14px glyph at a 7px gap,
|
|
7
|
+
* and a hairline under every row but the last.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
IconActivity,
|
|
12
|
+
IconAlignLeft,
|
|
13
|
+
IconCalendar,
|
|
14
|
+
IconCircleDot,
|
|
15
|
+
IconClock,
|
|
16
|
+
IconCurrencyDollar,
|
|
17
|
+
IconHash,
|
|
18
|
+
IconId,
|
|
19
|
+
IconLink,
|
|
20
|
+
IconMail,
|
|
21
|
+
IconMapPin,
|
|
22
|
+
IconPhone,
|
|
23
|
+
IconSquareCheck,
|
|
24
|
+
IconStar,
|
|
25
|
+
IconTag,
|
|
26
|
+
IconUser,
|
|
27
|
+
IconWorld,
|
|
28
|
+
} from "@tabler/icons-react";
|
|
29
|
+
|
|
30
|
+
import { overlayProps } from "@/components/crm/shared/ui-tokens";
|
|
31
|
+
|
|
32
|
+
import type { CrmAttributeType } from "../../../../shared/crm-attributes";
|
|
33
|
+
|
|
34
|
+
const TYPE_ICONS: Record<CrmAttributeType, typeof IconAlignLeft> = {
|
|
35
|
+
text: IconAlignLeft,
|
|
36
|
+
number: IconHash,
|
|
37
|
+
checkbox: IconSquareCheck,
|
|
38
|
+
currency: IconCurrencyDollar,
|
|
39
|
+
date: IconCalendar,
|
|
40
|
+
timestamp: IconClock,
|
|
41
|
+
rating: IconStar,
|
|
42
|
+
status: IconCircleDot,
|
|
43
|
+
select: IconTag,
|
|
44
|
+
"record-reference": IconLink,
|
|
45
|
+
"actor-reference": IconUser,
|
|
46
|
+
location: IconMapPin,
|
|
47
|
+
domain: IconWorld,
|
|
48
|
+
"email-address": IconMail,
|
|
49
|
+
"phone-number": IconPhone,
|
|
50
|
+
interaction: IconActivity,
|
|
51
|
+
"personal-name": IconId,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Section headings sit at body size in the secondary content color — the same
|
|
55
|
+
* relationship the grid's column headers use. Small uppercase micro-labels are
|
|
56
|
+
* the tell we are moving away from. */
|
|
57
|
+
export const PANEL_SECTION_HEADING =
|
|
58
|
+
"text-sm font-medium text-content-secondary";
|
|
59
|
+
|
|
60
|
+
export function AttributeTypeIcon({ type }: { type: CrmAttributeType }) {
|
|
61
|
+
const Icon = TYPE_ICONS[type] ?? IconAlignLeft;
|
|
62
|
+
return (
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden
|
|
65
|
+
className="grid size-5 shrink-0 place-items-center text-content-tertiary"
|
|
66
|
+
>
|
|
67
|
+
<Icon className="size-3.5" />
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `min-h-9` rather than a fixed 36px: a long attribute name ("Desired Cadence
|
|
74
|
+
* Days") in a 30% column has to go somewhere, and truncating it hides the one
|
|
75
|
+
* piece of text the row exists to identify. The row grows; it never clips.
|
|
76
|
+
*/
|
|
77
|
+
export function AttributeRowShell({
|
|
78
|
+
type,
|
|
79
|
+
label,
|
|
80
|
+
title,
|
|
81
|
+
affordance,
|
|
82
|
+
children,
|
|
83
|
+
}: {
|
|
84
|
+
type: CrmAttributeType;
|
|
85
|
+
label: string;
|
|
86
|
+
title?: string;
|
|
87
|
+
affordance?: React.ReactNode;
|
|
88
|
+
children: React.ReactNode;
|
|
89
|
+
}) {
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
{...overlayProps({
|
|
93
|
+
className:
|
|
94
|
+
"group grid min-h-9 grid-cols-[30%_1fr] items-center gap-2 rounded-card py-1 pl-[7px] pr-3 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-hairline",
|
|
95
|
+
})}
|
|
96
|
+
title={title}
|
|
97
|
+
>
|
|
98
|
+
<div className="flex min-w-0 items-center gap-[7px]">
|
|
99
|
+
<AttributeTypeIcon type={type} />
|
|
100
|
+
<span className="min-w-0 break-words text-sm text-content-secondary">
|
|
101
|
+
{label}
|
|
102
|
+
</span>
|
|
103
|
+
{affordance}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="min-w-0 text-sm">{children}</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|