feat : add monthly
CI / Verify (push) Successful in 2m50s
CI / Build immutable images and deploy (push) Successful in 2m26s

This commit is contained in:
2026-10-04 15:29:02 +07:00 Unverified
parent 33ad2f498b
commit 643e77a88f
14 changed files with 4245 additions and 28 deletions
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { and, asc, eq, gte, lt } from "drizzle-orm";
import { redirect } from "next/navigation";
import { connection } from "next/server";
import { AdminHeader } from "@/components/admin/admin-header";
import { CommissionIncomeCalendar } from "@/components/commission/income-calendar";
import { getDb } from "@/db";
import { commissionPayments, commissionTickets, users } from "@/db/schema";
import { getAdminSession } from "@/lib/auth/server";
import { commissionIncomeMonth } from "@/lib/commission/income";
export const instant = false;
export default async function CommissionIncomePage({ searchParams }: PageProps<"/admin/commission/income">) {
await connection();
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin%2Fcommission%2Fincome");
const { month: rawMonth } = await searchParams;
const period = commissionIncomeMonth(rawMonth);
const rows = await getDb().select({
ticketId: commissionTickets.id,
title: commissionTickets.title,
creatorName: users.name,
amountBaht: commissionPayments.amountBaht,
transferredAt: commissionPayments.transferredAt,
}).from(commissionPayments)
.innerJoin(commissionTickets, eq(commissionTickets.id, commissionPayments.ticketId))
.innerJoin(users, eq(users.id, commissionTickets.userId))
.where(and(gte(commissionPayments.transferredAt, period.from), lt(commissionPayments.transferredAt, period.to)))
.orderBy(asc(commissionPayments.transferredAt), asc(commissionPayments.id));
return <div className="min-h-svh"><AdminHeader />
<main className="mx-auto flex max-w-6xl flex-col gap-5 p-4 py-10 sm:p-8">
<Link href="/admin/commission" className="w-fit text-sm underline">กลับไป Commission tickets</Link>
<header><h1 className="font-heading text-3xl font-semibold">Commission income</h1>
<p className="text-muted-foreground">รายได้ประจำเดือนจาก Commision</p></header>
<CommissionIncomeCalendar period={{ month: period.month, year: period.year, number: period.number,
previous: period.previous, next: period.next, days: period.days, leadingDays: period.leadingDays }} rows={rows} />
</main>
</div>;
}
+1
View File
@@ -31,6 +31,7 @@ export default async function AdminCommissionPage({ searchParams }: PageProps<"/
<main className="mx-auto flex max-w-5xl flex-col gap-5 p-4 py-10 sm:p-8"> <main className="mx-auto flex max-w-5xl flex-col gap-5 p-4 py-10 sm:p-8">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="font-heading text-3xl font-semibold">Commission tickets</h1> <h1 className="font-heading text-3xl font-semibold">Commission tickets</h1>
<Button variant="outline" nativeButton={false} render={<Link href="/admin/commission/income" />}>ดูรายได้ประจำเดือน</Button>
</div> </div>
<AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`} <AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`}
{...availability} /> {...availability} />
+22 -4
View File
@@ -12,17 +12,35 @@ import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
export default async function CreateGuidePage() { export default async function CreateGuidePage() {
await connection(); await connection();
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin"); if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const [catalog, guides] = await Promise.all([getActiveCatalog(), listGuideCards()]); const [catalog, guides] = await Promise.all([
getActiveCatalog(),
listGuideCards(),
]);
if (!catalog) redirect("/admin?sync=required"); if (!catalog) redirect("/admin?sync=required");
return ( return (
<div className="min-h-svh"> <div className="min-h-svh">
<AdminHeader /> <AdminHeader />
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8"> <main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
<header className="flex items-center gap-4"> <header className="flex items-center gap-4">
<Button variant="ghost" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button> <Button
<div><p className="text-sm text-muted-foreground">Catalog ล่าสุดจาก Lunaris</p><h1 className="font-heading text-3xl font-semibold">สร้าง Guide ใหม่</h1></div> variant="ghost"
size="icon"
render={<Link href="/admin" />}
nativeButton={false}
aria-label="กลับ Dashboard"
>
<ArrowLeftIcon />
</Button>
<div>
<h1 className="font-heading text-3xl font-semibold">
สร้าง Guide ใหม่
</h1>
</div>
</header> </header>
<CreateGuideFlow characters={catalog.characters} existingKeys={guides.map((guide) => guide.characterKey)} /> <CreateGuideFlow
characters={catalog.characters}
existingKeys={guides.map((guide) => guide.characterKey)}
/>
</main> </main>
</div> </div>
); );
+11 -20
View File
@@ -68,26 +68,6 @@ export function AdminHeader() {
<BookOpenIcon data-icon="inline-start" /> <BookOpenIcon data-icon="inline-start" />
<span className="hidden xl:inline">Glossary</span> <span className="hidden xl:inline">Glossary</span>
</Button> </Button>
<Button
variant={pathname === "/admin" ? "secondary" : "ghost"}
size="sm"
nativeButton={false}
render={<Link href="/admin" />}
aria-label="Dashboard"
>
<LayoutDashboardIcon data-icon="inline-start" />
<span className="hidden xl:inline">Dashboard</span>
</Button>
<Button
variant={pathname === "/admin/create" ? "secondary" : "ghost"}
size="sm"
nativeButton={false}
render={<Link href="/admin/create" />}
aria-label="New Guide"
>
<PlusIcon data-icon="inline-start" />
<span className="hidden xl:inline">New Guide</span>
</Button>
<Button <Button
variant={ variant={
pathname.startsWith("/admin/image") ? "secondary" : "ghost" pathname.startsWith("/admin/image") ? "secondary" : "ghost"
@@ -100,6 +80,17 @@ export function AdminHeader() {
<ImageIcon data-icon="inline-start" /> <ImageIcon data-icon="inline-start" />
<span className="hidden xl:inline">Image Generator</span> <span className="hidden xl:inline">Image Generator</span>
</Button> </Button>
<Button
variant={pathname === "/admin" ? "secondary" : "ghost"}
size="sm"
nativeButton={false}
render={<Link href="/admin" />}
aria-label="Dashboard"
>
<LayoutDashboardIcon data-icon="inline-start" />
<span className="hidden xl:inline">Dashboard</span>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -32,7 +32,7 @@ export function AdminCommissionAvailabilityControl({ isOpen, closureMessage }: {
return <Card><CardHeader> return <Card><CardHeader>
<CardTitle>การรับ Commission</CardTitle> <CardTitle>การรับ Commission</CardTitle>
<CardDescription>การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้</CardDescription> <CardDescription>การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้ ทุก Ticket ยังคงคุยได้ตามปกติ</CardDescription>
</CardHeader><CardContent className="flex flex-col gap-3"> </CardHeader><CardContent className="flex flex-col gap-3">
<label htmlFor="commission-closure-message" className="text-sm font-medium">ข้อความเมื่อปิดรับ</label> <label htmlFor="commission-closure-message" className="text-sm font-medium">ข้อความเมื่อปิดรับ</label>
<Textarea id="commission-closure-message" value={message} maxLength={1000} <Textarea id="commission-closure-message" value={message} maxLength={1000}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { commissionIncomeDay, commissionIncomeShares } from "@/lib/commission/income";
type IncomeRow = { ticketId: string; title: string | null; creatorName: string; amountBaht: number; transferredAt: Date };
type Period = { month: string; year: number; number: number; previous: string; next: string; days: number; leadingDays: number };
const weekdays = ["จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส.", "อา."];
const money = (satang: number) => `฿${(satang / 100).toLocaleString("th-TH", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const time = new Intl.DateTimeFormat("th-TH", { timeZone: "Asia/Bangkok", hour: "2-digit", minute: "2-digit" });
const date = new Intl.DateTimeFormat("th-TH", { timeZone: "Asia/Bangkok", day: "numeric", month: "long", year: "numeric" });
function Totals({ amount }: { amount: number }) {
const shares = commissionIncomeShares(amount);
return <span>รวม {money(amount * 100)} · Buzz {money(shares.buzzSatang)} · Gunshin {money(shares.gunshinSatang)}</span>;
}
export function CommissionIncomeCalendar({ period, rows }: { period: Period; rows: IncomeRow[] }) {
const byDay = new Map<number, IncomeRow[]>();
for (const row of rows) {
const day = commissionIncomeDay(row.transferredAt);
const list = byDay.get(day) ?? [];
list.push(row);
byDay.set(day, list);
}
const total = rows.reduce((sum, row) => sum + row.amountBaht, 0);
const monthLabel = new Intl.DateTimeFormat("th-TH", { month: "long", year: "numeric", timeZone: "UTC" })
.format(new Date(Date.UTC(period.year, period.number - 1, 1)));
return <>
<div className="flex flex-wrap items-center justify-between gap-3">
<nav className="flex items-center gap-2" aria-label="เดือนของรายงาน">
<Button variant="outline" size="icon" nativeButton={false} render={<Link href={`/admin/commission/income?month=${period.previous}`} prefetch={false} />} aria-label="เดือนก่อนหน้า"><ChevronLeft /></Button>
<span className="min-w-36 text-center text-lg font-semibold">{monthLabel}</span>
<Button variant="outline" size="icon" nativeButton={false} render={<Link href={`/admin/commission/income?month=${period.next}`} prefetch={false} />} aria-label="เดือนถัดไป"><ChevronRight /></Button>
</nav>
<p className="text-sm font-medium"><Totals amount={total} /></p>
</div>
<div className="overflow-x-auto">
<div className="grid min-w-[700px] grid-cols-7 gap-1">
{weekdays.map((day) => <div key={day} className="p-2 text-center text-sm text-muted-foreground">{day}</div>)}
{Array.from({ length: period.leadingDays }, (_, index) => <div key={`pad-${index}`} />)}
{Array.from({ length: period.days }, (_, index) => {
const day = index + 1;
const tickets = byDay.get(day) ?? [];
const dayTotal = tickets.reduce((sum, row) => sum + row.amountBaht, 0);
const label = date.format(new Date(`${period.month}-${String(day).padStart(2, "0")}T12:00:00+07:00`));
if (tickets.length === 0) return <div key={day} className="min-h-24 rounded-lg border p-2 font-medium">{day}</div>;
return <Dialog key={day}>
<DialogTrigger render={<button type="button" className="flex min-h-24 flex-col gap-1 rounded-lg border p-2 text-left hover:bg-muted/50" />}>
<span className="font-medium">{day}</span>
<span className="text-xs text-muted-foreground">{tickets.length} tickets</span>
<span className="text-sm">{money(dayTotal * 100)}</span>
</DialogTrigger>
<DialogContent className="max-h-[85svh] overflow-y-auto sm:max-w-2xl">
<DialogHeader><DialogTitle>{label}</DialogTitle></DialogHeader>
<p className="text-sm"><Totals amount={dayTotal} /></p>
<div className="space-y-2">
{tickets.map((row) => <div key={row.ticketId} className="rounded-lg border p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link href={`/admin/commission/${row.ticketId}`} className="font-medium underline">{row.title || `Ticket #${row.ticketId.slice(0, 8)}`}</Link>
<p className="text-muted-foreground">สร้างโดย {row.creatorName}</p>
</div>
<div className="shrink-0 text-right">
<time className="block text-muted-foreground">{time.format(row.transferredAt)}</time>
<span className="font-medium">{money(row.amountBaht * 100)}</span>
</div>
</div>
</div>)}
</div>
</DialogContent>
</Dialog>;
})}
</div>
</div>
</>;
}
+1 -2
View File
@@ -85,7 +85,6 @@ export function MaterialPoster({
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null); const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const stats = character.baseStats.slice(0, 5); const stats = character.baseStats.slice(0, 5);
const statBoxCount = Math.max(4, stats.length); const statBoxCount = Math.max(4, stats.length);
const hasFiveStats = statBoxCount === 5;
const materials = mergePosterMaterials( const materials = mergePosterMaterials(
[...character.ascension, ...character.leveling].flatMap((group) => group.items), [...character.ascension, ...character.leveling].flatMap((group) => group.items),
); );
@@ -218,7 +217,7 @@ export function MaterialPoster({
</div> </div>
</div> </div>
<div className={cn("absolute inset-x-[4%]", hasFiveStats ? "top-[57%]" : "top-[49%]")} style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic" }}> <div className="absolute inset-x-[4%] top-[49%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic" }}>
<h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>MATERIALS</h2> <h2 className="text-[7cqw] leading-none" style={{ textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>MATERIALS</h2>
<p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p> <p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p>
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]"> <div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
+1
View File
@@ -200,6 +200,7 @@ export const commissionPayments = commissionSchema.table("payment", {
uniqueIndex("commission_payment_checkout_unique").on(table.checkoutId), uniqueIndex("commission_payment_checkout_unique").on(table.checkoutId),
uniqueIndex("commission_payment_reference_unique").on(table.referenceId), uniqueIndex("commission_payment_reference_unique").on(table.referenceId),
uniqueIndex("commission_payment_trans_ref_unique").on(table.transRef), uniqueIndex("commission_payment_trans_ref_unique").on(table.transRef),
index("commission_payment_transferred_at_idx").on(table.transferredAt),
]); ]);
export const commissionPushSubscriptions = commissionSchema.table("push_subscription", { export const commissionPushSubscriptions = commissionSchema.table("push_subscription", {
+1
View File
@@ -0,0 +1 @@
CREATE INDEX "commission_payment_transferred_at_idx" ON "commission"."payment" USING btree ("transferred_at");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -498,6 +498,13 @@
"when": 1791015188934, "when": 1791015188934,
"tag": "0070_outgoing_chimera", "tag": "0070_outgoing_chimera",
"breakpoints": true "breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1791100706838,
"tag": "0071_first_norman_osborn",
"breakpoints": true
} }
] ]
} }
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { commissionIncomeDay, commissionIncomeMonth, commissionIncomeShares } from "./income";
describe("commission income periods", () => {
it("uses Bangkok month boundaries and rolls across years", () => {
const december = commissionIncomeMonth("2026-12");
expect(december.from.toISOString()).toBe("2026-11-30T17:00:00.000Z");
expect(december.to.toISOString()).toBe("2026-12-31T17:00:00.000Z");
expect(december.next).toBe("2027-01");
expect(commissionIncomeMonth("2027-01").previous).toBe("2026-12");
});
it("defaults to the current Bangkok month for an invalid query", () => {
expect(commissionIncomeMonth("2026-13", new Date("2026-09-30T18:00:00Z")).month).toBe("2026-10");
});
it("assigns payments around midnight to the correct Bangkok day", () => {
expect(commissionIncomeDay(new Date("2026-10-01T16:59:59Z"))).toBe(1);
expect(commissionIncomeDay(new Date("2026-10-01T17:00:00Z"))).toBe(2);
});
});
it("calculates Buzz and Gunshin shares in satang", () => {
expect(commissionIncomeShares(101)).toEqual({ buzzSatang: 9090, gunshinSatang: 1010 });
});
+29
View File
@@ -0,0 +1,29 @@
const BANGKOK_OFFSET = "+07:00";
export function commissionIncomeMonth(value: string | string[] | undefined, now = new Date()) {
const currentParts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Bangkok", year: "numeric", month: "2-digit",
}).formatToParts(now);
const current = `${currentParts.find((part) => part.type === "year")!.value}-${currentParts.find((part) => part.type === "month")!.value}`;
const month = typeof value === "string" && /^(20\d{2}|[3-9]\d{3})-(0[1-9]|1[0-2])$/.test(value) ? value : current;
const [year, number] = month.split("-").map(Number);
const next = number === 12 ? `${year + 1}-01` : `${year}-${String(number + 1).padStart(2, "0")}`;
const previous = number === 1 ? `${year - 1}-12` : `${year}-${String(number - 1).padStart(2, "0")}`;
return {
month, year, number, previous, next,
from: new Date(`${month}-01T00:00:00${BANGKOK_OFFSET}`),
to: new Date(`${next}-01T00:00:00${BANGKOK_OFFSET}`),
days: new Date(Date.UTC(year, number, 0)).getUTCDate(),
leadingDays: (new Date(Date.UTC(year, number - 1, 1)).getUTCDay() + 6) % 7,
};
}
export function commissionIncomeDay(date: Date) {
return Number(new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Bangkok", day: "numeric",
}).format(date));
}
export function commissionIncomeShares(amountBaht: number) {
return { buzzSatang: amountBaht * 90, gunshinSatang: amountBaht * 10 };
}
+1 -1
View File
@@ -218,7 +218,7 @@ describe("material poster text", () => {
expect(html).toContain("Energy Recharge%"); expect(html).toContain("Energy Recharge%");
expect(html).toContain("132%"); expect(html).toContain("132%");
expect(html).toContain("h-[7cqw]"); expect(html).toContain("h-[7cqw]");
expect(html).toContain("top-[57%]"); expect(html).toContain("top-[49%]");
}); });
it("optimizes scale-aware character artwork and bleeding layers for export", () => { it("optimizes scale-aware character artwork and bleeding layers for export", () => {