376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { requireCurrentUser } from "@/lib/auth";
|
|
import { Prisma } from "@prisma/client";
|
|
import type {
|
|
OrderWithDetails,
|
|
DashboardStats,
|
|
FormState,
|
|
OrderStatus,
|
|
} from "@/lib/types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types for parsed JSON payloads from hidden form inputs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ParsedCustomer {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
interface ParsedItem {
|
|
id: string;
|
|
customerId: string | null;
|
|
itemName: string;
|
|
initPrice: number;
|
|
myPrice: number;
|
|
taxRatio: number;
|
|
quantity: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function computePrices(item: ParsedItem) {
|
|
const netPrice = (item.initPrice + item.initPrice * item.taxRatio) * item.quantity;
|
|
const myNetPrice = (item.myPrice + item.myPrice * item.taxRatio) * item.quantity;
|
|
const finalPrice = netPrice;
|
|
return { netPrice, myNetPrice, finalPrice };
|
|
}
|
|
|
|
/**
|
|
* Resolve a parsed customer to a database Customer id.
|
|
* First tries to find an existing customer by id, then by name.
|
|
* If neither exists, creates a new Customer record.
|
|
*/
|
|
async function resolveCustomer(
|
|
customer: ParsedCustomer,
|
|
tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0],
|
|
): Promise<string> {
|
|
// Try by id first
|
|
const existing = await tx.customer.findUnique({
|
|
where: { id: customer.id },
|
|
});
|
|
if (existing) return existing.id;
|
|
|
|
// Try by name (prevents duplicates from re-adding)
|
|
const byName = await tx.customer.findUnique({
|
|
where: { name: customer.name },
|
|
});
|
|
if (byName) return byName.id;
|
|
|
|
// Create new
|
|
const created = await tx.customer.create({
|
|
data: { name: customer.name },
|
|
});
|
|
return created.id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createOrder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function createOrder(
|
|
prevState: FormState | undefined,
|
|
formData: FormData,
|
|
): Promise<FormState> {
|
|
await requireCurrentUser();
|
|
|
|
const title = (formData.get("title") as string) ?? "";
|
|
const status = ((formData.get("status") as string) ?? "pending") as OrderStatus;
|
|
const notes = (formData.get("notes") as string) || null;
|
|
|
|
const customersRaw = formData.get("customers") as string | null;
|
|
const itemsRaw = formData.get("items") as string | null;
|
|
|
|
const customers: ParsedCustomer[] = customersRaw ? JSON.parse(customersRaw) : [];
|
|
const items: ParsedItem[] = itemsRaw ? JSON.parse(itemsRaw) : [];
|
|
|
|
// Validation
|
|
const errors: Record<string, string[]> = {};
|
|
|
|
if (!title.trim()) {
|
|
errors.title = ["Title is required"];
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
errors.items = ["At least one item is required"];
|
|
}
|
|
|
|
if (Object.keys(errors).length > 0) {
|
|
return { errors };
|
|
}
|
|
|
|
// Transaction
|
|
const order = await prisma.$transaction(async (tx) => {
|
|
const created = await tx.order.create({
|
|
data: { title: title.trim(), status, notes },
|
|
});
|
|
|
|
// Resolve customer IDs (maps client-side "new-xxx" IDs to real DB IDs)
|
|
const customerIdMap: Record<string, string> = {};
|
|
for (const c of customers) {
|
|
const dbId = await resolveCustomer(c, tx);
|
|
customerIdMap[c.id] = dbId;
|
|
await tx.orderCustomer.create({
|
|
data: { orderId: created.id, customerId: dbId },
|
|
});
|
|
}
|
|
|
|
for (const item of items) {
|
|
const { netPrice, myNetPrice, finalPrice } = computePrices(item);
|
|
// Resolve item's customerId through the map (handles "new-xxx" references)
|
|
const resolvedCustomerId = item.customerId
|
|
? (customerIdMap[item.customerId] ?? item.customerId)
|
|
: null;
|
|
await tx.orderItem.create({
|
|
data: {
|
|
orderId: created.id,
|
|
customerId: resolvedCustomerId,
|
|
itemName: item.itemName,
|
|
initPrice: item.initPrice,
|
|
myPrice: item.myPrice,
|
|
taxRatio: item.taxRatio,
|
|
quantity: item.quantity,
|
|
netPrice,
|
|
myNetPrice,
|
|
finalPrice,
|
|
},
|
|
});
|
|
}
|
|
|
|
return created;
|
|
});
|
|
|
|
revalidatePath("/");
|
|
redirect(`/orders/${order.id}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// updateOrder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function updateOrder(
|
|
orderId: string,
|
|
prevState: FormState | undefined,
|
|
formData: FormData,
|
|
): Promise<FormState> {
|
|
await requireCurrentUser();
|
|
|
|
// Fetch existing order and verify it is not closed
|
|
const existing = await prisma.order.findUnique({ where: { id: orderId } });
|
|
if (!existing) {
|
|
return { message: "Order not found" };
|
|
}
|
|
if (existing.status === "closed") {
|
|
return { message: "Cannot edit a closed order" };
|
|
}
|
|
|
|
const title = (formData.get("title") as string) ?? "";
|
|
const status = ((formData.get("status") as string) ?? existing.status) as OrderStatus;
|
|
const notes = (formData.get("notes") as string) || null;
|
|
|
|
const customersRaw = formData.get("customers") as string | null;
|
|
const itemsRaw = formData.get("items") as string | null;
|
|
|
|
const customers: ParsedCustomer[] = customersRaw ? JSON.parse(customersRaw) : [];
|
|
const items: ParsedItem[] = itemsRaw ? JSON.parse(itemsRaw) : [];
|
|
|
|
// Validation
|
|
const errors: Record<string, string[]> = {};
|
|
|
|
if (!title.trim()) {
|
|
errors.title = ["Title is required"];
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
errors.items = ["At least one item is required"];
|
|
}
|
|
|
|
if (Object.keys(errors).length > 0) {
|
|
return { errors };
|
|
}
|
|
|
|
// Transaction: delete old relations, update order, recreate relations
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.orderCustomer.deleteMany({ where: { orderId } });
|
|
await tx.orderItem.deleteMany({ where: { orderId } });
|
|
|
|
await tx.order.update({
|
|
where: { id: orderId },
|
|
data: { title: title.trim(), status, notes },
|
|
});
|
|
|
|
// Resolve customer IDs (maps client-side "new-xxx" IDs to real DB IDs)
|
|
const customerIdMap: Record<string, string> = {};
|
|
for (const c of customers) {
|
|
const dbId = await resolveCustomer(c, tx);
|
|
customerIdMap[c.id] = dbId;
|
|
await tx.orderCustomer.create({
|
|
data: { orderId, customerId: dbId },
|
|
});
|
|
}
|
|
|
|
for (const item of items) {
|
|
const { netPrice, myNetPrice, finalPrice } = computePrices(item);
|
|
// Resolve item's customerId through the map (handles "new-xxx" references)
|
|
const resolvedCustomerId = item.customerId
|
|
? (customerIdMap[item.customerId] ?? item.customerId)
|
|
: null;
|
|
await tx.orderItem.create({
|
|
data: {
|
|
orderId,
|
|
customerId: resolvedCustomerId,
|
|
itemName: item.itemName,
|
|
initPrice: item.initPrice,
|
|
myPrice: item.myPrice,
|
|
taxRatio: item.taxRatio,
|
|
quantity: item.quantity,
|
|
netPrice,
|
|
myNetPrice,
|
|
finalPrice,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
revalidatePath("/");
|
|
revalidatePath(`/orders/${orderId}`);
|
|
return { message: "Order saved successfully", success: true };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getOrders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function getOrders(
|
|
search?: string,
|
|
statusFilter?: string,
|
|
): Promise<OrderWithDetails[]> {
|
|
await requireCurrentUser();
|
|
|
|
const where: Prisma.OrderWhereInput = {};
|
|
|
|
if (search) {
|
|
where.OR = [
|
|
{ title: { contains: search } },
|
|
{ customers: { some: { customer: { name: { contains: search } } } } },
|
|
];
|
|
}
|
|
|
|
if (statusFilter) {
|
|
where.status = statusFilter;
|
|
}
|
|
|
|
const orders = await prisma.order.findMany({
|
|
where,
|
|
include: {
|
|
customers: { include: { customer: true } },
|
|
items: { include: { customer: true } },
|
|
},
|
|
orderBy: { updatedAt: "desc" },
|
|
});
|
|
|
|
return orders.map((order) => ({
|
|
id: order.id,
|
|
title: order.title,
|
|
status: order.status as OrderStatus,
|
|
notes: order.notes,
|
|
createdAt: order.createdAt,
|
|
updatedAt: order.updatedAt,
|
|
customers: order.customers.map((oc) => ({
|
|
customer: { id: oc.customer.id, name: oc.customer.name },
|
|
})),
|
|
items: order.items.map((item) => ({
|
|
id: item.id,
|
|
orderId: item.orderId,
|
|
customerId: item.customerId,
|
|
customerName: item.customer?.name ?? null,
|
|
itemName: item.itemName,
|
|
initPrice: item.initPrice,
|
|
myPrice: item.myPrice,
|
|
taxRatio: item.taxRatio,
|
|
quantity: item.quantity,
|
|
netPrice: item.netPrice,
|
|
myNetPrice: item.myNetPrice,
|
|
finalPrice: item.finalPrice,
|
|
})),
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getOrderById
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function getOrderById(
|
|
orderId: string,
|
|
): Promise<OrderWithDetails | null> {
|
|
await requireCurrentUser();
|
|
|
|
const order = await prisma.order.findUnique({
|
|
where: { id: orderId },
|
|
include: {
|
|
customers: { include: { customer: true } },
|
|
items: { include: { customer: true } },
|
|
},
|
|
});
|
|
|
|
if (!order) return null;
|
|
|
|
return {
|
|
id: order.id,
|
|
title: order.title,
|
|
status: order.status as OrderStatus,
|
|
notes: order.notes,
|
|
createdAt: order.createdAt,
|
|
updatedAt: order.updatedAt,
|
|
customers: order.customers.map((oc) => ({
|
|
customer: { id: oc.customer.id, name: oc.customer.name },
|
|
})),
|
|
items: order.items.map((item) => ({
|
|
id: item.id,
|
|
orderId: item.orderId,
|
|
customerId: item.customerId,
|
|
customerName: item.customer?.name ?? null,
|
|
itemName: item.itemName,
|
|
initPrice: item.initPrice,
|
|
myPrice: item.myPrice,
|
|
taxRatio: item.taxRatio,
|
|
quantity: item.quantity,
|
|
netPrice: item.netPrice,
|
|
myNetPrice: item.myNetPrice,
|
|
finalPrice: item.finalPrice,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getDashboardStats
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function getDashboardStats(): Promise<DashboardStats> {
|
|
await requireCurrentUser();
|
|
|
|
const groups = await prisma.order.groupBy({
|
|
by: ["status"],
|
|
_count: { id: true },
|
|
});
|
|
|
|
const counts: Record<string, number> = {};
|
|
for (const g of groups) {
|
|
counts[g.status] = g._count.id;
|
|
}
|
|
|
|
return {
|
|
pending: counts["pending"] ?? 0,
|
|
purchased: counts["purchased"] ?? 0,
|
|
delivered: counts["delivered"] ?? 0,
|
|
closed: counts["closed"] ?? 0,
|
|
totalOrders: Object.values(counts).reduce((sum, n) => sum + n, 0),
|
|
};
|
|
}
|