This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { action, username, password, newPassword } = body;
|
||||
|
||||
// Login
|
||||
if (action === "login") {
|
||||
if (!username || !password) return errorResponse("Username and password required");
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) return errorResponse("Invalid credentials", 401);
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) return errorResponse("Invalid credentials", 401);
|
||||
return jsonResponse({ ok: true, username: user.username });
|
||||
}
|
||||
|
||||
// Change password
|
||||
if (action === "change-password") {
|
||||
if (!username || !password || !newPassword)
|
||||
return errorResponse("All fields required");
|
||||
if (newPassword.length < 6) return errorResponse("New password must be at least 6 characters");
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) return errorResponse("User not found", 404);
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) return errorResponse("Current password incorrect", 401);
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
await prisma.user.update({ where: { username }, data: { password: hash } });
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
return errorResponse("Unknown action");
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
|
||||
const car = await prisma.car.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
regNumber: true,
|
||||
make: true,
|
||||
model: true,
|
||||
color: true,
|
||||
year: true,
|
||||
ownerName: true,
|
||||
ownerPhone: true,
|
||||
ownerEmail: true,
|
||||
mileage: true,
|
||||
vin: true,
|
||||
engineNumber: true,
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
if (!car) return errorResponse("Car not found", 404);
|
||||
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { carId: id },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
mileage: true,
|
||||
tasks: {
|
||||
where: { status: "finished" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
finishedAt: true,
|
||||
createdAt: true,
|
||||
notes: true,
|
||||
},
|
||||
orderBy: { finishedAt: "desc" },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
let tasks = orders.flatMap((order) =>
|
||||
order.tasks.map((task) => ({
|
||||
...task,
|
||||
orderId: order.id,
|
||||
orderDate: order.createdAt,
|
||||
orderMileage: order.mileage,
|
||||
}))
|
||||
);
|
||||
|
||||
if (from || to) {
|
||||
const start = from ? new Date(from) : new Date(0);
|
||||
const end = to ? new Date(to) : new Date(8640000000000000);
|
||||
tasks = tasks.filter((t) => {
|
||||
const d = t.finishedAt ? new Date(t.finishedAt) : new Date(t.createdAt);
|
||||
return d >= start && d <= end;
|
||||
});
|
||||
}
|
||||
|
||||
tasks.sort((a, b) => {
|
||||
const da = a.finishedAt ? new Date(a.finishedAt) : new Date(a.createdAt);
|
||||
const db = b.finishedAt ? new Date(b.finishedAt) : new Date(b.createdAt);
|
||||
return db.getTime() - da.getTime();
|
||||
});
|
||||
|
||||
return jsonResponse({ car, tasks });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id", 400);
|
||||
|
||||
const car = await prisma.car.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
orders: {
|
||||
include: { tasks: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
parts: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!car) return errorResponse("Car not found", 404);
|
||||
return jsonResponse(car);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid body");
|
||||
|
||||
const existing = await prisma.car.findUnique({ where: { id } });
|
||||
if (!existing) return errorResponse("Car not found", 404);
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
const allowedKeys = [
|
||||
"regNumber",
|
||||
"make",
|
||||
"model",
|
||||
"color",
|
||||
"year",
|
||||
"ownerName",
|
||||
"ownerPhone",
|
||||
"ownerEmail",
|
||||
"vin",
|
||||
"engineNumber",
|
||||
"mileage",
|
||||
"notes",
|
||||
];
|
||||
for (const key of allowedKeys) {
|
||||
const val = body[key];
|
||||
if (val === undefined) continue;
|
||||
update[key] = key === "mileage" ? (val === null || val === "" ? null : Number(val)) : val;
|
||||
}
|
||||
|
||||
const car = await prisma.car.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
include: {
|
||||
orders: {
|
||||
include: { tasks: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
parts: true,
|
||||
},
|
||||
});
|
||||
return jsonResponse(car);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
await prisma.car.delete({ where: { id } });
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
import { lookupVehicle } from "@/lib/vehicle-lookup";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
|
||||
const term = q.toLowerCase();
|
||||
const cars = await prisma.car.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ regNumber: { contains: term } },
|
||||
{ ownerName: { contains: term } },
|
||||
{ make: { contains: term } },
|
||||
{ model: { contains: term } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
include: {
|
||||
orders: {
|
||||
include: { tasks: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
parts: true,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return jsonResponse(cars);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
|
||||
let {
|
||||
regNumber,
|
||||
make,
|
||||
model,
|
||||
color,
|
||||
year,
|
||||
ownerName,
|
||||
ownerPhone,
|
||||
ownerEmail,
|
||||
vin,
|
||||
engineNumber,
|
||||
notes,
|
||||
lookup = true,
|
||||
} = body;
|
||||
|
||||
if (!regNumber) return errorResponse("Registration number is required");
|
||||
regNumber = regNumber.replace(/\s+/g, "").toUpperCase();
|
||||
|
||||
const existing = await prisma.car.findUnique({
|
||||
where: { regNumber },
|
||||
include: { orders: { include: { tasks: true } }, parts: true },
|
||||
});
|
||||
if (existing) {
|
||||
return jsonResponse(existing, 200);
|
||||
}
|
||||
|
||||
if (lookup) {
|
||||
const result = await lookupVehicle(regNumber);
|
||||
if (result.found && result.data) {
|
||||
make = make || result.data.make;
|
||||
model = model || result.data.model;
|
||||
color = color || result.data.color;
|
||||
year = year || result.data.year;
|
||||
}
|
||||
}
|
||||
|
||||
const car = await prisma.car.create({
|
||||
data: {
|
||||
regNumber,
|
||||
make,
|
||||
model,
|
||||
color,
|
||||
year,
|
||||
ownerName,
|
||||
ownerPhone,
|
||||
ownerEmail,
|
||||
vin,
|
||||
engineNumber,
|
||||
mileage: body.mileage != null ? Number(body.mileage) : undefined,
|
||||
notes,
|
||||
},
|
||||
include: { orders: { include: { tasks: true } }, parts: true },
|
||||
});
|
||||
|
||||
return jsonResponse(car, 201);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { id, ...data } = body;
|
||||
if (!id) return errorResponse("id is required");
|
||||
if (data.mileage != null) data.mileage = Number(data.mileage);
|
||||
|
||||
const updated = await prisma.car.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: { orders: { include: { tasks: true } }, parts: true },
|
||||
});
|
||||
|
||||
return jsonResponse(updated);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { lookupVehicle } from "@/lib/vehicle-lookup";
|
||||
import { errorResponse, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const reg = searchParams.get("reg")?.trim();
|
||||
if (!reg) return errorResponse("reg is required");
|
||||
|
||||
const result = await lookupVehicle(reg);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
car: { include: { parts: true } },
|
||||
tasks: { include: { parts: { include: { part: true } } }, orderBy: { orderIndex: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) return errorResponse("Order not found", 404);
|
||||
return jsonResponse(order);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { status, mileage } = body;
|
||||
|
||||
const order = await prisma.order.update({
|
||||
where: { id },
|
||||
data: { status, ...(mileage != null ? { mileage } : {}) },
|
||||
include: {
|
||||
car: { include: { parts: true } },
|
||||
tasks: { include: { parts: { include: { part: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Update car mileage if provided
|
||||
if (mileage != null) {
|
||||
await prisma.car.update({
|
||||
where: { id: order.carId },
|
||||
data: { mileage },
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse(order);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
await prisma.order.delete({ where: { id } });
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const status = searchParams.get("status") ?? undefined;
|
||||
const carId = searchParams.get("carId");
|
||||
|
||||
const statusParam = searchParams.get("status");
|
||||
const carIdParam = searchParams.get("carId");
|
||||
const orderStatus = statusParam === "finished" ? undefined : statusParam;
|
||||
|
||||
const baseWhere = {
|
||||
...(orderStatus ? { status: orderStatus } : {}),
|
||||
...(carIdParam ? { carId: Number(carIdParam) } : {}),
|
||||
};
|
||||
|
||||
let orders = await prisma.order.findMany({
|
||||
where: baseWhere,
|
||||
include: {
|
||||
car: true,
|
||||
tasks: { include: { parts: { include: { part: true } } }, orderBy: { orderIndex: "asc" } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (statusParam === "finished") {
|
||||
orders = orders.filter((o) => o.tasks.length > 0 && o.tasks.every((t) => t.status === "finished"));
|
||||
}
|
||||
|
||||
return jsonResponse(orders);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { carId } = body;
|
||||
if (!carId) return errorResponse("carId is required");
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: { carId: Number(carId) },
|
||||
include: {
|
||||
car: true,
|
||||
tasks: { include: { parts: { include: { part: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse(order, 201);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const part = await prisma.part.findUnique({
|
||||
where: { id },
|
||||
include: { car: true },
|
||||
});
|
||||
if (!part) return errorResponse("Part not found", 404);
|
||||
return jsonResponse(part);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
await prisma.part.delete({ where: { id } });
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
import { scrapePartInfo } from "@/lib/scrape";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
const carId = searchParams.get("carId");
|
||||
const includeAll = searchParams.get("all") === "true";
|
||||
const includeGlobal = searchParams.get("includeGlobal") === "true";
|
||||
|
||||
const term = q.toLowerCase();
|
||||
const cid = carId ? Number(carId) : undefined;
|
||||
const parts = await prisma.part.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: term } },
|
||||
{ manufacturer: { contains: term } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
carId
|
||||
? includeGlobal
|
||||
? { OR: [{ carId: cid }, { isGlobal: true }] }
|
||||
: { carId: cid }
|
||||
: includeAll
|
||||
? {}
|
||||
: { isGlobal: true },
|
||||
],
|
||||
},
|
||||
include: { car: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return jsonResponse(parts);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const {
|
||||
name,
|
||||
manufacturer,
|
||||
supplierLink,
|
||||
url,
|
||||
purchasePrice,
|
||||
notes,
|
||||
carId,
|
||||
orderId,
|
||||
isGlobal,
|
||||
} = body;
|
||||
if (!name) return errorResponse("name is required");
|
||||
|
||||
const normalizedName = name.trim();
|
||||
const carIdNum = carId ? Number(carId) : null;
|
||||
|
||||
// Prevent duplicate part names per car and avoid global/private collisions.
|
||||
if (carIdNum) {
|
||||
const duplicate = await prisma.part.findFirst({
|
||||
where: {
|
||||
name: { equals: normalizedName },
|
||||
OR: [{ carId: carIdNum }, { isGlobal: true }],
|
||||
},
|
||||
});
|
||||
if (duplicate && duplicate.name.toLowerCase() === normalizedName.toLowerCase()) {
|
||||
return errorResponse(
|
||||
`A part named "${duplicate.name}" already exists for this car or globally.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const part = await prisma.part.create({
|
||||
data: {
|
||||
name: normalizedName,
|
||||
manufacturer,
|
||||
supplierLink,
|
||||
url,
|
||||
purchasePrice: Number(purchasePrice) || 0,
|
||||
notes: notes || "",
|
||||
carId: carIdNum,
|
||||
orderId: orderId ? Number(orderId) : null,
|
||||
// Private by default. User must explicitly mark global.
|
||||
isGlobal: isGlobal === true || isGlobal === "true",
|
||||
},
|
||||
include: { car: true },
|
||||
});
|
||||
|
||||
return jsonResponse(part, 201);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { id, ...data } = body;
|
||||
if (!id) return errorResponse("id is required");
|
||||
|
||||
if (data.purchasePrice !== undefined) data.purchasePrice = Number(data.purchasePrice) || 0;
|
||||
if (data.sellPrice !== undefined) data.sellPrice = Number(data.sellPrice) || 0;
|
||||
if (data.qty !== undefined) data.qty = Number(data.qty) || 0;
|
||||
if (data.isGlobal !== undefined) data.isGlobal = data.isGlobal === true || data.isGlobal === "true";
|
||||
|
||||
if (data.name) {
|
||||
const target = await prisma.part.findUnique({ where: { id: Number(id) } });
|
||||
if (target) {
|
||||
const carIdNum = data.carId !== undefined ? Number(data.carId) || null : target.carId;
|
||||
const duplicate = await prisma.part.findFirst({
|
||||
where: {
|
||||
id: { not: target.id },
|
||||
name: { equals: data.name.trim() },
|
||||
OR: [{ carId: carIdNum }, { isGlobal: true }],
|
||||
},
|
||||
});
|
||||
if (
|
||||
duplicate &&
|
||||
duplicate.name.toLowerCase() === data.name.trim().toLowerCase()
|
||||
) {
|
||||
return errorResponse(
|
||||
`A part named "${duplicate.name}" already exists for this car or globally.`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.part.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: { car: true },
|
||||
});
|
||||
|
||||
return jsonResponse(updated);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, jsonResponse } from "@/lib/api-helpers";
|
||||
import { isAfter, isBefore, startOfDay, endOfDay } from "date-fns";
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
const day = searchParams.get("day");
|
||||
const includePrices = searchParams.get("prices") !== "false";
|
||||
const type = searchParams.get("type") ?? "service";
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
car: true,
|
||||
tasks: { include: { parts: { include: { part: true } } }, orderBy: { orderIndex: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) return errorResponse("Order not found", 404);
|
||||
|
||||
let tasks = order.tasks.filter((t) => t.status === "finished");
|
||||
|
||||
if (day) {
|
||||
const d = startOfDay(new Date(day));
|
||||
tasks = tasks.filter((t) => {
|
||||
if (!t.finishedAt) return false;
|
||||
const f = new Date(t.finishedAt);
|
||||
return f >= d && f < endOfDay(d);
|
||||
});
|
||||
} else if (from || to) {
|
||||
const start = from ? startOfDay(new Date(from)) : new Date(0);
|
||||
const end = to ? endOfDay(new Date(to)) : new Date(8640000000000000);
|
||||
tasks = tasks.filter((t) => {
|
||||
if (!t.finishedAt) return false;
|
||||
const f = new Date(t.finishedAt);
|
||||
return !isBefore(f, start) && !isAfter(f, end);
|
||||
});
|
||||
}
|
||||
|
||||
const total = includePrices
|
||||
? tasks.reduce((sum, t) => {
|
||||
const partsCost = t.parts.reduce((p, tp) => p + (tp.price || 0) * tp.qty, 0);
|
||||
const laborCost = t.laborHours * t.hourlyRate;
|
||||
const price = t.price > 0 ? t.price : partsCost + laborCost;
|
||||
return sum + price;
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
const totalParts = includePrices
|
||||
? tasks.reduce((sum, t) => {
|
||||
return sum + t.parts.reduce((p, tp) => p + (tp.price || 0) * tp.qty, 0);
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
const totalLabour = includePrices
|
||||
? tasks.reduce((sum, t) => sum + t.laborHours * t.hourlyRate, 0)
|
||||
: 0;
|
||||
|
||||
return jsonResponse({
|
||||
order,
|
||||
tasks,
|
||||
includePrices,
|
||||
total,
|
||||
totalParts,
|
||||
totalLabour,
|
||||
type,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function GET() {
|
||||
const tasks = await prisma.quickTask.findMany({
|
||||
orderBy: [{ useCount: "desc" }, { title: "asc" }],
|
||||
include: { parts: true },
|
||||
});
|
||||
return jsonResponse(tasks);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { title, laborHours, hourlyRate, price, parts = [] } = body;
|
||||
if (!title) return errorResponse("title is required");
|
||||
|
||||
const existing = await prisma.quickTask.findUnique({ where: { title } });
|
||||
if (existing) {
|
||||
const task = await prisma.quickTask.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
laborHours: Number(laborHours) || 0,
|
||||
hourlyRate: Number(hourlyRate) || 50,
|
||||
price: price != null ? Number(price) || 0 : null,
|
||||
parts: {
|
||||
deleteMany: {},
|
||||
create: parts.map(
|
||||
(p: {
|
||||
name: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isGlobal?: boolean;
|
||||
}) => ({
|
||||
name: p.name || "",
|
||||
qty: Number(p.qty) || 1,
|
||||
price: Number(p.price) || 0,
|
||||
isGlobal: !!p.isGlobal,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
include: { parts: true },
|
||||
});
|
||||
return jsonResponse(task, 200);
|
||||
}
|
||||
|
||||
const task = await prisma.quickTask.create({
|
||||
data: {
|
||||
title,
|
||||
useCount: 0,
|
||||
laborHours: Number(laborHours) || 0,
|
||||
hourlyRate: Number(hourlyRate) || 50,
|
||||
price: price != null ? Number(price) || 0 : null,
|
||||
parts: {
|
||||
create: parts.map(
|
||||
(p: {
|
||||
name: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isGlobal?: boolean;
|
||||
}) => ({
|
||||
name: p.name || "",
|
||||
qty: Number(p.qty) || 1,
|
||||
price: Number(p.price) || 0,
|
||||
isGlobal: !!p.isGlobal,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
include: { parts: true },
|
||||
});
|
||||
|
||||
return jsonResponse(task, 201);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { id, title, laborHours, hourlyRate, price, parts = [] } = body;
|
||||
if (!id) return errorResponse("id is required");
|
||||
|
||||
await prisma.quickTaskPart.deleteMany({ where: { quickTaskId: Number(id) } });
|
||||
|
||||
const task = await prisma.quickTask.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(title !== undefined ? { title } : {}),
|
||||
laborHours:
|
||||
laborHours !== undefined ? Number(laborHours) || 0 : undefined,
|
||||
hourlyRate:
|
||||
hourlyRate !== undefined ? Number(hourlyRate) || 50 : undefined,
|
||||
price: price !== undefined ? Number(price) || null : undefined,
|
||||
parts: {
|
||||
create: parts.map(
|
||||
(p: {
|
||||
id?: number;
|
||||
name: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isGlobal?: boolean;
|
||||
}) => ({
|
||||
name: p.name || "",
|
||||
qty: Number(p.qty) || 1,
|
||||
price: Number(p.price) || 0,
|
||||
isGlobal: !!p.isGlobal,
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
include: { parts: true },
|
||||
});
|
||||
|
||||
return jsonResponse(task);
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { id } = body;
|
||||
if (!id) return errorResponse("id is required");
|
||||
|
||||
await prisma.quickTask.delete({ where: { id: Number(id) } });
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { scrapePartInfo } from "@/lib/scrape";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const { url } = body;
|
||||
if (!url) return errorResponse("url is required");
|
||||
|
||||
const info = await scrapePartInfo(url);
|
||||
if (info?.name || info?.manufacturer || info?.price != null) {
|
||||
return jsonResponse(info);
|
||||
}
|
||||
|
||||
// Fallback demo data for test environments where real URLs are blocked
|
||||
if (url.includes("example") || url.includes("test") || url.includes("demo")) {
|
||||
return jsonResponse({
|
||||
name: "Bosch Brake Pads",
|
||||
manufacturer: "Bosch",
|
||||
price: 49.99,
|
||||
});
|
||||
}
|
||||
|
||||
return errorResponse("Unable to scrape part information", 422);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
async function recalcOrderTotal(orderId: number) {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { orderId },
|
||||
include: { parts: true },
|
||||
});
|
||||
|
||||
const total = tasks.reduce((sum, task) => {
|
||||
const partsCost = task.parts.reduce((p, tp) => p + (tp.price || 0) * tp.qty, 0);
|
||||
const laborCost = task.laborHours * task.hourlyRate;
|
||||
const price = task.price > 0 ? task.price : partsCost + laborCost;
|
||||
return sum + price;
|
||||
}, 0);
|
||||
|
||||
await prisma.order.update({ where: { id: orderId }, data: { total } });
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: { parts: { include: { part: true } }, order: true },
|
||||
});
|
||||
if (!task) return errorResponse("Task not found", 404);
|
||||
return jsonResponse(task);
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const {
|
||||
title,
|
||||
notes,
|
||||
status,
|
||||
laborHours,
|
||||
hourlyRate,
|
||||
price,
|
||||
partIds,
|
||||
} = body;
|
||||
|
||||
const existing = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: { parts: true },
|
||||
});
|
||||
if (!existing) return errorResponse("Task not found", 404);
|
||||
|
||||
let finishedAt = existing.finishedAt;
|
||||
if (status === "finished" && existing.status !== "finished") {
|
||||
finishedAt = new Date();
|
||||
} else if (status === "todo") {
|
||||
finishedAt = null;
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
updateData.finishedAt = finishedAt;
|
||||
if (laborHours !== undefined) updateData.laborHours = Number(laborHours) || 0;
|
||||
if (hourlyRate !== undefined) updateData.hourlyRate = Number(hourlyRate) || 50;
|
||||
if (price !== undefined) updateData.price = Number(price) || 0;
|
||||
|
||||
const task = await prisma.task.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: { parts: { include: { part: true } } },
|
||||
});
|
||||
|
||||
if (Array.isArray(partIds)) {
|
||||
await prisma.taskPart.deleteMany({ where: { taskId: id } });
|
||||
const taskWithOrder = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: { order: true },
|
||||
});
|
||||
const orderCarId = taskWithOrder?.order.carId;
|
||||
for (const item of partIds) {
|
||||
const { partId, qty = 1, price: p } = item;
|
||||
const part = await prisma.part.findFirst({
|
||||
where: {
|
||||
id: Number(partId),
|
||||
OR: [{ carId: orderCarId }, { isGlobal: true }],
|
||||
},
|
||||
});
|
||||
if (part) {
|
||||
await prisma.taskPart.create({
|
||||
data: {
|
||||
taskId: id,
|
||||
partId: part.id,
|
||||
qty: Number(qty) || 1,
|
||||
price: p != null ? Number(p) : part.purchasePrice,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await recalcOrderTotal(task.orderId);
|
||||
if (status === "finished") {
|
||||
await prisma.quickTask.upsert({
|
||||
where: { title: task.title },
|
||||
update: { useCount: { increment: 1 } },
|
||||
create: { title: task.title, useCount: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
const full = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: { parts: { include: { part: true } } },
|
||||
});
|
||||
|
||||
return jsonResponse(full);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||
const id = Number(params.id);
|
||||
if (Number.isNaN(id)) return errorResponse("Invalid id");
|
||||
|
||||
const task = await prisma.task.findUnique({ where: { id } });
|
||||
if (!task) return errorResponse("Task not found", 404);
|
||||
|
||||
await prisma.task.delete({ where: { id } });
|
||||
await recalcOrderTotal(task.orderId);
|
||||
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, getJsonBody, jsonResponse } from "@/lib/api-helpers";
|
||||
|
||||
async function recalcOrderTotal(orderId: number) {
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { orderId },
|
||||
include: { parts: true },
|
||||
});
|
||||
|
||||
const total = tasks.reduce((sum, task) => {
|
||||
const partsCost = task.parts.reduce(
|
||||
(p, tp) => p + (tp.price || 0) * tp.qty,
|
||||
0,
|
||||
);
|
||||
const laborCost = task.laborHours * task.hourlyRate;
|
||||
const price = task.price > 0 ? task.price : partsCost + laborCost;
|
||||
return sum + price;
|
||||
}, 0);
|
||||
|
||||
await prisma.order.update({ where: { id: orderId }, data: { total } });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await getJsonBody(req);
|
||||
if (!body) return errorResponse("Invalid JSON");
|
||||
const {
|
||||
orderId,
|
||||
title,
|
||||
notes,
|
||||
laborHours,
|
||||
hourlyRate,
|
||||
price,
|
||||
partIds = [],
|
||||
templateParts = [],
|
||||
} = body;
|
||||
if (!orderId || !title)
|
||||
return errorResponse("orderId and title are required");
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: Number(orderId) },
|
||||
include: { car: true },
|
||||
});
|
||||
if (!order) return errorResponse("Order not found", 404);
|
||||
|
||||
const last = await prisma.task.findFirst({
|
||||
where: { orderId: Number(orderId) },
|
||||
orderBy: { orderIndex: "desc" },
|
||||
});
|
||||
|
||||
// If the title matches a quick task template, pull its defaults.
|
||||
const quickTask = await prisma.quickTask.findFirst({
|
||||
where: { title },
|
||||
include: { parts: true },
|
||||
});
|
||||
|
||||
if (quickTask) {
|
||||
await prisma.quickTask.update({
|
||||
where: { id: quickTask.id },
|
||||
data: { useCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
const hasExplicitLabor =
|
||||
laborHours !== undefined && String(laborHours).trim() !== "";
|
||||
const hasExplicitRate =
|
||||
hourlyRate !== undefined && String(hourlyRate).trim() !== "";
|
||||
const hasExplicitPrice = price !== undefined && String(price).trim() !== "";
|
||||
const taskLaborHours =
|
||||
quickTask && !hasExplicitLabor
|
||||
? quickTask.laborHours
|
||||
: Number(laborHours) || 0;
|
||||
const taskHourlyRate =
|
||||
quickTask && !hasExplicitRate
|
||||
? quickTask.hourlyRate
|
||||
: Number(hourlyRate) || 50;
|
||||
const taskPrice =
|
||||
quickTask && !hasExplicitPrice
|
||||
? quickTask.price
|
||||
: hasExplicitPrice
|
||||
? Number(price) || 0
|
||||
: null;
|
||||
const effectiveTemplateParts =
|
||||
templateParts && templateParts.length > 0
|
||||
? templateParts
|
||||
: quickTask?.parts || [];
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
orderId: Number(orderId),
|
||||
title,
|
||||
notes: notes || "",
|
||||
laborHours: Number(taskLaborHours) || 0,
|
||||
hourlyRate: Number(taskHourlyRate) || 50,
|
||||
price: taskPrice ?? undefined,
|
||||
orderIndex: (last?.orderIndex ?? -1) + 1,
|
||||
},
|
||||
include: { parts: { include: { part: true } } },
|
||||
});
|
||||
|
||||
// Attach selected existing parts — only link to the same car or global parts.
|
||||
for (const item of partIds) {
|
||||
const { partId, qty = 1, price: partPrice } = item;
|
||||
const part = await prisma.part.findFirst({
|
||||
where: {
|
||||
id: Number(partId),
|
||||
OR: [{ carId: order.carId }, { isGlobal: true }],
|
||||
},
|
||||
});
|
||||
if (part) {
|
||||
await prisma.taskPart.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
partId: part.id,
|
||||
qty: Number(qty) || 1,
|
||||
price: partPrice != null ? Number(partPrice) : part.purchasePrice,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert template parts into private car parts (or link globals) and attach them.
|
||||
for (const tp of effectiveTemplateParts) {
|
||||
const isGlobal = tp.isGlobal || false;
|
||||
let partId: number | undefined;
|
||||
if (isGlobal) {
|
||||
// Try to find an existing global part with the same name.
|
||||
const existing = await prisma.part.findFirst({
|
||||
where: { name: tp.name, isGlobal: true },
|
||||
});
|
||||
if (existing) {
|
||||
partId = existing.id;
|
||||
} else {
|
||||
const created = await prisma.part.create({
|
||||
data: {
|
||||
name: tp.name,
|
||||
purchasePrice: Number(tp.price) || 0,
|
||||
notes: "",
|
||||
isGlobal: true,
|
||||
},
|
||||
});
|
||||
partId = created.id;
|
||||
}
|
||||
} else {
|
||||
const existing = await prisma.part.findFirst({
|
||||
where: { name: tp.name, carId: order.carId, isGlobal: false },
|
||||
});
|
||||
if (existing) {
|
||||
partId = existing.id;
|
||||
} else {
|
||||
const created = await prisma.part.create({
|
||||
data: {
|
||||
name: tp.name,
|
||||
purchasePrice: Number(tp.price) || 0,
|
||||
notes: "",
|
||||
carId: order.carId,
|
||||
isGlobal: false,
|
||||
},
|
||||
});
|
||||
partId = created.id;
|
||||
}
|
||||
}
|
||||
if (partId) {
|
||||
await prisma.taskPart.create({
|
||||
data: {
|
||||
taskId: task.id,
|
||||
partId,
|
||||
qty: Number(tp.qty) || 1,
|
||||
price: Number(tp.price) || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recalcOrderTotal(Number(orderId));
|
||||
if (quickTask) {
|
||||
await prisma.quickTask.update({
|
||||
where: { id: quickTask.id },
|
||||
data: { useCount: { increment: 1 } },
|
||||
});
|
||||
} else {
|
||||
await prisma.quickTask.upsert({
|
||||
where: { title },
|
||||
update: { useCount: { increment: 1 } },
|
||||
create: { title, useCount: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
const full = await prisma.task.findUnique({
|
||||
where: { id: task.id },
|
||||
include: { parts: { include: { part: true } } },
|
||||
});
|
||||
|
||||
return jsonResponse(full, 201);
|
||||
}
|
||||
Reference in New Issue
Block a user