@exxatdesignux/ui 0.2.17 → 0.2.19
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/CHANGELOG.md +30 -0
- package/consumer-extras/AGENTS.md +76 -0
- package/consumer-extras/README.md +5 -1
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
- package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
- package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
- package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
- package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
- package/consumer-extras/patterns/data-views-pattern.md +42 -3
- package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
- package/package.json +2 -1
- package/src/components/ui/button-group.tsx +81 -0
- package/src/components/ui/button.tsx +4 -4
- package/src/components/ui/sidebar.tsx +2 -2
- package/src/globals.css +7 -1807
- package/src/theme.css +10 -1126
- package/src/tokens/README.md +15 -0
- package/src/tokens/base.css +337 -0
- package/src/tokens/high-contrast.css +1195 -0
- package/src/tokens/layers.css +224 -0
- package/src/tokens/tailwind-bridge.css +118 -0
- package/src/tokens/themes.css +201 -0
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/AGENTS.md +66 -21
- package/template/app/(app)/dashboard/loading.tsx +3 -15
- package/template/app/(app)/dashboard/page.tsx +2 -14
- package/template/app/(app)/data-list/layout.tsx +43 -0
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
- package/template/app/(app)/examples/page.tsx +1 -0
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/loading.tsx +1 -18
- package/template/app/(app)/question-bank/find/page.tsx +2 -1
- package/template/app/(app)/question-bank/library/page.tsx +2 -1
- package/template/app/(app)/question-bank/list/page.tsx +2 -1
- package/template/app/(app)/question-bank/new/page.tsx +15 -23
- package/template/app/(app)/question-bank/page.tsx +2 -1
- package/template/app/(app)/settings/page.tsx +4 -5
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +7 -1934
- package/template/app/layout.tsx +2 -0
- package/template/components/app-route-loading.tsx +14 -0
- package/template/components/app-sidebar.tsx +71 -55
- package/template/components/data-table/index.tsx +31 -67
- package/template/components/data-table/use-table-state.ts +33 -6
- package/template/components/data-views/index.ts +37 -9
- package/template/components/data-views/list-page-calendar-view.tsx +593 -0
- package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
- package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
- package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/examples/focused-workflow-showcase.tsx +183 -0
- package/template/components/exxat-product-logo.tsx +2 -6
- package/template/components/key-metrics.tsx +54 -22
- package/template/components/list-hub-board-view.tsx +68 -0
- package/template/components/list-hub-client.tsx +186 -0
- package/template/components/list-hub-list-view.tsx +36 -0
- package/template/components/list-hub-panel-activator.tsx +8 -0
- package/template/components/list-hub-secondary-nav.tsx +121 -0
- package/template/components/list-hub-table.tsx +336 -0
- package/template/components/new-question-composer.tsx +6 -24
- package/template/components/product-switcher.tsx +5 -5
- package/template/components/product-wordmark.tsx +4 -7
- package/template/components/question-bank-client.tsx +4 -1
- package/template/components/question-bank-folder-columns-panel.tsx +104 -0
- package/template/components/question-bank-hub-client.tsx +2 -5
- package/template/components/question-bank-table.tsx +155 -509
- package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
- package/template/components/secondary-panel.tsx +4 -44
- package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
- package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
- package/template/components/secondary-panels/registry.tsx +15 -0
- package/template/components/settings-appearance-card.tsx +3 -2
- package/template/components/settings-client.tsx +59 -15
- package/template/components/settings-form-row.tsx +9 -4
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/table-properties/drawer-button.tsx +51 -20
- package/template/components/table-properties/drawer.tsx +81 -17
- package/template/components/templates/focused-workflow-layouts.tsx +448 -0
- package/template/components/templates/focused-workflow-page-template.tsx +69 -0
- package/template/components/templates/list-page.tsx +40 -13
- package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
- package/template/components/templates/page-loading-shell.tsx +262 -0
- package/template/components/ui/button-group.tsx +1 -0
- package/template/contexts/product-context.tsx +21 -2
- package/template/docs/consumer-app-pattern.md +39 -0
- package/template/docs/data-views-pattern.md +42 -3
- package/template/docs/drawer-vs-dialog-pattern.md +3 -1
- package/template/docs/focused-workflow-page-pattern.md +84 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +54 -0
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/command-menu-search-data.ts +11 -27
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-display-options.ts +16 -2
- package/template/lib/data-list-view-registry.ts +104 -0
- package/template/lib/data-list-view-surface.ts +15 -1
- package/template/lib/data-list-view.ts +16 -1
- package/template/lib/data-view-dashboard-storage.ts +38 -35
- package/template/lib/hub-connected-view-renderers.ts +58 -0
- package/template/lib/list-hub-nav.ts +121 -0
- package/template/lib/list-hub-supported-views.ts +10 -0
- package/template/lib/list-page-table-properties.ts +3 -7
- package/template/lib/list-status-badges.ts +4 -97
- package/template/lib/mock/list-hub-directory.ts +27 -0
- package/template/lib/mock/list-hub-kpi.ts +27 -0
- package/template/lib/mock/navigation.tsx +1 -0
- package/template/lib/page-loading-variant.ts +40 -0
- package/template/lib/question-bank-supported-views.ts +13 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +60 -13
- package/template/app/(app)/data-list/[id]/page.tsx +0 -44
- package/template/app/(app)/data-list/new/page.tsx +0 -34
- package/template/components/compliance-board-view.tsx +0 -142
- package/template/components/compliance-client.tsx +0 -92
- package/template/components/compliance-list-view.tsx +0 -54
- package/template/components/compliance-page-header.tsx +0 -89
- package/template/components/compliance-table.tsx +0 -632
- package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
- package/template/components/data-view-dashboard-charts-team.tsx +0 -971
- package/template/components/data-view-dashboard-charts.tsx +0 -1503
- package/template/components/new-placement-back-btn.tsx +0 -28
- package/template/components/new-placement-form.tsx +0 -1068
- package/template/components/placement-board-card.tsx +0 -262
- package/template/components/placement-detail.tsx +0 -438
- package/template/components/placements-board-view.tsx +0 -404
- package/template/components/placements-client.tsx +0 -252
- package/template/components/placements-list-view.tsx +0 -171
- package/template/components/placements-page-header.tsx +0 -166
- package/template/components/placements-table-cells.test.tsx +0 -22
- package/template/components/placements-table-cells.tsx +0 -173
- package/template/components/placements-table-columns.tsx +0 -640
- package/template/components/placements-table.tsx +0 -1675
- package/template/components/rotations-empty-state.tsx +0 -50
- package/template/components/rotations-panel-activator.tsx +0 -8
- package/template/components/sites-all-client.tsx +0 -154
- package/template/components/sites-board-view.tsx +0 -67
- package/template/components/sites-list-view.tsx +0 -42
- package/template/components/sites-table.tsx +0 -402
- package/template/components/team-board-view.tsx +0 -122
- package/template/components/team-client.tsx +0 -100
- package/template/components/team-list-view.tsx +0 -59
- package/template/components/team-page-header.tsx +0 -92
- package/template/components/team-table.tsx +0 -714
- package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
- package/template/lib/mock/compliance-kpi.ts +0 -61
- package/template/lib/mock/compliance.ts +0 -146
- package/template/lib/mock/placements-kpi.ts +0 -134
- package/template/lib/mock/placements.ts +0 -183
- package/template/lib/mock/sites-directory.ts +0 -16
- package/template/lib/mock/sites-kpi.ts +0 -25
- package/template/lib/mock/team-kpi.ts +0 -60
- package/template/lib/mock/team.ts +0 -118
- package/template/lib/placement-board-card-layout.ts +0 -79
- package/template/lib/placement-lifecycle.ts +0 -5
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
SelectTrigger,
|
|
27
27
|
SelectValue,
|
|
28
28
|
} from "@/components/ui/select"
|
|
29
|
-
import
|
|
29
|
+
import {
|
|
30
|
+
CALENDAR_MAIN_VIEW_TILES,
|
|
31
|
+
type DataListDisplayOptions,
|
|
32
|
+
} from "@/lib/data-list-display-options"
|
|
30
33
|
import { Tip } from "@/components/ui/tip"
|
|
31
34
|
import { ToggleSwitch } from "@/components/ui/toggle-switch"
|
|
32
35
|
import { Button } from "@/components/ui/button"
|
|
@@ -95,6 +98,11 @@ export interface TablePropertiesDrawerProps {
|
|
|
95
98
|
// View type
|
|
96
99
|
currentView?: DataListViewType
|
|
97
100
|
onViewChange?: (view: DataListViewType) => void
|
|
101
|
+
/**
|
|
102
|
+
* View-type tiles in Properties — defaults to all `DATA_LIST_VIEW_TILES`.
|
|
103
|
+
* Pass hub-filtered options (same set as `ListPageTemplate` `supportedViewTypes`).
|
|
104
|
+
*/
|
|
105
|
+
viewTypeOptions?: readonly { value: DataListViewType; label: string; icon: string }[]
|
|
98
106
|
/** Lifecycle context (e.g. tab filter) — shown in the drawer header */
|
|
99
107
|
lifecycleTabLabel?: string
|
|
100
108
|
/**
|
|
@@ -117,6 +125,9 @@ export interface TablePropertiesDrawerProps {
|
|
|
117
125
|
|
|
118
126
|
type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules"
|
|
119
127
|
|
|
128
|
+
/** Properties sheet uses `z-[80]`; default portaled menus are `z-50` and sit underneath. */
|
|
129
|
+
const PROPERTIES_SHEET_PORTAL_Z = "z-[90]"
|
|
130
|
+
|
|
120
131
|
export function TablePropertiesDrawer({
|
|
121
132
|
open,
|
|
122
133
|
onOpenChange,
|
|
@@ -158,6 +169,7 @@ export function TablePropertiesDrawer({
|
|
|
158
169
|
filterFields = FILTER_FIELDS,
|
|
159
170
|
currentView,
|
|
160
171
|
onViewChange,
|
|
172
|
+
viewTypeOptions = DATA_LIST_VIEW_TILES,
|
|
161
173
|
lifecycleTabLabel,
|
|
162
174
|
fieldDefinitions,
|
|
163
175
|
resolveColumnLabel: resolveColumnLabelProp,
|
|
@@ -197,6 +209,7 @@ export function TablePropertiesDrawer({
|
|
|
197
209
|
|
|
198
210
|
const viewSurface = currentView ?? "table"
|
|
199
211
|
const isBoardView = viewSurface === "board"
|
|
212
|
+
const isCalendarView = viewSurface === "calendar"
|
|
200
213
|
const boardGroupByLabel =
|
|
201
214
|
boardGroupByColumnOptions?.find(o => o.key === displayOptions.boardGroupByColumnKey)?.label
|
|
202
215
|
const viewDisplayLabel = dataListViewLabel(viewSurface)
|
|
@@ -219,10 +232,16 @@ export function TablePropertiesDrawer({
|
|
|
219
232
|
if (viewSurface === "dashboard") {
|
|
220
233
|
return "Charts · KPI metrics"
|
|
221
234
|
}
|
|
235
|
+
if (viewSurface === "calendar") {
|
|
236
|
+
return [
|
|
237
|
+
displayOptions.showCalendarSummaryPanel ? "Summary panel" : "No summary",
|
|
238
|
+
displayOptions.calendarMainView === "week" ? "Week layout" : "Month layout",
|
|
239
|
+
].join(" · ")
|
|
240
|
+
}
|
|
222
241
|
return [showGridlines ? "Gridlines" : null, pagination ? "Paginated" : null].filter(Boolean).join(" · ") || "Default"
|
|
223
242
|
})()
|
|
224
243
|
const viewDisplayIcon =
|
|
225
|
-
|
|
244
|
+
viewTypeOptions.find(t => t.value === viewSurface)?.icon ?? "fa-table"
|
|
226
245
|
|
|
227
246
|
// ── Sort drag-and-drop ────────────────────────────────────────────────────
|
|
228
247
|
const sortDrag = useDraggableList(sortRules, r => r.id, onSortRulesChange)
|
|
@@ -243,7 +262,7 @@ export function TablePropertiesDrawer({
|
|
|
243
262
|
: "—"
|
|
244
263
|
|
|
245
264
|
return (
|
|
246
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
265
|
+
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
|
247
266
|
<SheetContent
|
|
248
267
|
side="right"
|
|
249
268
|
showCloseButton={false}
|
|
@@ -284,7 +303,7 @@ export function TablePropertiesDrawer({
|
|
|
284
303
|
<div className="px-4 pb-3">
|
|
285
304
|
<SelectionTileGrid<DataListViewType>
|
|
286
305
|
sectionLabel="View type"
|
|
287
|
-
options={
|
|
306
|
+
options={viewTypeOptions}
|
|
288
307
|
columns={4}
|
|
289
308
|
value={currentView}
|
|
290
309
|
onValueChange={onViewChange}
|
|
@@ -435,6 +454,12 @@ export function TablePropertiesDrawer({
|
|
|
435
454
|
</p>
|
|
436
455
|
) : null}
|
|
437
456
|
|
|
457
|
+
{isCalendarView ? (
|
|
458
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
459
|
+
{dataListViewLabel("calendar")} uses the same filtered rows as table and board. Scroll the main calendar vertically to move between months; use the summary panel for a mini month picker and event list.
|
|
460
|
+
</p>
|
|
461
|
+
) : null}
|
|
462
|
+
|
|
438
463
|
{isBoardView && boardGroupByColumnOptions && boardGroupByColumnOptions.length > 1 ? (
|
|
439
464
|
<div className="flex items-center justify-between gap-3 py-2">
|
|
440
465
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
@@ -462,7 +487,7 @@ export function TablePropertiesDrawer({
|
|
|
462
487
|
>
|
|
463
488
|
<SelectValue />
|
|
464
489
|
</SelectTrigger>
|
|
465
|
-
<SelectContent align="end">
|
|
490
|
+
<SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
466
491
|
{boardGroupByColumnOptions.map(o => (
|
|
467
492
|
<SelectItem key={o.key} value={o.key}>
|
|
468
493
|
{o.label}
|
|
@@ -510,7 +535,8 @@ export function TablePropertiesDrawer({
|
|
|
510
535
|
<div
|
|
511
536
|
className={cn(
|
|
512
537
|
"space-y-3",
|
|
513
|
-
(viewSurface === "board" || viewSurface === "table") &&
|
|
538
|
+
(viewSurface === "board" || viewSurface === "table" || isCalendarView) &&
|
|
539
|
+
"border-t border-border pt-4",
|
|
514
540
|
)}
|
|
515
541
|
>
|
|
516
542
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Display options</p>
|
|
@@ -533,7 +559,7 @@ export function TablePropertiesDrawer({
|
|
|
533
559
|
<SelectTrigger size="sm" className="w-[6.5rem] shrink-0" id="board-line-count" aria-label="Line count">
|
|
534
560
|
<SelectValue />
|
|
535
561
|
</SelectTrigger>
|
|
536
|
-
<SelectContent align="end">
|
|
562
|
+
<SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
537
563
|
<SelectItem value="1">1 line</SelectItem>
|
|
538
564
|
<SelectItem value="2">2 lines</SelectItem>
|
|
539
565
|
<SelectItem value="3">3 lines</SelectItem>
|
|
@@ -618,6 +644,44 @@ export function TablePropertiesDrawer({
|
|
|
618
644
|
</>
|
|
619
645
|
)}
|
|
620
646
|
|
|
647
|
+
{isCalendarView && (
|
|
648
|
+
<>
|
|
649
|
+
<div className="flex items-center justify-between gap-2 py-2">
|
|
650
|
+
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
651
|
+
<span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
|
|
652
|
+
<i className="fa-light fa-sidebar text-[15px] text-secondary-foreground" aria-hidden="true" />
|
|
653
|
+
</span>
|
|
654
|
+
<div className="min-w-0">
|
|
655
|
+
<p className="text-sm font-medium text-foreground leading-tight">Summary panel</p>
|
|
656
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
657
|
+
Mini month, event list, and layout on the left.
|
|
658
|
+
</p>
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
<ToggleSwitch
|
|
662
|
+
id="toggle-calendar-summary"
|
|
663
|
+
checked={displayOptions.showCalendarSummaryPanel}
|
|
664
|
+
onChange={v => onDisplayOptionsChange({ showCalendarSummaryPanel: v })}
|
|
665
|
+
/>
|
|
666
|
+
</div>
|
|
667
|
+
<div className="pt-2">
|
|
668
|
+
<SelectionTileGrid
|
|
669
|
+
sectionLabel="Main calendar layout"
|
|
670
|
+
options={CALENDAR_MAIN_VIEW_TILES.map(t => ({
|
|
671
|
+
value: t.value,
|
|
672
|
+
label: t.label,
|
|
673
|
+
icon: t.icon,
|
|
674
|
+
}))}
|
|
675
|
+
columns={2}
|
|
676
|
+
value={displayOptions.calendarMainView}
|
|
677
|
+
onValueChange={v => onDisplayOptionsChange({ calendarMainView: v })}
|
|
678
|
+
interaction="button"
|
|
679
|
+
idPrefix="props-calendar-main-view"
|
|
680
|
+
/>
|
|
681
|
+
</div>
|
|
682
|
+
</>
|
|
683
|
+
)}
|
|
684
|
+
|
|
621
685
|
{(viewSurface === "table" || viewSurface === "list") && (
|
|
622
686
|
<div className="flex items-center justify-between gap-2 py-2">
|
|
623
687
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
|
@@ -659,7 +723,7 @@ export function TablePropertiesDrawer({
|
|
|
659
723
|
{[
|
|
660
724
|
{ icon: "fa-circle-1", text: "Click \"Add filter\" below" },
|
|
661
725
|
{ icon: "fa-circle-2", text: "Choose a field to filter by" },
|
|
662
|
-
{ icon: "fa-circle-3", text: "Pick
|
|
726
|
+
{ icon: "fa-circle-3", text: "Pick at least one value — the grid updates immediately" },
|
|
663
727
|
].map(step => (
|
|
664
728
|
<div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
665
729
|
<i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
|
|
@@ -726,7 +790,7 @@ export function TablePropertiesDrawer({
|
|
|
726
790
|
|
|
727
791
|
{/* Add filter + Remove all */}
|
|
728
792
|
<div className="flex items-center gap-2 pt-2">
|
|
729
|
-
<DropdownMenu>
|
|
793
|
+
<DropdownMenu modal={false}>
|
|
730
794
|
<DropdownMenuTrigger asChild>
|
|
731
795
|
<Button
|
|
732
796
|
type="button"
|
|
@@ -737,11 +801,11 @@ export function TablePropertiesDrawer({
|
|
|
737
801
|
Add filter
|
|
738
802
|
</Button>
|
|
739
803
|
</DropdownMenuTrigger>
|
|
740
|
-
<DropdownMenuContent align="start">
|
|
804
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
741
805
|
<DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
|
|
742
806
|
<DropdownMenuSeparator />
|
|
743
807
|
{filterFields.map(f => (
|
|
744
|
-
<DropdownMenuItem key={f.key}
|
|
808
|
+
<DropdownMenuItem key={f.key} onSelect={() => onAddFilter(f.key)}>
|
|
745
809
|
<i className={`fa-light ${f.icon}`} aria-hidden="true" />
|
|
746
810
|
{f.label}
|
|
747
811
|
</DropdownMenuItem>
|
|
@@ -833,7 +897,7 @@ export function TablePropertiesDrawer({
|
|
|
833
897
|
|
|
834
898
|
{/* Add sort + Remove all */}
|
|
835
899
|
<div className="flex items-center gap-2 pt-2">
|
|
836
|
-
<DropdownMenu>
|
|
900
|
+
<DropdownMenu modal={false}>
|
|
837
901
|
<DropdownMenuTrigger asChild>
|
|
838
902
|
<Button
|
|
839
903
|
type="button"
|
|
@@ -844,11 +908,11 @@ export function TablePropertiesDrawer({
|
|
|
844
908
|
Add sort
|
|
845
909
|
</Button>
|
|
846
910
|
</DropdownMenuTrigger>
|
|
847
|
-
<DropdownMenuContent align="start">
|
|
911
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
848
912
|
<DropdownMenuLabel className="text-xs">Sort by field</DropdownMenuLabel>
|
|
849
913
|
<DropdownMenuSeparator />
|
|
850
914
|
{sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).map(col => (
|
|
851
|
-
<DropdownMenuItem key={col.key}
|
|
915
|
+
<DropdownMenuItem key={col.key} onSelect={() => onAddSortRule(col.key)}>
|
|
852
916
|
<i className="fa-light fa-arrow-up-arrow-down text-xs" aria-hidden="true" />
|
|
853
917
|
{col.label}
|
|
854
918
|
</DropdownMenuItem>
|
|
@@ -1052,7 +1116,7 @@ function ConditionalRulesPanel({
|
|
|
1052
1116
|
)}
|
|
1053
1117
|
|
|
1054
1118
|
<div className="flex items-center gap-2 pt-2">
|
|
1055
|
-
<DropdownMenu>
|
|
1119
|
+
<DropdownMenu modal={false}>
|
|
1056
1120
|
<DropdownMenuTrigger asChild>
|
|
1057
1121
|
<Button
|
|
1058
1122
|
type="button"
|
|
@@ -1063,13 +1127,13 @@ function ConditionalRulesPanel({
|
|
|
1063
1127
|
Add rule
|
|
1064
1128
|
</Button>
|
|
1065
1129
|
</DropdownMenuTrigger>
|
|
1066
|
-
<DropdownMenuContent align="start">
|
|
1130
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
1067
1131
|
<DropdownMenuLabel className="text-xs">Rule for column</DropdownMenuLabel>
|
|
1068
1132
|
<DropdownMenuSeparator />
|
|
1069
1133
|
{filterFields.map(f => (
|
|
1070
1134
|
<DropdownMenuItem
|
|
1071
1135
|
key={f.key}
|
|
1072
|
-
|
|
1136
|
+
onSelect={() => onAdd({
|
|
1073
1137
|
fieldKey: f.key,
|
|
1074
1138
|
operator: f.operators[0],
|
|
1075
1139
|
values: [],
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Body layouts for `FocusedWorkflowPageTemplate` — single column, stepped wizard,
|
|
5
|
+
* sectioned sidebar (settings-style), and empty placeholder.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
|
|
10
|
+
import { Button } from "@/components/ui/button"
|
|
11
|
+
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
12
|
+
import { Shortcut } from "@/components/ui/dropdown-menu"
|
|
13
|
+
import { cn } from "@/lib/utils"
|
|
14
|
+
import { useModKeyLabel } from "@/hooks/use-mod-key-label"
|
|
15
|
+
import { useAltKeyLabel } from "@/hooks/use-mod-key-label"
|
|
16
|
+
|
|
17
|
+
export interface FocusedWorkflowStep {
|
|
18
|
+
id: string
|
|
19
|
+
label: string
|
|
20
|
+
description?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FocusedWorkflowSingleColumnProps {
|
|
24
|
+
children: React.ReactNode
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Default body — stack header, form sections, and actions in one column. */
|
|
29
|
+
export function FocusedWorkflowSingleColumn({
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
}: FocusedWorkflowSingleColumnProps) {
|
|
33
|
+
return <div className={cn("flex min-h-0 flex-1 flex-col gap-6", className)}>{children}</div>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FocusedWorkflowStepIndicatorProps {
|
|
37
|
+
steps: readonly FocusedWorkflowStep[]
|
|
38
|
+
currentIndex: number
|
|
39
|
+
className?: string
|
|
40
|
+
/** When set, step buttons call this instead of being decorative only. */
|
|
41
|
+
onStepSelect?: (index: number) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function FocusedWorkflowStepIndicator({
|
|
45
|
+
steps,
|
|
46
|
+
currentIndex,
|
|
47
|
+
className,
|
|
48
|
+
onStepSelect,
|
|
49
|
+
}: FocusedWorkflowStepIndicatorProps) {
|
|
50
|
+
const progress =
|
|
51
|
+
steps.length > 0 ? ((currentIndex + 1) / steps.length) * 100 : 0
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<nav
|
|
55
|
+
className={cn("flex flex-col gap-3", className)}
|
|
56
|
+
aria-label="Workflow progress"
|
|
57
|
+
>
|
|
58
|
+
<div className="flex flex-col gap-1">
|
|
59
|
+
<p className="text-xs font-medium text-muted-foreground">
|
|
60
|
+
Step{" "}
|
|
61
|
+
<span className="tabular-nums text-foreground">{currentIndex + 1}</span>{" "}
|
|
62
|
+
of <span className="tabular-nums">{steps.length}</span>
|
|
63
|
+
</p>
|
|
64
|
+
<div
|
|
65
|
+
className="h-2 w-full overflow-hidden rounded-full bg-muted"
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
>
|
|
68
|
+
<div
|
|
69
|
+
className="h-full rounded-full transition-all duration-300"
|
|
70
|
+
style={{ width: `${progress}%`, background: "var(--brand-color)" }}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<ol className="flex flex-col gap-2 sm:gap-1.5">
|
|
75
|
+
{steps.map((step, index) => {
|
|
76
|
+
const isComplete = index < currentIndex
|
|
77
|
+
const isCurrent = index === currentIndex
|
|
78
|
+
const rowClass = cn(
|
|
79
|
+
"flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors",
|
|
80
|
+
isCurrent
|
|
81
|
+
? "border-[var(--brand-color)]/40 bg-muted/50"
|
|
82
|
+
: "border-border bg-card",
|
|
83
|
+
onStepSelect && !isCurrent && "hover:bg-muted/30",
|
|
84
|
+
)
|
|
85
|
+
const inner = (
|
|
86
|
+
<>
|
|
87
|
+
{isComplete ? (
|
|
88
|
+
<span
|
|
89
|
+
className="mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-emerald-300/70 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/15 dark:text-emerald-300"
|
|
90
|
+
aria-hidden="true"
|
|
91
|
+
>
|
|
92
|
+
<i className="fa-light fa-check text-xs" aria-hidden="true" />
|
|
93
|
+
</span>
|
|
94
|
+
) : (
|
|
95
|
+
<span
|
|
96
|
+
className={cn(
|
|
97
|
+
"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums",
|
|
98
|
+
isCurrent
|
|
99
|
+
? "border-transparent bg-[var(--brand-color)] text-primary-foreground"
|
|
100
|
+
: "border-border bg-background text-muted-foreground",
|
|
101
|
+
)}
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
>
|
|
104
|
+
{index + 1}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
<span className="min-w-0 flex-1">
|
|
108
|
+
<span className="block text-sm font-semibold text-foreground">
|
|
109
|
+
{step.label}
|
|
110
|
+
</span>
|
|
111
|
+
{step.description && isCurrent ? (
|
|
112
|
+
<span className="mt-1 block text-xs leading-snug text-muted-foreground sm:text-sm">
|
|
113
|
+
{step.description}
|
|
114
|
+
</span>
|
|
115
|
+
) : null}
|
|
116
|
+
</span>
|
|
117
|
+
</>
|
|
118
|
+
)
|
|
119
|
+
return (
|
|
120
|
+
<li key={step.id} aria-current={isCurrent ? "step" : undefined}>
|
|
121
|
+
{onStepSelect ? (
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => onStepSelect(index)}
|
|
125
|
+
className={cn(
|
|
126
|
+
rowClass,
|
|
127
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{inner}
|
|
131
|
+
</button>
|
|
132
|
+
) : (
|
|
133
|
+
<div className={rowClass}>{inner}</div>
|
|
134
|
+
)}
|
|
135
|
+
</li>
|
|
136
|
+
)
|
|
137
|
+
})}
|
|
138
|
+
</ol>
|
|
139
|
+
</nav>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface FocusedWorkflowStepFormProps {
|
|
144
|
+
steps: readonly FocusedWorkflowStep[]
|
|
145
|
+
currentIndex: number
|
|
146
|
+
onStepSelect?: (index: number) => void
|
|
147
|
+
children: React.ReactNode
|
|
148
|
+
footer: React.ReactNode
|
|
149
|
+
className?: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Multi-step wizard body — step list, active panel, sticky action footer. */
|
|
153
|
+
export function FocusedWorkflowStepForm({
|
|
154
|
+
steps,
|
|
155
|
+
currentIndex,
|
|
156
|
+
onStepSelect,
|
|
157
|
+
children,
|
|
158
|
+
footer,
|
|
159
|
+
className,
|
|
160
|
+
}: FocusedWorkflowStepFormProps) {
|
|
161
|
+
return (
|
|
162
|
+
<div className={cn("flex min-h-0 flex-1 flex-col gap-8", className)}>
|
|
163
|
+
<FocusedWorkflowStepIndicator
|
|
164
|
+
steps={steps}
|
|
165
|
+
currentIndex={currentIndex}
|
|
166
|
+
onStepSelect={onStepSelect}
|
|
167
|
+
/>
|
|
168
|
+
<div className="min-h-0 flex-1">{children}</div>
|
|
169
|
+
<div className="sticky bottom-0 z-10 -mx-4 border-t border-border bg-background/95 px-4 py-4 backdrop-blur-sm sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
|
170
|
+
{footer}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface FocusedWorkflowSidebarSection {
|
|
177
|
+
id: string
|
|
178
|
+
label: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface FocusedWorkflowSidebarSectionsProps {
|
|
182
|
+
sections: readonly FocusedWorkflowSidebarSection[]
|
|
183
|
+
activeSectionId?: string
|
|
184
|
+
onSectionSelect?: (id: string) => void
|
|
185
|
+
/** Full-width block above the nav + content grid (e.g. `PageHeader`). */
|
|
186
|
+
header?: React.ReactNode
|
|
187
|
+
children: React.ReactNode
|
|
188
|
+
className?: string
|
|
189
|
+
navLabel?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Grid for settings-style section nav + body (shared with route loading skeleton). */
|
|
193
|
+
export const FOCUSED_WORKFLOW_SIDEBAR_SECTIONS_GRID_CLASS =
|
|
194
|
+
"lg:grid lg:grid-cols-[minmax(10rem,12rem)_minmax(0,1fr)] lg:gap-x-12 lg:gap-y-8 lg:items-start"
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Sectioned form with a **left nav rail** (settings-style). Use `id` on each
|
|
198
|
+
* `<section>` in `children` matching `sections[].id` for in-page anchors.
|
|
199
|
+
*/
|
|
200
|
+
export function FocusedWorkflowSidebarSections({
|
|
201
|
+
sections,
|
|
202
|
+
activeSectionId,
|
|
203
|
+
onSectionSelect,
|
|
204
|
+
header,
|
|
205
|
+
children,
|
|
206
|
+
className,
|
|
207
|
+
navLabel = "Sections",
|
|
208
|
+
}: FocusedWorkflowSidebarSectionsProps) {
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex min-h-0 flex-1 flex-col gap-8",
|
|
213
|
+
FOCUSED_WORKFLOW_SIDEBAR_SECTIONS_GRID_CLASS,
|
|
214
|
+
className,
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{header ? <div className="min-w-0 lg:col-span-2">{header}</div> : null}
|
|
218
|
+
<nav
|
|
219
|
+
className="flex shrink-0 flex-col gap-0.5 lg:sticky lg:top-6 lg:self-start"
|
|
220
|
+
aria-label={navLabel}
|
|
221
|
+
>
|
|
222
|
+
{sections.map(section => {
|
|
223
|
+
const isActive = section.id === activeSectionId
|
|
224
|
+
return (
|
|
225
|
+
<button
|
|
226
|
+
key={section.id}
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={() => onSectionSelect?.(section.id)}
|
|
229
|
+
className={cn(
|
|
230
|
+
"rounded-md py-2 text-left text-sm transition-colors",
|
|
231
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
232
|
+
isActive
|
|
233
|
+
? "bg-muted font-medium text-foreground"
|
|
234
|
+
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
|
235
|
+
)}
|
|
236
|
+
aria-current={isActive ? "true" : undefined}
|
|
237
|
+
>
|
|
238
|
+
{section.label}
|
|
239
|
+
</button>
|
|
240
|
+
)
|
|
241
|
+
})}
|
|
242
|
+
</nav>
|
|
243
|
+
<div className="min-w-0 flex-1 flex flex-col gap-16">{children}</div>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface FocusedWorkflowEmptyStateProps {
|
|
249
|
+
iconClass?: string
|
|
250
|
+
title: string
|
|
251
|
+
description?: string
|
|
252
|
+
action?: React.ReactNode
|
|
253
|
+
className?: string
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function FocusedWorkflowEmptyState({
|
|
257
|
+
iconClass = "fa-layer-group",
|
|
258
|
+
title,
|
|
259
|
+
description,
|
|
260
|
+
action,
|
|
261
|
+
className,
|
|
262
|
+
}: FocusedWorkflowEmptyStateProps) {
|
|
263
|
+
return (
|
|
264
|
+
<div
|
|
265
|
+
className={cn(
|
|
266
|
+
"flex min-h-[min(24rem,50vh)] flex-col items-center justify-center gap-4 px-4 text-center",
|
|
267
|
+
className,
|
|
268
|
+
)}
|
|
269
|
+
role="status"
|
|
270
|
+
>
|
|
271
|
+
<span
|
|
272
|
+
className="flex size-14 items-center justify-center rounded-xl bg-muted text-muted-foreground"
|
|
273
|
+
aria-hidden="true"
|
|
274
|
+
>
|
|
275
|
+
<i className={cn("fa-light text-xl", iconClass)} aria-hidden="true" />
|
|
276
|
+
</span>
|
|
277
|
+
<div className="max-w-md space-y-2">
|
|
278
|
+
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
|
279
|
+
{description ? (
|
|
280
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
281
|
+
) : null}
|
|
282
|
+
</div>
|
|
283
|
+
{action}
|
|
284
|
+
</div>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface FocusedWorkflowActionFooterProps {
|
|
289
|
+
onCancel: () => void
|
|
290
|
+
cancelLabel?: string
|
|
291
|
+
cancelDisabled?: boolean
|
|
292
|
+
primary: React.ReactNode
|
|
293
|
+
secondary?: React.ReactNode
|
|
294
|
+
className?: string
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Workflow footer — Cancel (Esc) + primary slot (usually submit with ⏎ Kbd). */
|
|
298
|
+
export function FocusedWorkflowActionFooter({
|
|
299
|
+
onCancel,
|
|
300
|
+
cancelLabel = "Cancel",
|
|
301
|
+
cancelDisabled,
|
|
302
|
+
primary,
|
|
303
|
+
secondary,
|
|
304
|
+
className,
|
|
305
|
+
}: FocusedWorkflowActionFooterProps) {
|
|
306
|
+
return (
|
|
307
|
+
<>
|
|
308
|
+
<Shortcut keys="Escape" disabled={cancelDisabled} onInvoke={onCancel} />
|
|
309
|
+
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
|
310
|
+
<Button
|
|
311
|
+
type="button"
|
|
312
|
+
variant="outline"
|
|
313
|
+
className="flex-1 min-w-[8rem] sm:flex-none"
|
|
314
|
+
disabled={cancelDisabled}
|
|
315
|
+
onClick={onCancel}
|
|
316
|
+
>
|
|
317
|
+
{cancelLabel}
|
|
318
|
+
<KbdGroup className="ml-1.5">
|
|
319
|
+
<Kbd variant="bare">Esc</Kbd>
|
|
320
|
+
</KbdGroup>
|
|
321
|
+
</Button>
|
|
322
|
+
{secondary}
|
|
323
|
+
<div className="flex flex-1 min-w-[8rem] justify-end sm:flex-none">{primary}</div>
|
|
324
|
+
</div>
|
|
325
|
+
</>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface FocusedWorkflowWizardFooterProps {
|
|
330
|
+
stepIndex: number
|
|
331
|
+
stepCount: number
|
|
332
|
+
onBack: () => void
|
|
333
|
+
onCancel: () => void
|
|
334
|
+
onNext: () => void
|
|
335
|
+
onSubmit?: () => void
|
|
336
|
+
isFirstStep?: boolean
|
|
337
|
+
isLastStep?: boolean
|
|
338
|
+
nextLabel?: string
|
|
339
|
+
submitLabel?: string
|
|
340
|
+
cancelLabel?: string
|
|
341
|
+
disabled?: boolean
|
|
342
|
+
submitting?: boolean
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Step wizard footer — Back (⌘⌥←), Cancel (Esc), Next (⌘⏎) or Submit (⏎). */
|
|
346
|
+
export function FocusedWorkflowWizardFooter({
|
|
347
|
+
stepIndex,
|
|
348
|
+
stepCount,
|
|
349
|
+
onBack,
|
|
350
|
+
onCancel,
|
|
351
|
+
onNext,
|
|
352
|
+
onSubmit,
|
|
353
|
+
isFirstStep,
|
|
354
|
+
isLastStep,
|
|
355
|
+
nextLabel = "Next",
|
|
356
|
+
submitLabel = "Submit",
|
|
357
|
+
cancelLabel = "Cancel",
|
|
358
|
+
disabled,
|
|
359
|
+
submitting,
|
|
360
|
+
}: FocusedWorkflowWizardFooterProps) {
|
|
361
|
+
const mod = useModKeyLabel()
|
|
362
|
+
const alt = useAltKeyLabel()
|
|
363
|
+
const first = isFirstStep ?? stepIndex === 0
|
|
364
|
+
const last = isLastStep ?? stepIndex >= stepCount - 1
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<>
|
|
368
|
+
<Shortcut keys="Escape" disabled={disabled || submitting} onInvoke={onCancel} />
|
|
369
|
+
{!first ? (
|
|
370
|
+
<Shortcut
|
|
371
|
+
keys={`${mod}${alt}←`}
|
|
372
|
+
disabled={disabled || submitting}
|
|
373
|
+
onInvoke={onBack}
|
|
374
|
+
/>
|
|
375
|
+
) : null}
|
|
376
|
+
{last ? (
|
|
377
|
+
<Shortcut keys="Enter" disabled={disabled || submitting} onInvoke={() => onSubmit?.()} />
|
|
378
|
+
) : (
|
|
379
|
+
<Shortcut keys={`${mod}Enter`} disabled={disabled || submitting} onInvoke={onNext} />
|
|
380
|
+
)}
|
|
381
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
382
|
+
<Button
|
|
383
|
+
type="button"
|
|
384
|
+
variant="outline"
|
|
385
|
+
disabled={disabled || submitting}
|
|
386
|
+
onClick={onCancel}
|
|
387
|
+
>
|
|
388
|
+
{cancelLabel}
|
|
389
|
+
<KbdGroup className="ml-1.5">
|
|
390
|
+
<Kbd variant="bare">Esc</Kbd>
|
|
391
|
+
</KbdGroup>
|
|
392
|
+
</Button>
|
|
393
|
+
{!first ? (
|
|
394
|
+
<Button
|
|
395
|
+
type="button"
|
|
396
|
+
variant="outline"
|
|
397
|
+
disabled={disabled || submitting}
|
|
398
|
+
onClick={onBack}
|
|
399
|
+
>
|
|
400
|
+
Back
|
|
401
|
+
<KbdGroup className="ml-1.5">
|
|
402
|
+
<Kbd variant="bare">
|
|
403
|
+
{mod}
|
|
404
|
+
{alt}←
|
|
405
|
+
</Kbd>
|
|
406
|
+
</KbdGroup>
|
|
407
|
+
</Button>
|
|
408
|
+
) : null}
|
|
409
|
+
<div className="ms-auto flex flex-1 min-w-[8rem] justify-end sm:flex-none">
|
|
410
|
+
{last ? (
|
|
411
|
+
<Button
|
|
412
|
+
type="button"
|
|
413
|
+
disabled={disabled || submitting}
|
|
414
|
+
aria-busy={submitting}
|
|
415
|
+
onClick={() => onSubmit?.()}
|
|
416
|
+
>
|
|
417
|
+
{submitting ? (
|
|
418
|
+
<>
|
|
419
|
+
<i
|
|
420
|
+
className="fa-light fa-spinner-third fa-spin text-[13px]"
|
|
421
|
+
aria-hidden="true"
|
|
422
|
+
/>
|
|
423
|
+
Saving…
|
|
424
|
+
</>
|
|
425
|
+
) : (
|
|
426
|
+
<>
|
|
427
|
+
{submitLabel}
|
|
428
|
+
<KbdGroup className="ml-1.5">
|
|
429
|
+
<Kbd variant="bare">⏎</Kbd>
|
|
430
|
+
</KbdGroup>
|
|
431
|
+
</>
|
|
432
|
+
)}
|
|
433
|
+
</Button>
|
|
434
|
+
) : (
|
|
435
|
+
<Button type="button" disabled={disabled || submitting} onClick={onNext}>
|
|
436
|
+
{nextLabel}
|
|
437
|
+
<KbdGroup className="ml-1.5">
|
|
438
|
+
<Kbd variant="bare">
|
|
439
|
+
{mod}⏎
|
|
440
|
+
</Kbd>
|
|
441
|
+
</KbdGroup>
|
|
442
|
+
</Button>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
</>
|
|
447
|
+
)
|
|
448
|
+
}
|