Add handed-out flag and distribution mode
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m34s
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m34s
This commit is contained in:
1 parent
7d5ce7bcc8
commit
d3517d8f47
9 files changed
+663
-313
No files matched your search
@@ -37,6 +37,7 @@ def init_db() -> None:
|
||||
choices_json TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL DEFAULT 0,
|
||||
paid INTEGER NOT NULL DEFAULT 0,
|
||||
handed_out INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(group_order_id) REFERENCES group_orders(id) ON DELETE CASCADE
|
||||
@@ -81,6 +82,10 @@ def init_db() -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
submission_columns = {row["name"] for row in conn.execute("PRAGMA table_info(submissions)")}
|
||||
if "handed_out" not in submission_columns:
|
||||
conn.execute("ALTER TABLE submissions ADD COLUMN handed_out INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_account_confirmation_tokens_user_id
|
||||
@@ -114,6 +119,7 @@ def row_to_submission(row: sqlite3.Row) -> dict:
|
||||
"choices": choices,
|
||||
"accepted": bool(row["accepted"]),
|
||||
"paid": bool(row["paid"]),
|
||||
"handed_out": bool(row["handed_out"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class SubmissionPayload(BaseModel):
|
||||
class SubmissionStatusUpdateRequest(BaseModel):
|
||||
accepted: bool
|
||||
paid: bool
|
||||
handed_out: bool | None = None # None keeps the current value
|
||||
|
||||
|
||||
class OrderStatusUpdateRequest(BaseModel):
|
||||
@@ -862,6 +863,7 @@ def create_submission(
|
||||
"formatted_string": build_formatted_submission_string(normalized_choices, order_config),
|
||||
"accepted": False,
|
||||
"paid": False,
|
||||
"handed_out": False,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
@@ -913,7 +915,7 @@ def update_submission(
|
||||
|
||||
token = auth["submission_token"]
|
||||
existing = conn.execute(
|
||||
"SELECT id, accepted, paid, created_at FROM submissions WHERE group_order_id = ? AND submission_token = ?",
|
||||
"SELECT id, accepted, paid, handed_out, created_at FROM submissions WHERE group_order_id = ? AND submission_token = ?",
|
||||
(order_id, token),
|
||||
).fetchone()
|
||||
|
||||
@@ -944,6 +946,7 @@ def update_submission(
|
||||
"formatted_string": build_formatted_submission_string(normalized_choices, order_config),
|
||||
"accepted": False,
|
||||
"paid": bool(existing["paid"]),
|
||||
"handed_out": bool(existing["handed_out"]),
|
||||
"created_at": existing["created_at"],
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
@@ -1050,12 +1053,13 @@ def admin_update_submission_status(
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE submissions
|
||||
SET accepted = ?, paid = ?, updated_at = ?
|
||||
SET accepted = ?, paid = ?, handed_out = COALESCE(?, handed_out), updated_at = ?
|
||||
WHERE group_order_id = ? AND id = ?
|
||||
""",
|
||||
(
|
||||
int(payload.accepted),
|
||||
int(payload.paid),
|
||||
None if payload.handed_out is None else int(payload.handed_out),
|
||||
now_iso(),
|
||||
order_id,
|
||||
submission_id,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Grid, Modal, Progress, Space, Typography, message } from "antd";
|
||||
import { filterAndSortSubmissions } from "../../lib/submissionFiltering";
|
||||
import type { Submission, SubmissionSortBy, SubmissionState } from "../../lib/types";
|
||||
import {
|
||||
SubmissionsFilterBar,
|
||||
SubmissionsFooter,
|
||||
SubmissionsList,
|
||||
getTotalEstimatedText,
|
||||
type UpdateSubmissionStatus,
|
||||
} from "../views/AdminSubmissionsList";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function DistributionModal({
|
||||
open,
|
||||
onClose,
|
||||
submissions,
|
||||
savingStatusKey,
|
||||
onUpdateSubmissionStatus,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
submissions: Submission[];
|
||||
savingStatusKey: string | null;
|
||||
onUpdateSubmissionStatus: UpdateSubmissionStatus;
|
||||
}) {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("to_hand_out");
|
||||
const [sortBy, setSortBy] = useState<SubmissionSortBy>("email");
|
||||
const submissionsRef = useRef(submissions);
|
||||
submissionsRef.current = submissions;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchQuery("");
|
||||
setSelectedSubmissionState("to_hand_out");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const visibleSubmissions = useMemo(
|
||||
() => filterAndSortSubmissions(submissions, { query: searchQuery, state: selectedSubmissionState, sortBy }),
|
||||
[submissions, searchQuery, selectedSubmissionState, sortBy],
|
||||
);
|
||||
|
||||
const acceptedCount = submissions.filter((submission) => submission.accepted).length;
|
||||
const handedOutCount = submissions.filter((submission) => submission.accepted && submission.handed_out).length;
|
||||
|
||||
// Handed out rows leave the "to hand out" list right away, so offer a quick undo.
|
||||
const updateStatus: UpdateSubmissionStatus = (submission, changes) => {
|
||||
onUpdateSubmissionStatus(submission, changes);
|
||||
if (changes.handed_out !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageKey = `handed-out-${submission.id}`;
|
||||
message.success({
|
||||
key: messageKey,
|
||||
content: (
|
||||
<Space size={8}>
|
||||
<span>Handed out to {submission.email || "participant"}</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => {
|
||||
message.destroy(messageKey);
|
||||
const current = submissionsRef.current.find((item) => item.id === submission.id) || submission;
|
||||
onUpdateSubmissionStatus(current, { handed_out: false });
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={isMobile ? "100vw" : "min(1100px, 95vw)"}
|
||||
style={isMobile ? { top: 0, margin: 0, maxWidth: "100vw", paddingBottom: 0 } : { top: 24 }}
|
||||
styles={{
|
||||
content: isMobile ? { borderRadius: 0, minHeight: "100dvh", padding: "16px 12px" } : undefined,
|
||||
}}
|
||||
title={
|
||||
<Space direction="vertical" size={4} style={{ width: "100%", paddingRight: 32 }}>
|
||||
<span>Distribution</span>
|
||||
<Space size={12} style={{ width: "100%" }}>
|
||||
<Progress
|
||||
percent={acceptedCount > 0 ? Math.round((handedOutCount / acceptedCount) * 100) : 0}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
style={{ width: 120, margin: 0 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontWeight: "normal", fontSize: 13 }}>
|
||||
{handedOutCount} of {acceptedCount} accepted handed out
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<SubmissionsFilterBar
|
||||
searchQuery={searchQuery}
|
||||
selectedSubmissionState={selectedSubmissionState}
|
||||
sortBy={sortBy}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onSelectedSubmissionStateChange={setSelectedSubmissionState}
|
||||
onSortByChange={setSortBy}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<SubmissionsList
|
||||
distribution
|
||||
submissions={visibleSubmissions}
|
||||
isMobile={isMobile}
|
||||
savingStatusKey={savingStatusKey}
|
||||
onUpdateSubmissionStatus={updateStatus}
|
||||
maxHeight={isMobile ? "calc(100dvh - 230px)" : "calc(100vh - 330px)"}
|
||||
/>
|
||||
<SubmissionsFooter
|
||||
count={visibleSubmissions.length}
|
||||
totalEstimatedText={getTotalEstimatedText(visibleSubmissions)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,43 +6,24 @@ import {
|
||||
DownOutlined,
|
||||
MailOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
ShoppingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Grid,
|
||||
Input,
|
||||
List,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { formatEstimatedTotal, stripPriceDecorations } from "../../lib/orderFormatting";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function getEstimatedTotalText(rawValue: unknown): string | null {
|
||||
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||
return formatEstimatedTotal(rawValue);
|
||||
}
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return formatEstimatedTotal(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import type { Submission, SubmissionSortBy, SubmissionState } from "../../lib/types";
|
||||
import {
|
||||
SubmissionsFilterBar,
|
||||
SubmissionsFooter,
|
||||
SubmissionsList,
|
||||
type UpdateSubmissionStatus,
|
||||
} from "./AdminSubmissionsList";
|
||||
|
||||
export default function AdminSubmissionsCard({
|
||||
searchQuery,
|
||||
@@ -56,6 +37,7 @@ export default function AdminSubmissionsCard({
|
||||
onCopy,
|
||||
mailtoHref,
|
||||
onOpenMailto,
|
||||
onOpenDistribution,
|
||||
selectedRowKeys,
|
||||
onSelectedRowKeysChange,
|
||||
submissions,
|
||||
@@ -67,23 +49,24 @@ export default function AdminSubmissionsCard({
|
||||
selectedCount,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
selectedSubmissionState: "all" | "pending" | "unpaid" | "paid";
|
||||
sortBy: "time" | "order" | "email" | "amount";
|
||||
selectedSubmissionState: SubmissionState;
|
||||
sortBy: SubmissionSortBy;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
onSelectedSubmissionStateChange: (value: "all" | "pending" | "unpaid" | "paid") => void;
|
||||
onSortByChange: (value: "time" | "order" | "email" | "amount") => void;
|
||||
onSelectedSubmissionStateChange: (value: SubmissionState) => void;
|
||||
onSortByChange: (value: SubmissionSortBy) => void;
|
||||
onReload: () => void;
|
||||
copyText: string;
|
||||
onCopy: (text: string, didCopy: boolean) => void;
|
||||
mailtoHref: string;
|
||||
onOpenMailto: () => void;
|
||||
onOpenDistribution: () => void;
|
||||
selectedRowKeys: Array<string | number>;
|
||||
onSelectedRowKeysChange: (keys: Array<string | number>) => void;
|
||||
submissions: any[];
|
||||
submissions: Submission[];
|
||||
deletingId: string | number | null;
|
||||
savingStatusKey: string | null;
|
||||
onDeleteSubmission: (ids: Array<string | number>) => void;
|
||||
onUpdateSubmissionStatus: (submission: any, changes: { accepted?: boolean; paid?: boolean }) => void;
|
||||
onUpdateSubmissionStatus: UpdateSubmissionStatus;
|
||||
totalEstimatedText: string | null;
|
||||
selectedCount: number;
|
||||
}) {
|
||||
@@ -92,6 +75,16 @@ export default function AdminSubmissionsCard({
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const actionMenuItems = [
|
||||
{
|
||||
key: "distribution",
|
||||
label: (
|
||||
<div onClick={onOpenDistribution} style={{ width: "100%" }}>
|
||||
<ShoppingOutlined style={{ marginRight: 8 }} />
|
||||
Distribution mode
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ type: "divider" as const },
|
||||
{
|
||||
key: "copy",
|
||||
label: (
|
||||
@@ -147,59 +140,6 @@ export default function AdminSubmissionsCard({
|
||||
: []),
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Submission",
|
||||
dataIndex: "formatted_string",
|
||||
key: "submission",
|
||||
render: (value: string, record: any) => {
|
||||
const cleanValue = stripPriceDecorations(String(value || "")).trim();
|
||||
const estimateText = getEstimatedTotalText(record.estimated_total);
|
||||
const emailText = String(record.email || "").trim() || "-";
|
||||
const secondaryText = `${emailText} • ${estimateText || "-"}`;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text code copyable={{ text: cleanValue }}>{cleanValue || "-"}</Text>
|
||||
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||
<Text type="secondary">{secondaryText}</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Accepted",
|
||||
key: "accepted",
|
||||
width: 120,
|
||||
render: (_: any, record: any) => {
|
||||
const isSaving = savingStatusKey === `${record.id}:accepted`;
|
||||
return (
|
||||
<Switch
|
||||
checked={!!record.accepted}
|
||||
loading={isSaving}
|
||||
onChange={(checked) => onUpdateSubmissionStatus(record, { accepted: checked })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Paid",
|
||||
key: "paid",
|
||||
width: 100,
|
||||
render: (_: any, record: any) => {
|
||||
const isSaving = savingStatusKey === `${record.id}:paid`;
|
||||
return (
|
||||
<Switch
|
||||
checked={!!record.paid}
|
||||
loading={isSaving}
|
||||
onChange={(checked) => onUpdateSubmissionStatus(record, { paid: checked })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Submissions"
|
||||
@@ -216,136 +156,29 @@ export default function AdminSubmissionsCard({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Flex style={{ marginBottom: 12, width: "100%" }} gap={8} wrap>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||
style={{ flex: isMobile ? "1 1 100%" : "3 1 0%" }}
|
||||
/>
|
||||
<Select
|
||||
value={selectedSubmissionState}
|
||||
style={{ flex: isMobile ? "1 1 calc(50% - 4px)" : "1 1 0%" }}
|
||||
options={[
|
||||
{ label: "Show all", value: "all" },
|
||||
{ label: "Show pending", value: "pending" },
|
||||
{ label: "Show unpaid", value: "unpaid" },
|
||||
{ label: "Show paid", value: "paid" },
|
||||
]}
|
||||
onChange={(value) => onSelectedSubmissionStateChange(value)}
|
||||
/>
|
||||
<Select
|
||||
value={sortBy}
|
||||
style={{ flex: isMobile ? "1 1 calc(50% - 4px)" : "1 1 0%" }}
|
||||
options={[
|
||||
{ label: "by time", value: "time" },
|
||||
{ label: "by order", value: "order" },
|
||||
{ label: "by email", value: "email" },
|
||||
{ label: "by amount", value: "amount" },
|
||||
]}
|
||||
onChange={(value) => onSortByChange(value)}
|
||||
/>
|
||||
</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: "66vh",
|
||||
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
|
||||
justify="space-between"
|
||||
align="center"
|
||||
gap={12}
|
||||
wrap
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
<Text type="secondary">
|
||||
{selectedCount > 0
|
||||
? `${selectedCount} of ${submissions.length} orders selected`
|
||||
: `${submissions.length} order${submissions.length === 1 ? "" : "s"}`}
|
||||
</Text>
|
||||
{totalEstimatedText ? (
|
||||
<Tooltip title="Rough estimate based on menu prices. Final amount may differ.">
|
||||
<Text type="secondary">
|
||||
Total {selectedCount > 0 ? "(selected)" : ""}: {totalEstimatedText}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</Flex>
|
||||
<SubmissionsFilterBar
|
||||
searchQuery={searchQuery}
|
||||
selectedSubmissionState={selectedSubmissionState}
|
||||
sortBy={sortBy}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onSelectedSubmissionStateChange={onSelectedSubmissionStateChange}
|
||||
onSortByChange={onSortByChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<SubmissionsList
|
||||
submissions={submissions}
|
||||
isMobile={isMobile}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
onSelectedRowKeysChange={onSelectedRowKeysChange}
|
||||
savingStatusKey={savingStatusKey}
|
||||
onUpdateSubmissionStatus={onUpdateSubmissionStatus}
|
||||
maxHeight={isMobile ? "66vh" : 400}
|
||||
/>
|
||||
<SubmissionsFooter
|
||||
count={submissions.length}
|
||||
selectedCount={selectedCount}
|
||||
totalEstimatedText={totalEstimatedText}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import React from "react";
|
||||
import { CheckOutlined, SearchOutlined, UndoOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Input,
|
||||
List,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import { formatEstimatedTotal, stripPriceDecorations } from "../../lib/orderFormatting";
|
||||
import {
|
||||
SUBMISSION_SORT_OPTIONS,
|
||||
SUBMISSION_STATE_OPTIONS,
|
||||
getEstimatedTotalNumber,
|
||||
} from "../../lib/submissionFiltering";
|
||||
import type { Submission, SubmissionSortBy, SubmissionState, SubmissionStatusUpdate } from "../../lib/types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ESTIMATE_HINT = "Rough estimate based on menu prices. Final amount may differ.";
|
||||
|
||||
export type UpdateSubmissionStatus = (submission: Submission, changes: SubmissionStatusUpdate) => void;
|
||||
|
||||
function getSubmissionTexts(record: Submission) {
|
||||
const cleanValue = stripPriceDecorations(String(record.formatted_string || "")).trim();
|
||||
const estimate = getEstimatedTotalNumber(record.estimated_total);
|
||||
const estimateText = estimate === null ? null : formatEstimatedTotal(estimate);
|
||||
const emailText = String(record.email || "").trim() || "-";
|
||||
return { cleanValue, emailText, secondaryText: `${emailText} • ${estimateText || "-"}` };
|
||||
}
|
||||
|
||||
export function getTotalEstimatedText(submissions: Submission[]): string | null {
|
||||
const values = submissions
|
||||
.map((submission) => getEstimatedTotalNumber(submission.estimated_total))
|
||||
.filter((value): value is number => value !== null);
|
||||
return values.length > 0 ? formatEstimatedTotal(values.reduce((sum, value) => sum + value, 0)) : null;
|
||||
}
|
||||
|
||||
export function SubmissionsFilterBar({
|
||||
searchQuery,
|
||||
selectedSubmissionState,
|
||||
sortBy,
|
||||
onSearchQueryChange,
|
||||
onSelectedSubmissionStateChange,
|
||||
onSortByChange,
|
||||
isMobile,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
selectedSubmissionState: SubmissionState;
|
||||
sortBy: SubmissionSortBy;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
onSelectedSubmissionStateChange: (value: SubmissionState) => void;
|
||||
onSortByChange: (value: SubmissionSortBy) => void;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Flex style={{ marginBottom: 12, width: "100%" }} gap={8} wrap>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||
style={{ flex: isMobile ? "1 1 100%" : "3 1 0%" }}
|
||||
/>
|
||||
<Select
|
||||
value={selectedSubmissionState}
|
||||
style={{ flex: isMobile ? "1 1 calc(50% - 4px)" : "1 1 0%" }}
|
||||
options={SUBMISSION_STATE_OPTIONS}
|
||||
onChange={onSelectedSubmissionStateChange}
|
||||
/>
|
||||
<Select
|
||||
value={sortBy}
|
||||
style={{ flex: isMobile ? "1 1 calc(50% - 4px)" : "1 1 0%" }}
|
||||
options={SUBMISSION_SORT_OPTIONS}
|
||||
onChange={onSortByChange}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusSwitch({
|
||||
label,
|
||||
field,
|
||||
record,
|
||||
savingStatusKey,
|
||||
onUpdateSubmissionStatus,
|
||||
}: {
|
||||
label?: string;
|
||||
field: "accepted" | "paid" | "handed_out";
|
||||
record: Submission;
|
||||
savingStatusKey: string | null;
|
||||
onUpdateSubmissionStatus: UpdateSubmissionStatus;
|
||||
}) {
|
||||
const control = (
|
||||
<Switch
|
||||
checked={!!record[field]}
|
||||
loading={savingStatusKey === `${record.id}:${field}`}
|
||||
onChange={(checked) => onUpdateSubmissionStatus(record, { [field]: checked })}
|
||||
/>
|
||||
);
|
||||
|
||||
return label ? (
|
||||
<Space size={8}>
|
||||
<Text>{label}</Text>
|
||||
{control}
|
||||
</Space>
|
||||
) : (
|
||||
control
|
||||
);
|
||||
}
|
||||
|
||||
function HandOutButton({
|
||||
record,
|
||||
savingStatusKey,
|
||||
onUpdateSubmissionStatus,
|
||||
block,
|
||||
}: {
|
||||
record: Submission;
|
||||
savingStatusKey: string | null;
|
||||
onUpdateSubmissionStatus: UpdateSubmissionStatus;
|
||||
block?: boolean;
|
||||
}) {
|
||||
const handedOut = !!record.handed_out;
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="large"
|
||||
block={block}
|
||||
type={handedOut ? "default" : "primary"}
|
||||
icon={handedOut ? <UndoOutlined /> : <CheckOutlined />}
|
||||
loading={savingStatusKey === `${record.id}:handed_out`}
|
||||
onClick={() => onUpdateSubmissionStatus(record, { handed_out: !handedOut })}
|
||||
>
|
||||
{handedOut ? "Undo" : "Hand out"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubmissionsList({
|
||||
submissions,
|
||||
isMobile,
|
||||
distribution = false,
|
||||
selectedRowKeys,
|
||||
onSelectedRowKeysChange,
|
||||
savingStatusKey,
|
||||
onUpdateSubmissionStatus,
|
||||
maxHeight,
|
||||
}: {
|
||||
submissions: Submission[];
|
||||
isMobile: boolean;
|
||||
// Tuned for handing out orders: big hand-out button, no accepted switch, no selection.
|
||||
distribution?: boolean;
|
||||
selectedRowKeys?: Array<string | number>;
|
||||
onSelectedRowKeysChange?: (keys: Array<string | number>) => void;
|
||||
savingStatusKey: string | null;
|
||||
onUpdateSubmissionStatus: UpdateSubmissionStatus;
|
||||
maxHeight: string | number;
|
||||
}) {
|
||||
const selectable = !distribution && !!selectedRowKeys && !!onSelectedRowKeysChange;
|
||||
const statusProps = { savingStatusKey, onUpdateSubmissionStatus };
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<List
|
||||
dataSource={submissions}
|
||||
locale={{ emptyText: distribution ? "Nothing left to hand out" : undefined }}
|
||||
renderItem={(record) => {
|
||||
const { cleanValue, secondaryText } = getSubmissionTexts(record);
|
||||
const isSelected = !!selectedRowKeys?.includes(record.id);
|
||||
|
||||
return (
|
||||
<List.Item>
|
||||
<Flex gap={12} align="flex-start" style={{ width: "100%" }}>
|
||||
{selectable && (
|
||||
<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}>
|
||||
{distribution ? (
|
||||
<Text strong style={{ fontSize: 16, wordBreak: "break-word" }}>
|
||||
{cleanValue || "-"}
|
||||
</Text>
|
||||
) : (
|
||||
<Text code copyable={{ text: cleanValue }} style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
|
||||
{cleanValue || "-"}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip title={ESTIMATE_HINT}>
|
||||
<Text type="secondary">{secondaryText}</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
{distribution ? (
|
||||
<Flex gap={12} align="center" justify="space-between">
|
||||
<StatusSwitch label="Paid" field="paid" record={record} {...statusProps} />
|
||||
<div style={{ flex: "0 1 60%" }}>
|
||||
<HandOutButton record={record} block {...statusProps} />
|
||||
</div>
|
||||
</Flex>
|
||||
) : (
|
||||
<Space size={16} wrap>
|
||||
<StatusSwitch label="Accepted" field="accepted" record={record} {...statusProps} />
|
||||
<StatusSwitch label="Paid" field="paid" record={record} {...statusProps} />
|
||||
<StatusSwitch label="Handed out" field="handed_out" record={record} {...statusProps} />
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
maxHeight,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Submission",
|
||||
dataIndex: "formatted_string",
|
||||
key: "submission",
|
||||
render: (_value: string, record: Submission) => {
|
||||
const { cleanValue, secondaryText } = getSubmissionTexts(record);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
{distribution ? (
|
||||
<Text strong style={{ fontSize: 15 }}>{cleanValue || "-"}</Text>
|
||||
) : (
|
||||
<Text code copyable={{ text: cleanValue }}>{cleanValue || "-"}</Text>
|
||||
)}
|
||||
<Tooltip title={ESTIMATE_HINT}>
|
||||
<Text type="secondary">{secondaryText}</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(distribution
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: "Accepted",
|
||||
key: "accepted",
|
||||
width: 110,
|
||||
render: (_: unknown, record: Submission) => (
|
||||
<StatusSwitch field="accepted" record={record} {...statusProps} />
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
title: "Paid",
|
||||
key: "paid",
|
||||
width: distribution ? 90 : 80,
|
||||
render: (_: unknown, record: Submission) =>
|
||||
distribution && !record.paid ? (
|
||||
<Space size={8}>
|
||||
<StatusSwitch field="paid" record={record} {...statusProps} />
|
||||
<Tag color="red">Unpaid</Tag>
|
||||
</Space>
|
||||
) : (
|
||||
<StatusSwitch field="paid" record={record} {...statusProps} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Handed out",
|
||||
key: "handed_out",
|
||||
width: distribution ? 150 : 110,
|
||||
render: (_: unknown, record: Submission) =>
|
||||
distribution ? (
|
||||
<HandOutButton record={record} {...statusProps} />
|
||||
) : (
|
||||
<StatusSwitch field="handed_out" record={record} {...statusProps} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={submissions}
|
||||
locale={{ emptyText: distribution ? "Nothing left to hand out" : undefined }}
|
||||
rowSelection={
|
||||
selectable
|
||||
? {
|
||||
selectedRowKeys,
|
||||
onChange: (nextSelectedRowKeys) =>
|
||||
onSelectedRowKeysChange!(nextSelectedRowKeys as Array<string | number>),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={false}
|
||||
scroll={{ y: maxHeight, x: true }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubmissionsFooter({
|
||||
count,
|
||||
selectedCount = 0,
|
||||
totalEstimatedText,
|
||||
}: {
|
||||
count: number;
|
||||
selectedCount?: number;
|
||||
totalEstimatedText: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Flex justify="space-between" align="center" gap={12} wrap style={{ marginTop: 12 }}>
|
||||
<Text type="secondary">
|
||||
{selectedCount > 0
|
||||
? `${selectedCount} of ${count} orders selected`
|
||||
: `${count} order${count === 1 ? "" : "s"}`}
|
||||
</Text>
|
||||
{totalEstimatedText ? (
|
||||
<Tooltip title={ESTIMATE_HINT}>
|
||||
<Text type="secondary">
|
||||
Total {selectedCount > 0 ? "(selected)" : ""}: {totalEstimatedText}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,9 @@ export default function ParticipantSubmissionSummary({
|
||||
<Checkbox checked={submission.paid} style={{ pointerEvents: "none" }}>
|
||||
Paid
|
||||
</Checkbox>
|
||||
<Checkbox checked={!!submission.handed_out} style={{ pointerEvents: "none" }}>
|
||||
Received
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Updated {formatTimestamp(submission.updated_at)}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { stripPriceDecorations } from "./orderFormatting";
|
||||
import type { Submission, SubmissionSortBy, SubmissionState } from "./types";
|
||||
|
||||
export const SUBMISSION_STATE_OPTIONS: Array<{ label: string; value: SubmissionState }> = [
|
||||
{ label: "Show all", value: "all" },
|
||||
{ label: "Show pending", value: "pending" },
|
||||
{ label: "Show unpaid", value: "unpaid" },
|
||||
{ label: "Show paid", value: "paid" },
|
||||
{ label: "Show to hand out", value: "to_hand_out" },
|
||||
{ label: "Show handed out", value: "handed_out" },
|
||||
];
|
||||
|
||||
export const SUBMISSION_SORT_OPTIONS: Array<{ label: string; value: SubmissionSortBy }> = [
|
||||
{ label: "by time", value: "time" },
|
||||
{ label: "by order", value: "order" },
|
||||
{ label: "by email", value: "email" },
|
||||
{ label: "by amount", value: "amount" },
|
||||
];
|
||||
|
||||
export function getEstimatedTotalNumber(rawValue: unknown): number | null {
|
||||
const parsed = typeof rawValue === "string" ? Number(rawValue) : rawValue;
|
||||
return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getCleanOrderText(submission: Submission): string {
|
||||
return stripPriceDecorations(String(submission.formatted_string || "")).trim();
|
||||
}
|
||||
|
||||
export function matchesSubmissionState(submission: Submission, state: SubmissionState): boolean {
|
||||
switch (state) {
|
||||
case "pending":
|
||||
return !submission.paid && !submission.accepted;
|
||||
case "unpaid":
|
||||
return !submission.paid && !!submission.accepted;
|
||||
case "paid":
|
||||
return !!submission.paid;
|
||||
case "to_hand_out":
|
||||
return !!submission.accepted && !submission.handed_out;
|
||||
case "handed_out":
|
||||
return !!submission.handed_out;
|
||||
case "all":
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAndSortSubmissions(
|
||||
submissions: Submission[],
|
||||
{ query, state, sortBy }: { query: string; state: SubmissionState; sortBy: SubmissionSortBy },
|
||||
): Submission[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
const list = submissions.filter((submission) => {
|
||||
if (!matchesSubmissionState(submission, state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [
|
||||
String(submission.email || "").toLowerCase(),
|
||||
getCleanOrderText(submission).toLowerCase(),
|
||||
].join(" ");
|
||||
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
|
||||
list.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "order":
|
||||
return getCleanOrderText(a).toLowerCase().localeCompare(getCleanOrderText(b).toLowerCase());
|
||||
case "email":
|
||||
return String(a.email || "").toLowerCase().localeCompare(String(b.email || "").toLowerCase());
|
||||
case "amount":
|
||||
// Highest amount first
|
||||
return (getEstimatedTotalNumber(b.estimated_total) || 0) - (getEstimatedTotalNumber(a.estimated_total) || 0);
|
||||
case "time":
|
||||
default:
|
||||
// Oldest first
|
||||
return new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
@@ -165,6 +165,7 @@ export type Submission = {
|
||||
updated_at: string;
|
||||
accepted: boolean;
|
||||
paid: boolean;
|
||||
handed_out?: boolean;
|
||||
};
|
||||
|
||||
export type SubmissionInput = {
|
||||
@@ -174,6 +175,7 @@ export type SubmissionInput = {
|
||||
export type SubmissionStatusUpdate = {
|
||||
accepted?: boolean;
|
||||
paid?: boolean;
|
||||
handed_out?: boolean;
|
||||
};
|
||||
|
||||
/* ==================== API Response Models ==================== */
|
||||
@@ -273,7 +275,9 @@ export type StorageUserIdentity = {
|
||||
|
||||
/* ==================== UI State Models ==================== */
|
||||
|
||||
export type SubmissionState = SubmissionStatus | "all";
|
||||
export type SubmissionState = SubmissionStatus | "all" | "to_hand_out" | "handed_out";
|
||||
|
||||
export type SubmissionSortBy = "time" | "order" | "email" | "amount";
|
||||
|
||||
export type OrderStateFilter = "all" | "open" | "closed";
|
||||
|
||||
|
||||
@@ -15,9 +15,12 @@ import type {
|
||||
OrderFormConfig,
|
||||
OrderTemplates,
|
||||
Submission,
|
||||
SubmissionStatus,
|
||||
SubmissionSortBy,
|
||||
SubmissionState,
|
||||
SubmissionStatusUpdate,
|
||||
} from "../lib/types";
|
||||
import { formatEstimatedTotal, stripPriceDecorations } from "../lib/orderFormatting";
|
||||
import { formatEstimatedTotal } from "../lib/orderFormatting";
|
||||
import { filterAndSortSubmissions, getEstimatedTotalNumber } from "../lib/submissionFiltering";
|
||||
import {
|
||||
DEFAULT_ORDER_TEMPLATES,
|
||||
buildMailtoHref,
|
||||
@@ -30,6 +33,7 @@ import {
|
||||
import AdminParticipantShareAlert from "../components/views/AdminParticipantShareAlert";
|
||||
import AdminSubmissionsCard from "../components/views/AdminSubmissionsCard";
|
||||
import AdminControlCenterCard from "../components/views/AdminControlCenterCard";
|
||||
import DistributionModal from "../components/modals/DistributionModal";
|
||||
|
||||
type AdminViewData = Order & {
|
||||
submissions: Submission[];
|
||||
@@ -46,21 +50,6 @@ function shouldRedirectToParticipant(error: unknown): boolean {
|
||||
return status === 404 && message === "Admin order view not found";
|
||||
}
|
||||
|
||||
function getEstimatedTotalNumber(rawValue: unknown): number | null {
|
||||
if (typeof rawValue === "number" && Number.isFinite(rawValue)) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
const parsed = Number(rawValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AdminView({ orderId }: { orderId: string }) {
|
||||
const [deletingId, setDeletingId] = useState<string | number | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<(string | number)[]>([]);
|
||||
@@ -68,20 +57,9 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
const [updatingOrderStatus, setUpdatingOrderStatus] = useState(false);
|
||||
const [deletingOrder, setDeletingOrder] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<"all" | "pending" | "unpaid" | "paid">("all");
|
||||
const [sortBy, setSortBy] = useState<"time" | "order" | "email" | "amount">("time");
|
||||
|
||||
const getSubmissionState = (submission: Submission): SubmissionStatus => {
|
||||
if (submission?.paid) {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
if (submission?.accepted) {
|
||||
return "unpaid";
|
||||
}
|
||||
|
||||
return "pending";
|
||||
};
|
||||
const [selectedSubmissionState, setSelectedSubmissionState] = useState<SubmissionState>("all");
|
||||
const [sortBy, setSortBy] = useState<SubmissionSortBy>("time");
|
||||
const [distributionOpen, setDistributionOpen] = useState(false);
|
||||
|
||||
const loadAdminData = useCallback(
|
||||
async (): Promise<AdminViewData> => {
|
||||
@@ -144,13 +122,15 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const updateSubmissionStatus = async (submission: any, changes: any) => {
|
||||
const updateSubmissionStatus = async (submission: Submission, changes: SubmissionStatusUpdate) => {
|
||||
const nextAccepted =
|
||||
typeof changes.accepted === "boolean"
|
||||
? changes.accepted
|
||||
: !!submission.accepted;
|
||||
const nextPaid =
|
||||
typeof changes.paid === "boolean" ? changes.paid : !!submission.paid;
|
||||
const nextHandedOut =
|
||||
typeof changes.handed_out === "boolean" ? changes.handed_out : !!submission.handed_out;
|
||||
|
||||
const statusKey = `${submission.id}:${Object.keys(changes).join(",")}`;
|
||||
setSavingStatusKey(statusKey);
|
||||
@@ -162,6 +142,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
{
|
||||
accepted: nextAccepted,
|
||||
paid: nextPaid,
|
||||
handed_out: nextHandedOut,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -287,68 +268,15 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
};
|
||||
}, [refreshSubmissions]);
|
||||
|
||||
const filteredSubmissions = useMemo(() => {
|
||||
if (!data?.submissions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
|
||||
return data.submissions.filter((submission: any) => {
|
||||
if (selectedSubmissionState !== "all") {
|
||||
if (getSubmissionState(submission) !== selectedSubmissionState) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cleanOrderText = stripPriceDecorations(
|
||||
String(submission.formatted_string || ""),
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const haystack = [
|
||||
String(submission.email || "").toLowerCase(),
|
||||
cleanOrderText,
|
||||
].join(" ");
|
||||
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}, [data?.submissions, searchQuery, selectedSubmissionState]);
|
||||
|
||||
const sortedAndFilteredSubmissions = useMemo(() => {
|
||||
const list = [...filteredSubmissions];
|
||||
list.sort((a: any, b: any) => {
|
||||
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 sortedAndFilteredSubmissions = useMemo(
|
||||
() =>
|
||||
filterAndSortSubmissions(data?.submissions || [], {
|
||||
query: searchQuery,
|
||||
state: selectedSubmissionState,
|
||||
sortBy,
|
||||
}),
|
||||
[data?.submissions, searchQuery, selectedSubmissionState, sortBy],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const visibleIds = new Set(sortedAndFilteredSubmissions.map((submission: any) => submission.id));
|
||||
@@ -466,6 +394,7 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
onCopy={copySubmissionList}
|
||||
mailtoHref={mailtoHref}
|
||||
onOpenMailto={openSubmissionEmailDraft}
|
||||
onOpenDistribution={() => setDistributionOpen(true)}
|
||||
selectedRowKeys={selectedRowKeys}
|
||||
onSelectedRowKeysChange={setSelectedRowKeys}
|
||||
submissions={sortedAndFilteredSubmissions}
|
||||
@@ -476,6 +405,14 @@ export default function AdminView({ orderId }: { orderId: string }) {
|
||||
totalEstimatedText={totalEstimatedText}
|
||||
selectedCount={selected.length}
|
||||
/>
|
||||
|
||||
<DistributionModal
|
||||
open={distributionOpen}
|
||||
onClose={() => setDistributionOpen(false)}
|
||||
submissions={data?.submissions || []}
|
||||
savingStatusKey={savingStatusKey}
|
||||
onUpdateSubmissionStatus={updateSubmissionStatus}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user