Add functions to commission form
This commit is contained in:
24
prisma/migrations/20260101072430_com_3/migration.sql
Normal file
24
prisma/migrations/20260101072430_com_3/migration.sql
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CommissionRequest" ADD COLUMN "customFields" JSONB,
|
||||||
|
ADD COLUMN "customerSocials" TEXT,
|
||||||
|
ADD COLUMN "ipAddress" TEXT,
|
||||||
|
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'NEW',
|
||||||
|
ADD COLUMN "userAgent" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionRequestFile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"objectKey" TEXT NOT NULL,
|
||||||
|
"originalName" TEXT NOT NULL,
|
||||||
|
"mimeType" TEXT NOT NULL,
|
||||||
|
"sizeBytes" INTEGER NOT NULL,
|
||||||
|
"requestId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionRequestFile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionRequestFile" ADD CONSTRAINT "CommissionRequestFile_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "CommissionRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
30
prisma/migrations/20260101072657_com_4/migration.sql
Normal file
30
prisma/migrations/20260101072657_com_4/migration.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `mimeType` on the `CommissionRequestFile` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `objectKey` on the `CommissionRequestFile` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `originalName` on the `CommissionRequestFile` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `sizeBytes` on the `CommissionRequestFile` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `sortIndex` on the `CommissionRequestFile` table. All the data in the column will be lost.
|
||||||
|
- A unique constraint covering the columns `[fileKey]` on the table `CommissionRequestFile` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `fileKey` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `fileSize` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `fileType` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `originalFile` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `uploadDate` to the `CommissionRequestFile` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CommissionRequestFile" DROP COLUMN "mimeType",
|
||||||
|
DROP COLUMN "objectKey",
|
||||||
|
DROP COLUMN "originalName",
|
||||||
|
DROP COLUMN "sizeBytes",
|
||||||
|
DROP COLUMN "sortIndex",
|
||||||
|
ADD COLUMN "fileKey" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "fileSize" INTEGER NOT NULL,
|
||||||
|
ADD COLUMN "fileType" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "originalFile" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "uploadDate" TIMESTAMP(3) NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CommissionRequestFile_fileKey_key" ON "CommissionRequestFile"("fileKey");
|
||||||
@ -360,6 +360,12 @@ model CommissionRequest {
|
|||||||
customerName String
|
customerName String
|
||||||
customerEmail String
|
customerEmail String
|
||||||
message String
|
message String
|
||||||
|
status String @default("NEW") // NEW | REVIEWING | ACCEPTED | REJECTED | SPAM
|
||||||
|
|
||||||
|
customerSocials String?
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
customFields Json?
|
||||||
|
|
||||||
optionId String?
|
optionId String?
|
||||||
typeId String?
|
typeId String?
|
||||||
@ -367,6 +373,7 @@ model CommissionRequest {
|
|||||||
type CommissionType? @relation(fields: [typeId], references: [id])
|
type CommissionType? @relation(fields: [typeId], references: [id])
|
||||||
|
|
||||||
extras CommissionExtra[]
|
extras CommissionExtra[]
|
||||||
|
files CommissionRequestFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommissionGuidelines {
|
model CommissionGuidelines {
|
||||||
@ -380,6 +387,21 @@ model CommissionGuidelines {
|
|||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CommissionRequestFile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
fileKey String @unique
|
||||||
|
originalFile String
|
||||||
|
fileType String
|
||||||
|
fileSize Int
|
||||||
|
uploadDate DateTime
|
||||||
|
|
||||||
|
requestId String
|
||||||
|
request CommissionRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model TermsOfService {
|
model TermsOfService {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
14
src/actions/commissions/deleteCommissionRequest.ts
Normal file
14
src/actions/commissions/deleteCommissionRequest.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function deleteCommissionRequest(id: string) {
|
||||||
|
const parsed = z.string().min(1).parse(id);
|
||||||
|
|
||||||
|
await prisma.commissionRequest.delete({
|
||||||
|
where: { id: parsed },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
110
src/actions/commissions/getCommissionRequestsTablePage.ts
Normal file
110
src/actions/commissions/getCommissionRequestsTablePage.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
commissionRequestTableRowSchema,
|
||||||
|
commissionStatusSchema,
|
||||||
|
} from "@/schemas/commissions/tableSchema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type CursorPagination = { pageIndex: number; pageSize: number };
|
||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
const triStateSchema = z.enum(["any", "true", "false"]);
|
||||||
|
type TriState = z.infer<typeof triStateSchema>;
|
||||||
|
|
||||||
|
const sortingSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
desc: z.boolean(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtersSchema = z.object({
|
||||||
|
q: z.string().optional(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
status: z.union([z.literal("any"), commissionStatusSchema]).default("any"),
|
||||||
|
hasFiles: triStateSchema.default("any"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getCommissionRequestsTablePage(input: {
|
||||||
|
pagination: CursorPagination;
|
||||||
|
sorting: z.infer<typeof sortingSchema>;
|
||||||
|
filters: z.infer<typeof filtersSchema>;
|
||||||
|
}) {
|
||||||
|
const { pagination, sorting, filters } = input;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (filters.q) {
|
||||||
|
const q = filters.q.trim();
|
||||||
|
if (q) {
|
||||||
|
where.OR = [
|
||||||
|
{ customerName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ customerEmail: { contains: q, mode: "insensitive" } },
|
||||||
|
{ message: { contains: q, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.email) {
|
||||||
|
const e = filters.email.trim();
|
||||||
|
if (e) where.customerEmail = { contains: e, mode: "insensitive" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status !== "any") {
|
||||||
|
where.status = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.hasFiles !== "any") {
|
||||||
|
where.files = filters.hasFiles === "true" ? { some: {} } : { none: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// sorting
|
||||||
|
const sort = sorting?.[0] ?? { id: "createdAt", desc: true };
|
||||||
|
const orderBy: any =
|
||||||
|
sort.id === "createdAt"
|
||||||
|
? { createdAt: sort.desc ? "desc" : "asc" }
|
||||||
|
: sort.id === "status"
|
||||||
|
? { status: sort.desc ? "desc" : "asc" }
|
||||||
|
: { createdAt: "desc" };
|
||||||
|
|
||||||
|
const [total, rows] = await prisma.$transaction([
|
||||||
|
prisma.commissionRequest.count({ where }),
|
||||||
|
prisma.commissionRequest.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: pagination.pageIndex * pagination.pageSize,
|
||||||
|
take: pagination.pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
status: true,
|
||||||
|
customerName: true,
|
||||||
|
customerEmail: true,
|
||||||
|
customerSocials: true,
|
||||||
|
message: true,
|
||||||
|
_count: { select: { files: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mapped = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
status: r.status as any,
|
||||||
|
customerName: r.customerName,
|
||||||
|
customerEmail: r.customerEmail,
|
||||||
|
customerSocials: r.customerSocials ?? null,
|
||||||
|
messagePreview: r.message.slice(0, 140),
|
||||||
|
filesCount: r._count.files,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Validate output once (helps catch schema drift)
|
||||||
|
const parsed = z.array(commissionRequestTableRowSchema).safeParse(mapped);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error("Commission table row shape mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, rows: parsed.data };
|
||||||
|
}
|
||||||
20
src/actions/commissions/setCommissionRequestStatus.ts
Normal file
20
src/actions/commissions/setCommissionRequestStatus.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { commissionStatusSchema } from "@/schemas/commissions/tableSchema";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export async function setCommissionRequestStatus(input: {
|
||||||
|
id: string;
|
||||||
|
status: z.infer<typeof commissionStatusSchema>;
|
||||||
|
}) {
|
||||||
|
const id = z.string().min(1).parse(input.id);
|
||||||
|
const status = commissionStatusSchema.parse(input.status);
|
||||||
|
|
||||||
|
await prisma.commissionRequest.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
|
import { CommissionRequestsTable } from "@/components/commissions/CommissionRequestsTable";
|
||||||
|
|
||||||
export default function CommissionPage() {
|
export default function CommissionPage() {
|
||||||
return (
|
return (
|
||||||
<div>CommissionPage</div>
|
<div>
|
||||||
|
<CommissionRequestsTable />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
139
src/app/api/v1/commissions/route.ts
Normal file
139
src/app/api/v1/commissions/route.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { s3 } from "@/lib/s3";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const runtime = "nodejs"; // required for AWS SDK on many setups
|
||||||
|
export const dynamic = "force-dynamic"; // API endpoint should be dynamic
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
typeId: z.string().min(1).optional().nullable(),
|
||||||
|
optionId: z.string().min(1).optional().nullable(),
|
||||||
|
extraIds: z.array(z.string().min(1)).default([]),
|
||||||
|
|
||||||
|
customerName: z.string().min(1).max(200),
|
||||||
|
customerEmail: z.string().email().max(320),
|
||||||
|
customerSocials: z.string().max(2000).optional().nullable(),
|
||||||
|
message: z.string().min(1).max(20_000),
|
||||||
|
|
||||||
|
// customFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function safeJsonParse(input: string) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(input) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Optional: basic origin allowlist check (recommended)
|
||||||
|
// const origin = request.headers.get("origin");
|
||||||
|
// if (origin && !["https://domain.com", "https://www.domain.com"].includes(origin)) {
|
||||||
|
// return NextResponse.json({ error: "Invalid origin" }, { status: 403 });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
|
||||||
|
const payloadRaw = form.get("payload");
|
||||||
|
if (typeof payloadRaw !== "string") {
|
||||||
|
return NextResponse.json({ error: "Missing payload" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedJson = safeJsonParse(payloadRaw);
|
||||||
|
if (!parsedJson) {
|
||||||
|
return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = payloadSchema.safeParse(parsedJson);
|
||||||
|
if (!payload.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Validation error", issues: payload.error.issues },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = form.getAll("files").filter((v): v is File => v instanceof File);
|
||||||
|
|
||||||
|
// Optional: enforce limits
|
||||||
|
const MAX_FILES = 10;
|
||||||
|
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
|
||||||
|
if (files.length > MAX_FILES) {
|
||||||
|
return NextResponse.json({ error: "Too many files" }, { status: 413 });
|
||||||
|
}
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.size > MAX_BYTES_EACH) {
|
||||||
|
return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture basic metadata
|
||||||
|
const ipAddress =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
|
||||||
|
const userAgent = request.headers.get("user-agent") ?? null;
|
||||||
|
|
||||||
|
// Create request first to get requestId; then upload files and store file rows
|
||||||
|
// Use a transaction to keep DB consistent.
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.commissionRequest.create({
|
||||||
|
data: {
|
||||||
|
status: "NEW",
|
||||||
|
customerName: payload.data.customerName,
|
||||||
|
customerEmail: payload.data.customerEmail,
|
||||||
|
customerSocials: payload.data.customerSocials ?? null,
|
||||||
|
message: payload.data.message,
|
||||||
|
|
||||||
|
typeId: payload.data.typeId ?? null,
|
||||||
|
optionId: payload.data.optionId ?? null,
|
||||||
|
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
|
||||||
|
// customFields: payload.data.customFields ?? undefined,
|
||||||
|
|
||||||
|
// Extras are M:N; connect by ids
|
||||||
|
extras: payload.data.extraIds?.length
|
||||||
|
? { connect: payload.data.extraIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
select: { id: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const fileKey = uuidv4();
|
||||||
|
const fileType = f.type;
|
||||||
|
const realFileType = fileType.split("/")[1];
|
||||||
|
const originalKey = `commissions/${fileKey}.${realFileType}`;
|
||||||
|
|
||||||
|
const arrayBuffer = await f.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: `${process.env.BUCKET_NAME}`,
|
||||||
|
Key: originalKey,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: f.type || "application/octet-stream",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.commissionRequestFile.create({
|
||||||
|
data: {
|
||||||
|
requestId: created.id,
|
||||||
|
fileKey: originalKey,
|
||||||
|
originalFile: f.name,
|
||||||
|
fileType: fileType || "application/octet-stream",
|
||||||
|
fileSize: f.size,
|
||||||
|
uploadDate: created.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
}
|
||||||
590
src/components/commissions/CommissionRequestsTable.tsx
Normal file
590
src/components/commissions/CommissionRequestsTable.tsx
Normal file
@ -0,0 +1,590 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { deleteCommissionRequest } from "@/actions/commissions/deleteCommissionRequest";
|
||||||
|
import { getCommissionRequestsTablePage } from "@/actions/commissions/getCommissionRequestsTablePage";
|
||||||
|
import { setCommissionRequestStatus } from "@/actions/commissions/setCommissionRequestStatus";
|
||||||
|
import type {
|
||||||
|
CommissionRequestTableRow,
|
||||||
|
CommissionStatus,
|
||||||
|
} from "@/schemas/commissions/tableSchema";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
type TriState = "any" | "true" | "false";
|
||||||
|
|
||||||
|
function useDebouncedValue<T>(value: T, delayMs: number) {
|
||||||
|
const [debounced, setDebounced] = React.useState(value);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortHeader(props: { title: string; column: any }) {
|
||||||
|
const sorted = props.column.getIsSorted() as false | "asc" | "desc";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.column.toggleSorting(sorted === "asc")}
|
||||||
|
className="group inline-flex items-center gap-2 font-semibold text-foreground/90 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span>{props.title}</span>
|
||||||
|
{sorted === "asc" ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : sorted === "desc" ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="h-4 w-4 opacity-60 group-hover:opacity-100" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: CommissionStatus }) {
|
||||||
|
const variant =
|
||||||
|
status === "COMPLETED"
|
||||||
|
? "default"
|
||||||
|
: status === "REJECTED" || status === "SPAM"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant as any} className="px-2 py-0.5">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriSelectInline(props: { value: TriState; onChange: (v: TriState) => void }) {
|
||||||
|
return (
|
||||||
|
<Select value={props.value} onValueChange={(v) => props.onChange(v as TriState)}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
<SelectItem value="true">Yes</SelectItem>
|
||||||
|
<SelectItem value="false">No</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: CommissionStatus[] = [
|
||||||
|
"NEW",
|
||||||
|
"REVIEWING",
|
||||||
|
"ACCEPTED",
|
||||||
|
"REJECTED",
|
||||||
|
"COMPLETED",
|
||||||
|
"SPAM",
|
||||||
|
];
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
q: string;
|
||||||
|
email: string;
|
||||||
|
status: "any" | CommissionStatus;
|
||||||
|
hasFiles: TriState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommissionRequestsTable() {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([
|
||||||
|
{ id: "createdAt", desc: true },
|
||||||
|
]);
|
||||||
|
const [pageIndex, setPageIndex] = React.useState(0);
|
||||||
|
const [pageSize, setPageSize] = React.useState(25);
|
||||||
|
|
||||||
|
const [filters, setFilters] = React.useState<Filters>({
|
||||||
|
q: "",
|
||||||
|
email: "",
|
||||||
|
status: "any",
|
||||||
|
hasFiles: "any",
|
||||||
|
});
|
||||||
|
|
||||||
|
const debouncedQ = useDebouncedValue(filters.q, 300);
|
||||||
|
const debouncedEmail = useDebouncedValue(filters.email, 300);
|
||||||
|
|
||||||
|
const [rows, setRows] = React.useState<CommissionRequestTableRow[]>([]);
|
||||||
|
const [total, setTotal] = React.useState(0);
|
||||||
|
|
||||||
|
const [isPending, startTransition] = React.useTransition();
|
||||||
|
|
||||||
|
// Delete dialog
|
||||||
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = React.useState<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const refresh = React.useCallback(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await getCommissionRequestsTablePage({
|
||||||
|
pagination: { pageIndex, pageSize },
|
||||||
|
sorting,
|
||||||
|
filters: {
|
||||||
|
q: debouncedQ || undefined,
|
||||||
|
email: debouncedEmail || undefined,
|
||||||
|
status: filters.status,
|
||||||
|
hasFiles: filters.hasFiles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setRows(res.rows);
|
||||||
|
setTotal(res.total);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
sorting,
|
||||||
|
debouncedQ,
|
||||||
|
debouncedEmail,
|
||||||
|
filters.status,
|
||||||
|
filters.hasFiles,
|
||||||
|
]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const columns = React.useMemo<ColumnDef<CommissionRequestTableRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => <SortHeader title="Submitted" column={column} />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-foreground/80">
|
||||||
|
{new Date(row.original.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "filesCount",
|
||||||
|
header: ({ column }) => <SortHeader title="Files" column={column} />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm font-medium tabular-nums">{row.original.filesCount}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "requestor",
|
||||||
|
header: "Requestor",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 max-w-[420px]">
|
||||||
|
<div className="truncate text-sm font-medium">{r.customerName}</div>
|
||||||
|
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||||
|
{r.customerEmail}
|
||||||
|
{r.customerSocials ? ` · ${r.customerSocials}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({ column }) => <SortHeader title="Status" column={column} />,
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "complete",
|
||||||
|
header: "",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const isCompleted = r.status === "COMPLETED";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant={isCompleted ? "secondary" : "outline"}
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
disabled={isPending || isCompleted}
|
||||||
|
title={isCompleted ? "Completed" : "Mark as completed"}
|
||||||
|
onClick={() => {
|
||||||
|
if (isCompleted) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
await setCommissionRequestStatus({ id: r.id, status: "COMPLETED" });
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Mark complete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open row actions</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
{/* status changes */}
|
||||||
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
Set status
|
||||||
|
</div>
|
||||||
|
{STATUS_OPTIONS.filter((s) => s !== "COMPLETED").map((s) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={s}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startTransition(async () => {
|
||||||
|
await setCommissionRequestStatus({ id: r.id, status: s });
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/commissions/requests/${r.id}`} className="cursor-pointer">
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDeleteTarget({
|
||||||
|
id: r.id,
|
||||||
|
label: `${r.customerName} (${r.customerEmail})`,
|
||||||
|
});
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[isPending, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
manualPagination: true,
|
||||||
|
manualSorting: true,
|
||||||
|
pageCount,
|
||||||
|
onSortingChange: (updater) => {
|
||||||
|
setSorting((prev) => (typeof updater === "function" ? updater(prev) : updater));
|
||||||
|
setPageIndex(0);
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerGroup = table.getHeaderGroups()[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-gradient-to-b from-background/60 to-transparent" />
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-20 bg-card">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
className="whitespace-nowrap border-b border-border/70 bg-muted/40 py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
{/* filter row */}
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const colId = header.column.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id} className="border-b border-border/70 bg-muted/30 py-2">
|
||||||
|
{colId === "createdAt" ? (
|
||||||
|
<div className="h-9" />
|
||||||
|
) : colId === "filesCount" ? (
|
||||||
|
<TriSelectInline
|
||||||
|
value={filters.hasFiles}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilters((f) => ({ ...f, hasFiles: v }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : colId === "requestor" ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="h-9"
|
||||||
|
placeholder="Search name/message…"
|
||||||
|
value={filters.q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilters((f) => ({ ...f, q: e.target.value }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="h-9"
|
||||||
|
placeholder="Email…"
|
||||||
|
value={filters.email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilters((f) => ({ ...f, email: e.target.value }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : colId === "status" ? (
|
||||||
|
<Select
|
||||||
|
value={filters.status}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setFilters((f) => ({ ...f, status: v as any }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="Any" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="h-9" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="py-14 text-center">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{isPending ? "Loading…" : "No results."}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Adjust filters or change page size.
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((r, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={r.id}
|
||||||
|
className={[
|
||||||
|
"transition-colors",
|
||||||
|
"hover:bg-muted/50",
|
||||||
|
idx % 2 === 0 ? "bg-background" : "bg-muted/10",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{r.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-3 align-top border-b border-border/40">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* pagination */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{isPending ? "Updating…" : null} Total: {total}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPageSize(Number(v));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[10, 25, 50, 100].map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n} / page
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex(0)}
|
||||||
|
disabled={pageIndex === 0 || isPending}
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={pageIndex === 0 || isPending}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="min-w-[120px] text-center text-sm tabular-nums">
|
||||||
|
Page {pageIndex + 1} / {pageCount}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
disabled={pageIndex >= pageCount - 1 || isPending}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex(Math.max(0, pageCount - 1))}
|
||||||
|
disabled={pageIndex >= pageCount - 1 || isPending}
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* delete confirmation */}
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete request?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete{" "}
|
||||||
|
<span className="font-medium">{deleteTarget?.label}</span>. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={isPending || !deleteTarget}
|
||||||
|
onClick={() => {
|
||||||
|
const target = deleteTarget;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteCommissionRequest(target.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
|
||||||
|
// If current page becomes empty after delete, clamp page index.
|
||||||
|
setPageIndex((p) => (p > 0 && rows.length === 1 ? p - 1 : p));
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -27,7 +27,7 @@ const artworkItems = [
|
|||||||
|
|
||||||
const commissionItems = [
|
const commissionItems = [
|
||||||
{
|
{
|
||||||
title: "Commissions",
|
title: "Requests",
|
||||||
href: "/commissions",
|
href: "/commissions",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -26,5 +26,5 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api/auth|api/image|login|_next/static|_next/image|favicon.ico).*)"],
|
matcher: ["/((?!api/auth|api/image|api/v1|login|_next/static|_next/image|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
26
src/schemas/commissions/tableSchema.ts
Normal file
26
src/schemas/commissions/tableSchema.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const commissionStatusSchema = z.enum([
|
||||||
|
"NEW",
|
||||||
|
"REVIEWING",
|
||||||
|
"ACCEPTED",
|
||||||
|
"REJECTED",
|
||||||
|
"COMPLETED",
|
||||||
|
"SPAM",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const commissionRequestTableRowSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
createdAt: z.string(), // ISO
|
||||||
|
status: commissionStatusSchema,
|
||||||
|
|
||||||
|
customerName: z.string(),
|
||||||
|
customerEmail: z.string(),
|
||||||
|
customerSocials: z.string().nullable().optional(),
|
||||||
|
|
||||||
|
messagePreview: z.string().optional(), // optional, useful for hover later
|
||||||
|
filesCount: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CommissionRequestTableRow = z.infer<typeof commissionRequestTableRowSchema>;
|
||||||
|
export type CommissionStatus = z.infer<typeof commissionStatusSchema>;
|
||||||
Reference in New Issue
Block a user