Add handed-out flag and distribution mode
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m34s

This commit is contained in:
sim committed 2026-10-20 21:24:07 +02:00
1 parent 7d5ce7bcc8
commit d3517d8f47
9 files changed
+663 -313

No files matched your search

+6
View File
@@ -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"],
}
+6 -2
View File
@@ -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;
}
+5 -1
View File
@@ -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";
+31 -94
View File
@@ -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>
);
}