Compare commits

...

8 Commits

Author SHA1 Message Date
Simon Gruber 1ff82ec2ba Enhance mobile UI
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m39s
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 10:53:11 +02:00
Simon Gruber cacdb5b2bd Enhance mobile UI
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 1m41s
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 10:44:31 +02:00
Simon Gruber 7cf05d5801 Make chips wrap 2026-05-07 10:01:23 +02:00
Simon Gruber e489b045fc Remove admin view pagination
Build and Push Lunchtime Images (Kaniko) / build-and-push (push) Successful in 2m12s
2026-05-07 07:45:55 +02:00
Simon Gruber b00cde2a6e UI adjustments 2026-05-06 08:07:08 +02:00
Simon Gruber bfa04936b2 Retain mailpit data 2026-05-06 08:06:47 +02:00
Simon Gruber 24fd13340e Add owner chip for unpaid submissions 2026-05-06 07:34:41 +02:00
Simon Gruber d037f1ded0 Fix description export 2026-05-06 07:32:49 +02:00
9 changed files with 301 additions and 169 deletions
+6 -1
View File
@@ -130,7 +130,7 @@ def row_to_my_order(row: sqlite3.Row) -> dict:
except json.JSONDecodeError:
submission_choices = {}
return {
result = {
"id": row["id"],
"title": row["title"],
"description": row["description"],
@@ -145,3 +145,8 @@ def row_to_my_order(row: sqlite3.Row) -> dict:
"paid": bool(row["paid"]) if row["paid"] is not None else False,
},
}
if "has_unpaid_submissions" in row.keys() and row["has_unpaid_submissions"] is not None:
result["has_unpaid_submissions"] = bool(row["has_unpaid_submissions"])
return result
+6 -1
View File
@@ -674,7 +674,12 @@ def get_my_orders(
uot.submission_token,
s.choices_json,
s.accepted,
s.paid
s.paid,
CASE
WHEN uot.admin_token IS NOT NULL THEN
(SELECT COUNT(*) FROM submissions s2 WHERE s2.group_order_id = go.id AND s2.accepted = 1 AND (s2.paid IS NULL OR s2.paid = 0))
ELSE 0
END AS has_unpaid_submissions
FROM user_order_tokens uot
JOIN group_orders go ON go.id = uot.group_order_id
LEFT JOIN submissions s ON s.submission_token = uot.submission_token
+7 -2
View File
@@ -36,15 +36,20 @@ services:
container_name: lunchtime-mailpit
ports:
- "8025:8025"
volumes:
- mailpit-data:/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "8080:8080"
- "8020:8080"
volumes:
- ../nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
- frontend
restart: unless-stopped
restart: unless-stopped
volumes:
mailpit-data:
@@ -6,6 +6,7 @@ import ExportSelectionModal, { type ExportSelectionState } from "../modals/Expor
type MenuConfigImportExportProps = {
config?: OrderFormConfig | null;
description?: string;
onImportConfig?: (config: OrderFormConfig) => void | Promise<void>;
showImport?: boolean;
showExport?: boolean;
@@ -23,6 +24,7 @@ function normalizeConfig(value: unknown): OrderFormConfig {
export default function MenuConfigImportExport({
config,
description,
onImportConfig,
showImport = true,
showExport = true,
@@ -98,8 +100,8 @@ export default function MenuConfigImportExport({
payload.title = fileNameBase || "";
}
if (exportSelection.description) {
payload.description = "";
if (exportSelection.description || description) {
payload.description = description || "";
}
if (exportSelection.menu) {
@@ -76,6 +76,7 @@ export default function ReadOnlyOrderOverviewCard({
extra={
<MenuConfigImportExport
config={currentConfig}
description={currentDescription}
showImport={false}
onImportConfig={onImportConfig}
fileNameBase={order.title || "order"}
@@ -1,17 +1,22 @@
import React from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import {
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DownOutlined,
MailOutlined,
ReloadOutlined,
SearchOutlined,
} from "@ant-design/icons";
import {
Button,
Card,
Checkbox,
Dropdown,
Flex,
Grid,
Input,
Pagination,
List,
Popconfirm,
Select,
Space,
@@ -42,8 +47,10 @@ function getEstimatedTotalText(rawValue: unknown): string | null {
export default function AdminSubmissionsCard({
searchQuery,
selectedSubmissionState,
sortBy,
onSearchQueryChange,
onSelectedSubmissionStateChange,
onSortByChange,
onReload,
copyText,
onCopy,
@@ -51,22 +58,20 @@ export default function AdminSubmissionsCard({
onOpenMailto,
selectedRowKeys,
onSelectedRowKeysChange,
pagedSubmissions,
submissions,
deletingId,
savingStatusKey,
onDeleteSubmission,
onUpdateSubmissionStatus,
totalEstimatedText,
selectedCount,
submissionsPage,
pageSize,
totalCount,
onSubmissionsPageChange,
}: {
searchQuery: string;
selectedSubmissionState: "all" | "pending" | "unpaid" | "paid";
sortBy: "time" | "order" | "email" | "amount";
onSearchQueryChange: (value: string) => void;
onSelectedSubmissionStateChange: (value: "all" | "pending" | "unpaid" | "paid") => void;
onSortByChange: (value: "time" | "order" | "email" | "amount") => void;
onReload: () => void;
copyText: string;
onCopy: (text: string, didCopy: boolean) => void;
@@ -74,18 +79,74 @@ export default function AdminSubmissionsCard({
onOpenMailto: () => void;
selectedRowKeys: Array<string | number>;
onSelectedRowKeysChange: (keys: Array<string | number>) => void;
pagedSubmissions: any[];
submissions: any[];
deletingId: string | number | null;
savingStatusKey: string | null;
onDeleteSubmission: (id: string | number) => void;
onDeleteSubmission: (ids: Array<string | number>) => void;
onUpdateSubmissionStatus: (submission: any, changes: { accepted?: boolean; paid?: boolean }) => void;
totalEstimatedText: string | null;
selectedCount: number;
submissionsPage: number;
pageSize: number;
totalCount: number;
onSubmissionsPageChange: (page: number) => void;
}) {
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 = [
{
title: "Submission",
@@ -137,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 (
@@ -167,72 +205,125 @@ export default function AdminSubmissionsCard({
title="Submissions"
extra={
<Space>
<Tooltip
title={
selectedRowKeys.length > 0
? "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"}
<Dropdown menu={{ items: actionMenuItems }}>
<Button>
Actions <DownOutlined />
</Button>
</Tooltip>
</Dropdown>
<Tooltip title="Reload latest order details">
<Button icon={<ReloadOutlined />} onClick={onReload} />
</Tooltip>
</Space>
}
>
<Space style={{ marginBottom: 12 }} wrap>
<Flex style={{ marginBottom: 12, width: "100%" }} gap={8} wrap>
<Input
allowClear
placeholder="Filter by email or order text"
prefix={<SearchOutlined />}
placeholder="Search"
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
style={{ minWidth: 260 }}
style={{ flex: isMobile ? "1 1 100%" : "3 1 0%" }}
/>
<Select
value={selectedSubmissionState}
style={{ minWidth: 180 }}
style={{ flex: isMobile ? "1 1 calc(50% - 4px)" : "1 1 0%" }}
options={[
{ label: "All statuses", value: "all" },
{ label: "Pending", value: "pending" },
{ label: "Unpaid", value: "unpaid" },
{ label: "Paid", value: "paid" },
{ label: "Show all", value: "all" },
{ label: "Show pending", value: "pending" },
{ label: "Show unpaid", value: "unpaid" },
{ label: "Show paid", value: "paid" },
]}
onChange={(value) => onSelectedSubmissionStateChange(value)}
/>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={pagedSubmissions}
rowSelection={{
selectedRowKeys,
onChange: (nextSelectedRowKeys) =>
onSelectedRowKeysChange(nextSelectedRowKeys as Array<string | number>),
}}
pagination={false}
scroll={{ x: true }}
/>
<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"
@@ -249,14 +340,6 @@ export default function AdminSubmissionsCard({
) : (
<span />
)}
<Pagination
current={submissionsPage}
pageSize={pageSize}
total={totalCount}
showSizeChanger={false}
hideOnSinglePage
onChange={onSubmissionsPageChange}
/>
</Flex>
</Card>
);
@@ -92,33 +92,52 @@ export default function HomeOrdersTable({
const isClosed = !!item.closed;
const closedStr = isClosed ? " Closed" : "Open";
const dateStr =
item.created_at
? new Date(item.created_at).toLocaleString()
: "Unknown creation time";
const dateStr = item.created_at
? new Date(item.created_at).toLocaleString()
: "Unknown creation time";
return (
<Space size={16}>
<Icon path={mdiFood} size={1} style={getOrderIconStyle(isClosed)} />
<Icon
path={mdiFood}
size={1}
style={getOrderIconStyle(isClosed)}
/>
<Space direction="vertical" size={0}>
<Space size={8} align="center">
<Space size={8} align="center" wrap>
<Text strong>{item.title || item.id}</Text>
{item.is_owner ? <Tag color="geekblue">Owner</Tag> : null}
{state === "paid" ? <Tag color="green">Paid</Tag> : null}
{state === "unpaid" ? (
<Tag color="volcano">Unpaid</Tag>
) : null}
{state === "pending" ? <Tag color="blue">Pending</Tag> : null}
<Space size={0} align="center" wrap>
{item.is_owner ? <Tag color="geekblue">Owner</Tag> : null}
{item.is_owner && item.has_unpaid_submissions ? (
<Tag color="volcano">Open payments</Tag>
) : null}
{state === "paid" ? <Tag color="green">Paid</Tag> : null}
{state === "unpaid" ? (
<Tag color="volcano">Unpaid</Tag>
) : null}
{state === "pending" ? (
<Tag color="blue">Pending</Tag>
) : null}
</Space>
</Space>
<Text type="secondary">
{<Tooltip title={`Order is ${closedStr.toLocaleLowerCase()}`}>{closedStr}</Tooltip>}{" • "}
{<Tooltip title={`Created at: ${dateStr}`}>{dateStr}</Tooltip>}
{
<Tooltip
title={`Order is ${closedStr.toLocaleLowerCase()}`}
>
{closedStr}
</Tooltip>
}
{" • "}
{
<Tooltip title={`Created at: ${dateStr}`}>
{dateStr}
</Tooltip>
}
{item.is_participant && formatted && (
<>
{" • "}
<Tooltip title="Your order">
{formatted}
</Tooltip>
<Tooltip title="Your order">{formatted}</Tooltip>
</>
)}
</Text>
+1
View File
@@ -73,6 +73,7 @@ export type Order = OrderBase & {
is_owner: boolean;
is_participant: boolean;
};
has_unpaid_submissions?: boolean;
};
export type OrderAdminView = OrderBase & {
+65 -54
View File
@@ -59,10 +59,9 @@ export default function AdminView({ orderId }: { orderId: string }) {
const [savingStatusKey, setSavingStatusKey] = useState<string | null>(null);
const [updatingOrderStatus, setUpdatingOrderStatus] = useState(false);
const [deletingOrder, setDeletingOrder] = useState(false);
const [submissionsPage, setSubmissionsPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const [selectedSubmissionState, setSelectedSubmissionState] = useState<"all" | "pending" | "unpaid" | "paid">("all");
const SUBMISSIONS_PAGE_SIZE = 8;
const [sortBy, setSortBy] = useState<"time" | "order" | "email" | "amount">("time");
const getSubmissionState = (submission: Submission): SubmissionStatus => {
if (submission?.paid) {
@@ -119,11 +118,14 @@ export default function AdminView({ orderId }: { orderId: string }) {
},
});
const deleteAsAdmin = async (submissionId: string | number) => {
setDeletingId(submissionId);
const deleteAsAdmin = async (submissionIds: (string | number)[]) => {
setDeletingId(submissionIds.length === 1 ? submissionIds[0] : "multiple");
try {
await apiService.submissions.removeAsAdmin(orderId, submissionId);
message.success("Submission deleted");
for (const id of submissionIds) {
await apiService.submissions.removeAsAdmin(orderId, id);
}
message.success(`Submission${submissionIds.length > 1 ? "s" : ""} deleted`);
setSelectedRowKeys((prev) => prev.filter(k => !submissionIds.includes(k)));
await loadAdmin();
} catch (error: any) {
message.error(error.message);
@@ -263,17 +265,6 @@ export default function AdminView({ orderId }: { orderId: string }) {
};
}, [refreshSubmissions]);
useEffect(() => {
if (!data) {
return;
}
const maxPage = Math.max(1, Math.ceil(data.submissions.length / SUBMISSIONS_PAGE_SIZE));
if (submissionsPage > maxPage) {
setSubmissionsPage(maxPage);
}
}, [data, submissionsPage]);
const filteredSubmissions = useMemo(() => {
if (!data?.submissions) {
return [];
@@ -307,19 +298,45 @@ export default function AdminView({ orderId }: { orderId: string }) {
});
}, [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]);
useEffect(() => {
const visibleIds = new Set(filteredSubmissions.map((submission: any) => submission.id));
const visibleIds = new Set(sortedAndFilteredSubmissions.map((submission: any) => submission.id));
setSelectedRowKeys((previous) => previous.filter((id) => visibleIds.has(id)));
}, [filteredSubmissions]);
}, [sortedAndFilteredSubmissions]);
useEffect(() => {
setSubmissionsPage(1);
}, [searchQuery, selectedSubmissionState]);
const selected = filteredSubmissions.filter((submission: any) =>
const selected = sortedAndFilteredSubmissions.filter((submission: any) =>
selectedRowKeys.includes(submission.id),
);
const rowsToCopy = selected.length > 0 ? selected : filteredSubmissions;
const rowsToCopy = selected.length > 0 ? selected : sortedAndFilteredSubmissions;
const copyText = rowsToCopy
.map((submission: Submission) => getCleanSubmissionText(submission))
.filter(Boolean)
@@ -357,10 +374,6 @@ export default function AdminView({ orderId }: { orderId: string }) {
const totalEstimatedText = hasAnyEstimatedValue
? formatEstimatedTotal(totalEstimatedValue)
: null;
const pagedSubmissions = filteredSubmissions.slice(
(submissionsPage - 1) * SUBMISSIONS_PAGE_SIZE,
submissionsPage * SUBMISSIONS_PAGE_SIZE,
);
if (pageError || loading) {
return (
@@ -417,31 +430,6 @@ export default function AdminView({ orderId }: { orderId: string }) {
onImportConfig={handleImportConfig}
/>
<AdminSubmissionsCard
searchQuery={searchQuery}
selectedSubmissionState={selectedSubmissionState}
onSearchQueryChange={setSearchQuery}
onSelectedSubmissionStateChange={setSelectedSubmissionState}
onReload={refreshSubmissions}
copyText={copyText}
onCopy={copySubmissionList}
mailtoHref={mailtoHref}
onOpenMailto={openSubmissionEmailDraft}
selectedRowKeys={selectedRowKeys}
onSelectedRowKeysChange={setSelectedRowKeys}
pagedSubmissions={pagedSubmissions}
deletingId={deletingId}
savingStatusKey={savingStatusKey}
onDeleteSubmission={deleteAsAdmin}
onUpdateSubmissionStatus={updateSubmissionStatus}
totalEstimatedText={totalEstimatedText}
selectedCount={selected.length}
submissionsPage={submissionsPage}
pageSize={SUBMISSIONS_PAGE_SIZE}
totalCount={filteredSubmissions.length}
onSubmissionsPageChange={setSubmissionsPage}
/>
<AdminControlCenterCard
isClosed={!!data?.closed}
updatingOrderStatus={updatingOrderStatus}
@@ -449,6 +437,29 @@ export default function AdminView({ orderId }: { orderId: string }) {
onToggleClosed={updateOrderClosedStatus}
onDeleteOrder={deleteOrder}
/>
<AdminSubmissionsCard
searchQuery={searchQuery}
selectedSubmissionState={selectedSubmissionState}
sortBy={sortBy}
onSearchQueryChange={setSearchQuery}
onSelectedSubmissionStateChange={setSelectedSubmissionState}
onSortByChange={setSortBy}
onReload={refreshSubmissions}
copyText={copyText}
onCopy={copySubmissionList}
mailtoHref={mailtoHref}
onOpenMailto={openSubmissionEmailDraft}
selectedRowKeys={selectedRowKeys}
onSelectedRowKeysChange={setSelectedRowKeys}
submissions={sortedAndFilteredSubmissions}
deletingId={deletingId}
savingStatusKey={savingStatusKey}
onDeleteSubmission={deleteAsAdmin}
onUpdateSubmissionStatus={updateSubmissionStatus}
totalEstimatedText={totalEstimatedText}
selectedCount={selected.length}
/>
</Space>
);
}