Bernt Christian Egeland hace 7 meses
padre
commit
5b7d2c6577

+ 88 - 0
src/app/api/desktop/v1/board-assignments/[id]/route.ts

@@ -0,0 +1,88 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { moveAssignmentSchema } from "@/features/workboard/Schema/workboardSchema";
+import { recordDeletion } from "@/lib/sync-deletion";
+
+export async function PUT(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+      const body = await request.json();
+      const data = moveAssignmentSchema.parse({ ...body, id });
+
+      const existing = await db.boardAssignment.findFirst({
+        where: { id, organizationId },
+      });
+      if (!existing) {
+        return NextResponse.json({ error: "Assignment not found" }, { status: 404 });
+      }
+
+      const updated = await db.boardAssignment.update({
+        where: { id },
+        data: {
+          technicianId: data.technicianId,
+          date: new Date(data.date),
+          sortOrder: data.sortOrder,
+        },
+        include: { technician: true },
+      });
+
+      // Sync service record tech name and date
+      if (updated.serviceRecordId) {
+        await db.serviceRecord.update({
+          where: { id: updated.serviceRecordId },
+          data: { techName: updated.technician.name, serviceDate: new Date(data.date) },
+        });
+      }
+
+      return NextResponse.json({ assignment: updated });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+
+      const assignment = await db.boardAssignment.findFirst({
+        where: { id, organizationId },
+      });
+      if (!assignment) {
+        return NextResponse.json({ error: "Assignment not found" }, { status: 404 });
+      }
+
+      // Clear techName on linked service record
+      if (assignment.serviceRecordId) {
+        await db.serviceRecord.update({
+          where: { id: assignment.serviceRecordId },
+          data: { techName: null },
+        });
+      }
+
+      await recordDeletion("boardAssignment", id, organizationId);
+      await db.boardAssignment.delete({ where: { id } });
+      return new NextResponse(null, { status: 204 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}

+ 53 - 0
src/app/api/desktop/v1/board-assignments/route.ts

@@ -0,0 +1,53 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { createBoardAssignmentSchema } from "@/features/workboard/Schema/workboardSchema";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const body = await request.json();
+      const data = createBoardAssignmentSchema.parse(body);
+
+      const tech = await db.technician.findFirst({
+        where: { id: data.technicianId, organizationId },
+      });
+      if (!tech) {
+        return NextResponse.json({ error: "Technician not found" }, { status: 404 });
+      }
+
+      if (!data.serviceRecordId && !data.inspectionId) {
+        return NextResponse.json({ error: "Must provide serviceRecordId or inspectionId" }, { status: 400 });
+      }
+
+      const assignment = await db.boardAssignment.create({
+        data: {
+          date: new Date(data.date),
+          sortOrder: data.sortOrder,
+          notes: data.notes,
+          technicianId: data.technicianId,
+          serviceRecordId: data.serviceRecordId || null,
+          inspectionId: data.inspectionId || null,
+          organizationId,
+        },
+      });
+
+      // Sync service record tech name and date
+      if (assignment.serviceRecordId) {
+        await db.serviceRecord.update({
+          where: { id: assignment.serviceRecordId },
+          data: { techName: tech.name, serviceDate: new Date(data.date) },
+        });
+      }
+
+      return NextResponse.json({ assignment }, { status: 201 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.WORK_BOARD },
+      ],
+    },
+  );
+}

+ 36 - 0
src/app/api/desktop/v1/inspections/[id]/complete/route.ts

@@ -0,0 +1,36 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+
+      const inspection = await db.inspection.findFirst({
+        where: { id, organizationId },
+      });
+      if (!inspection) {
+        return NextResponse.json({ error: "Inspection not found" }, { status: 404 });
+      }
+
+      await db.inspection.updateMany({
+        where: { id, organizationId },
+        data: { status: "completed", completedAt: new Date() },
+      });
+
+      const updated = await db.inspection.findUniqueOrThrow({ where: { id } });
+      return NextResponse.json({ inspection: updated });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS },
+      ],
+    },
+  );
+}

+ 42 - 0
src/app/api/desktop/v1/inspections/[id]/items/[itemId]/route.ts

@@ -0,0 +1,42 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { updateInspectionItemSchema } from "@/features/inspections/Schema/inspectionSchema";
+
+export async function PUT(
+  request: Request,
+  { params }: { params: Promise<{ id: string; itemId: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id, itemId } = await params;
+      const body = await request.json();
+      const data = updateInspectionItemSchema.parse(body);
+
+      const item = await db.inspectionItem.findFirst({
+        where: { id: itemId, inspectionId: id, inspection: { organizationId } },
+      });
+      if (!item) {
+        return NextResponse.json({ error: "Inspection item not found" }, { status: 404 });
+      }
+
+      const updated = await db.inspectionItem.update({
+        where: { id: itemId },
+        data: {
+          condition: data.condition,
+          notes: data.notes,
+          imageUrls: data.imageUrls,
+        },
+      });
+
+      return NextResponse.json({ item: updated });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INSPECTIONS },
+      ],
+    },
+  );
+}

+ 41 - 0
src/app/api/desktop/v1/inspections/[id]/route.ts

@@ -0,0 +1,41 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { recordDeletion } from "@/lib/sync-deletion";
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+
+      const inspection = await db.inspection.findFirst({
+        where: { id, organizationId },
+        include: {
+          items: { select: { id: true } },
+          boardAssignments: { select: { id: true } },
+        },
+      });
+      if (!inspection) {
+        return NextResponse.json({ error: "Inspection not found" }, { status: 404 });
+      }
+
+      const deletions: Promise<void>[] = [recordDeletion("inspection", id, organizationId)];
+      if (inspection.items.length) deletions.push(recordDeletion("inspectionItem", inspection.items.map((i) => i.id), organizationId));
+      if (inspection.boardAssignments.length) deletions.push(recordDeletion("boardAssignment", inspection.boardAssignments.map((b) => b.id), organizationId));
+      await Promise.all(deletions);
+
+      await db.inspection.deleteMany({ where: { id, organizationId } });
+      return new NextResponse(null, { status: 204 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.INSPECTIONS },
+      ],
+    },
+  );
+}

+ 72 - 0
src/app/api/desktop/v1/inspections/route.ts

@@ -0,0 +1,72 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { createInspectionSchema } from "@/features/inspections/Schema/inspectionSchema";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(
+    request,
+    async ({ userId, organizationId }) => {
+      const body = await request.json();
+      const data = createInspectionSchema.parse(body);
+
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: data.vehicleId, organizationId },
+      });
+      if (!vehicle) {
+        return NextResponse.json({ error: "Vehicle not found" }, { status: 404 });
+      }
+
+      const template = await db.inspectionTemplate.findFirst({
+        where: { id: data.templateId, organizationId },
+        include: {
+          sections: {
+            include: { items: { orderBy: { sortOrder: "asc" } } },
+            orderBy: { sortOrder: "asc" },
+          },
+        },
+      });
+      if (!template) {
+        return NextResponse.json({ error: "Template not found" }, { status: 404 });
+      }
+
+      const inspection = await db.$transaction(async (tx) => {
+        const created = await tx.inspection.create({
+          data: {
+            vehicleId: data.vehicleId,
+            templateId: data.templateId,
+            mileage: data.mileage,
+            technicianId: userId,
+            organizationId,
+          },
+        });
+
+        const items = template.sections.flatMap((section, sIdx) =>
+          section.items.map((item) => ({
+            name: item.name,
+            section: section.name,
+            sortOrder: sIdx * 1000 + item.sortOrder,
+            inspectionId: created.id,
+          })),
+        );
+
+        if (items.length > 0) {
+          await tx.inspectionItem.createMany({ data: items });
+        }
+
+        return tx.inspection.findUniqueOrThrow({
+          where: { id: created.id },
+          include: { items: { orderBy: { sortOrder: "asc" } } },
+        });
+      });
+
+      return NextResponse.json({ inspection }, { status: 201 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.INSPECTIONS },
+      ],
+    },
+  );
+}

+ 44 - 0
src/app/api/desktop/v1/inventory/[id]/adjust/route.ts

@@ -0,0 +1,44 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { adjustStockSchema } from "@/features/inventory/Schema/inventorySchema";
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+      const body = await request.json();
+      const { adjustment } = adjustStockSchema.parse({ ...body, id });
+
+      const part = await db.inventoryPart.findFirst({
+        where: { id, organizationId },
+      });
+      if (!part) {
+        return NextResponse.json({ error: "Part not found" }, { status: 404 });
+      }
+
+      const newQuantity = part.quantity + adjustment;
+      if (newQuantity < 0) {
+        return NextResponse.json({ error: "Insufficient stock" }, { status: 400 });
+      }
+
+      await db.inventoryPart.updateMany({
+        where: { id, organizationId },
+        data: { quantity: newQuantity },
+      });
+
+      const updated = await db.inventoryPart.findUniqueOrThrow({ where: { id } });
+      return NextResponse.json({ part: updated });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY },
+      ],
+    },
+  );
+}

+ 74 - 0
src/app/api/desktop/v1/inventory/[id]/route.ts

@@ -0,0 +1,74 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { updateInventoryPartSchema } from "@/features/inventory/Schema/inventorySchema";
+import { recordDeletion } from "@/lib/sync-deletion";
+
+export async function PUT(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+      const body = await request.json();
+      const data = updateInventoryPartSchema.parse({ ...body, id });
+      const { id: _id, ...updateData } = data;
+
+      const result = await db.inventoryPart.updateMany({
+        where: { id, organizationId },
+        data: {
+          ...updateData,
+          partNumber: updateData.partNumber !== undefined ? (updateData.partNumber || null) : undefined,
+          description: updateData.description !== undefined ? (updateData.description || null) : undefined,
+          category: updateData.category !== undefined ? (updateData.category || null) : undefined,
+          supplier: updateData.supplier !== undefined ? (updateData.supplier || null) : undefined,
+          supplierPhone: updateData.supplierPhone !== undefined ? (updateData.supplierPhone || null) : undefined,
+          supplierEmail: updateData.supplierEmail !== undefined ? (updateData.supplierEmail || null) : undefined,
+          supplierUrl: updateData.supplierUrl !== undefined ? (updateData.supplierUrl || null) : undefined,
+          location: updateData.location !== undefined ? (updateData.location || null) : undefined,
+        },
+      });
+      if (result.count === 0) {
+        return NextResponse.json({ error: "Part not found" }, { status: 404 });
+      }
+
+      const part = await db.inventoryPart.findUniqueOrThrow({ where: { id } });
+      return NextResponse.json({ part });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.INVENTORY },
+      ],
+    },
+  );
+}
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+
+      await recordDeletion("inventoryPart", id, organizationId);
+      const result = await db.inventoryPart.deleteMany({
+        where: { id, organizationId },
+      });
+      if (result.count === 0) {
+        return NextResponse.json({ error: "Part not found" }, { status: 404 });
+      }
+
+      return new NextResponse(null, { status: 204 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.INVENTORY },
+      ],
+    },
+  );
+}

+ 39 - 0
src/app/api/desktop/v1/inventory/route.ts

@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { createInventoryPartSchema } from "@/features/inventory/Schema/inventorySchema";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(
+    request,
+    async ({ userId, organizationId }) => {
+      const body = await request.json();
+      const data = createInventoryPartSchema.parse(body);
+
+      const part = await db.inventoryPart.create({
+        data: {
+          ...data,
+          partNumber: data.partNumber || undefined,
+          description: data.description || undefined,
+          category: data.category || undefined,
+          supplier: data.supplier || undefined,
+          supplierPhone: data.supplierPhone || undefined,
+          supplierEmail: data.supplierEmail || undefined,
+          supplierUrl: data.supplierUrl || undefined,
+          imageUrl: data.imageUrl || undefined,
+          location: data.location || undefined,
+          userId,
+          organizationId,
+        },
+      });
+
+      return NextResponse.json({ part }, { status: 201 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.INVENTORY },
+      ],
+    },
+  );
+}

+ 33 - 0
src/app/api/desktop/v1/payments/[id]/route.ts

@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { recordDeletion } from "@/lib/sync-deletion";
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+
+      const payment = await db.payment.findFirst({
+        where: { id, serviceRecord: { vehicle: { organizationId } } },
+      });
+      if (!payment) {
+        return NextResponse.json({ error: "Payment not found" }, { status: 404 });
+      }
+
+      await recordDeletion("payment", id, organizationId);
+      await db.payment.delete({ where: { id } });
+      return new NextResponse(null, { status: 204 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.BILLING },
+      ],
+    },
+  );
+}

+ 40 - 0
src/app/api/desktop/v1/payments/route.ts

@@ -0,0 +1,40 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { createPaymentSchema } from "@/features/payments/Schema/paymentSchema";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const body = await request.json();
+      const data = createPaymentSchema.parse(body);
+
+      const serviceRecord = await db.serviceRecord.findFirst({
+        where: { id: data.serviceRecordId, vehicle: { organizationId } },
+        select: { id: true },
+      });
+      if (!serviceRecord) {
+        return NextResponse.json({ error: "Service record not found" }, { status: 404 });
+      }
+
+      const payment = await db.payment.create({
+        data: {
+          serviceRecordId: data.serviceRecordId,
+          amount: data.amount,
+          date: new Date(data.date),
+          method: data.method,
+          note: data.note || null,
+        },
+      });
+
+      return NextResponse.json({ payment }, { status: 201 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.BILLING },
+      ],
+    },
+  );
+}

+ 158 - 0
src/app/api/desktop/v1/quotes/[id]/convert/route.ts

@@ -0,0 +1,158 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { resolveInvoicePrefix } from "@/lib/invoice-utils";
+import { copyFile, mkdir } from "fs/promises";
+import path from "path";
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+      const { vehicleId } = await request.json();
+
+      const quote = await db.quote.findFirst({
+        where: { id, organizationId },
+        include: { partItems: true, laborItems: true, attachments: true },
+      });
+      if (!quote) {
+        return NextResponse.json({ error: "Quote not found" }, { status: 404 });
+      }
+
+      const vehicle = await db.vehicle.findFirst({
+        where: { id: vehicleId, organizationId },
+      });
+      if (!vehicle) {
+        return NextResponse.json({ error: "Vehicle not found" }, { status: 404 });
+      }
+
+      // Get settings for invoice number
+      const [settings, org] = await Promise.all([
+        db.appSetting.findMany({
+          where: { organizationId, key: { in: ["workshop.invoicePrefix"] } },
+        }),
+        db.organization.findUnique({
+          where: { id: organizationId },
+          select: { name: true },
+        }),
+      ]);
+      const settingsMap: Record<string, string> = {};
+      for (const s of settings) settingsMap[s.key] = s.value;
+      const prefix = resolveInvoicePrefix(settingsMap["workshop.invoicePrefix"] || "{year}-");
+
+      const lastRecord = await db.serviceRecord.findFirst({
+        where: { vehicle: { organizationId } },
+        orderBy: { createdAt: "desc" },
+        select: { invoiceNumber: true },
+      });
+      let nextNum = 1001;
+      if (lastRecord?.invoiceNumber) {
+        const match = lastRecord.invoiceNumber.match(/(\d+)$/);
+        if (match) nextNum = parseInt(match[1], 10) + 1;
+      }
+      const invoiceNumber = `${prefix}${nextNum}`;
+
+      const record = await db.$transaction(async (tx) => {
+        const created = await tx.serviceRecord.create({
+          data: {
+            title: quote.title,
+            description: quote.description,
+            type: "repair",
+            status: "pending",
+            vehicleId,
+            shopName: org?.name || undefined,
+            invoiceNumber,
+            subtotal: quote.subtotal,
+            taxRate: quote.taxRate,
+            taxAmount: quote.taxAmount,
+            totalAmount: quote.totalAmount,
+            cost: quote.totalAmount,
+            discountType: quote.discountType,
+            discountValue: quote.discountValue,
+            discountAmount: quote.discountAmount,
+            serviceDate: new Date(),
+          },
+        });
+
+        const includedParts = quote.partItems.filter((p) => !p.excluded);
+        if (includedParts.length > 0) {
+          await tx.servicePart.createMany({
+            data: includedParts.map((p) => ({
+              partNumber: p.partNumber,
+              name: p.name,
+              quantity: p.quantity,
+              unitPrice: p.unitPrice,
+              total: p.total,
+              serviceRecordId: created.id,
+            })),
+          });
+        }
+
+        const includedLabor = quote.laborItems.filter((l) => !l.excluded);
+        if (includedLabor.length > 0) {
+          await tx.serviceLabor.createMany({
+            data: includedLabor.map((l) => ({
+              description: l.description,
+              hours: l.hours,
+              rate: l.rate,
+              total: l.total,
+              serviceRecordId: created.id,
+            })),
+          });
+        }
+
+        // Copy attachments
+        if (quote.attachments.length > 0) {
+          const quotesDir = path.join(process.cwd(), "data", "uploads", organizationId, "quotes");
+          const servicesDir = path.join(process.cwd(), "data", "uploads", organizationId, "services");
+          await mkdir(servicesDir, { recursive: true });
+
+          for (const att of quote.attachments) {
+            try {
+              const filename = att.fileUrl.split("/").pop()!;
+              const srcPath = path.join(quotesDir, filename);
+              const destPath = path.join(servicesDir, filename);
+              await copyFile(srcPath, destPath);
+
+              const newUrl = att.fileUrl.replace("/quotes/", "/services/");
+              await tx.serviceAttachment.create({
+                data: {
+                  fileName: att.fileName,
+                  fileUrl: newUrl,
+                  fileType: att.fileType,
+                  fileSize: att.fileSize,
+                  category: att.category === "document" ? "document" : "image",
+                  description: att.description,
+                  includeInInvoice: att.includeInInvoice,
+                  serviceRecordId: created.id,
+                },
+              });
+            } catch (err) {
+              console.warn(`[convertQuote] Failed to copy attachment "${att.fileName}":`, err);
+            }
+          }
+        }
+
+        // Mark quote as converted
+        await tx.quote.updateMany({
+          where: { id, organizationId },
+          data: { status: "converted", convertedToId: created.id },
+        });
+
+        return created;
+      });
+
+      return NextResponse.json({ workOrder: record, quote: { id, status: "converted" } }, { status: 201 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.SERVICES },
+      ],
+    },
+  );
+}

+ 108 - 0
src/app/api/desktop/v1/quotes/[id]/route.ts

@@ -0,0 +1,108 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { updateQuoteSchema } from "@/features/quotes/Schema/quoteSchema";
+import { recordDeletion } from "@/lib/sync-deletion";
+
+export async function PUT(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+      const body = await request.json();
+      const data = updateQuoteSchema.parse({ ...body, id });
+
+      const existing = await db.quote.findFirst({
+        where: { id, organizationId },
+      });
+      if (!existing) {
+        return NextResponse.json({ error: "Quote not found" }, { status: 404 });
+      }
+
+      const { id: _id, partItems, laborItems, ...quoteData } = data;
+
+      const quote = await db.$transaction(async (tx) => {
+        await tx.quote.update({
+          where: { id },
+          data: {
+            ...quoteData,
+            validUntil: quoteData.validUntil ? new Date(quoteData.validUntil) : undefined,
+            discountType: quoteData.discountType === "none" ? null : quoteData.discountType,
+          },
+        });
+
+        if (partItems !== undefined) {
+          await tx.quotePart.deleteMany({ where: { quoteId: id } });
+          if (partItems.length > 0) {
+            await tx.quotePart.createMany({
+              data: partItems.map((p) => ({ ...p, quoteId: id })),
+            });
+          }
+        }
+
+        if (laborItems !== undefined) {
+          await tx.quoteLabor.deleteMany({ where: { quoteId: id } });
+          if (laborItems.length > 0) {
+            await tx.quoteLabor.createMany({
+              data: laborItems.map((l) => ({ ...l, quoteId: id })),
+            });
+          }
+        }
+
+        return tx.quote.findUniqueOrThrow({
+          where: { id },
+          include: { partItems: true, laborItems: true },
+        });
+      });
+
+      return NextResponse.json({ quote });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES },
+      ],
+    },
+  );
+}
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+
+      const quote = await db.quote.findFirst({
+        where: { id, organizationId },
+        include: {
+          partItems: { select: { id: true } },
+          laborItems: { select: { id: true } },
+          attachments: { select: { id: true } },
+        },
+      });
+      if (!quote) {
+        return NextResponse.json({ error: "Quote not found" }, { status: 404 });
+      }
+
+      const deletions: Promise<void>[] = [recordDeletion("quote", id, organizationId)];
+      if (quote.partItems.length) deletions.push(recordDeletion("quotePart", quote.partItems.map((p) => p.id), organizationId));
+      if (quote.laborItems.length) deletions.push(recordDeletion("quoteLabor", quote.laborItems.map((l) => l.id), organizationId));
+      if (quote.attachments.length) deletions.push(recordDeletion("quoteAttachment", quote.attachments.map((a) => a.id), organizationId));
+      await Promise.all(deletions);
+
+      await db.quote.deleteMany({ where: { id, organizationId } });
+      return new NextResponse(null, { status: 204 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.QUOTES },
+      ],
+    },
+  );
+}

+ 37 - 0
src/app/api/desktop/v1/quotes/[id]/status/route.ts

@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+
+export async function PATCH(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+      const { status } = await request.json();
+
+      const quote = await db.quote.findFirst({
+        where: { id, organizationId },
+      });
+      if (!quote) {
+        return NextResponse.json({ error: "Quote not found" }, { status: 404 });
+      }
+
+      await db.quote.updateMany({
+        where: { id, organizationId },
+        data: { status },
+      });
+
+      const updated = await db.quote.findUniqueOrThrow({ where: { id } });
+      return NextResponse.json({ quote: updated });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.QUOTES },
+      ],
+    },
+  );
+}

+ 74 - 0
src/app/api/desktop/v1/quotes/route.ts

@@ -0,0 +1,74 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { createQuoteSchema } from "@/features/quotes/Schema/quoteSchema";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(
+    request,
+    async ({ userId, organizationId }) => {
+      const body = await request.json();
+      const data = createQuoteSchema.parse(body);
+
+      // Generate quote number
+      const settings = await db.appSetting.findMany({
+        where: { organizationId, key: { in: ["workshop.quotePrefix"] } },
+      });
+      const settingsMap: Record<string, string> = {};
+      for (const s of settings) settingsMap[s.key] = s.value;
+      const prefix = settingsMap["workshop.quotePrefix"] || "QT-";
+
+      const lastQuote = await db.quote.findFirst({
+        where: { organizationId },
+        orderBy: { createdAt: "desc" },
+        select: { quoteNumber: true },
+      });
+      let nextNum = 1001;
+      if (lastQuote?.quoteNumber) {
+        const match = lastQuote.quoteNumber.match(/(\d+)$/);
+        if (match) nextNum = parseInt(match[1], 10) + 1;
+      }
+      const quoteNumber = `${prefix}${nextNum}`;
+
+      const { partItems, laborItems, ...quoteData } = data;
+
+      const quote = await db.$transaction(async (tx) => {
+        const created = await tx.quote.create({
+          data: {
+            ...quoteData,
+            quoteNumber,
+            userId,
+            organizationId,
+            validUntil: quoteData.validUntil ? new Date(quoteData.validUntil) : undefined,
+            discountType: quoteData.discountType === "none" ? null : quoteData.discountType,
+          },
+        });
+
+        if (partItems && partItems.length > 0) {
+          await tx.quotePart.createMany({
+            data: partItems.map((p) => ({ ...p, quoteId: created.id })),
+          });
+        }
+
+        if (laborItems && laborItems.length > 0) {
+          await tx.quoteLabor.createMany({
+            data: laborItems.map((l) => ({ ...l, quoteId: created.id })),
+          });
+        }
+
+        return tx.quote.findUniqueOrThrow({
+          where: { id: created.id },
+          include: { partItems: true, laborItems: true },
+        });
+      });
+
+      return NextResponse.json({ quote }, { status: 201 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.QUOTES },
+      ],
+    },
+  );
+}

+ 33 - 0
src/app/api/desktop/v1/sms/[id]/route.ts

@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { recordDeletion } from "@/lib/sync-deletion";
+
+export async function DELETE(
+  request: Request,
+  { params }: { params: Promise<{ id: string }> },
+) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { id } = await params;
+
+      const message = await db.smsMessage.findFirst({
+        where: { id, organizationId },
+      });
+      if (!message) {
+        return NextResponse.json({ error: "Message not found" }, { status: 404 });
+      }
+
+      await recordDeletion("smsMessage", id, organizationId);
+      await db.smsMessage.delete({ where: { id } });
+      return new NextResponse(null, { status: 204 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}

+ 80 - 0
src/app/api/desktop/v1/sms/route.ts

@@ -0,0 +1,80 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { sendOrgSms, getOrgSmsPhoneNumber } from "@/lib/sms";
+import { requireFeature } from "@/lib/features";
+
+export async function POST(request: Request) {
+  return withDesktopAuth(
+    request,
+    async ({ organizationId }) => {
+      const { customerId, body: messageBody, relatedEntityType, relatedEntityId } = await request.json();
+
+      if (!customerId || !messageBody) {
+        return NextResponse.json({ error: "customerId and body are required" }, { status: 400 });
+      }
+
+      await requireFeature(organizationId, "sms");
+
+      const customer = await db.customer.findFirst({
+        where: { id: customerId, organizationId },
+        select: { phone: true },
+      });
+      if (!customer) {
+        return NextResponse.json({ error: "Customer not found" }, { status: 404 });
+      }
+      if (!customer.phone) {
+        return NextResponse.json({ error: "Customer has no phone number" }, { status: 400 });
+      }
+
+      const fromNumber = await getOrgSmsPhoneNumber(organizationId);
+      if (!fromNumber) {
+        return NextResponse.json({ error: "SMS phone number is not configured" }, { status: 400 });
+      }
+
+      // Create message record
+      const message = await db.smsMessage.create({
+        data: {
+          direction: "outbound",
+          fromNumber,
+          toNumber: customer.phone,
+          body: messageBody,
+          status: "queued",
+          customerId,
+          organizationId,
+          relatedEntityType: relatedEntityType || null,
+          relatedEntityId: relatedEntityId || null,
+        },
+      });
+
+      try {
+        const result = await sendOrgSms(organizationId, {
+          to: customer.phone,
+          body: messageBody,
+        });
+        await db.smsMessage.update({
+          where: { id: message.id },
+          data: { status: "sent", providerMsgId: result?.messageId || null },
+        });
+      } catch (err) {
+        await db.smsMessage.update({
+          where: { id: message.id },
+          data: {
+            status: "failed",
+            errorMessage: err instanceof Error ? err.message : "Unknown error",
+          },
+        });
+        return NextResponse.json({ error: "Failed to send SMS" }, { status: 500 });
+      }
+
+      const updated = await db.smsMessage.findUniqueOrThrow({ where: { id: message.id } });
+      return NextResponse.json({ message: updated }, { status: 201 });
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    },
+  );
+}