feat : add monthly
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="font-heading text-3xl font-semibold">Commission tickets</h1>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/admin/commission/income" />}>ดูรายได้ประจำเดือน</Button>
|
||||
</div>
|
||||
<AdminCommissionAvailabilityControl key={`${availability.isOpen}:${availability.closureMessage}`}
|
||||
{...availability} />
|
||||
|
||||
@@ -12,17 +12,35 @@ import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
|
||||
export default async function CreateGuidePage() {
|
||||
await connection();
|
||||
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");
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" render={<Link href="/admin" />} nativeButton={false} aria-label="กลับ Dashboard"><ArrowLeftIcon /></Button>
|
||||
<div><p className="text-sm text-muted-foreground">Catalog ล่าสุดจาก Lunaris</p><h1 className="font-heading text-3xl font-semibold">สร้าง Guide ใหม่</h1></div>
|
||||
<Button
|
||||
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>
|
||||
<CreateGuideFlow characters={catalog.characters} existingKeys={guides.map((guide) => guide.characterKey)} />
|
||||
<CreateGuideFlow
|
||||
characters={catalog.characters}
|
||||
existingKeys={guides.map((guide) => guide.characterKey)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -68,26 +68,6 @@ export function AdminHeader() {
|
||||
<BookOpenIcon data-icon="inline-start" />
|
||||
<span className="hidden xl:inline">Glossary</span>
|
||||
</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
|
||||
variant={
|
||||
pathname.startsWith("/admin/image") ? "secondary" : "ghost"
|
||||
@@ -100,6 +80,17 @@ export function AdminHeader() {
|
||||
<ImageIcon data-icon="inline-start" />
|
||||
<span className="hidden xl:inline">Image Generator</span>
|
||||
</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="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
@@ -32,7 +32,7 @@ export function AdminCommissionAvailabilityControl({ isOpen, closureMessage }: {
|
||||
|
||||
return <Card><CardHeader>
|
||||
<CardTitle>การรับ Commission</CardTitle>
|
||||
<CardDescription>การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้</CardDescription>
|
||||
<CardDescription>การปิดรับจะหยุดคำขอใหม่ ลูกค้าที่มีคำขออยู่แล้วชำระเงินต่อได้ ทุก Ticket ยังคงคุยได้ตามปกติ</CardDescription>
|
||||
</CardHeader><CardContent className="flex flex-col gap-3">
|
||||
<label htmlFor="commission-closure-message" className="text-sm font-medium">ข้อความเมื่อปิดรับ</label>
|
||||
<Textarea id="commission-closure-message" value={message} maxLength={1000}
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
}
|
||||
@@ -85,7 +85,6 @@ export function MaterialPoster({
|
||||
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||
const stats = character.baseStats.slice(0, 5);
|
||||
const statBoxCount = Math.max(4, stats.length);
|
||||
const hasFiveStats = statBoxCount === 5;
|
||||
const materials = mergePosterMaterials(
|
||||
[...character.ascension, ...character.leveling].flatMap((group) => group.items),
|
||||
);
|
||||
@@ -218,7 +217,7 @@ export function MaterialPoster({
|
||||
</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>
|
||||
<p className="mt-[1%] text-[4.5cqw] leading-none">LV.90</p>
|
||||
<div className="mt-[5%] grid grid-cols-8 gap-[1.2%]">
|
||||
|
||||
@@ -200,6 +200,7 @@ export const commissionPayments = commissionSchema.table("payment", {
|
||||
uniqueIndex("commission_payment_checkout_unique").on(table.checkoutId),
|
||||
uniqueIndex("commission_payment_reference_unique").on(table.referenceId),
|
||||
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", {
|
||||
|
||||
@@ -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
@@ -498,6 +498,13 @@
|
||||
"when": 1791015188934,
|
||||
"tag": "0070_outgoing_chimera",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1791100706838,
|
||||
"tag": "0071_first_norman_osborn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -218,7 +218,7 @@ describe("material poster text", () => {
|
||||
expect(html).toContain("Energy Recharge%");
|
||||
expect(html).toContain("132%");
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user