Enhance mobile UI
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m41s
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m41s
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,17 +1,22 @@
|
|||||||
import React from "react";
|
|
||||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||||
import {
|
import {
|
||||||
|
CloseOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
DownOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Dropdown,
|
||||||
Flex,
|
Flex,
|
||||||
|
Grid,
|
||||||
Input,
|
Input,
|
||||||
Pagination,
|
List,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -42,8 +47,10 @@ function getEstimatedTotalText(rawValue: unknown): string | null {
|
|||||||
export default function AdminSubmissionsCard({
|
export default function AdminSubmissionsCard({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedSubmissionState,
|
selectedSubmissionState,
|
||||||
|
sortBy,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
onSelectedSubmissionStateChange,
|
onSelectedSubmissionStateChange,
|
||||||
|
onSortByChange,
|
||||||
onReload,
|
onReload,
|
||||||
copyText,
|
copyText,
|
||||||
onCopy,
|
onCopy,
|
||||||
@@ -61,8 +68,10 @@ export default function AdminSubmissionsCard({
|
|||||||
}: {
|
}: {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedSubmissionState: "all" | "pending" | "unpaid" | "paid";
|
selectedSubmissionState: "all" | "pending" | "unpaid" | "paid";
|
||||||
|
sortBy: "time" | "order" | "email" | "amount";
|
||||||
onSearchQueryChange: (value: string) => void;
|
onSearchQueryChange: (value: string) => void;
|
||||||
onSelectedSubmissionStateChange: (value: "all" | "pending" | "unpaid" | "paid") => void;
|
onSelectedSubmissionStateChange: (value: "all" | "pending" | "unpaid" | "paid") => void;
|
||||||
|
onSortByChange: (value: "time" | "order" | "email" | "amount") => void;
|
||||||
onReload: () => void;
|
onReload: () => void;
|
||||||
copyText: string;
|
copyText: string;
|
||||||
onCopy: (text: string, didCopy: boolean) => void;
|
onCopy: (text: string, didCopy: boolean) => void;
|
||||||
@@ -73,11 +82,71 @@ export default function AdminSubmissionsCard({
|
|||||||
submissions: any[];
|
submissions: any[];
|
||||||
deletingId: string | number | null;
|
deletingId: string | number | null;
|
||||||
savingStatusKey: string | null;
|
savingStatusKey: string | null;
|
||||||
onDeleteSubmission: (id: string | number) => void;
|
onDeleteSubmission: (ids: Array<string | number>) => void;
|
||||||
onUpdateSubmissionStatus: (submission: any, changes: { accepted?: boolean; paid?: boolean }) => void;
|
onUpdateSubmissionStatus: (submission: any, changes: { accepted?: boolean; paid?: boolean }) => void;
|
||||||
totalEstimatedText: string | null;
|
totalEstimatedText: string | null;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
}) {
|
}) {
|
||||||
|
const hasSelection = selectedRowKeys.length > 0;
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
const isMobile = !screens.md;
|
||||||
|
|
||||||
|
const actionMenuItems = [
|
||||||
|
{
|
||||||
|
key: "copy",
|
||||||
|
label: (
|
||||||
|
<CopyToClipboard text={copyText} onCopy={onCopy}>
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<CopyOutlined style={{ marginRight: 8 }} />
|
||||||
|
{hasSelection ? "Copy (selected)" : "Copy"}
|
||||||
|
</div>
|
||||||
|
</CopyToClipboard>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
label: (
|
||||||
|
<a href={mailtoHref || undefined} onClick={onOpenMailto}>
|
||||||
|
<MailOutlined style={{ marginRight: 8 }} />
|
||||||
|
{hasSelection ? "Email (selected)" : "Email"}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(hasSelection
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "clear",
|
||||||
|
label: (
|
||||||
|
<div
|
||||||
|
onClick={() => onSelectedRowKeysChange([])}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<CloseOutlined style={{ marginRight: 8 }} />
|
||||||
|
Clear selection
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: (
|
||||||
|
<Popconfirm
|
||||||
|
title={`Delete ${selectedRowKeys.length} submission${selectedRowKeys.length > 1 ? "s" : ""}?`}
|
||||||
|
description="This cannot be undone."
|
||||||
|
okText="Delete"
|
||||||
|
okButtonProps={{ danger: true, loading: deletingId === "multiple" }}
|
||||||
|
onConfirm={() => onDeleteSubmission(selectedRowKeys)}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#ff4d4f", width: "100%" }}>
|
||||||
|
<DeleteOutlined style={{ marginRight: 8 }} />
|
||||||
|
Delete Selected
|
||||||
|
</div>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: "Submission",
|
title: "Submission",
|
||||||
@@ -129,29 +198,6 @@ export default function AdminSubmissionsCard({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Actions",
|
|
||||||
key: "actions",
|
|
||||||
width: 1,
|
|
||||||
render: (_: any, record: any) => (
|
|
||||||
<Popconfirm
|
|
||||||
title="Delete this submission?"
|
|
||||||
description="This cannot be undone."
|
|
||||||
okText="Delete"
|
|
||||||
okButtonProps={{ danger: true, loading: deletingId === record.id }}
|
|
||||||
onConfirm={() => onDeleteSubmission(record.id)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
size="small"
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
loading={deletingId === record.id}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,72 +205,125 @@ export default function AdminSubmissionsCard({
|
|||||||
title="Submissions"
|
title="Submissions"
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip
|
<Dropdown menu={{ items: actionMenuItems }}>
|
||||||
title={
|
<Button>
|
||||||
selectedRowKeys.length > 0
|
Actions <DownOutlined />
|
||||||
? "Copy selected submissions as list to clipboard"
|
|
||||||
: "Copy submissions as list to clipboard"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CopyToClipboard text={copyText} onCopy={onCopy}>
|
|
||||||
<Button icon={<CopyOutlined />}>
|
|
||||||
{selectedRowKeys.length > 0 ? "Copy (selected)" : "Copy"}
|
|
||||||
</Button>
|
|
||||||
</CopyToClipboard>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
title={
|
|
||||||
selectedRowKeys.length > 0
|
|
||||||
? "Create draft email with selected submissions"
|
|
||||||
: "Create draft email with submissions"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<MailOutlined />}
|
|
||||||
href={mailtoHref || undefined}
|
|
||||||
onClick={onOpenMailto}
|
|
||||||
>
|
|
||||||
{selectedRowKeys.length > 0 ? "Email (selected)" : "Email"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Dropdown>
|
||||||
<Tooltip title="Reload latest order details">
|
<Tooltip title="Reload latest order details">
|
||||||
<Button icon={<ReloadOutlined />} onClick={onReload} />
|
<Button icon={<ReloadOutlined />} onClick={onReload} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space style={{ marginBottom: 12 }} wrap>
|
<Flex style={{ marginBottom: 12, width: "100%" }} gap={8} wrap>
|
||||||
<Input
|
<Input
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="Filter by email or order text"
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="Search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||||
style={{ minWidth: 260 }}
|
style={{ flex: "1 1 260px" }}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={selectedSubmissionState}
|
value={selectedSubmissionState}
|
||||||
style={{ minWidth: 180 }}
|
style={{ flex: "1 1 140px" }}
|
||||||
options={[
|
options={[
|
||||||
{ label: "All statuses", value: "all" },
|
{ label: "Show all", value: "all" },
|
||||||
{ label: "Pending", value: "pending" },
|
{ label: "Show pending", value: "pending" },
|
||||||
{ label: "Unpaid", value: "unpaid" },
|
{ label: "Show unpaid", value: "unpaid" },
|
||||||
{ label: "Paid", value: "paid" },
|
{ label: "Show paid", value: "paid" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => onSelectedSubmissionStateChange(value)}
|
onChange={(value) => onSelectedSubmissionStateChange(value)}
|
||||||
/>
|
/>
|
||||||
</Space>
|
<Select
|
||||||
<Table
|
value={sortBy}
|
||||||
rowKey="id"
|
style={{ flex: "1 1 120px" }}
|
||||||
columns={columns}
|
options={[
|
||||||
dataSource={submissions}
|
{ label: "by time", value: "time" },
|
||||||
rowSelection={{
|
{ label: "by order", value: "order" },
|
||||||
selectedRowKeys,
|
{ label: "by email", value: "email" },
|
||||||
onChange: (nextSelectedRowKeys) =>
|
{ label: "by amount", value: "amount" },
|
||||||
onSelectedRowKeysChange(nextSelectedRowKeys as Array<string | number>),
|
]}
|
||||||
}}
|
onChange={(value) => onSortByChange(value)}
|
||||||
pagination={false}
|
/>
|
||||||
scroll={{ y: 400, x: true }}
|
</Flex>
|
||||||
/>
|
{isMobile ? (
|
||||||
|
<List
|
||||||
|
dataSource={submissions}
|
||||||
|
renderItem={(record) => {
|
||||||
|
const cleanValue = stripPriceDecorations(String(record.formatted_string || "")).trim();
|
||||||
|
const estimateText = getEstimatedTotalText(record.estimated_total);
|
||||||
|
const emailText = String(record.email || "").trim() || "-";
|
||||||
|
const secondaryText = `${emailText} • ${estimateText || "-"}`;
|
||||||
|
const isAcceptedSaving = savingStatusKey === `${record.id}:accepted`;
|
||||||
|
const isPaidSaving = savingStatusKey === `${record.id}:paid`;
|
||||||
|
const isSelected = selectedRowKeys.includes(record.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item>
|
||||||
|
<Flex gap={12} align="flex-start" style={{ width: "100%" }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextKeys = e.target.checked
|
||||||
|
? [...selectedRowKeys, record.id]
|
||||||
|
: selectedRowKeys.filter((k) => k !== record.id);
|
||||||
|
onSelectedRowKeysChange(nextKeys);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Space direction="vertical" size={8} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text code copyable={{ text: cleanValue }} style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
|
||||||
|
{cleanValue || "-"}
|
||||||
|
</Text>
|
||||||
|
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||||
|
<Text type="secondary">{secondaryText}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
<Space size={16} wrap>
|
||||||
|
<Space size={8}>
|
||||||
|
<Text>Accepted</Text>
|
||||||
|
<Switch
|
||||||
|
checked={!!record.accepted}
|
||||||
|
loading={isAcceptedSaving}
|
||||||
|
onChange={(checked) => onUpdateSubmissionStatus(record, { accepted: checked })}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space size={8}>
|
||||||
|
<Text>Paid</Text>
|
||||||
|
<Switch
|
||||||
|
checked={!!record.paid}
|
||||||
|
loading={isPaidSaving}
|
||||||
|
onChange={(checked) => onUpdateSubmissionStatus(record, { paid: checked })}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
maxHeight: "80vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
overflowX: "hidden"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={submissions}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (nextSelectedRowKeys) =>
|
||||||
|
onSelectedRowKeysChange(nextSelectedRowKeys as Array<string | number>),
|
||||||
|
}}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 400, x: true }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Flex
|
<Flex
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
const [deletingOrder, setDeletingOrder] = useState(false);
|
const [deletingOrder, setDeletingOrder] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<"all" | "pending" | "unpaid" | "paid">("all");
|
const [selectedSubmissionState, setSelectedSubmissionState] = useState<"all" | "pending" | "unpaid" | "paid">("all");
|
||||||
|
const [sortBy, setSortBy] = useState<"time" | "order" | "email" | "amount">("time");
|
||||||
|
|
||||||
const getSubmissionState = (submission: Submission): SubmissionStatus => {
|
const getSubmissionState = (submission: Submission): SubmissionStatus => {
|
||||||
if (submission?.paid) {
|
if (submission?.paid) {
|
||||||
@@ -117,11 +118,14 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteAsAdmin = async (submissionId: string | number) => {
|
const deleteAsAdmin = async (submissionIds: (string | number)[]) => {
|
||||||
setDeletingId(submissionId);
|
setDeletingId(submissionIds.length === 1 ? submissionIds[0] : "multiple");
|
||||||
try {
|
try {
|
||||||
await apiService.submissions.removeAsAdmin(orderId, submissionId);
|
for (const id of submissionIds) {
|
||||||
message.success("Submission deleted");
|
await apiService.submissions.removeAsAdmin(orderId, id);
|
||||||
|
}
|
||||||
|
message.success(`Submission${submissionIds.length > 1 ? "s" : ""} deleted`);
|
||||||
|
setSelectedRowKeys((prev) => prev.filter(k => !submissionIds.includes(k)));
|
||||||
await loadAdmin();
|
await loadAdmin();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message);
|
message.error(error.message);
|
||||||
@@ -294,15 +298,45 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
});
|
});
|
||||||
}, [data?.submissions, searchQuery, selectedSubmissionState]);
|
}, [data?.submissions, searchQuery, selectedSubmissionState]);
|
||||||
|
|
||||||
useEffect(() => {
|
const sortedAndFilteredSubmissions = useMemo(() => {
|
||||||
const visibleIds = new Set(filteredSubmissions.map((submission: any) => submission.id));
|
const list = [...filteredSubmissions];
|
||||||
setSelectedRowKeys((previous) => previous.filter((id) => visibleIds.has(id)));
|
list.sort((a: any, b: any) => {
|
||||||
}, [filteredSubmissions]);
|
switch (sortBy) {
|
||||||
|
case "order": {
|
||||||
|
const aText = stripPriceDecorations(String(a.formatted_string || "")).toLowerCase();
|
||||||
|
const bText = stripPriceDecorations(String(b.formatted_string || "")).toLowerCase();
|
||||||
|
return aText.localeCompare(bText);
|
||||||
|
}
|
||||||
|
case "email": {
|
||||||
|
const aEmail = String(a.email || "").toLowerCase();
|
||||||
|
const bEmail = String(b.email || "").toLowerCase();
|
||||||
|
return aEmail.localeCompare(bEmail);
|
||||||
|
}
|
||||||
|
case "amount": {
|
||||||
|
const aAmount = getEstimatedTotalNumber(a.estimated_total) || 0;
|
||||||
|
const bAmount = getEstimatedTotalNumber(b.estimated_total) || 0;
|
||||||
|
return bAmount - aAmount; // sort highest amount first
|
||||||
|
}
|
||||||
|
case "time":
|
||||||
|
default: {
|
||||||
|
const aTime = new Date(a.created_at || 0).getTime();
|
||||||
|
const bTime = new Date(b.created_at || 0).getTime();
|
||||||
|
return aTime - bTime; // oldest first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}, [filteredSubmissions, sortBy]);
|
||||||
|
|
||||||
const selected = filteredSubmissions.filter((submission: any) =>
|
useEffect(() => {
|
||||||
|
const visibleIds = new Set(sortedAndFilteredSubmissions.map((submission: any) => submission.id));
|
||||||
|
setSelectedRowKeys((previous) => previous.filter((id) => visibleIds.has(id)));
|
||||||
|
}, [sortedAndFilteredSubmissions]);
|
||||||
|
|
||||||
|
const selected = sortedAndFilteredSubmissions.filter((submission: any) =>
|
||||||
selectedRowKeys.includes(submission.id),
|
selectedRowKeys.includes(submission.id),
|
||||||
);
|
);
|
||||||
const rowsToCopy = selected.length > 0 ? selected : filteredSubmissions;
|
const rowsToCopy = selected.length > 0 ? selected : sortedAndFilteredSubmissions;
|
||||||
const copyText = rowsToCopy
|
const copyText = rowsToCopy
|
||||||
.map((submission: Submission) => getCleanSubmissionText(submission))
|
.map((submission: Submission) => getCleanSubmissionText(submission))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -407,8 +441,10 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
<AdminSubmissionsCard
|
<AdminSubmissionsCard
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
selectedSubmissionState={selectedSubmissionState}
|
selectedSubmissionState={selectedSubmissionState}
|
||||||
|
sortBy={sortBy}
|
||||||
onSearchQueryChange={setSearchQuery}
|
onSearchQueryChange={setSearchQuery}
|
||||||
onSelectedSubmissionStateChange={setSelectedSubmissionState}
|
onSelectedSubmissionStateChange={setSelectedSubmissionState}
|
||||||
|
onSortByChange={setSortBy}
|
||||||
onReload={refreshSubmissions}
|
onReload={refreshSubmissions}
|
||||||
copyText={copyText}
|
copyText={copyText}
|
||||||
onCopy={copySubmissionList}
|
onCopy={copySubmissionList}
|
||||||
@@ -416,7 +452,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
|||||||
onOpenMailto={openSubmissionEmailDraft}
|
onOpenMailto={openSubmissionEmailDraft}
|
||||||
selectedRowKeys={selectedRowKeys}
|
selectedRowKeys={selectedRowKeys}
|
||||||
onSelectedRowKeysChange={setSelectedRowKeys}
|
onSelectedRowKeysChange={setSelectedRowKeys}
|
||||||
submissions={filteredSubmissions}
|
submissions={sortedAndFilteredSubmissions}
|
||||||
deletingId={deletingId}
|
deletingId={deletingId}
|
||||||
savingStatusKey={savingStatusKey}
|
savingStatusKey={savingStatusKey}
|
||||||
onDeleteSubmission={deleteAsAdmin}
|
onDeleteSubmission={deleteAsAdmin}
|
||||||
|
|||||||
Reference in New Issue
Block a user