Feature/81 technical debt #82
+271
-167
@@ -9,53 +9,260 @@ import {
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
// Module-level rather than nested in Customers: a function's cognitive
|
// The two halves of the disable/re-enable confirm, as components rather than
|
||||||
// complexity includes everything declared inside it, and this confirm — with a
|
// branches inside the handler: every piece of copy differs between them, so
|
||||||
// branch on `disabling` for each of the title, body, button label and both
|
// one decision up front reads better than the same condition asked five times.
|
||||||
// outcome messages — was most of the component's score on its own.
|
function DisableWarning({ customer }: Readonly<{ customer: CustomerSummary }>) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
They will be signed out everywhere immediately and told the account is disabled if they
|
||||||
|
try to sign in.
|
||||||
|
{customer.reserved_count > 0 && (
|
||||||
|
<> Their {customer.reserved_count} reserved item
|
||||||
|
{customer.reserved_count === 1 ? '' : 's'} will be released back to the storefront.</>
|
||||||
|
)}
|
||||||
|
{' '}Self-service data export and account deletion stop working too, so any such request
|
||||||
|
has to be handled by hand.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReEnableWarning() {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
They will be able to sign in again. Items released when the account was disabled are not
|
||||||
|
returned — those may already have sold.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The customer drawer's body. Extracted because its states — loading, then a
|
||||||
|
// description list, then either an empty order history or a table — are all
|
||||||
|
// branches, and every one of them counted toward Customers().
|
||||||
|
function CustomerDetailPanel({
|
||||||
|
detail,
|
||||||
|
loading
|
||||||
|
}: Readonly<{ detail: CustomerDetail | null; loading: boolean }>) {
|
||||||
|
if (loading || !detail) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Verified">
|
||||||
|
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
|
||||||
|
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Marketing consent">
|
||||||
|
<Tag color={detail.customer.marketing_consent ? 'blue' : 'default'}>
|
||||||
|
{detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'}
|
||||||
|
</Tag>
|
||||||
|
{detail.customer.marketing_consent_at && (
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.65, marginTop: 4 }}>
|
||||||
|
since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Joined">
|
||||||
|
{new Date(detail.customer.created_at).toLocaleDateString()}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Title level={5} style={{ marginTop: 24 }}>Order History</Title>
|
||||||
|
{detail.orders.length === 0 ? (
|
||||||
|
<Empty description="No orders yet" />
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
dataSource={detail.orders}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: 'Item', dataIndex: 'item_name' },
|
||||||
|
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
||||||
|
{
|
||||||
|
title: 'Processor',
|
||||||
|
dataIndex: 'processor',
|
||||||
|
render: (v: string) => <Tag>{v}</Tag>
|
||||||
|
},
|
||||||
|
{ title: 'Status', dataIndex: 'status' },
|
||||||
|
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reserved-items dialog body, extracted for the same reason: three
|
||||||
|
// mutually exclusive states rendered as three separate conditionals.
|
||||||
|
function ReservedItemsBody({
|
||||||
|
loading,
|
||||||
|
reserved,
|
||||||
|
releasing,
|
||||||
|
onRelease
|
||||||
|
}: Readonly<{
|
||||||
|
loading: boolean;
|
||||||
|
reserved: ReservedItem[];
|
||||||
|
releasing: number | null;
|
||||||
|
onRelease: (item: ReservedItem) => void;
|
||||||
|
}>) {
|
||||||
|
if (loading) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
if (!reserved.length) {
|
||||||
|
return <Empty description="This customer isn't holding any items" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey="item_id"
|
||||||
|
dataSource={reserved}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
columns={[
|
||||||
|
{ title: 'Item', dataIndex: 'name' },
|
||||||
|
{
|
||||||
|
title: 'Price',
|
||||||
|
dataIndex: 'price_cents',
|
||||||
|
render: (v: number) => `$${(v / 100).toFixed(2)}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Reservation expires',
|
||||||
|
dataIndex: 'expires_at',
|
||||||
|
render: (v: string) => new Date(v).toLocaleString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
render: (_: unknown, item: ReservedItem) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
loading={releasing === item.item_id}
|
||||||
|
onClick={() => onRelease(item)}
|
||||||
|
>
|
||||||
|
Release
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell renderers live at module level for the same reason the confirm does:
|
||||||
|
// every branch inside a column's render callback counted toward Customers(),
|
||||||
|
// and a table with nine columns is mostly branches.
|
||||||
|
function NameAndEmail({ email, name }: Readonly<{ email: string; name: string | null }>) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>{name || <span style={{ opacity: 0.5 }}>No name</span>}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.65 }}>{email}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BooleanTag({
|
||||||
|
value,
|
||||||
|
onColor,
|
||||||
|
onLabel,
|
||||||
|
offLabel
|
||||||
|
}: Readonly<{ value: boolean; onColor: string; onLabel: string; offLabel: string }>) {
|
||||||
|
return <Tag color={value ? onColor : 'default'}>{value ? onLabel : offLabel}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReservedCell({
|
||||||
|
count,
|
||||||
|
customer,
|
||||||
|
onOpen
|
||||||
|
}: Readonly<{ count: number; customer: CustomerSummary; onOpen: (c: CustomerSummary) => void }>) {
|
||||||
|
if (Number(count) <= 0) {
|
||||||
|
return <Text type="secondary">0</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
// The whole row opens the customer drawer, so without this the click
|
||||||
|
// reaches both handlers and the drawer opens behind the reserved-items
|
||||||
|
// dialog.
|
||||||
|
onClick={(event) => { event.stopPropagation(); onOpen(customer); }}
|
||||||
|
>
|
||||||
|
{count} item{Number(count) === 1 ? '' : 's'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleDisabledButton({
|
||||||
|
customer,
|
||||||
|
busy,
|
||||||
|
onToggle
|
||||||
|
}: Readonly<{ customer: CustomerSummary; busy: boolean; onToggle: (c: CustomerSummary) => void }>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger={!customer.disabled_at}
|
||||||
|
loading={busy}
|
||||||
|
// The row opens the detail drawer, so this must not bubble.
|
||||||
|
onClick={(event) => { event.stopPropagation(); onToggle(customer); }}
|
||||||
|
>
|
||||||
|
{customer.disabled_at ? 'Re-enable' : 'Disable'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates arrive as ISO strings or null; an em dash reads better than "Invalid Date".
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
return value ? new Date(value).toLocaleDateString() : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level rather than nested in Customers, because a function's cognitive
|
||||||
|
// complexity counts everything declared inside it.
|
||||||
function confirmToggleDisabled(
|
function confirmToggleDisabled(
|
||||||
customer: CustomerSummary,
|
customer: CustomerSummary,
|
||||||
setTogglingId: (id: number | null) => void,
|
setTogglingId: (id: number | null) => void,
|
||||||
reload: () => void
|
reload: () => void
|
||||||
) {
|
) {
|
||||||
const disabling = !customer.disabled_at;
|
const disabling = !customer.disabled_at;
|
||||||
|
// Chosen once. Asking `disabling` again for each field is what put this
|
||||||
Modal.confirm({
|
// function over the complexity limit even after it was hoisted out.
|
||||||
title: disabling ? `Disable ${customer.email}?` : `Re-enable ${customer.email}?`,
|
const copy = disabling
|
||||||
content: disabling ? (
|
? {
|
||||||
<span>
|
title: `Disable ${customer.email}?`,
|
||||||
They will be signed out everywhere immediately and told the account is disabled if they
|
content: <DisableWarning customer={customer} />,
|
||||||
try to sign in.
|
okText: 'Disable',
|
||||||
{customer.reserved_count > 0 && (
|
verb: 'disable',
|
||||||
<> Their {customer.reserved_count} reserved item
|
done: 'Account disabled'
|
||||||
{customer.reserved_count === 1 ? '' : 's'} will be released back to the storefront.</>
|
|
||||||
)}
|
|
||||||
{' '}Self-service data export and account deletion stop working too, so any such request
|
|
||||||
has to be handled by hand.
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
They will be able to sign in again. Items released when the account was disabled are not
|
|
||||||
returned — those may already have sold.
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
okText: disabling ? 'Disable' : 'Re-enable',
|
|
||||||
okButtonProps: { danger: disabling },
|
|
||||||
onOk: async () => {
|
|
||||||
setTogglingId(customer.id);
|
|
||||||
try {
|
|
||||||
await setCustomerDisabled(customer.id, disabling);
|
|
||||||
} catch (err) {
|
|
||||||
message.error(`Couldn't ${disabling ? 'disable' : 're-enable'} — ${(err as Error).message}`);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
setTogglingId(null);
|
|
||||||
}
|
|
||||||
message.success(disabling ? 'Account disabled' : 'Account re-enabled');
|
|
||||||
reload();
|
|
||||||
}
|
}
|
||||||
});
|
: {
|
||||||
}
|
title: `Re-enable ${customer.email}?`,
|
||||||
|
content: <ReEnableWarning />,
|
||||||
|
okText: 'Re-enable',
|
||||||
|
verb: 're-enable',
|
||||||
|
done: 'Account re-enabled'
|
||||||
|
};
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: copy.title,
|
||||||
|
content: copy.content,
|
||||||
|
okText: copy.okText,
|
||||||
|
okButtonProps: { danger: disabling },
|
||||||
|
onOk: async () => {
|
||||||
|
setTogglingId(customer.id);
|
||||||
|
try {
|
||||||
|
await setCustomerDisabled(customer.id, disabling);
|
||||||
|
} catch (err) {
|
||||||
|
message.error(`Couldn't ${copy.verb} — ${(err as Error).message}`);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setTogglingId(null);
|
||||||
|
}
|
||||||
|
message.success(copy.done);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function Customers() {
|
export default function Customers() {
|
||||||
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
|
const [customers, setCustomers] = useState<CustomerSummary[]>([]);
|
||||||
@@ -127,53 +334,35 @@ export default function Customers() {
|
|||||||
title: 'Customer',
|
title: 'Customer',
|
||||||
dataIndex: 'email',
|
dataIndex: 'email',
|
||||||
sorter: (a, b) => a.email.localeCompare(b.email),
|
sorter: (a, b) => a.email.localeCompare(b.email),
|
||||||
render: (email: string, row: CustomerSummary) => (
|
render: (email: string, row: CustomerSummary) => <NameAndEmail email={email} name={row.name} />
|
||||||
<div>
|
|
||||||
<div>{row.name || <span style={{ opacity: 0.5 }}>No name</span>}</div>
|
|
||||||
<div style={{ fontSize: 12, opacity: 0.65 }}>{email}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Verified',
|
title: 'Verified',
|
||||||
dataIndex: 'email_verified',
|
dataIndex: 'email_verified',
|
||||||
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
|
filters: [{ text: 'Verified', value: true }, { text: 'Unverified', value: false }],
|
||||||
onFilter: (value, row) => row.email_verified === value,
|
onFilter: (value, row) => row.email_verified === value,
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? 'Verified' : 'Unverified'}</Tag>
|
render: (v: boolean) => <BooleanTag value={v} onColor="green" onLabel="Verified" offLabel="Unverified" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Subscribed',
|
title: 'Subscribed',
|
||||||
dataIndex: 'marketing_consent',
|
dataIndex: 'marketing_consent',
|
||||||
filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }],
|
filters: [{ text: 'Subscribed', value: true }, { text: 'Not subscribed', value: false }],
|
||||||
onFilter: (value, row) => row.marketing_consent === value,
|
onFilter: (value, row) => row.marketing_consent === value,
|
||||||
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? 'Yes' : 'No'}</Tag>
|
render: (v: boolean) => <BooleanTag value={v} onColor="blue" onLabel="Yes" offLabel="No" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
dataIndex: 'disabled_at',
|
dataIndex: 'disabled_at',
|
||||||
render: (disabledAt: string | null) =>
|
render: (disabledAt: string | null) => (
|
||||||
disabledAt
|
<BooleanTag value={!disabledAt} onColor="green" onLabel="ACTIVE" offLabel="DISABLED" />
|
||||||
? <Tag color="red">DISABLED</Tag>
|
)
|
||||||
: <Tag color="green">ACTIVE</Tag>
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Reserved',
|
title: 'Reserved',
|
||||||
dataIndex: 'reserved_count',
|
dataIndex: 'reserved_count',
|
||||||
render: (count: number, customer: CustomerSummary) =>
|
render: (count: number, customer: CustomerSummary) => (
|
||||||
Number(count) > 0 ? (
|
<ReservedCell count={count} customer={customer} onOpen={(c) => void openReserved(c)} />
|
||||||
<Button
|
)
|
||||||
type="link"
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
// The whole row opens the customer drawer, so without this the
|
|
||||||
// click reaches both handlers and the drawer opens behind the
|
|
||||||
// reserved-items dialog.
|
|
||||||
onClick={(event) => { event.stopPropagation(); void openReserved(customer); }}
|
|
||||||
>
|
|
||||||
{count} item{Number(count) === 1 ? '' : 's'}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Text type="secondary">0</Text>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Orders',
|
title: 'Orders',
|
||||||
@@ -191,28 +380,24 @@ export default function Customers() {
|
|||||||
title: 'Last Order',
|
title: 'Last Order',
|
||||||
dataIndex: 'last_order_at',
|
dataIndex: 'last_order_at',
|
||||||
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
|
sorter: (a, b) => new Date(a.last_order_at || 0).getTime() - new Date(b.last_order_at || 0).getTime(),
|
||||||
render: (v: string | null) => (v ? new Date(v).toLocaleDateString() : '—')
|
render: (v: string | null) => formatDate(v)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
render: (_: unknown, customer: CustomerSummary) => (
|
render: (_: unknown, customer: CustomerSummary) => (
|
||||||
<Button
|
<ToggleDisabledButton
|
||||||
size="small"
|
customer={customer}
|
||||||
danger={!customer.disabled_at}
|
busy={togglingId === customer.id}
|
||||||
loading={togglingId === customer.id}
|
onToggle={handleToggleDisabled}
|
||||||
// The row opens the detail drawer, so this must not bubble.
|
/>
|
||||||
onClick={(event) => { event.stopPropagation(); handleToggleDisabled(customer); }}
|
|
||||||
>
|
|
||||||
{customer.disabled_at ? 'Re-enable' : 'Disable'}
|
|
||||||
</Button>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Joined',
|
title: 'Joined',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||||
render: (v: string) => new Date(v).toLocaleDateString()
|
render: (v: string) => formatDate(v)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -234,56 +419,7 @@ export default function Customers() {
|
|||||||
onClose={() => { setDrawerOpen(false); setDetail(null); }}
|
onClose={() => { setDrawerOpen(false); setDetail(null); }}
|
||||||
width={480}
|
width={480}
|
||||||
>
|
>
|
||||||
{detailLoading || !detail ? (
|
<CustomerDetailPanel detail={detail} loading={detailLoading} />
|
||||||
<Spin />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Descriptions column={1} size="small" bordered>
|
|
||||||
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Verified">
|
|
||||||
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
|
|
||||||
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Marketing consent">
|
|
||||||
<Tag color={detail.customer.marketing_consent ? 'blue' : 'default'}>
|
|
||||||
{detail.customer.marketing_consent ? 'Subscribed' : 'Not subscribed'}
|
|
||||||
</Tag>
|
|
||||||
{detail.customer.marketing_consent_at && (
|
|
||||||
<div style={{ fontSize: 12, opacity: 0.65, marginTop: 4 }}>
|
|
||||||
since {new Date(detail.customer.marketing_consent_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Joined">
|
|
||||||
{new Date(detail.customer.created_at).toLocaleDateString()}
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
|
|
||||||
<Title level={5} style={{ marginTop: 24 }}>Order History</Title>
|
|
||||||
{detail.orders.length === 0 ? (
|
|
||||||
<Empty description="No orders yet" />
|
|
||||||
) : (
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
dataSource={detail.orders}
|
|
||||||
pagination={false}
|
|
||||||
columns={[
|
|
||||||
{ title: 'Item', dataIndex: 'item_name' },
|
|
||||||
{ title: 'Amount', dataIndex: 'amount_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
|
|
||||||
{
|
|
||||||
title: 'Processor',
|
|
||||||
dataIndex: 'processor',
|
|
||||||
render: (v: string) => <Tag>{v}</Tag>
|
|
||||||
},
|
|
||||||
{ title: 'Status', dataIndex: 'status' },
|
|
||||||
{ title: 'Date', dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleDateString() }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -294,44 +430,12 @@ export default function Customers() {
|
|||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
width={640}
|
width={640}
|
||||||
>
|
>
|
||||||
{reservedLoading ? <Spin /> : null}
|
<ReservedItemsBody
|
||||||
{!reservedLoading && !reserved.length ? (
|
loading={reservedLoading}
|
||||||
<Empty description="This customer isn't holding any items" />
|
reserved={reserved}
|
||||||
) : null}
|
releasing={releasing}
|
||||||
{!reservedLoading && reserved.length > 0 && (
|
onRelease={handleRelease}
|
||||||
<Table
|
/>
|
||||||
rowKey="item_id"
|
|
||||||
dataSource={reserved}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
columns={[
|
|
||||||
{ title: 'Item', dataIndex: 'name' },
|
|
||||||
{
|
|
||||||
title: 'Price',
|
|
||||||
dataIndex: 'price_cents',
|
|
||||||
render: (v: number) => `$${(v / 100).toFixed(2)}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Reservation expires',
|
|
||||||
dataIndex: 'expires_at',
|
|
||||||
render: (v: string) => new Date(v).toLocaleString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
render: (_: unknown, item: ReservedItem) => (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
loading={releasing === item.item_id}
|
|
||||||
onClick={() => handleRelease(item)}
|
|
||||||
>
|
|
||||||
Release
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user