@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
|
@@ -9,12 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
|
|
12
|
-
import { useT } from "@agent-native/core/client/i18n";
|
|
12
|
+
import { useFormatters, useT } from "@agent-native/core/client/i18n";
|
|
13
13
|
import {
|
|
14
14
|
IconAlertTriangle,
|
|
15
15
|
IconClock,
|
|
16
16
|
IconClockExclamation,
|
|
17
|
-
IconGripVertical,
|
|
18
17
|
} from "@tabler/icons-react";
|
|
19
18
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
20
19
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
@@ -32,9 +31,12 @@ import {
|
|
|
32
31
|
TableHeader,
|
|
33
32
|
TableRow,
|
|
34
33
|
} from "@/components/ui/table";
|
|
34
|
+
import { cn } from "@/lib/utils";
|
|
35
35
|
|
|
36
|
+
import type { CrmAttributeType } from "../../../../shared/crm-attributes";
|
|
36
37
|
import { ProvenanceMarker } from "../grid/GridCell";
|
|
37
38
|
import type { CrmCellProvenance } from "../grid/model";
|
|
39
|
+
import { AttributeTypeIcon } from "../record/attribute-row";
|
|
38
40
|
import {
|
|
39
41
|
currencyCodeOf,
|
|
40
42
|
formatAttributeValue,
|
|
@@ -46,6 +48,7 @@ import {
|
|
|
46
48
|
AttributeOptionChip,
|
|
47
49
|
AttributeRating,
|
|
48
50
|
} from "../shared/AttributeValueParts";
|
|
51
|
+
import { MOTION, overlayProps } from "../shared/ui-tokens";
|
|
49
52
|
import {
|
|
50
53
|
BOARD_UNGROUPED,
|
|
51
54
|
boardCardSla,
|
|
@@ -127,6 +130,32 @@ interface RecordsResponse {
|
|
|
127
130
|
|
|
128
131
|
const PAGE_LIMIT = 100;
|
|
129
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Time in stage, compactly: "3d", "14d", "+5d". `Intl` owns the unit, so a
|
|
135
|
+
* locale that does not abbreviate days still reads correctly (ar-SA renders
|
|
136
|
+
* "١٤ ي") without a per-locale string of our own. The long sentence stays as
|
|
137
|
+
* the row's `title`.
|
|
138
|
+
*/
|
|
139
|
+
const DAY_UNIT: Intl.NumberFormatOptions = {
|
|
140
|
+
style: "unit",
|
|
141
|
+
unit: "day",
|
|
142
|
+
unitDisplay: "narrow",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Drop confirmation: the accent tint lands instantly on the card that just
|
|
147
|
+
* committed, then eases back to rest. Board-local rather than a shared motion
|
|
148
|
+
* token — nothing else in the app confirms a drop, and 900ms is far outside
|
|
149
|
+
* the interaction scale the other surfaces share.
|
|
150
|
+
*
|
|
151
|
+
* It REPLACES the resting `bg-card` rather than being layered on top of it.
|
|
152
|
+
* Two `bg-*` utilities on one element are decided by their order in the
|
|
153
|
+
* generated stylesheet, not by the order they are written, and the theme
|
|
154
|
+
* colour wins — so a flash appended to the class list silently paints nothing.
|
|
155
|
+
*/
|
|
156
|
+
const CARD_REST_CLASS = "bg-card";
|
|
157
|
+
const DROP_FLASH_CLASS = "bg-[hsl(var(--crm-accent)/0.12)] transition-none";
|
|
158
|
+
|
|
130
159
|
export interface CrmBoardTarget {
|
|
131
160
|
kind: "list" | "object";
|
|
132
161
|
/** The list id for a list target, the object type for an object target. */
|
|
@@ -502,6 +531,16 @@ export function CrmBoard(props: CrmBoardProps) {
|
|
|
502
531
|
const now = useMemo(() => new Date(), [cards]);
|
|
503
532
|
const overruns = useMemo(() => boardOverruns(columns, now), [columns, now]);
|
|
504
533
|
|
|
534
|
+
// The confirmed card's id, held only long enough for the tint to land. It is
|
|
535
|
+
// set after the commit resolves, never on the optimistic paint: the flash
|
|
536
|
+
// means "this move is saved", so a move that later rolls back never flashes.
|
|
537
|
+
const [flashCardId, setFlashCardId] = useState<string | null>(null);
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (!flashCardId) return;
|
|
540
|
+
const timer = setTimeout(() => setFlashCardId(null), MOTION.fast);
|
|
541
|
+
return () => clearTimeout(timer);
|
|
542
|
+
}, [flashCardId]);
|
|
543
|
+
|
|
505
544
|
async function move(cardId: string, toValue: string) {
|
|
506
545
|
const result = await moveBoardCard({
|
|
507
546
|
cards,
|
|
@@ -519,7 +558,10 @@ export function CrmBoard(props: CrmBoardProps) {
|
|
|
519
558
|
);
|
|
520
559
|
return;
|
|
521
560
|
}
|
|
522
|
-
if (result.moved)
|
|
561
|
+
if (result.moved) {
|
|
562
|
+
setFlashCardId(cardId);
|
|
563
|
+
data.refetch();
|
|
564
|
+
}
|
|
523
565
|
}
|
|
524
566
|
|
|
525
567
|
if (data.error) {
|
|
@@ -542,7 +584,7 @@ export function CrmBoard(props: CrmBoardProps) {
|
|
|
542
584
|
return (
|
|
543
585
|
<div className="flex min-h-0 flex-col">
|
|
544
586
|
<div className="flex flex-wrap items-center gap-2 px-5 pt-4 sm:px-7">
|
|
545
|
-
<span className="text-xs text-
|
|
587
|
+
<span className="text-xs text-content-tertiary">
|
|
546
588
|
{t("board.groupedBy", { attribute: data.groupAttribute.label })}
|
|
547
589
|
</span>
|
|
548
590
|
{overruns.length ? (
|
|
@@ -555,7 +597,7 @@ export function CrmBoard(props: CrmBoardProps) {
|
|
|
555
597
|
</Badge>
|
|
556
598
|
) : null}
|
|
557
599
|
{data.complete ? null : (
|
|
558
|
-
<span className="text-xs text-
|
|
600
|
+
<span className="text-xs text-content-tertiary">
|
|
559
601
|
{t("board.partialPage", { limit: PAGE_LIMIT })}
|
|
560
602
|
</span>
|
|
561
603
|
)}
|
|
@@ -563,12 +605,13 @@ export function CrmBoard(props: CrmBoardProps) {
|
|
|
563
605
|
{props.mode === "table" ? (
|
|
564
606
|
<BoardTable columns={columns} now={now} />
|
|
565
607
|
) : (
|
|
566
|
-
<div className="flex gap-
|
|
608
|
+
<div className="flex gap-4 overflow-x-auto p-5 sm:p-7">
|
|
567
609
|
{columns.map((column, index) => (
|
|
568
610
|
<BoardColumnView
|
|
569
611
|
key={column.key}
|
|
570
612
|
column={column}
|
|
571
613
|
now={now}
|
|
614
|
+
flashCardId={flashCardId}
|
|
572
615
|
onDropCard={(cardId) => void move(cardId, column.key)}
|
|
573
616
|
onMoveCard={(cardId, offset) => {
|
|
574
617
|
const next = columns[index + offset];
|
|
@@ -591,9 +634,9 @@ function BoardNotice({
|
|
|
591
634
|
}) {
|
|
592
635
|
const t = useT();
|
|
593
636
|
return (
|
|
594
|
-
<div className="m-5 flex flex-wrap items-center gap-3 rounded-
|
|
595
|
-
<IconAlertTriangle className="size-4 text-
|
|
596
|
-
<p className="text-sm text-
|
|
637
|
+
<div className="m-5 flex flex-wrap items-center gap-3 rounded-card border border-hairline bg-card p-4 sm:m-7">
|
|
638
|
+
<IconAlertTriangle className="size-4 text-content-tertiary" />
|
|
639
|
+
<p className="text-sm text-content-secondary">{message}</p>
|
|
597
640
|
{onRetry ? (
|
|
598
641
|
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
599
642
|
{t("board.retry")}
|
|
@@ -603,14 +646,23 @@ function BoardNotice({
|
|
|
603
646
|
);
|
|
604
647
|
}
|
|
605
648
|
|
|
649
|
+
/**
|
|
650
|
+
* A column is 300px of nothing: no trough, no border, the page background. The
|
|
651
|
+
* cards carry the only boundary on the board, which is what keeps a wide
|
|
652
|
+
* pipeline from reading as a row of grey boxes. Its one visual state is
|
|
653
|
+
* drag-over, and that rides the shared overlay so it cross-fades rather than
|
|
654
|
+
* swapping a background.
|
|
655
|
+
*/
|
|
606
656
|
function BoardColumnView({
|
|
607
657
|
column,
|
|
608
658
|
now,
|
|
659
|
+
flashCardId,
|
|
609
660
|
onDropCard,
|
|
610
661
|
onMoveCard,
|
|
611
662
|
}: {
|
|
612
663
|
column: BoardColumn;
|
|
613
664
|
now: Date;
|
|
665
|
+
flashCardId: string | null;
|
|
614
666
|
onDropCard: (cardId: string) => void;
|
|
615
667
|
onMoveCard: (cardId: string, offset: -1 | 1) => void;
|
|
616
668
|
}) {
|
|
@@ -620,14 +672,22 @@ function BoardColumnView({
|
|
|
620
672
|
|
|
621
673
|
return (
|
|
622
674
|
<section
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
675
|
+
{...overlayProps({
|
|
676
|
+
selected: isOver,
|
|
677
|
+
className: "flex w-75 shrink-0 flex-col rounded-column",
|
|
678
|
+
})}
|
|
626
679
|
onDragOver={(event) => {
|
|
627
680
|
event.preventDefault();
|
|
628
681
|
setIsOver(true);
|
|
629
682
|
}}
|
|
630
|
-
onDragLeave={() =>
|
|
683
|
+
onDragLeave={(event) => {
|
|
684
|
+
// dragleave also fires crossing into a child, which would strobe the
|
|
685
|
+
// drop tint over every card the cursor passes.
|
|
686
|
+
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
setIsOver(false);
|
|
690
|
+
}}
|
|
631
691
|
onDrop={(event) => {
|
|
632
692
|
event.preventDefault();
|
|
633
693
|
setIsOver(false);
|
|
@@ -635,27 +695,22 @@ function BoardColumnView({
|
|
|
635
695
|
if (cardId) onDropCard(cardId);
|
|
636
696
|
}}
|
|
637
697
|
>
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
{columnTitle(column, t)}
|
|
650
|
-
</p>
|
|
651
|
-
</div>
|
|
652
|
-
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
653
|
-
{totals.count}
|
|
654
|
-
</span>
|
|
655
|
-
</div>
|
|
656
|
-
<ColumnTotals totals={totals} />
|
|
698
|
+
{/* Dot, name, count read as one cluster. Pushing the count to the column's
|
|
699
|
+
far edge turns an invisible column into an implied box the whole point
|
|
700
|
+
of dropping the trough was to remove. */}
|
|
701
|
+
<header className="flex h-9 shrink-0 items-center gap-2">
|
|
702
|
+
<StageDot color={column.option?.color} />
|
|
703
|
+
<p className="min-w-0 truncate text-sm font-semibold">
|
|
704
|
+
{columnTitle(column, t)}
|
|
705
|
+
</p>
|
|
706
|
+
<span className="shrink-0 rounded-badge bg-muted px-1.5 py-0.5 text-xs tabular-nums text-content-secondary">
|
|
707
|
+
{totals.count}
|
|
708
|
+
</span>
|
|
657
709
|
</header>
|
|
658
|
-
<
|
|
710
|
+
<ColumnTotals totals={totals} />
|
|
711
|
+
{/* The 8px fade is exactly the list's own top padding, so it is invisible
|
|
712
|
+
at rest and only bites once a card scrolls under the header. */}
|
|
713
|
+
<div className="flex max-h-[70svh] min-h-24 flex-col gap-2 overflow-y-auto pt-2 [mask-image:linear-gradient(to_bottom,transparent,black_8px)]">
|
|
659
714
|
{column.cards.map((card) => (
|
|
660
715
|
<BoardCardView
|
|
661
716
|
key={card.id}
|
|
@@ -663,11 +718,12 @@ function BoardColumnView({
|
|
|
663
718
|
option={column.option}
|
|
664
719
|
columnLabel={columnTitle(column, t)}
|
|
665
720
|
now={now}
|
|
721
|
+
flash={card.id === flashCardId}
|
|
666
722
|
onMove={(offset) => onMoveCard(card.id, offset)}
|
|
667
723
|
/>
|
|
668
724
|
))}
|
|
669
725
|
{column.cards.length ? null : (
|
|
670
|
-
<p className="
|
|
726
|
+
<p className="py-3 text-xs text-content-tertiary">
|
|
671
727
|
{t("board.columnEmpty")}
|
|
672
728
|
</p>
|
|
673
729
|
)}
|
|
@@ -676,6 +732,20 @@ function BoardColumnView({
|
|
|
676
732
|
);
|
|
677
733
|
}
|
|
678
734
|
|
|
735
|
+
/** The stage's own colour, and the board's only decorative motion. */
|
|
736
|
+
function StageDot({ color }: { color?: string | undefined }) {
|
|
737
|
+
return (
|
|
738
|
+
<span
|
|
739
|
+
aria-hidden
|
|
740
|
+
className={cn(
|
|
741
|
+
"size-2.5 shrink-0 rounded-full transition-transform hover:scale-120",
|
|
742
|
+
color ? null : "ring-1 ring-inset ring-hairline",
|
|
743
|
+
)}
|
|
744
|
+
{...(color ? { style: { backgroundColor: color } } : {})}
|
|
745
|
+
/>
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
679
749
|
function ColumnTotals({
|
|
680
750
|
totals,
|
|
681
751
|
}: {
|
|
@@ -684,14 +754,16 @@ function ColumnTotals({
|
|
|
684
754
|
const t = useT();
|
|
685
755
|
if (totals.mixedCurrency) {
|
|
686
756
|
return (
|
|
687
|
-
<p className="text-xs text-
|
|
757
|
+
<p className="text-xs text-content-tertiary">
|
|
688
758
|
{t("board.mixedCurrency")}
|
|
689
759
|
</p>
|
|
690
760
|
);
|
|
691
761
|
}
|
|
762
|
+
// No summable amount means no total line at all. A "0" here would read as a
|
|
763
|
+
// real, empty pipeline stage.
|
|
692
764
|
if (totals.sum === null) return null;
|
|
693
765
|
return (
|
|
694
|
-
<p className="flex flex-wrap items-baseline gap-x-1.5 text-xs text-
|
|
766
|
+
<p className="flex flex-wrap items-baseline gap-x-1.5 text-xs text-content-tertiary">
|
|
695
767
|
<span className="font-medium tabular-nums text-foreground">
|
|
696
768
|
{formatMoney(totals.sum, totals.currencyCode)}
|
|
697
769
|
</span>
|
|
@@ -707,21 +779,31 @@ function BoardCardView({
|
|
|
707
779
|
option,
|
|
708
780
|
columnLabel,
|
|
709
781
|
now,
|
|
782
|
+
flash,
|
|
710
783
|
onMove,
|
|
711
784
|
}: {
|
|
712
785
|
card: BoardCard;
|
|
713
786
|
option: BoardOption | null;
|
|
714
787
|
columnLabel: string;
|
|
715
788
|
now: Date;
|
|
789
|
+
flash: boolean;
|
|
716
790
|
onMove: (offset: -1 | 1) => void;
|
|
717
791
|
}) {
|
|
718
792
|
const t = useT();
|
|
793
|
+
const [dragging, setDragging] = useState(false);
|
|
719
794
|
const sla = boardCardSla(card, option, now);
|
|
720
795
|
const shown = card.attributes.flatMap((attribute) => {
|
|
721
796
|
const node = cardAttributeNode(attribute, t);
|
|
722
797
|
return node === null
|
|
723
798
|
? []
|
|
724
|
-
: [
|
|
799
|
+
: [
|
|
800
|
+
{
|
|
801
|
+
slug: attribute.slug,
|
|
802
|
+
label: attribute.label,
|
|
803
|
+
attributeType: attribute.attributeType,
|
|
804
|
+
node,
|
|
805
|
+
},
|
|
806
|
+
];
|
|
725
807
|
});
|
|
726
808
|
// The record/entry has no per-field provenance at this data layer, only who
|
|
727
809
|
// created it — so this is coarser than the grid's per-cell marker, but the
|
|
@@ -741,7 +823,9 @@ function BoardCardView({
|
|
|
741
823
|
onDragStart={(event) => {
|
|
742
824
|
event.dataTransfer.setData("text/plain", card.id);
|
|
743
825
|
event.dataTransfer.effectAllowed = "move";
|
|
826
|
+
setDragging(true);
|
|
744
827
|
}}
|
|
828
|
+
onDragEnd={() => setDragging(false)}
|
|
745
829
|
onKeyDown={(event) => {
|
|
746
830
|
// Keyboard parity with the drag: a card can be moved without a mouse.
|
|
747
831
|
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
@@ -749,7 +833,13 @@ function BoardCardView({
|
|
|
749
833
|
onMove(event.key === "ArrowLeft" ? -1 : 1);
|
|
750
834
|
}
|
|
751
835
|
}}
|
|
752
|
-
|
|
836
|
+
{...overlayProps({
|
|
837
|
+
className: cn(
|
|
838
|
+
"group flex cursor-grab flex-col gap-1.5 rounded-lg border border-hairline p-3 outline-none transition-[background-color] duration-[900ms] ease-drop focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing",
|
|
839
|
+
dragging && "shadow-e2",
|
|
840
|
+
flash ? DROP_FLASH_CLASS : CARD_REST_CLASS,
|
|
841
|
+
),
|
|
842
|
+
})}
|
|
753
843
|
>
|
|
754
844
|
{/* A zero-size anchor: `ProvenanceMarker`'s own wedge is absolutely
|
|
755
845
|
positioned at its containing block's bottom-right corner, so this is
|
|
@@ -758,52 +848,71 @@ function BoardCardView({
|
|
|
758
848
|
<span className="absolute right-1.5 top-1.5 size-0">
|
|
759
849
|
<ProvenanceMarker provenance={provenance} attributeLabel={card.title} />
|
|
760
850
|
</span>
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
851
|
+
{/* 16px avatar + 8px gap and the 20px type-icon box + 4px gap below both
|
|
852
|
+
come to a 24px gutter, so the title, the subtitle and every value
|
|
853
|
+
share one left edge. */}
|
|
854
|
+
<div className="flex items-center gap-2">
|
|
855
|
+
<span
|
|
856
|
+
aria-hidden
|
|
857
|
+
className="grid size-4 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-medium text-content-secondary ring-1 ring-inset ring-hairline"
|
|
858
|
+
>
|
|
859
|
+
{initials(card.title).slice(0, 1)}
|
|
860
|
+
</span>
|
|
861
|
+
<Link
|
|
862
|
+
to={`/records/${encodeURIComponent(card.recordId)}`}
|
|
863
|
+
className="min-w-0 flex-1 truncate text-sm font-medium underline decoration-content-ghost underline-offset-2 hover:decoration-current"
|
|
864
|
+
>
|
|
865
|
+
{card.title}
|
|
866
|
+
</Link>
|
|
867
|
+
</div>
|
|
868
|
+
{card.subtitle ? (
|
|
869
|
+
<p className="flex items-center gap-1 text-xs text-content-tertiary">
|
|
870
|
+
<span aria-hidden className="size-5 shrink-0" />
|
|
871
|
+
<span className="min-w-0 flex-1 truncate">{card.subtitle}</span>
|
|
872
|
+
</p>
|
|
873
|
+
) : null}
|
|
874
|
+
{/* One row per value, each led by its attribute type's glyph in a fixed
|
|
875
|
+
gutter. The glyph is what carries the label, so the name itself is
|
|
876
|
+
only announced, not printed — a 300px card has no room for both. */}
|
|
877
|
+
{card.amount !== null || shown.length ? (
|
|
878
|
+
<dl className="flex flex-col gap-1.5">
|
|
775
879
|
{card.amount !== null ? (
|
|
776
|
-
<
|
|
777
|
-
|
|
778
|
-
|
|
880
|
+
<div className="flex items-center gap-1">
|
|
881
|
+
<AttributeTypeIcon type="currency" />
|
|
882
|
+
<dt className="sr-only">{t("board.table.amount")}</dt>
|
|
883
|
+
<dd className="min-w-0 flex-1 truncate text-sm font-medium tabular-nums">
|
|
884
|
+
{formatMoney(card.amount, card.currencyCode)}
|
|
885
|
+
</dd>
|
|
886
|
+
</div>
|
|
779
887
|
) : null}
|
|
780
|
-
</div>
|
|
781
|
-
</div>
|
|
782
|
-
{shown.length ? (
|
|
783
|
-
<dl className="mt-2 grid gap-1">
|
|
784
888
|
{shown.map((attribute) => (
|
|
785
|
-
<div
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
889
|
+
<div
|
|
890
|
+
key={attribute.slug}
|
|
891
|
+
className="flex items-center gap-1"
|
|
892
|
+
title={attribute.label}
|
|
893
|
+
>
|
|
894
|
+
<AttributeTypeIcon
|
|
895
|
+
type={attribute.attributeType as CrmAttributeType}
|
|
896
|
+
/>
|
|
897
|
+
<dt className="sr-only">{attribute.label}</dt>
|
|
898
|
+
<dd className="min-w-0 flex-1 truncate text-sm">
|
|
790
899
|
{attribute.node}
|
|
791
900
|
</dd>
|
|
792
901
|
</div>
|
|
793
902
|
))}
|
|
794
903
|
</dl>
|
|
795
904
|
) : null}
|
|
796
|
-
<footer className="
|
|
905
|
+
<footer className="flex items-center justify-between gap-2">
|
|
797
906
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
798
907
|
{card.owner ? (
|
|
799
908
|
<>
|
|
800
909
|
<span
|
|
801
910
|
aria-hidden
|
|
802
|
-
className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-medium text-
|
|
911
|
+
className="grid size-5 shrink-0 place-items-center rounded-full bg-muted text-[10px] font-medium text-content-secondary ring-1 ring-inset ring-hairline"
|
|
803
912
|
>
|
|
804
913
|
{initials(card.owner)}
|
|
805
914
|
</span>
|
|
806
|
-
<span className="truncate text-xs text-
|
|
915
|
+
<span className="truncate text-xs text-content-secondary">
|
|
807
916
|
{card.owner}
|
|
808
917
|
</span>
|
|
809
918
|
</>
|
|
@@ -854,30 +963,36 @@ function cardAttributeNode(
|
|
|
854
963
|
return text || null;
|
|
855
964
|
}
|
|
856
965
|
|
|
966
|
+
/**
|
|
967
|
+
* Time in stage, in the card footer's right slot. The compact form is what
|
|
968
|
+
* fits; the full sentence stays reachable as the title, so "14d" never has to
|
|
969
|
+
* carry the whole meaning on its own.
|
|
970
|
+
*/
|
|
857
971
|
function SlaBadge({ sla }: { sla: ReturnType<typeof boardCardSla> }) {
|
|
858
972
|
const t = useT();
|
|
973
|
+
const { formatNumber } = useFormatters();
|
|
859
974
|
if (sla.status === "not-tracked") return null;
|
|
860
975
|
if (sla.status === "unknown") {
|
|
861
976
|
return (
|
|
862
|
-
<span
|
|
863
|
-
className="text-muted-foreground/60"
|
|
864
|
-
title={t(`board.sla.${sla.reason}`)}
|
|
865
|
-
>
|
|
977
|
+
<span className="text-content-ghost" title={t(`board.sla.${sla.reason}`)}>
|
|
866
978
|
<IconClock className="size-3.5" />
|
|
867
979
|
</span>
|
|
868
980
|
);
|
|
869
981
|
}
|
|
870
982
|
if (sla.status === "within") {
|
|
871
983
|
return (
|
|
872
|
-
<span
|
|
984
|
+
<span
|
|
985
|
+
className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-content-tertiary"
|
|
986
|
+
title={t("board.daysInStage", { days: sla.days })}
|
|
987
|
+
>
|
|
873
988
|
<IconClock className="size-3.5" />
|
|
874
|
-
{
|
|
989
|
+
{formatNumber(sla.days, DAY_UNIT)}
|
|
875
990
|
</span>
|
|
876
991
|
);
|
|
877
992
|
}
|
|
878
993
|
return (
|
|
879
994
|
<span
|
|
880
|
-
className="flex items-center gap-1 text-xs font-medium tabular-nums text-amber-700 dark:text-amber-400"
|
|
995
|
+
className="flex shrink-0 items-center gap-1 text-xs font-medium tabular-nums text-amber-700 dark:text-amber-400"
|
|
881
996
|
title={t("board.overrunDetail", {
|
|
882
997
|
days: sla.days,
|
|
883
998
|
overBy: sla.overBy,
|
|
@@ -885,7 +1000,7 @@ function SlaBadge({ sla }: { sla: ReturnType<typeof boardCardSla> }) {
|
|
|
885
1000
|
})}
|
|
886
1001
|
>
|
|
887
1002
|
<IconClockExclamation className="size-3.5" />
|
|
888
|
-
{
|
|
1003
|
+
{formatNumber(sla.overBy, { ...DAY_UNIT, signDisplay: "always" })}
|
|
889
1004
|
</span>
|
|
890
1005
|
);
|
|
891
1006
|
}
|