@spree/dashboard-ui 0.13.1 → 1.0.0-beta.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/README.md +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- package/src/ui/sonner.tsx +0 -47
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { lazy, Suspense } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structural shape `<AddressMap>` needs. Declared here rather than imported
|
|
6
|
+
* so the design system stays decoupled from any SDK's `Address` type — the
|
|
7
|
+
* same reason `<AddressBlock>` carries its own.
|
|
8
|
+
*/
|
|
9
|
+
export interface AddressMapValue {
|
|
10
|
+
latitude?: number | null
|
|
11
|
+
longitude?: number | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// MapLibre and its styles are a few megabytes for a map that appears on one
|
|
15
|
+
// card of a page, so it is loaded on demand and every other screen is spared.
|
|
16
|
+
const LazyMap = lazy(async () => ({ default: (await import('../ui/map')).Map }))
|
|
17
|
+
const LazyMapControls = lazy(async () => ({ default: (await import('../ui/map')).MapControls }))
|
|
18
|
+
const LazyMapMarker = lazy(async () => ({ default: (await import('../ui/map')).MapMarker }))
|
|
19
|
+
const LazyMarkerContent = lazy(async () => ({ default: (await import('../ui/map')).MarkerContent }))
|
|
20
|
+
const LazyMarkerTooltip = lazy(async () => ({ default: (await import('../ui/map')).MarkerTooltip }))
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where an address actually is, beside the lines that describe it — a returns
|
|
24
|
+
* address is what shoppers are told to post to, so it is worth seeing on a map
|
|
25
|
+
* rather than trusting as typed.
|
|
26
|
+
*
|
|
27
|
+
* Coordinates are geocoded in the background after an address is saved, so a
|
|
28
|
+
* freshly entered one has none yet; the map is simply absent until then rather
|
|
29
|
+
* than showing the middle of the ocean.
|
|
30
|
+
*/
|
|
31
|
+
export function AddressMap({
|
|
32
|
+
address,
|
|
33
|
+
label,
|
|
34
|
+
className,
|
|
35
|
+
}: {
|
|
36
|
+
address: AddressMapValue
|
|
37
|
+
/** Names whose address the pin marks — a seller, a customer, a branch. */
|
|
38
|
+
label?: string
|
|
39
|
+
className?: string
|
|
40
|
+
}) {
|
|
41
|
+
const { t } = useTranslation()
|
|
42
|
+
const { latitude, longitude } = address
|
|
43
|
+
|
|
44
|
+
if (latitude == null || longitude == null) {
|
|
45
|
+
return <p className="text-muted-foreground text-xs">{t('admin.address.not_located_yet')}</p>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className={className ?? 'h-40 overflow-hidden rounded-md border'}>
|
|
50
|
+
<Suspense fallback={<div className="size-full animate-pulse bg-muted" />}>
|
|
51
|
+
<LazyMap center={[longitude, latitude]} zoom={13}>
|
|
52
|
+
<LazyMapControls />
|
|
53
|
+
<LazyMapMarker longitude={longitude} latitude={latitude}>
|
|
54
|
+
{/* The default pin; the tooltip names whose address it is. */}
|
|
55
|
+
<LazyMarkerContent />
|
|
56
|
+
{label && <LazyMarkerTooltip>{label}</LazyMarkerTooltip>}
|
|
57
|
+
</LazyMapMarker>
|
|
58
|
+
</LazyMap>
|
|
59
|
+
</Suspense>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { useParams, useRouter } from '@tanstack/react-router'
|
|
2
|
-
import { ArrowLeftIcon } from 'lucide-react'
|
|
3
2
|
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { ArrowLeftIcon } from './icons'
|
|
4
4
|
|
|
5
5
|
interface BackButtonProps {
|
|
6
6
|
/**
|
|
7
|
-
* Fallback path segment under the current
|
|
8
|
-
* Example: "products" → navigates to `/$storeId/products`
|
|
7
|
+
* Fallback path segment under the current tenant when there is no history to
|
|
8
|
+
* go back to. Example: "products" → navigates to `/$storeId/products` in the
|
|
9
|
+
* operator's dashboard, `/$sellerId/products` in a seller's panel.
|
|
9
10
|
*/
|
|
10
11
|
fallback: string
|
|
11
12
|
className?: string
|
|
@@ -13,22 +14,26 @@ interface BackButtonProps {
|
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Back button that goes to the previous page in history (preserving that page's
|
|
16
|
-
* state — filters, column selection, etc.), falling back to
|
|
17
|
-
* when there's nothing to go back to (e.g. deep-link, new tab).
|
|
17
|
+
* state — filters, column selection, etc.), falling back to the tenant's list
|
|
18
|
+
* page when there's nothing to go back to (e.g. deep-link, new tab).
|
|
18
19
|
*/
|
|
19
20
|
export function BackButton({ fallback, className }: BackButtonProps) {
|
|
20
21
|
const { t } = useTranslation()
|
|
21
22
|
const router = useRouter()
|
|
22
|
-
|
|
23
|
+
// Whichever tenant this panel routes under. Reading `storeId` alone sent a
|
|
24
|
+
// seller to `/undefined/...`, since their routes are keyed `$sellerId` —
|
|
25
|
+
// this component is shared, so it cannot assume the operator's param name.
|
|
26
|
+
const params = useParams({ strict: false }) as { storeId?: string; sellerId?: string }
|
|
27
|
+
const tenantId = params.storeId ?? params.sellerId
|
|
23
28
|
|
|
24
29
|
const handleClick = (e: React.MouseEvent) => {
|
|
25
30
|
e.preventDefault()
|
|
26
31
|
// If there's history within the app, go back to preserve previous state
|
|
27
32
|
if (window.history.length > 1 && document.referrer.includes(window.location.host)) {
|
|
28
33
|
router.history.back()
|
|
29
|
-
} else {
|
|
34
|
+
} else if (tenantId) {
|
|
30
35
|
// Fallback to the list page
|
|
31
|
-
router.navigate({ to: `/${
|
|
36
|
+
router.navigate({ to: `/${tenantId}/${fallback}` as string })
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -38,7 +43,7 @@ export function BackButton({ fallback, className }: BackButtonProps) {
|
|
|
38
43
|
onClick={handleClick}
|
|
39
44
|
className={
|
|
40
45
|
className ??
|
|
41
|
-
'inline-flex items-center justify-center rounded-lg p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors'
|
|
46
|
+
'inline-flex items-center justify-center rounded-lg p-1.5 text-muted-foreground bg-transparent hover:bg-accent hover:text-foreground transition-colors'
|
|
42
47
|
}
|
|
43
48
|
aria-label={t('admin.actions.back')}
|
|
44
49
|
>
|
|
@@ -57,10 +57,10 @@ export function BulkDialog({
|
|
|
57
57
|
<FieldGroup>{children}</FieldGroup>
|
|
58
58
|
</DialogBody>
|
|
59
59
|
<DialogFooter>
|
|
60
|
-
<Button type="button" variant="outline"
|
|
60
|
+
<Button type="button" variant="outline" onClick={onCancel}>
|
|
61
61
|
{t('admin.actions.cancel')}
|
|
62
62
|
</Button>
|
|
63
|
-
<Button type="submit"
|
|
63
|
+
<Button type="submit" disabled={submitDisabled}>
|
|
64
64
|
{submitLabel}
|
|
65
65
|
</Button>
|
|
66
66
|
</DialogFooter>
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
2
|
+
import { PlusIcon, XIcon } from 'lucide-react'
|
|
2
3
|
import { useMemo } from 'react'
|
|
3
4
|
import { Button } from '../ui/button'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { DataGrid, DecimalCell, editableRowIndex, MoneyCell, ReadOnlyCell } from './data-grid'
|
|
6
|
+
import { SearchInput } from './search-input'
|
|
6
7
|
|
|
7
8
|
export interface BulkPriceRow {
|
|
8
9
|
// Unique row id. For header rows use a synthetic key (e.g. `header:<groupId>`);
|
|
9
10
|
// the table renders headers via `renderSectionHeader` without an editable cell.
|
|
10
11
|
id: string
|
|
11
|
-
|
|
12
|
+
// `tier` rows are a variant's quantity breaks. They are editable rows like
|
|
13
|
+
// any other, so Tab and the arrow keys flow through them rather than
|
|
14
|
+
// stopping at the variant. The last one is always blank and waiting — a
|
|
15
|
+
// ladder is grown by typing in it, not by asking for a row first
|
|
16
|
+
// (docs/plans/6.0-volume-pricing.md).
|
|
17
|
+
kind: 'header' | 'item' | 'tier'
|
|
12
18
|
// Header rows: the section title (typically the product name).
|
|
13
19
|
groupLabel?: string
|
|
14
20
|
// Item rows.
|
|
@@ -18,6 +24,11 @@ export interface BulkPriceRow {
|
|
|
18
24
|
// user input or the API's canonical decimal. The table passes them through.
|
|
19
25
|
amount?: string | null
|
|
20
26
|
compareAt?: string | null
|
|
27
|
+
// On a `tier` row: the quantity it applies from, as typed.
|
|
28
|
+
minQuantity?: string
|
|
29
|
+
// The trailing blank row a variant always carries. It commits to a real
|
|
30
|
+
// break the moment it holds a quantity and a price.
|
|
31
|
+
blank?: boolean
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
export interface BulkPriceTableLabels {
|
|
@@ -33,6 +44,8 @@ export interface BulkPriceTableLabels {
|
|
|
33
44
|
variantDefault: string
|
|
34
45
|
/** Placeholder text for the search input. Omit to hide the search input. */
|
|
35
46
|
searchPlaceholder?: string
|
|
47
|
+
/** Accessible name for the search field's clear button. */
|
|
48
|
+
clearSearch?: string
|
|
36
49
|
/** A short message shown above the grid summarising the matching row count. */
|
|
37
50
|
countSummary?: string
|
|
38
51
|
/** Shown when the loading flag is set. */
|
|
@@ -53,6 +66,14 @@ export interface BulkPriceTableLabels {
|
|
|
53
66
|
priceAriaTemplate?: string
|
|
54
67
|
/** Aria-label template for the compare-at input. `{label}` is replaced with the variant label. */
|
|
55
68
|
compareAtAriaTemplate?: string
|
|
69
|
+
/** Column header over the quantity column. Omit to hide the column. */
|
|
70
|
+
tierFrom?: string
|
|
71
|
+
/** The "add a break" row's label. */
|
|
72
|
+
tierAdd?: string
|
|
73
|
+
/** Accessible name for a tier row's remove button. */
|
|
74
|
+
tierRemove?: string
|
|
75
|
+
/** Accessible name for a tier row's quantity input. */
|
|
76
|
+
tierQuantity?: string
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
export interface BulkPriceTableProps {
|
|
@@ -65,6 +86,12 @@ export interface BulkPriceTableProps {
|
|
|
65
86
|
labels: BulkPriceTableLabels
|
|
66
87
|
/** Called when the user commits a value to a cell. The id is the row id. */
|
|
67
88
|
onChange: (rowId: string, field: 'amount' | 'compareAt', value: string | null) => void
|
|
89
|
+
/** Edits a tier row's quantity. */
|
|
90
|
+
onTierQuantityChange?: (rowId: string, value: string) => void
|
|
91
|
+
/** Appends a break to the variant the `tier-add` row belongs to. */
|
|
92
|
+
onTierAdd?: (rowId: string) => void
|
|
93
|
+
/** Removes a tier row. */
|
|
94
|
+
onTierRemove?: (rowId: string) => void
|
|
68
95
|
|
|
69
96
|
// Optional state for the toolbar + pagination footer. Omitting all of these
|
|
70
97
|
// hides the toolbar/pagination entirely (use for in-memory single-page lists).
|
|
@@ -89,6 +116,9 @@ export function BulkPriceTable({
|
|
|
89
116
|
decimal,
|
|
90
117
|
labels,
|
|
91
118
|
onChange,
|
|
119
|
+
onTierQuantityChange,
|
|
120
|
+
onTierAdd,
|
|
121
|
+
onTierRemove,
|
|
92
122
|
search,
|
|
93
123
|
onSearchChange,
|
|
94
124
|
page,
|
|
@@ -103,6 +133,11 @@ export function BulkPriceTable({
|
|
|
103
133
|
header: labels.variant,
|
|
104
134
|
cell: ({ row }) => {
|
|
105
135
|
const r = row.original
|
|
136
|
+
|
|
137
|
+
// A break belongs to the variant above it, so it says nothing here
|
|
138
|
+
// — the indent and the quantity column carry the relationship.
|
|
139
|
+
if (r.kind === 'tier') return null
|
|
140
|
+
|
|
106
141
|
if (r.kind !== 'item') return null
|
|
107
142
|
return (
|
|
108
143
|
<ReadOnlyCell className="text-muted-foreground">
|
|
@@ -124,14 +159,61 @@ export function BulkPriceTable({
|
|
|
124
159
|
)
|
|
125
160
|
},
|
|
126
161
|
},
|
|
162
|
+
// The quantity a row's price applies from. A variant's own price is the
|
|
163
|
+
// ladder's first rung and always reads 1 — which is why this is a
|
|
164
|
+
// column rather than a panel: every price in the sheet has a quantity,
|
|
165
|
+
// and only the breaks have one worth typing
|
|
166
|
+
// (docs/plans/6.0-volume-pricing.md).
|
|
167
|
+
...(labels.tierFrom
|
|
168
|
+
? [
|
|
169
|
+
{
|
|
170
|
+
id: 'min_quantity',
|
|
171
|
+
header: () => <span className="block text-right">{labels.tierFrom}</span>,
|
|
172
|
+
cell: ({ row, table }) => {
|
|
173
|
+
const r = row.original
|
|
174
|
+
if (r.kind === 'item') {
|
|
175
|
+
// justify-end, not text-right: ReadOnlyCell is a flex row,
|
|
176
|
+
// so the quantity would otherwise sit left of every editable
|
|
177
|
+
// quantity below it.
|
|
178
|
+
return (
|
|
179
|
+
<ReadOnlyCell className="justify-end text-muted-foreground tabular-nums">
|
|
180
|
+
1
|
|
181
|
+
</ReadOnlyCell>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (r.kind !== 'tier') return null
|
|
186
|
+
|
|
187
|
+
const coords = { row: editableRowIndex(table.getRowModel().rows, r.id), col: 2 }
|
|
188
|
+
return (
|
|
189
|
+
// Decimal rather than number: a rung being typed has no
|
|
190
|
+
// quantity yet, and a numeric cell would show that as 0 —
|
|
191
|
+
// a value nobody chose and the server refuses.
|
|
192
|
+
<DecimalCell
|
|
193
|
+
coords={coords}
|
|
194
|
+
value={r.minQuantity || null}
|
|
195
|
+
onChange={(next) => onTierQuantityChange?.(r.id, next ?? '')}
|
|
196
|
+
decimal={decimal}
|
|
197
|
+
ariaLabel={labels.tierQuantity ?? labels.tierFrom ?? ''}
|
|
198
|
+
/>
|
|
199
|
+
)
|
|
200
|
+
},
|
|
201
|
+
} satisfies ColumnDef<BulkPriceRow>,
|
|
202
|
+
]
|
|
203
|
+
: []),
|
|
127
204
|
{
|
|
128
205
|
id: 'amount',
|
|
129
206
|
header: () => <span className="block text-right">{labels.price}</span>,
|
|
130
207
|
cell: ({ row, table }) => {
|
|
131
208
|
const r = row.original
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
209
|
+
// A break's unit price sits in the price column, under the price it
|
|
210
|
+
// replaces from that quantity up.
|
|
211
|
+
if (r.kind !== 'item' && r.kind !== 'tier') return null
|
|
212
|
+
const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 3 }
|
|
213
|
+
const label =
|
|
214
|
+
r.kind === 'tier'
|
|
215
|
+
? `${labels.tierFrom ?? ''} ${r.minQuantity ?? ''}`.trim()
|
|
216
|
+
: (r.variantLabel ?? labels.variantDefault)
|
|
135
217
|
return (
|
|
136
218
|
<MoneyCell
|
|
137
219
|
coords={coords}
|
|
@@ -149,8 +231,10 @@ export function BulkPriceTable({
|
|
|
149
231
|
header: () => <span className="block text-right">{labels.compareAt}</span>,
|
|
150
232
|
cell: ({ row, table }) => {
|
|
151
233
|
const r = row.original
|
|
234
|
+
// Variants only: a break is a contracted unit price, never a claim
|
|
235
|
+
// about a former one.
|
|
152
236
|
if (r.kind !== 'item') return null
|
|
153
|
-
const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col:
|
|
237
|
+
const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 4 }
|
|
154
238
|
const label = r.variantLabel ?? labels.variantDefault
|
|
155
239
|
return (
|
|
156
240
|
<MoneyCell
|
|
@@ -164,8 +248,49 @@ export function BulkPriceTable({
|
|
|
164
248
|
)
|
|
165
249
|
},
|
|
166
250
|
},
|
|
251
|
+
// A break removes itself where it sits; the trailing blank row offers
|
|
252
|
+
// the plus that starts another. Both fill the cell, so the whole square
|
|
253
|
+
// is the target rather than a small glyph inside it.
|
|
254
|
+
...(onTierRemove
|
|
255
|
+
? [
|
|
256
|
+
{
|
|
257
|
+
id: 'tier_actions',
|
|
258
|
+
header: () => <span className="sr-only">{labels.tierRemove}</span>,
|
|
259
|
+
cell: ({ row }) => {
|
|
260
|
+
const r = row.original
|
|
261
|
+
if (r.kind !== 'tier') return null
|
|
262
|
+
|
|
263
|
+
if (r.blank) {
|
|
264
|
+
return (
|
|
265
|
+
<Button
|
|
266
|
+
type="button"
|
|
267
|
+
variant="ghost"
|
|
268
|
+
aria-label={labels.tierAdd}
|
|
269
|
+
className="size-full min-h-9 rounded-none text-muted-foreground"
|
|
270
|
+
onClick={() => onTierAdd?.(r.id)}
|
|
271
|
+
>
|
|
272
|
+
<PlusIcon className="size-4" />
|
|
273
|
+
</Button>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<Button
|
|
279
|
+
type="button"
|
|
280
|
+
variant="ghost"
|
|
281
|
+
aria-label={labels.tierRemove}
|
|
282
|
+
className="size-full min-h-9 rounded-none text-muted-foreground"
|
|
283
|
+
onClick={() => onTierRemove(r.id)}
|
|
284
|
+
>
|
|
285
|
+
<XIcon className="size-4" />
|
|
286
|
+
</Button>
|
|
287
|
+
)
|
|
288
|
+
},
|
|
289
|
+
} satisfies ColumnDef<BulkPriceRow>,
|
|
290
|
+
]
|
|
291
|
+
: []),
|
|
167
292
|
],
|
|
168
|
-
[symbol, decimal, onChange, labels],
|
|
293
|
+
[symbol, decimal, onChange, onTierQuantityChange, onTierAdd, onTierRemove, labels],
|
|
169
294
|
)
|
|
170
295
|
|
|
171
296
|
const showToolbar =
|
|
@@ -177,28 +302,34 @@ export function BulkPriceTable({
|
|
|
177
302
|
const hasSearch = !!search && search.length > 0
|
|
178
303
|
|
|
179
304
|
return (
|
|
305
|
+
// Padding lives on the toolbar and the pager, not on this wrapper: the
|
|
306
|
+
// grid between them should run to the container's edges, the way a
|
|
307
|
+
// table in a card does.
|
|
180
308
|
<div className="flex h-full flex-col gap-3">
|
|
181
309
|
{showToolbar && (
|
|
182
310
|
// Always-mounted toolbar. Conditionally rendering the search input
|
|
183
311
|
// would unmount it whenever a deferred query refetches into the
|
|
184
312
|
// loading state, blurring the field mid-keystroke.
|
|
185
|
-
<div className="flex shrink-0 items-center justify-between gap-3">
|
|
313
|
+
<div className="flex shrink-0 items-center justify-between gap-3 px-3">
|
|
186
314
|
{labels.countSummary !== undefined && (
|
|
187
315
|
<p className="text-xs text-muted-foreground">{labels.countSummary}</p>
|
|
188
316
|
)}
|
|
189
317
|
{onSearchChange !== undefined && labels.searchPlaceholder !== undefined && (
|
|
190
|
-
<
|
|
191
|
-
type="search"
|
|
318
|
+
<SearchInput
|
|
192
319
|
placeholder={labels.searchPlaceholder}
|
|
193
320
|
value={search ?? ''}
|
|
194
|
-
|
|
321
|
+
onValueChange={onSearchChange}
|
|
322
|
+
clearLabel={labels.clearSearch}
|
|
195
323
|
className="h-9 max-w-sm"
|
|
196
324
|
/>
|
|
197
325
|
)}
|
|
198
326
|
</div>
|
|
199
327
|
)}
|
|
200
328
|
|
|
201
|
-
|
|
329
|
+
{/* No `overflow-auto` here: the grid inside owns its own scroller
|
|
330
|
+
(it needs one for the sticky header), and a second one around it
|
|
331
|
+
nests two scrollbars over the same content. */}
|
|
332
|
+
<div className="min-h-0 flex-1">
|
|
202
333
|
{isLoading ? (
|
|
203
334
|
<p className="text-sm text-muted-foreground">{labels.loading}</p>
|
|
204
335
|
) : isEmpty ? (
|
|
@@ -223,7 +354,7 @@ export function BulkPriceTable({
|
|
|
223
354
|
</div>
|
|
224
355
|
|
|
225
356
|
{showPagination && (
|
|
226
|
-
<div className="flex shrink-0 items-center justify-between gap-2 text-xs text-muted-foreground">
|
|
357
|
+
<div className="flex shrink-0 items-center justify-between gap-2 px-3 pb-3 text-xs text-muted-foreground">
|
|
227
358
|
<span>
|
|
228
359
|
{labels.pageOf.replace('{page}', String(page)).replace('{total}', String(totalPages))}
|
|
229
360
|
</span>
|