refactor(rubgram): replace archive/round/slip system with deleted+submit_day columns and month-calendar
- drop endgameArchive table, remove archive() function - replace archived boolean with deleted on endgameSubmissions - add submit_day timestamp column - replace round-based slip manager with /rubgram/admin/calendar route - add monthly jsonb column to endgameSettings for month checkmark toggle - add toggleMonth server action with SimpleTooltip UI - fix publicQueue SQL bug (ambiguous column reference + precedence) - wipe() now sets deleted=true instead of round-based archive - cancel() uses soft-delete (deleted=true) instead of hard-delete - guard calendar page with adminCheck - update count route to exclude deleted submissions
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Database, ReceiptText } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CopyButton } from "@/app/(ui)/artifact/admin/[id]/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function DataViewer({
|
||||
entryYaml,
|
||||
slipYaml,
|
||||
}: {
|
||||
entryYaml: string;
|
||||
slipYaml: string;
|
||||
}) {
|
||||
const [isSlip, setIsSlip] = useState(false);
|
||||
|
||||
const yaml = useMemo(
|
||||
() => (isSlip ? slipYaml : entryYaml),
|
||||
[entryYaml, isSlip, slipYaml],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative max-h-[60svh] overflow-auto rounded-xl border bg-black/40 p-4 font-mono text-xs leading-relaxed">
|
||||
<div className="sticky -top-2 -mt-10 -mr-2 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsSlip((x) => !x)}
|
||||
>
|
||||
{isSlip ? <ReceiptText /> : <Database />}
|
||||
</Button>
|
||||
<CopyButton text={yaml} />
|
||||
</div>
|
||||
|
||||
<pre className="wrap-break-word whitespace-pre-wrap">{yaml}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { calcPrice } from "@/app/(ui)/rubgram/api";
|
||||
import Image from "@/components/image";
|
||||
import { DiscordMentionable } from "@/components/mentionable";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { db } from "@/lib/db";
|
||||
import { endgameArchive, endgameDiscord, endgameSlips } from "@/lib/db/schema";
|
||||
import { parseSearchNumber } from "@/lib/utils";
|
||||
import { YAML } from "bun";
|
||||
import { and, eq, getTableColumns } from "drizzle-orm";
|
||||
import { ReceiptText } from "lucide-react";
|
||||
import { DataViewer } from "./client";
|
||||
|
||||
export default async function ({
|
||||
params,
|
||||
}: PageProps<"/rubgram/admin/slip/[round]/[queue]">) {
|
||||
const { round: r, queue: q } = await params;
|
||||
const round = parseSearchNumber(r) || 1;
|
||||
const queue = parseSearchNumber(q) || 1;
|
||||
const { slip: _, ...slipColumns } = getTableColumns(endgameSlips);
|
||||
const [entry] = await db
|
||||
.select({
|
||||
...getTableColumns(endgameArchive),
|
||||
user: getTableColumns(endgameDiscord),
|
||||
slip: slipColumns,
|
||||
})
|
||||
.from(endgameArchive)
|
||||
.where(
|
||||
and(eq(endgameArchive.round, round), eq(endgameArchive.queue, queue)),
|
||||
)
|
||||
.innerJoin(endgameDiscord, eq(endgameDiscord.uid, endgameArchive.user))
|
||||
.leftJoin(endgameSlips, eq(endgameSlips.id, endgameArchive.slip))
|
||||
.limit(1);
|
||||
|
||||
const entryYaml = YAML.stringify(
|
||||
{ ...entry, slip: { ...entry.slip, data: undefined } },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const slipYaml = YAML.stringify(entry.slip, null, 2);
|
||||
|
||||
if (!entry)
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ReceiptText />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>ยังไม่มีสลิป</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-2 sm:flex-row">
|
||||
<div className="flex flex-1 items-center justify-center p-5">
|
||||
<div className="relative rounded border border-dashed border-white">
|
||||
<Image
|
||||
src={`/api/slip/${entry.slip?.id}`}
|
||||
alt="Slip"
|
||||
className="object-contain"
|
||||
width={1000}
|
||||
height={1000}
|
||||
/>
|
||||
<div className="absolute top-0 right-0 bottom-0 left-0 backdrop-blur-lg transition-opacity hover:opacity-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>จ่าย {entry.price} บาท</CardTitle>
|
||||
<CardDescription>
|
||||
{entry.service.join(", ")} (รวม {calcPrice(entry.service)} บาท)
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<DiscordMentionable
|
||||
id={entry.user.uid}
|
||||
name={entry.user.display}
|
||||
type="user"
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataViewer {...{ entryYaml, slipYaml }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { ChevronLeft, ChevronRight, ReceiptText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cache, Suspense } from "react";
|
||||
import { SidebarLink } from "@/app/(ui)/admin/client";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { db } from "@/lib/db";
|
||||
import { endgameArchive } from "@/lib/db/schema";
|
||||
import { parseSearchNumber } from "@/lib/utils";
|
||||
import { Watcher } from "../../../../admin/client";
|
||||
|
||||
export default async function ({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ round: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { round: r } = await params;
|
||||
const round = parseSearchNumber(r);
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={<Sidebar />}>
|
||||
<ThisSidebar round={round} />
|
||||
</Suspense>
|
||||
<SidebarInset className="bg-transparent">
|
||||
{children}
|
||||
<div className="absolute bottom-1 left-1 block opacity-50 hover:opacity-100 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
<Watcher />
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const getRange = cache(
|
||||
async () =>
|
||||
await db
|
||||
.select({
|
||||
min: sql<number>`min(${endgameArchive.round})`,
|
||||
max: sql<number>`max(${endgameArchive.round})`,
|
||||
})
|
||||
.from(endgameArchive),
|
||||
);
|
||||
const getEntries = cache(
|
||||
async (round: number) =>
|
||||
await db
|
||||
.select()
|
||||
.from(endgameArchive)
|
||||
.where(eq(endgameArchive.round, round))
|
||||
.orderBy(endgameArchive.queue),
|
||||
);
|
||||
|
||||
async function ThisSidebar({ round }: { round: number }) {
|
||||
const [[{ min, max }], entries] = await Promise.all([
|
||||
getRange(),
|
||||
getEntries(round),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Sidebar variant="floating">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-foreground">
|
||||
<ReceiptText className="size-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width)"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>เลือกรอบ</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={`${round}`}>
|
||||
{range(min, max + 1)
|
||||
.toReversed()
|
||||
.map((r) => (
|
||||
<Link href={`/rubgram/admin/slip/${r}`} key={r}>
|
||||
<DropdownMenuRadioItem value={`${r}`}>
|
||||
รอบที่ {r}
|
||||
</DropdownMenuRadioItem>
|
||||
</Link>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-medium">ประวัติสลิปรายได้</span>
|
||||
<span>รอบที่ {round}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex h-full items-center justify-evenly">
|
||||
{round <= min ? (
|
||||
<ChevronLeft className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<SimpleTooltip text="รอบก่อนหน้า" side="right">
|
||||
<Link
|
||||
href={`/rubgram/admin/slip/${round - 1}`}
|
||||
className="flex h-full items-center"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Link>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
{round >= max ? (
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<SimpleTooltip text="รอบถัดไป" side="right">
|
||||
<Link
|
||||
href={`/rubgram/admin/slip/${round + 1}`}
|
||||
className="flex h-full items-center"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
{entries.map((e) => (
|
||||
<SidebarMenu key={e.id}>
|
||||
<SidebarMenuItem>
|
||||
<SidebarLink
|
||||
prefetch
|
||||
href={`/rubgram/admin/slip/${round}/${e.queue}`}
|
||||
>
|
||||
{e.queue}. {e.name}
|
||||
</SidebarLink>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" className="justify-center">
|
||||
รายได้รวม {entries.reduce((a, e) => a + e.price, 0)} บาท
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const range = (start: number, stop?: number, step?: number) => {
|
||||
// Handle cases where stop/step are optional (e.g., range(5) -> [0, 1, 2, 3, 4])
|
||||
if (stop === undefined) {
|
||||
[stop, start] = [start, 0];
|
||||
}
|
||||
if (step === undefined) {
|
||||
step = start < stop ? 1 : -1;
|
||||
}
|
||||
|
||||
// Calculate the length of the array
|
||||
const length = Math.ceil((stop - start) / step);
|
||||
|
||||
// Create the array using Array.from() and a map function
|
||||
return Array.from({ length }, (_, index) => start + index * step);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { ReceiptText } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { db } from "@/lib/db";
|
||||
import { endgameArchive } from "@/lib/db/schema";
|
||||
import { parseSearchNumber } from "@/lib/utils";
|
||||
|
||||
export default async function ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ round: string }>;
|
||||
}) {
|
||||
const { round: r } = await params;
|
||||
const round = parseSearchNumber(r) || 1;
|
||||
const [first] = await db
|
||||
.select({ queue: endgameArchive.queue })
|
||||
.from(endgameArchive)
|
||||
.where(eq(endgameArchive.round, round))
|
||||
.orderBy(endgameArchive.queue)
|
||||
.limit(1);
|
||||
if (first) redirect(`/rubgram/admin/slip/${round}/${first.queue}`);
|
||||
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ReceiptText />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>ยังไม่มีสลิป</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { endgameArchive } from "@/lib/db/schema";
|
||||
|
||||
export async function GET() {
|
||||
const [{ max }] = await db
|
||||
.select({
|
||||
max: sql<number>`max(${endgameArchive.round})`,
|
||||
})
|
||||
.from(endgameArchive);
|
||||
redirect(`/rubgram/admin/slip/${max || "1"}`);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { format, getDaysInMonth, startOfMonth } from "date-fns";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
ExternalLink,
|
||||
ImageOff,
|
||||
ReceiptText,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import { toggleMonth } from "../../api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
queue: number;
|
||||
checked: boolean;
|
||||
server: "as" | "eu" | "us" | "tw";
|
||||
expires: Date | null;
|
||||
price: number;
|
||||
service: string[];
|
||||
slip: {
|
||||
id: string;
|
||||
ref: string;
|
||||
amount: string;
|
||||
data: unknown;
|
||||
} | null;
|
||||
paid: boolean;
|
||||
deleted: boolean;
|
||||
submit_day: Date;
|
||||
user: {
|
||||
uid: string;
|
||||
display: string;
|
||||
username: string;
|
||||
token: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const DAYS = ["จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส.", "อา."];
|
||||
|
||||
export function CalendarClient({
|
||||
year,
|
||||
month,
|
||||
rows,
|
||||
total,
|
||||
monthly,
|
||||
}: {
|
||||
year: number;
|
||||
month: number;
|
||||
rows: Row[];
|
||||
total: number;
|
||||
monthly: Record<string, boolean>;
|
||||
}) {
|
||||
const current = new Date(year, month);
|
||||
|
||||
const rowsByDay = useMemo(() => {
|
||||
const map: Record<number, Row[]> = {};
|
||||
for (const r of rows) {
|
||||
if (!r.submit_day) continue;
|
||||
const d = new Date(r.submit_day).getDate();
|
||||
(map[d] ??= []).push(r);
|
||||
}
|
||||
return map;
|
||||
}, [rows]);
|
||||
|
||||
const ym = `${year}-${String(month + 1).padStart(2, "0")}`;
|
||||
const [pending, startTransition] = useTransition();
|
||||
const monthChecked = monthly[ym] ?? false;
|
||||
|
||||
const daysInMonth = getDaysInMonth(current);
|
||||
const firstDayOfWeek = startOfMonth(current).getDay(); // 0=Sun
|
||||
const monthLabel = format(current, "MMMM yyyy");
|
||||
const prevY = month === 0 ? year - 1 : year;
|
||||
const prevM = month === 0 ? 11 : month - 1;
|
||||
const nextY = month === 11 ? year + 1 : year;
|
||||
const nextM = month === 11 ? 0 : month + 1;
|
||||
const prevHref = `/rubgram/admin/calendar?month=${prevY}-${String(prevM + 1).padStart(2, "0")}`;
|
||||
const nextHref = `/rubgram/admin/calendar?month=${nextY}-${String(nextM + 1).padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link href={prevHref}>
|
||||
<ChevronLeft />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="inline-block min-w-35 shrink-0 text-center text-lg font-semibold">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<Link href={nextHref}>
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
text={monthChecked ? "ทำเรื่องเงินแล้ว" : "ยังไม่ได้ทำเรื่องเงิน"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(() => {
|
||||
toggleMonth(ym).catch((e) => toast.error(e as string));
|
||||
})
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{monthChecked ? (
|
||||
<Check className="size-5 text-green-500" />
|
||||
) : (
|
||||
<Circle className="size-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
<div className="text-muted-foreground">
|
||||
รวม{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{total.toLocaleString()}
|
||||
</span>{" "}
|
||||
บาท
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-7 grid-rows-[min-content_repeat(6,minmax(0,1fr))] gap-px rounded-lg bg-black/25">
|
||||
{DAYS.map((d) => (
|
||||
<div
|
||||
key={d}
|
||||
className="p-2 text-center text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* some hack for month start */}
|
||||
{Array.from({
|
||||
length: firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1,
|
||||
}).map((_, i) => (
|
||||
<div key={`pad-${i}`} />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map((day) => {
|
||||
const dayRows = rowsByDay[day] || [];
|
||||
const dayTotal = dayRows.reduce((s, r) => s + r.price, 0);
|
||||
const hasData = dayRows.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog key={day}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 rounded-md border p-2 text-left transition-colors",
|
||||
hasData
|
||||
? "cursor-pointer border-primary/30 bg-primary/5 hover:bg-primary/10"
|
||||
: "border-transparent",
|
||||
!hasData && "cursor-default",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{day}</span>
|
||||
{hasData && (
|
||||
<>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 px-1 py-0 text-[10px]"
|
||||
>
|
||||
{dayRows.length} คิว
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{dayTotal.toLocaleString()} ฿
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[80svh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{format(
|
||||
new Date(current.getFullYear(), current.getMonth(), day),
|
||||
"d MMM yyyy",
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{dayRows.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">ไม่มีคิวในวันนี้</p>
|
||||
)}
|
||||
{dayRows.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-md border p-3",
|
||||
r.deleted && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{r.slip ? (
|
||||
<div className="relative size-16 shrink-0 overflow-hidden rounded">
|
||||
<Image
|
||||
src={`/api/slip/${r.slip.id}`}
|
||||
alt="Slip"
|
||||
fill
|
||||
className="object-cover blur-sm brightness-50"
|
||||
/>
|
||||
</div>
|
||||
) : r.price <= 0 ? (
|
||||
<div className="flex size-16 shrink-0 items-center justify-center rounded bg-muted">
|
||||
<ReceiptText className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-16 shrink-0 items-center justify-center rounded bg-muted">
|
||||
<ImageOff className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{r.queue}. {r.name}
|
||||
</span>
|
||||
{r.deleted && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Trash2 className="size-3.5 shrink-0 text-destructive" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>ลบแล้ว</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{r.service.join(", ")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium">{r.price} ฿</span>
|
||||
{r.user && (
|
||||
<span className="text-muted-foreground">
|
||||
{r.user.display}
|
||||
</span>
|
||||
)}
|
||||
{r.slip && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-5"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex h-full max-w-dvw! flex-col bg-[#2225] backdrop-blur-xs">
|
||||
<DialogTitle className="h-min">สลิป</DialogTitle>
|
||||
<DialogClose asChild>
|
||||
<div className="relative flex h-full w-full grow justify-center">
|
||||
<Image
|
||||
src={`/api/slip/${r.slip.id}`}
|
||||
alt="Slip"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
รวม {dayTotal.toLocaleString()} บาท
|
||||
</span>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { and, between, eq, getTableColumns } from "drizzle-orm";
|
||||
import { startOfMonth, endOfMonth } from "date-fns";
|
||||
import { redirect } from "next/navigation";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
endgameDiscord,
|
||||
endgameSettings,
|
||||
endgameSlips,
|
||||
endgameSubmissions,
|
||||
} from "@/lib/db/schema";
|
||||
import { CalendarClient } from "./client";
|
||||
|
||||
export const metadata = { title: "ปฏิทินคิว" };
|
||||
|
||||
export default async function CalendarPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ month?: string }>;
|
||||
}) {
|
||||
if (!(await adminCheck()))
|
||||
redirect(`/login?next=${encodeURIComponent("/rubgram/admin/calendar")}`);
|
||||
|
||||
const { month } = await searchParams;
|
||||
const date = month ? new Date(month + "-01T00:00:00") : new Date();
|
||||
if (isNaN(date.getTime())) redirect("/rubgram/admin/calendar");
|
||||
|
||||
const from = startOfMonth(date);
|
||||
const to = endOfMonth(date);
|
||||
|
||||
const { slip: _, ...slipColumns } = getTableColumns(endgameSlips);
|
||||
const [rows, [settings]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
...getTableColumns(endgameSubmissions),
|
||||
user: getTableColumns(endgameDiscord),
|
||||
slip: slipColumns,
|
||||
})
|
||||
.from(endgameSubmissions)
|
||||
.where(and(between(endgameSubmissions.submit_day, from, to)))
|
||||
.leftJoin(endgameDiscord, eq(endgameDiscord.uid, endgameSubmissions.user))
|
||||
.leftJoin(endgameSlips, eq(endgameSlips.id, endgameSubmissions.slip))
|
||||
.orderBy(endgameSubmissions.submit_day, endgameSubmissions.queue),
|
||||
db
|
||||
.select({ monthly: endgameSettings.monthly })
|
||||
.from(endgameSettings)
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
const total = rows.reduce((s, r) => s + r.price, 0);
|
||||
const monthly = settings?.monthly ?? {};
|
||||
|
||||
return (
|
||||
<CalendarClient
|
||||
year={date.getFullYear()}
|
||||
month={date.getMonth()}
|
||||
rows={rows}
|
||||
total={total}
|
||||
monthly={monthly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useProgress } from "@bprogress/next";
|
||||
import { Lock, Logs, Unlock } from "lucide-react";
|
||||
import { Calendar, Lock, Unlock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -72,7 +72,8 @@ export function SidebarLink({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{submission.queue}. {submission.name}
|
||||
{submission.checked ? <div className="px-1" /> : `${submission.queue}. `}
|
||||
{submission.name}
|
||||
<SidebarMenuBadge className="pointer-events-auto">
|
||||
<Checkbox
|
||||
className="mr-2"
|
||||
@@ -251,10 +252,9 @@ export function SubmissionList({
|
||||
id: string;
|
||||
name: string;
|
||||
checked: boolean;
|
||||
queue: number;
|
||||
publicQueue: number;
|
||||
paid: boolean;
|
||||
archived: boolean;
|
||||
deleted: boolean;
|
||||
}[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -277,18 +277,18 @@ export function SubmissionList({
|
||||
{subs
|
||||
.filter(
|
||||
(s) =>
|
||||
(debug || (s.paid && !s.archived)) &&
|
||||
(s.queue + s.name).toLowerCase().includes(query),
|
||||
(debug || (s.paid && !s.deleted)) &&
|
||||
(s.publicQueue + s.name).toLowerCase().includes(query),
|
||||
)
|
||||
.map((s) => (
|
||||
<SidebarMenuButton
|
||||
key={s.id}
|
||||
asChild
|
||||
className={cn(((debug && !s.paid) || s.archived) && "opacity-50")}
|
||||
className={cn(((debug && !s.paid) || s.deleted) && "opacity-50")}
|
||||
>
|
||||
<SidebarLink
|
||||
submission={debug ? s : { ...s, queue: s.publicQueue }}
|
||||
prefetch={!s.archived}
|
||||
submission={{ ...s, queue: s.publicQueue }}
|
||||
prefetch={!s.deleted}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
))}
|
||||
@@ -324,23 +324,18 @@ export function Watcher() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export function SlipButton() {
|
||||
export function CalendarButton() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => {
|
||||
window.location.href = "/rubgram/admin/slip";
|
||||
}}
|
||||
>
|
||||
<Logs size={24} className="size-6" />
|
||||
<Button variant="ghost" size="icon" className="size-8" asChild>
|
||||
<Link href="/rubgram/admin/calendar">
|
||||
<Calendar size={24} className="size-6" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>All time คิว</p>
|
||||
<p>ปฏิทินคิว</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,13 @@ import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { endgameSubmissions } from "@/lib/db/schema";
|
||||
import { getEndgameConfig, random, wipe } from "../api";
|
||||
import { LimitManager, SlipButton, SubmissionList, Watcher } from "./client";
|
||||
import { desc, sql } from "drizzle-orm";
|
||||
import {
|
||||
CalendarButton,
|
||||
LimitManager,
|
||||
SubmissionList,
|
||||
Watcher,
|
||||
} from "./client";
|
||||
import { sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||
import { ErrorModal } from "@/components/error";
|
||||
@@ -48,21 +53,19 @@ export default async function AdminLayout({
|
||||
id: endgameSubmissions.id,
|
||||
name: endgameSubmissions.name,
|
||||
checked: endgameSubmissions.checked,
|
||||
queue: endgameSubmissions.queue,
|
||||
publicQueue: sql<number>`
|
||||
${endgameSubmissions.queue} - (
|
||||
select count(*)
|
||||
select count(*)::int
|
||||
from ${endgameSubmissions} e2
|
||||
where e2.checked = true
|
||||
and e2.queue < ${endgameSubmissions.queue}
|
||||
or e2.archived
|
||||
where (e2.checked and e2.queue < endgame.submissions.queue)
|
||||
or e2.deleted
|
||||
)
|
||||
`,
|
||||
paid: endgameSubmissions.paid,
|
||||
archived: endgameSubmissions.archived,
|
||||
deleted: endgameSubmissions.deleted,
|
||||
})
|
||||
.from(endgameSubmissions)
|
||||
.orderBy(desc(endgameSubmissions.archived), endgameSubmissions.id);
|
||||
.orderBy(endgameSubmissions.deleted, endgameSubmissions.id);
|
||||
const config = await getEndgameConfig();
|
||||
return (
|
||||
<SidebarProvider>
|
||||
@@ -84,7 +87,7 @@ export default async function AdminLayout({
|
||||
>
|
||||
<Dice5 size={24} className="size-6" />
|
||||
</Button>
|
||||
<SlipButton />
|
||||
<CalendarButton />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
|
||||
+26
-44
@@ -8,7 +8,6 @@ import { actionLog } from "@/lib/api";
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
endgameArchive,
|
||||
endgameDiscord,
|
||||
endgameExpired,
|
||||
endgameSettings,
|
||||
@@ -23,52 +22,15 @@ import { checkSlip } from "@/lib/payment";
|
||||
const { DISCORD_WEBHOOK_URL, DISCORD_CLIENT_ID, BASE_URL } =
|
||||
process.env as Record<string, string>;
|
||||
|
||||
export async function archive({ onlyChecked = true, copy = true }) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await db.transaction(async (tx) => {
|
||||
const deleted = await (
|
||||
copy
|
||||
? tx
|
||||
.update(endgameSubmissions)
|
||||
.set({
|
||||
archived: true,
|
||||
})
|
||||
.returning()
|
||||
: tx.delete(endgameSubmissions).returning()
|
||||
).where(
|
||||
and(
|
||||
onlyChecked ? eq(endgameSubmissions.checked, true) : undefined,
|
||||
not(endgameSubmissions.archived),
|
||||
),
|
||||
);
|
||||
if (!onlyChecked && !copy)
|
||||
await tx.execute(
|
||||
sql`ALTER SEQUENCE endgame.submissions_queue_seq RESTART WITH 1`,
|
||||
);
|
||||
const [{ maxRound }] = await tx
|
||||
.select({ maxRound: sql<number>`MAX(${endgameArchive.round})` })
|
||||
.from(endgameArchive);
|
||||
await tx.insert(endgameArchive).values(
|
||||
deleted
|
||||
.filter((e) => e.paid)
|
||||
.map((s) => ({
|
||||
...s,
|
||||
round: maxRound + 1,
|
||||
})),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function wipe() {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await archive({ onlyChecked: false });
|
||||
await db.update(endgameSubmissions).set({ deleted: true });
|
||||
revalidatePath("/rubgram/admin");
|
||||
revalidatePath("/rubgram");
|
||||
|
||||
sse.rubgram.pub("update", { type: "wipe" });
|
||||
|
||||
await actionLog(`Deleted rubgram submissions`);
|
||||
redirect("/rubgram/admin");
|
||||
}
|
||||
|
||||
export async function random() {
|
||||
@@ -165,6 +127,23 @@ export async function setFree(free: number) {
|
||||
await actionLog(`Set rubgram free submission amount to ${free}`);
|
||||
}
|
||||
|
||||
export async function toggleMonth(month: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
const [s] = await db
|
||||
.select({ monthly: endgameSettings.monthly })
|
||||
.from(endgameSettings)
|
||||
.limit(1);
|
||||
const monthly = {
|
||||
...(s?.monthly ?? {}),
|
||||
[month]: !(s?.monthly?.[month] ?? false),
|
||||
};
|
||||
await db
|
||||
.update(endgameSettings)
|
||||
.set({ monthly })
|
||||
.where(eq(endgameSettings.id, true));
|
||||
revalidatePath("/rubgram/admin/calendar");
|
||||
}
|
||||
|
||||
export async function getEndgameConfig() {
|
||||
const [ngm]: (typeof endgameSettings.$inferSelect | undefined)[] = await db
|
||||
.select()
|
||||
@@ -222,7 +201,7 @@ export async function submitEndgame(formData: FormData) {
|
||||
.where(
|
||||
and(
|
||||
eq(endgameSubmissions.user, user),
|
||||
not(endgameSubmissions.archived),
|
||||
not(endgameSubmissions.deleted),
|
||||
not(endgameSubmissions.checked),
|
||||
not(endgameSubmissions.paid),
|
||||
),
|
||||
@@ -265,7 +244,9 @@ export async function submitEndgamePayment(formData: FormData) {
|
||||
const [s] = await tx
|
||||
.select()
|
||||
.from(endgameSubmissions)
|
||||
.where(eq(endgameSubmissions.id, sid))
|
||||
.where(
|
||||
and(eq(endgameSubmissions.id, sid), not(endgameSubmissions.deleted)),
|
||||
)
|
||||
.limit(1);
|
||||
const [a] = await tx
|
||||
.select()
|
||||
@@ -410,9 +391,10 @@ export async function removeExpiredSubmissions() {
|
||||
}
|
||||
|
||||
export async function cancel(sid: string) {
|
||||
await db.delete(endgameSubmissions).where(eq(endgameSubmissions.id, sid));
|
||||
|
||||
await removeExpiredSubmissions();
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ deleted: true })
|
||||
.where(eq(endgameSubmissions.id, sid));
|
||||
|
||||
sse.rubgram.pub("update", { type: "cancel" });
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export default async function EndgamePage() {
|
||||
select count(*)
|
||||
from ${endgameSubmissions} e2
|
||||
where e2.checked = true
|
||||
and e2.queue < ${endgameSubmissions.queue}
|
||||
and e2.queue < endgame.submissions.queue
|
||||
)
|
||||
`,
|
||||
paid: endgameSubmissions.paid,
|
||||
@@ -104,7 +104,7 @@ export default async function EndgamePage() {
|
||||
gt(endgameSubmissions.expires, new Date()),
|
||||
),
|
||||
),
|
||||
not(endgameSubmissions.archived),
|
||||
not(endgameSubmissions.deleted),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { not } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { endgameSubmissions } from "@/lib/db/schema";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(await db.$count(endgameSubmissions));
|
||||
return Response.json(
|
||||
await db.$count(endgameSubmissions, not(endgameSubmissions.deleted)),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-8
@@ -2,7 +2,7 @@ import { env } from "node:process";
|
||||
import { cron, redis } from "bun";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { and, asc, eq, gt, isNull, lt, not, sql } from "drizzle-orm";
|
||||
import { archive, removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
|
||||
import { removeExpiredSubmissions } from "@/app/(ui)/rubgram/api";
|
||||
import { auth, issueInternalToken } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
@@ -272,13 +272,6 @@ async function cacheCards() {
|
||||
} else schedule(120, cacheCards);
|
||||
}
|
||||
|
||||
cron("0 0 1 * *", async function rubgramArchive() {
|
||||
const { log } = logger("rubgramArchive");
|
||||
log("Started...");
|
||||
await archive({});
|
||||
log("Done");
|
||||
});
|
||||
|
||||
cron("0 0 */14 * *", async function syncAmber() {
|
||||
const { log, error } = logger("syncAmber");
|
||||
log("Syncing...");
|
||||
|
||||
+3
-15
@@ -159,21 +159,8 @@ export const endgameSubmissions = endgame.table("submissions", {
|
||||
isNotNull(endgameSubmissions.slip),
|
||||
)!,
|
||||
),
|
||||
archived: boolean().notNull().default(false),
|
||||
});
|
||||
|
||||
export const endgameArchive = endgame.table("sarchive", {
|
||||
id: text().primaryKey(),
|
||||
name: text().notNull(),
|
||||
user: text().notNull(),
|
||||
queue: integer().notNull(),
|
||||
price: integer().notNull(),
|
||||
slip: text().references(() => endgameSlips.id),
|
||||
round: integer().notNull(),
|
||||
service: text()
|
||||
.references(() => endgameTypes.id)
|
||||
.array()
|
||||
.notNull(),
|
||||
deleted: boolean().notNull().default(false),
|
||||
submit_day: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// expired submission
|
||||
@@ -207,6 +194,7 @@ export const endgameSettings = endgame.table("settings", {
|
||||
limit: integer().notNull().default(-1),
|
||||
free: integer().notNull().default(0),
|
||||
allDiscount: integer().notNull().default(10),
|
||||
monthly: jsonb().$type<Record<string, boolean>>().notNull().default({}),
|
||||
});
|
||||
|
||||
export const endgameDiscord = endgame.table("discord", {
|
||||
|
||||
Reference in New Issue
Block a user