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
@@ -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}
+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>
</>;
}