From 022ae1f02e92689002b7fc210febc71143272795 Mon Sep 17 00:00:00 2001 From: mehakseedat63 Date: Sat, 26 Apr 2025 11:12:22 +0500 Subject: [PATCH 1/5] Adde Projects Dashboar flow --- app/api/cart/bulk/route.ts | 84 ++++ app/api/cart/route.ts | 108 ++++++ .../contractor/materials/templates/route.ts | 114 ++++++ .../materials/[materialId]/route.ts | 107 +++++ .../projects/[projectId]/materials/route.ts | 131 +++++++ .../projects/[projectId]/recalculate/route.ts | 49 +++ .../contractor/projects/[projectId]/route.ts | 68 ++++ app/api/contractor/projects/route.ts | 75 ++++ app/api/products/route.ts | 30 ++ app/projects/[projectId]/materials/page.tsx | 308 +++++++++++++++ app/projects/layout.tsx | 36 ++ app/projects/page.tsx | 364 ++++++++++++++++++ app/projects/upload/page.tsx | 115 ++++++ components/Header.tsx | 42 +- .../migration.sql | 97 +++++ .../migration.sql | 46 +++ .../migration.sql | 22 ++ prisma/schema.prisma | 50 ++- scripts/seed-products.ts | 118 ++++++ .../migration.sql | 35 -- .../migration.sql | 17 - .../migration.sql | 10 - .../migration.sql | 10 - .../migration.sql | 2 - .../migration.sql | 26 -- .../migration.sql | 15 - .../migration.sql | 11 - .../migration.sql | 14 - .../migration.sql | 5 - .../migration.sql | 5 - .../migration.sql | 5 - .../migration.sql | 8 - .../20250426025921_init/migration.sql | 129 +++++++ server/prisma/schema.prisma | 21 + 34 files changed, 2092 insertions(+), 185 deletions(-) create mode 100644 app/api/cart/bulk/route.ts create mode 100644 app/api/cart/route.ts create mode 100644 app/api/contractor/materials/templates/route.ts create mode 100644 app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts create mode 100644 app/api/contractor/projects/[projectId]/materials/route.ts create mode 100644 app/api/contractor/projects/[projectId]/recalculate/route.ts create mode 100644 app/api/contractor/projects/[projectId]/route.ts create mode 100644 app/api/contractor/projects/route.ts create mode 100644 app/api/products/route.ts create mode 100644 app/projects/[projectId]/materials/page.tsx create mode 100644 app/projects/layout.tsx create mode 100644 app/projects/page.tsx create mode 100644 app/projects/upload/page.tsx create mode 100644 prisma/migrations/20250426034535_add_project_and_material_models/migration.sql create mode 100644 prisma/migrations/20250426042000_add_project_and_material_models/migration.sql create mode 100644 prisma/migrations/20250427000000_replace_materials_with_project_products/migration.sql create mode 100644 scripts/seed-products.ts delete mode 100644 server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql delete mode 100644 server/prisma/migrations/20240413064716_added_order_table/migration.sql delete mode 100644 server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql delete mode 100644 server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql delete mode 100644 server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql delete mode 100644 server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql delete mode 100644 server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql delete mode 100644 server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql delete mode 100644 server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql delete mode 100644 server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql delete mode 100644 server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql delete mode 100644 server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql delete mode 100644 server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql create mode 100644 server/prisma/migrations/20250426025921_init/migration.sql diff --git a/app/api/cart/bulk/route.ts b/app/api/cart/bulk/route.ts new file mode 100644 index 0000000..a25d36e --- /dev/null +++ b/app/api/cart/bulk/route.ts @@ -0,0 +1,84 @@ +// ********************* +// Role: Bulk Cart Operations API Route +// Purpose: Handles bulk operations for the shopping cart +// Endpoints: +// - POST: Adds all materials from a project to the cart +// Features: +// - Fetches all products from a project +// - Replaces current cart contents with project materials +// - Maintains quantities from project specifications +// Security: Requires authenticated session +// ********************* + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/utils/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// Type declaration for global cart storage +declare global { + var cartItems: { [key: string]: Array<{ productId: string; quantity: number }> }; +} + +// Initialize global cart storage if not exists +if (!global.cartItems) { + global.cartItems = {}; +} + +// POST handler: Add all project materials to cart +export async function POST(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body for project ID + const body = await request.json(); + const { projectId } = body; + + if (!projectId) { + return new NextResponse("Project ID is required", { status: 400 }); + } + + // Fetch all products associated with the project + const projectProducts = await prisma.projectProduct.findMany({ + where: { + projectId: projectId + }, + include: { + product: true + } + }); + + if (!projectProducts.length) { + return new NextResponse("Project has no products", { status: 400 }); + } + + // Ensure cart storage is initialized + if (!global.cartItems) { + global.cartItems = {}; + } + + // Initialize or clear user's cart + const userEmail = session.user.email as string; + if (!global.cartItems[userEmail]) { + global.cartItems[userEmail] = []; + } + + // Transform project products to cart format + const cartItems = projectProducts.map(pp => ({ + productId: pp.productId, + quantity: pp.quantity + })); + + // Replace current cart contents with project materials + global.cartItems[userEmail] = cartItems; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[CART_BULK_POST]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/cart/route.ts b/app/api/cart/route.ts new file mode 100644 index 0000000..1aa45d6 --- /dev/null +++ b/app/api/cart/route.ts @@ -0,0 +1,108 @@ +// ********************* +// Role: Cart API Route Handler +// Purpose: Manages shopping cart operations +// Endpoints: +// - GET: Retrieves all items in the user's cart with product details +// - POST: Adds a new item to the cart +// - DELETE: Clears the user's cart +// Storage: Uses global server-side storage keyed by user email +// Security: Requires authenticated session +// ********************* + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/utils/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// GET handler: Retrieve cart items with product details +export async function GET() { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Get cart items from session storage + const cartItems = global.cartItems?.[session.user?.email as string] || []; + + // Fetch full product details for all cart items + const products = await prisma.product.findMany({ + where: { + id: { + in: cartItems.map(item => item.productId) + } + } + }); + + // Combine cart quantities with product details + const cartWithDetails = cartItems.map(item => { + const product = products.find(p => p.id === item.productId); + return { + ...item, + product + }; + }); + + return NextResponse.json(cartWithDetails); + } catch (error) { + console.error("[CART_GET]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +// POST handler: Add new item to cart +export async function POST(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body for product details + const body = await request.json(); + const { productId, quantity } = body; + + // Initialize global cart storage if needed + if (!global.cartItems) { + global.cartItems = {}; + } + + // Initialize user's cart array if needed + const userEmail = session.user?.email as string; + if (!global.cartItems[userEmail]) { + global.cartItems[userEmail] = []; + } + + // Add new item to user's cart + global.cartItems[userEmail].push({ productId, quantity }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[CART_POST]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +// DELETE handler: Clear all items from cart +export async function DELETE() { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Reset user's cart to empty array + const userEmail = session.user?.email as string; + if (global.cartItems?.[userEmail]) { + global.cartItems[userEmail] = []; + } + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("[CART_DELETE]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/materials/templates/route.ts b/app/api/contractor/materials/templates/route.ts new file mode 100644 index 0000000..659708f --- /dev/null +++ b/app/api/contractor/materials/templates/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +// GET /api/contractor/materials/templates +export async function GET() { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const templates = await prisma.materialTemplate.findMany(); + return NextResponse.json(templates); + } catch (error) { + console.error("Error fetching material templates:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// POST /api/contractor/materials/templates +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { name, description, unit } = body; + + const template = await prisma.materialTemplate.create({ + data: { + name, + description, + unit, + }, + }); + + return NextResponse.json(template); + } catch (error) { + console.error("Error creating material template:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// PUT /api/contractor/materials/templates/[templateId] +export async function PUT(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { templateId, name, description, unit } = body; + + const template = await prisma.materialTemplate.update({ + where: { id: templateId }, + data: { + name, + description, + unit, + }, + }); + + return NextResponse.json(template); + } catch (error) { + console.error("Error updating material template:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// DELETE /api/contractor/materials/templates/[templateId] +export async function DELETE(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const templateId = searchParams.get("templateId"); + + if (!templateId) { + return NextResponse.json( + { error: "Template ID is required" }, + { status: 400 } + ); + } + + await prisma.materialTemplate.delete({ + where: { id: templateId }, + }); + + return NextResponse.json({ message: "Template deleted successfully" }); + } catch (error) { + console.error("Error deleting material template:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts b/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts new file mode 100644 index 0000000..72420f2 --- /dev/null +++ b/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/utils/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// PUT /api/contractor/projects/[projectId]/materials/[materialId] +export async function PUT( + request: Request, + { params }: { params: { projectId: string; materialId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First verify that the project exists + const project = await prisma.project.findUnique({ + where: { + id: params.projectId, + }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + const body = await request.json(); + const { quantity } = body; + + const projectProduct = await prisma.projectProduct.update({ + where: { + id: params.materialId, + projectId: params.projectId, + }, + data: { + quantity, + }, + include: { + product: true, + }, + }); + + // Transform to match expected format + const material = { + id: projectProduct.id, + name: projectProduct.product.title, + description: projectProduct.product.description, + quantity: projectProduct.quantity, + unit: "piece", + productId: projectProduct.product.id + }; + + return NextResponse.json(material); + } catch (error) { + console.error("[MATERIALS_PUT]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +// DELETE /api/contractor/projects/[projectId]/materials/[materialId] +export async function DELETE( + request: Request, + { params }: { params: { projectId: string; materialId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First verify that the project exists and get current itemCount + const project = await prisma.project.findUnique({ + where: { + id: params.projectId, + }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Use a transaction to ensure both operations succeed or fail together + await prisma.$transaction([ + prisma.projectProduct.delete({ + where: { + id: params.materialId, + projectId: params.projectId, + }, + }), + prisma.project.update({ + where: { + id: params.projectId, + }, + data: { + // Ensure itemCount never goes below 0 + itemCount: Math.max(0, (project.itemCount || 0) - 1), + }, + }), + ]); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("[MATERIALS_DELETE]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/materials/route.ts b/app/api/contractor/projects/[projectId]/materials/route.ts new file mode 100644 index 0000000..888c21e --- /dev/null +++ b/app/api/contractor/projects/[projectId]/materials/route.ts @@ -0,0 +1,131 @@ +// ********************* +// Role: Materials API Route Handler +// Purpose: Manages materials for a specific project +// Endpoints: +// - GET: Retrieves all materials for a project +// - POST: Adds a new material to a project +// Security: Requires authenticated session +// ********************* + +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +// Helper function to ensure image URL is absolute +function getAbsoluteImageUrl(imagePath: string | null) { + // Use placeholder if image path is missing or empty + if (!imagePath) { + return 'https://placehold.co/400x400?text=Product'; + } + if (imagePath.startsWith('http')) return imagePath; + // For relative paths, first ensure they start with / + const normalizedPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`; + return `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}${normalizedPath}`; +} + +type ProjectProductWithProduct = { + id: string; + quantity: number; + projectId: string; + productId: string; + product: { + title: string; + description: string; + id: string; + price: number; + mainImage: string; + }; +}; + +// GET handler: Fetch all materials for a project +export async function GET( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Fetch materials with associated product data + const materials = await prisma.projectProduct.findMany({ + where: { + projectId: params.projectId, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + }, + }, + }, + }); + + return NextResponse.json(materials); + } catch (error) { + console.error("Error fetching materials:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + +// POST handler: Add a new material to a project +export async function POST( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body + const body = await request.json(); + const { productId, quantity } = body; + + // Validate required fields + if (!productId || !quantity) { + return new NextResponse("Missing required fields", { status: 400 }); + } + + // Verify product exists + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + + if (!product) { + return new NextResponse("Product not found", { status: 404 }); + } + + // Create new project product entry + const material = await prisma.projectProduct.create({ + data: { + projectId: params.projectId, + productId, + quantity, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + }, + }, + }, + }); + + return NextResponse.json(material); + } catch (error) { + console.error("Error creating material:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/recalculate/route.ts b/app/api/contractor/projects/[projectId]/recalculate/route.ts new file mode 100644 index 0000000..a5a9fcb --- /dev/null +++ b/app/api/contractor/projects/[projectId]/recalculate/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import prisma from "@/utils/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// POST /api/contractor/projects/[projectId]/recalculate +export async function POST( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First verify that the project exists + const project = await prisma.project.findUnique({ + where: { + id: params.projectId, + }, + include: { + products: true, + }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Count the actual number of materials + const actualCount = project.products.length; + + // Update the project with the correct count + const updatedProject = await prisma.project.update({ + where: { + id: params.projectId, + }, + data: { + itemCount: actualCount, + }, + }); + + return NextResponse.json({ itemCount: updatedProject.itemCount }); + } catch (error) { + console.error("[PROJECT_RECALCULATE]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/route.ts b/app/api/contractor/projects/[projectId]/route.ts new file mode 100644 index 0000000..36dad0f --- /dev/null +++ b/app/api/contractor/projects/[projectId]/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +export async function PUT( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { name } = await request.json(); + if (!name || typeof name !== "string") { + return new NextResponse("Invalid project name", { status: 400 }); + } + + const project = await prisma.Project.update({ + where: { + id: params.projectId, + contractorId: session.user.id, + }, + data: { + name, + }, + }); + + return NextResponse.json(project); + } catch (error) { + console.error("Error updating project:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // First delete all project products associated with this project + await prisma.ProjectProduct.deleteMany({ + where: { + projectId: params.projectId, + }, + }); + + // Then delete the project itself + await prisma.Project.delete({ + where: { + id: params.projectId, + contractorId: session.user.id, + }, + }); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("Error deleting project:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/route.ts b/app/api/contractor/projects/route.ts new file mode 100644 index 0000000..ee9712a --- /dev/null +++ b/app/api/contractor/projects/route.ts @@ -0,0 +1,75 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import prisma from "@/utils/db"; + +export async function GET() { + try { + const session = await getServerSession(); + + if (!session?.user?.email) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + + if (!user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const projects = await prisma.project.findMany({ + where: { contractorId: user.id }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(projects); + } catch (error) { + console.error("[PROJECTS_GET]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} + +export async function POST(req: Request) { + try { + const session = await getServerSession(); + console.log("[PROJECTS_POST] Session:", session); + + if (!session?.user?.email) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + }); + console.log("[PROJECTS_POST] User:", user); + + if (!user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const body = await req.json(); + console.log("[PROJECTS_POST] Request body:", body); + const { name } = body; + + if (!name) { + return new NextResponse("Name is required", { status: 400 }); + } + + console.log("[PROJECTS_POST] Creating project with:", { name, contractorId: user.id }); + const project = await prisma.project.create({ + data: { + name, + contractorId: user.id, + }, + }); + console.log("[PROJECTS_POST] Created project:", project); + + return NextResponse.json(project); + } catch (error) { + console.error("[PROJECTS_POST] Error details:", error); + // Return more specific error message if available + const errorMessage = error instanceof Error ? error.message : "Internal error"; + return new NextResponse(errorMessage, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/products/route.ts b/app/api/products/route.ts new file mode 100644 index 0000000..67a0bda --- /dev/null +++ b/app/api/products/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/db"; + +// GET /api/products +export async function GET() { + try { + const products = await prisma.product.findMany({ + select: { + id: true, + title: true, + description: true, + price: true, + inStock: true, + }, + where: { + inStock: { + gt: 0 + } + }, + orderBy: { + title: 'asc' + } + }); + + return NextResponse.json(products); + } catch (error) { + console.error("[PRODUCTS_GET]", error); + return new NextResponse("Internal error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/projects/[projectId]/materials/page.tsx b/app/projects/[projectId]/materials/page.tsx new file mode 100644 index 0000000..51d53cf --- /dev/null +++ b/app/projects/[projectId]/materials/page.tsx @@ -0,0 +1,308 @@ +// ********************* +// Role: Project Materials Management Page +// Purpose: Allows contractors to manage materials for a specific project +// Features: +// - Lists all materials in a project +// - Adds new materials from product catalog +// - Updates material quantities +// - Removes materials from project +// - Maintains project item count +// ********************* + +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { toast } from "react-hot-toast"; + +// Type definitions for data structures +interface Material { + id: string; + name: string; + description: string | null; + quantity: number; + unit: string; + productId: string; +} + +interface Product { + id: string; + title: string; + description: string; + price: number; + inStock: number; +} + +export default function MaterialsPage() { + // State management for materials and UI + const [materials, setMaterials] = useState([]); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedProduct, setSelectedProduct] = useState(""); + const [newQuantity, setNewQuantity] = useState(1); + const [editingMaterial, setEditingMaterial] = useState(null); + + // Routing and authentication hooks + const params = useParams(); + const router = useRouter(); + const { data: session } = useSession(); + + // Fetch materials and products on component mount + useEffect(() => { + const fetchData = async () => { + try { + // Fetch project materials + const materialsResponse = await fetch(`/api/contractor/projects/${params.projectId}/materials`); + if (!materialsResponse.ok) { + throw new Error("Failed to fetch materials"); + } + const materialsData = await materialsResponse.json(); + setMaterials(materialsData); + + // Fetch available products catalog + const productsResponse = await fetch('/api/products'); + if (!productsResponse.ok) { + throw new Error("Failed to fetch products"); + } + const productsData = await productsResponse.json(); + setProducts(productsData); + } catch (error) { + console.error("Error fetching data:", error); + toast.error("Failed to load materials"); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [params.projectId]); + + // Handle adding new material to project + const handleAddMaterial = async () => { + try { + // Create new material entry + const response = await fetch(`/api/contractor/projects/${params.projectId}/materials`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + productId: selectedProduct, + quantity: newQuantity, + }), + }); + + if (!response.ok) { + throw new Error("Failed to add material"); + } + + // Update local state with new material + const newMaterial = await response.json(); + setMaterials([...materials, newMaterial]); + + // Update project item count + await fetch(`/api/contractor/projects/${params.projectId}/recalculate`, { + method: "POST", + }); + + // Reset form and show success message + toast.success("Material added successfully"); + setSelectedProduct(""); + setNewQuantity(1); + } catch (error) { + console.error("Error adding material:", error); + toast.error("Failed to add material"); + } + }; + + // Handle updating material quantity + const handleUpdateQuantity = async (materialId: string, newQuantity: number) => { + try { + // Update material quantity in database + const response = await fetch(`/api/contractor/projects/${params.projectId}/materials/${materialId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + quantity: newQuantity, + }), + }); + + if (!response.ok) { + throw new Error("Failed to update quantity"); + } + + // Update local state with new quantity + setMaterials(materials.map(material => + material.id === materialId + ? { ...material, quantity: newQuantity } + : material + )); + toast.success("Quantity updated successfully"); + } catch (error) { + console.error("Error updating quantity:", error); + toast.error("Failed to update quantity"); + } + }; + + // Handle removing material from project + const handleDeleteMaterial = async (materialId: string) => { + try { + // Delete material from database + const response = await fetch(`/api/contractor/projects/${params.projectId}/materials/${materialId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete material"); + } + + // Remove material from local state + setMaterials(materials.filter(material => material.id !== materialId)); + + // Update project item count + await fetch(`/api/contractor/projects/${params.projectId}/recalculate`, { + method: "POST", + }); + + toast.success("Material deleted successfully"); + } catch (error) { + console.error("Error deleting material:", error); + toast.error("Failed to delete material"); + } + }; + + // Loading state display + if (loading) { + return
Loading...
; + } + + return ( +
+
+

Add New Material

+
+ + setNewQuantity(parseInt(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder="Quantity" + /> + +
+
+ +
+
+

Project Materials

+
+
+ + + + + + + + + + + + {materials.map((material) => ( + + + + + + + + ))} + +
Material NameDescriptionQuantityUnit + Actions +
+ {material.name} + + {material.description || "-"} + + {editingMaterial?.id === material.id ? ( + setEditingMaterial({ + ...editingMaterial, + quantity: parseInt(e.target.value) + })} + className="block w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + /> + ) : ( + material.quantity + )} + + {material.unit} + + {editingMaterial?.id === material.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/projects/layout.tsx b/app/projects/layout.tsx new file mode 100644 index 0000000..372b212 --- /dev/null +++ b/app/projects/layout.tsx @@ -0,0 +1,36 @@ +// ********************* +// Role of the component: Projects Layout wrapper +// Purpose: Provides authentication check and consistent layout for all project pages +// Features: +// - Checks for valid user session +// - Redirects to login if not authenticated +// - Provides consistent padding and maximum width for content +// ********************* + +import { Metadata } from "next"; +import { getServerSession } from "next-auth/next"; +import { redirect } from "next/navigation"; + +export default async function ProjectsLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Check for authenticated session + const session = await getServerSession(); + if (!session?.user) { + redirect("/login"); + } + + return ( + // Main container with consistent background and minimum height +
+
+
+ {/* Content wrapper with maximum width and responsive padding */} +
{children}
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/projects/page.tsx b/app/projects/page.tsx new file mode 100644 index 0000000..02ccd85 --- /dev/null +++ b/app/projects/page.tsx @@ -0,0 +1,364 @@ +// ********************* +// Role: Projects List Page +// Purpose: Displays and manages all projects for the contractor +// Features: +// - Lists all projects with their details (name, date, item count) +// - Allows editing project names +// - Enables project deletion +// - Provides navigation to create new projects +// - Handles checkout functionality for project materials +// ********************* + +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { toast } from "react-hot-toast"; +import { FaEdit, FaTrash } from "react-icons/fa"; +import { useProductStore } from "../_zustand/store"; + +// Type definitions for project and material data +interface Project { + id: string; + name: string; + createdAt: string; + itemCount: number; +} + +interface Material { + id: string; + name: string; + quantity: number; + productId: string; + product: { + id: string; + title: string; + price: number; + mainImage: string; + }; +} + +export default function ProjectsPage() { + // State management for projects and UI + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [editingProject, setEditingProject] = useState(null); + const [editName, setEditName] = useState(""); + + // Authentication and routing hooks + const { data: session } = useSession(); + const router = useRouter(); + + // Cart management from global store + const { clearCart, addToCart, calculateTotals } = useProductStore(); + + // Fetch projects on component mount + useEffect(() => { + const fetchProjects = async () => { + try { + const response = await fetch("/api/contractor/projects"); + const data = await response.json(); + setProjects(data); + } catch (error) { + console.error("Error fetching projects:", error); + } finally { + setLoading(false); + } + }; + + fetchProjects(); + }, []); + + // Handle project name editing + const handleEdit = async (projectId: string) => { + if (!editName.trim()) { + toast.error("Project name cannot be empty"); + return; + } + + try { + const response = await fetch(`/api/contractor/projects/${projectId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: editName }), + }); + + if (!response.ok) { + throw new Error("Failed to update project"); + } + + // Update local state with new project name + setProjects(projects.map(project => + project.id === projectId + ? { ...project, name: editName } + : project + )); + setEditingProject(null); + setEditName(""); + toast.success("Project updated successfully"); + } catch (error) { + console.error("Error updating project:", error); + toast.error("Failed to update project"); + } + }; + + // Handle project deletion + const handleDelete = async (projectId: string) => { + if (!confirm("Are you sure you want to delete this project? This action cannot be undone.")) { + return; + } + + try { + const response = await fetch(`/api/contractor/projects/${projectId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete project"); + } + + // Remove deleted project from local state + setProjects(projects.filter(project => project.id !== projectId)); + toast.success("Project deleted successfully"); + } catch (error) { + console.error("Error deleting project:", error); + toast.error("Failed to delete project"); + } + }; + + // Handle checkout process for project materials + const handleCheckout = async (projectId: string) => { + try { + // Clear existing cart before adding new items + clearCart(); + + // Fetch materials for the project + const response = await fetch(`/api/contractor/projects/${projectId}/materials`); + if (!response.ok) { + throw new Error("Failed to fetch materials"); + } + + const materials = await response.json(); + if (!materials || materials.length === 0) { + toast.error("No materials to checkout"); + return; + } + + // Transform materials and add to cart + materials.forEach((material: any) => { + if (material.product && material.quantity > 0) { + addToCart({ + id: material.product.id, + title: material.product.title, + price: material.product.price, + image: material.product.mainImage, + amount: material.quantity + }); + } + }); + + // Update cart totals and redirect to cart page + calculateTotals(); + router.push("/cart"); + toast.success("Materials added to cart"); + } catch (error) { + console.error("Error during checkout:", error); + toast.error("Failed to process checkout"); + } + }; + + // Loading state display + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+
+

Projects

+

+ A list of all your construction projects and their materials. +

+
+ {projects.length > 0 && ( +
+ + Start New Project + +
+ )} +
+ + {projects.length === 0 ? ( +
+ +

No projects

+

+ No projects uploaded yet. Start by uploading your construction documents. +

+
+ + Upload Your First Project + +
+
+ ) : ( +
+
+
+
+ + + + + + + + + + + {projects.map((project) => ( + + + + + + + ))} + +
+ Project Name + + Created Date + + Number of Items + + Actions +
+ {editingProject === project.id ? ( +
+ setEditName(e.target.value)} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder="Project name" + /> + + + +
+ ) : ( +
+ + {project.name} + + + +
+ )} +
+ {new Date(project.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + {project.itemCount} items + + + View Materials + + {project.itemCount > 0 && ( + + )} +
+
+
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/projects/upload/page.tsx b/app/projects/upload/page.tsx new file mode 100644 index 0000000..d41eed8 --- /dev/null +++ b/app/projects/upload/page.tsx @@ -0,0 +1,115 @@ +// ********************* +// Role: Project Upload Page +// Purpose: Creates new projects and handles initial file upload +// Features: +// - Project name input +// - File upload interface (for future implementation) +// - Form validation +// - Automatic navigation to materials page after creation +// ********************* + +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "react-hot-toast"; + +export default function UploadProjectPage() { + // Routing and state management + const router = useRouter(); + const [projectName, setProjectName] = useState(""); + const [loading, setLoading] = useState(false); + + // Handle form submission and project creation + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + // Create new project in database + const response = await fetch("/api/contractor/projects", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: projectName, + }), + }); + + if (!response.ok) { + throw new Error("Failed to create project"); + } + + // Navigate to materials page on success + const data = await response.json(); + toast.success("Project created successfully!"); + router.push(`/projects/${data.id}/materials`); + } catch (error) { + console.error("Error creating project:", error); + toast.error("Failed to create project. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( +
+

Upload

+
+ {/* Project name input field */} +
+ +
+ setProjectName(e.target.value)} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder="Enter project name" + /> +
+
+ + {/* File upload input (prepared for future implementation) */} +
+ +
+ +
+
+ + {/* Submit button with loading state */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx index b50cdf3..706684a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,11 +1,7 @@ // ********************* -// Role of the component: Header component -// Name of the component: Header.tsx -// Developer: Aleksandar Kuzmanovic -// Version: 1.0 -// Component call:
-// Input parameters: no input parameters -// Output: Header component +// Role of the component: Main Header component with navigation and search +// Contains: Brand logo, search bar, navigation links, and cart/wishlist icons +// Layout: Responsive flex layout that adjusts for mobile and desktop views // ********************* "use client"; @@ -16,7 +12,6 @@ import Image from "next/image"; import SearchInput from "./SearchInput"; import Link from "next/link"; import { FaBell } from "react-icons/fa6"; - import CartElement from "./CartElement"; import HeartElement from "./HeartElement"; import { signOut, useSession } from "next-auth/react"; @@ -75,17 +70,42 @@ const Header = () => {
{pathname.startsWith("/admin") === false && ( + // Main navigation container - adjusts layout for different screen sizes
+ {/* Brand logo/text section */} - singitronic logo +
+ HardwareDirect +
- -
+ + {/* Search bar section - responsive width for better layout */} +
+ +
+ + {/* Navigation and icons section */} +
+ {/* Main navigation links - hidden on mobile */} +
+ + Home + + + Upload + + + My Projects + +
+ {/* Cart and wishlist icons */}
)} + + {/* Admin header section - shown only on admin pages */} {pathname.startsWith("/admin") === true && (
diff --git a/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql b/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql new file mode 100644 index 0000000..ab828cb --- /dev/null +++ b/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql @@ -0,0 +1,97 @@ +/* + Warnings: + + - You are about to drop the `customer_order` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `user` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE `customer_order`; + +-- DropTable +DROP TABLE `user`; + +-- CreateTable +CREATE TABLE `User` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `password` VARCHAR(191) NULL, + `role` VARCHAR(191) NULL DEFAULT 'user', + + UNIQUE INDEX `User_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Customer_order` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `lastname` VARCHAR(191) NOT NULL, + `phone` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `company` VARCHAR(191) NOT NULL, + `adress` VARCHAR(191) NOT NULL, + `apartment` VARCHAR(191) NOT NULL, + `postalCode` VARCHAR(191) NOT NULL, + `dateTime` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), + `status` VARCHAR(191) NOT NULL, + `city` VARCHAR(191) NOT NULL, + `country` VARCHAR(191) NOT NULL, + `orderNotice` VARCHAR(191) NULL, + `total` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Project` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `itemCount` INTEGER NOT NULL DEFAULT 0, + `contractorId` VARCHAR(191) NOT NULL, + + INDEX `Project_contractorId_fkey`(`contractorId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Material` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `description` VARCHAR(191) NULL, + `quantity` INTEGER NOT NULL, + `unit` VARCHAR(191) NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `templateId` VARCHAR(191) NOT NULL, + + INDEX `Material_projectId_fkey`(`projectId`), + INDEX `Material_templateId_fkey`(`templateId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `MaterialTemplate` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `description` VARCHAR(191) NULL, + `unit` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `MaterialTemplate_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Material` ADD CONSTRAINT `Material_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Material` ADD CONSTRAINT `Material_templateId_fkey` FOREIGN KEY (`templateId`) REFERENCES `MaterialTemplate`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql b/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql new file mode 100644 index 0000000..0fd1dd9 --- /dev/null +++ b/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql @@ -0,0 +1,46 @@ +-- CreateTable +CREATE TABLE `Project` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `itemCount` INTEGER NOT NULL DEFAULT 0, + `contractorId` VARCHAR(191) NOT NULL, + + INDEX `Project_contractorId_fkey`(`contractorId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `MaterialTemplate` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `description` VARCHAR(191) NULL, + `unit` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `MaterialTemplate_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Material` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `description` VARCHAR(191) NULL, + `quantity` INTEGER NOT NULL, + `unit` VARCHAR(191) NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `templateId` VARCHAR(191) NOT NULL, + + INDEX `Material_projectId_fkey`(`projectId`), + INDEX `Material_templateId_fkey`(`templateId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Material` ADD CONSTRAINT `Material_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Material` ADD CONSTRAINT `Material_templateId_fkey` FOREIGN KEY (`templateId`) REFERENCES `MaterialTemplate`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/migrations/20250427000000_replace_materials_with_project_products/migration.sql b/prisma/migrations/20250427000000_replace_materials_with_project_products/migration.sql new file mode 100644 index 0000000..6541d07 --- /dev/null +++ b/prisma/migrations/20250427000000_replace_materials_with_project_products/migration.sql @@ -0,0 +1,22 @@ +-- Drop the old tables +DROP TABLE IF EXISTS `Material`; +DROP TABLE IF EXISTS `MaterialTemplate`; + +-- Create the new ProjectProduct table +CREATE TABLE `ProjectProduct` ( + `id` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + + INDEX `ProjectProduct_projectId_fkey`(`projectId`), + INDEX `ProjectProduct_productId_fkey`(`productId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Add foreign key constraints +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` + FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` + FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cff4598..8c0b251 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -27,6 +21,9 @@ model Product { category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) customerOrders customer_order_product[] Wishlist Wishlist[] + projects ProjectProduct[] + + @@index([categoryId], map: "Product_categoryId_fkey") } model Image { @@ -40,6 +37,7 @@ model User { email String @unique password String? role String? @default("user") + projects Project[] Wishlist Wishlist[] } @@ -64,23 +62,53 @@ model Customer_order { model customer_order_product { id String @id @default(uuid()) - customerOrder Customer_order @relation(fields: [customerOrderId], references: [id]) customerOrderId String - product Product @relation(fields: [productId], references: [id]) productId String quantity Int + customerOrder Customer_order @relation(fields: [customerOrderId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id]) + + @@index([customerOrderId], map: "customer_order_product_customerOrderId_fkey") + @@index([productId], map: "customer_order_product_productId_fkey") } model Category { id String @id @default(uuid()) name String @unique - products Product[] // Define a one-to-many relationship + products Product[] } model Wishlist { id String @id @default(uuid()) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([productId], map: "Wishlist_productId_fkey") + @@index([userId], map: "Wishlist_userId_fkey") +} + +model Project { + id String @id @default(uuid()) + name String + createdAt DateTime @default(now()) + itemCount Int @default(0) + contractorId String + products ProjectProduct[] + contractor User @relation(fields: [contractorId], references: [id], onDelete: Cascade) + + @@index([contractorId], map: "Project_contractorId_fkey") } + +model ProjectProduct { + id String @id @default(uuid()) + quantity Int + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Restrict) + productId String + + @@index([projectId], map: "ProjectProduct_projectId_fkey") + @@index([productId], map: "ProjectProduct_productId_fkey") +} \ No newline at end of file diff --git a/scripts/seed-products.ts b/scripts/seed-products.ts new file mode 100644 index 0000000..13bc7e9 --- /dev/null +++ b/scripts/seed-products.ts @@ -0,0 +1,118 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // First, create a category for electronic components if it doesn't exist + const category = await prisma.category.upsert({ + where: { name: 'Electronic Components' }, + update: {}, + create: { + name: 'Electronic Components', + }, + }); + + // Sample electronic components + const products = [ + { + title: 'Arduino Uno R3', + description: 'Microcontroller board based on the ATmega328P', + price: 2499, // $24.99 + inStock: 50, + mainImage: '/images/products/arduino-uno.jpg', + slug: 'arduino-uno-r3', + manufacturer: 'Arduino', + categoryId: category.id, + }, + { + title: 'Raspberry Pi 4 Model B (4GB)', + description: 'Single-board computer with 4GB RAM', + price: 4999, // $49.99 + inStock: 30, + mainImage: '/images/products/raspberry-pi-4.jpg', + slug: 'raspberry-pi-4-model-b-4gb', + manufacturer: 'Raspberry Pi', + categoryId: category.id, + }, + { + title: 'Breadboard 830 Points', + description: 'Solderless prototype board with 830 tie points', + price: 799, // $7.99 + inStock: 100, + mainImage: '/images/products/breadboard.jpg', + slug: 'breadboard-830-points', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'LED Kit (100pcs)', + description: 'Assorted color 5mm LEDs kit', + price: 999, // $9.99 + inStock: 200, + mainImage: '/images/products/led-kit.jpg', + slug: 'led-kit-100pcs', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'Resistor Kit (600pcs)', + description: '1/4W resistors kit with various values', + price: 1299, // $12.99 + inStock: 150, + mainImage: '/images/products/resistor-kit.jpg', + slug: 'resistor-kit-600pcs', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'ESP32 Development Board', + description: 'WiFi & Bluetooth enabled microcontroller board', + price: 1999, // $19.99 + inStock: 75, + mainImage: '/images/products/esp32.jpg', + slug: 'esp32-development-board', + manufacturer: 'Espressif', + categoryId: category.id, + }, + { + title: 'Jumper Wires Kit (120pcs)', + description: 'Male-to-male, male-to-female, and female-to-female jumper wires', + price: 899, // $8.99 + inStock: 100, + mainImage: '/images/products/jumper-wires.jpg', + slug: 'jumper-wires-kit-120pcs', + manufacturer: 'Generic', + categoryId: category.id, + }, + { + title: 'Digital Multimeter', + description: 'Auto-ranging digital multimeter with backlight', + price: 2999, // $29.99 + inStock: 40, + mainImage: '/images/products/multimeter.jpg', + slug: 'digital-multimeter', + manufacturer: 'Generic', + categoryId: category.id, + }, + ]; + + // Create all products + for (const product of products) { + await prisma.product.upsert({ + where: { slug: product.slug }, + update: product, + create: product, + }); + } + + console.log('Sample products have been added to the database'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql b/server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql deleted file mode 100644 index 1eb93a9..0000000 --- a/server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql +++ /dev/null @@ -1,35 +0,0 @@ --- CreateTable -CREATE TABLE `Product` ( - `id` VARCHAR(191) NOT NULL, - `slug` VARCHAR(191) NOT NULL, - `title` VARCHAR(191) NOT NULL, - `mainImage` VARCHAR(191) NOT NULL, - `price` INTEGER NOT NULL DEFAULT 0, - `rating` INTEGER NOT NULL DEFAULT 0, - `description` VARCHAR(191) NOT NULL, - `manufacturer` VARCHAR(191) NOT NULL, - `category` VARCHAR(191) NOT NULL, - `inStock` INTEGER NOT NULL DEFAULT 1, - - UNIQUE INDEX `Product_slug_key`(`slug`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateTable -CREATE TABLE `Image` ( - `imageID` VARCHAR(191) NOT NULL, - `productID` VARCHAR(191) NOT NULL, - `image` VARCHAR(191) NOT NULL, - - PRIMARY KEY (`imageID`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateTable -CREATE TABLE `User` ( - `id` VARCHAR(191) NOT NULL, - `email` VARCHAR(191) NOT NULL, - `password` VARCHAR(191) NULL, - - UNIQUE INDEX `User_email_key`(`email`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240413064716_added_order_table/migration.sql b/server/prisma/migrations/20240413064716_added_order_table/migration.sql deleted file mode 100644 index defac89..0000000 --- a/server/prisma/migrations/20240413064716_added_order_table/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ --- CreateTable -CREATE TABLE `Order` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `lastname` VARCHAR(191) NOT NULL, - `phone` VARCHAR(191) NOT NULL, - `email` VARCHAR(191) NOT NULL, - `company` VARCHAR(191) NULL, - `adress` VARCHAR(191) NOT NULL, - `apartment` VARCHAR(191) NULL, - `city` VARCHAR(191) NOT NULL, - `country` VARCHAR(191) NOT NULL, - `postalCode` INTEGER NOT NULL, - - UNIQUE INDEX `Order_email_key`(`email`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql b/server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql deleted file mode 100644 index 049dac1..0000000 --- a/server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- AlterTable -ALTER TABLE `user` ADD COLUMN `role` VARCHAR(191) NULL DEFAULT 'user'; - --- CreateTable -CREATE TABLE `Category` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql b/server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql deleted file mode 100644 index e85be98..0000000 --- a/server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `category` on the `product` table. All the data in the column will be lost. - - Added the required column `categoryId` to the `Product` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE `product` DROP COLUMN `category`, - ADD COLUMN `categoryId` VARCHAR(191) NOT NULL; diff --git a/server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql b/server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql deleted file mode 100644 index d7b7ef2..0000000 --- a/server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AddForeignKey -ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql b/server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql deleted file mode 100644 index fc5dc39..0000000 --- a/server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql +++ /dev/null @@ -1,26 +0,0 @@ -/* - Warnings: - - - You are about to drop the `order` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropTable -DROP TABLE `order`; - --- CreateTable -CREATE TABLE `Customer_order` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `lastname` VARCHAR(191) NOT NULL, - `phone` VARCHAR(191) NOT NULL, - `email` VARCHAR(191) NOT NULL, - `company` VARCHAR(191) NOT NULL, - `adress` VARCHAR(191) NOT NULL, - `apartment` VARCHAR(191) NOT NULL, - `postalCode` VARCHAR(191) NOT NULL, - `dateTime` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), - `status` VARCHAR(191) NOT NULL, - `total` INTEGER NOT NULL, - - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql b/server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql deleted file mode 100644 index 54a3ba7..0000000 --- a/server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ --- CreateTable -CREATE TABLE `customer_order_product` ( - `id` VARCHAR(191) NOT NULL, - `customerOrderId` VARCHAR(191) NOT NULL, - `productId` VARCHAR(191) NOT NULL, - `quantity` INTEGER NOT NULL, - - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- AddForeignKey -ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql b/server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql deleted file mode 100644 index 05eea93..0000000 --- a/server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ -/* - Warnings: - - - Added the required column `city` to the `Customer_order` table without a default value. This is not possible if the table is not empty. - - Added the required column `country` to the `Customer_order` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE `customer_order` ADD COLUMN `city` VARCHAR(191) NOT NULL, - ADD COLUMN `country` VARCHAR(191) NOT NULL, - ADD COLUMN `orderNotice` VARCHAR(191) NULL; diff --git a/server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql b/server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql deleted file mode 100644 index 78be224..0000000 --- a/server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- CreateTable -CREATE TABLE `Wishlist` ( - `id` VARCHAR(191) NOT NULL, - `productId` VARCHAR(191) NOT NULL, - `userId` VARCHAR(191) NOT NULL, - - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- AddForeignKey -ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql b/server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql deleted file mode 100644 index 334b201..0000000 --- a/server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- DropForeignKey -ALTER TABLE `wishlist` DROP FOREIGN KEY `Wishlist_userId_fkey`; - --- AddForeignKey -ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql b/server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql deleted file mode 100644 index feb9041..0000000 --- a/server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- DropForeignKey -ALTER TABLE `product` DROP FOREIGN KEY `Product_categoryId_fkey`; - --- AddForeignKey -ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql b/server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql deleted file mode 100644 index 198ff4a..0000000 --- a/server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- DropForeignKey -ALTER TABLE `wishlist` DROP FOREIGN KEY `Wishlist_productId_fkey`; - --- AddForeignKey -ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql b/server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql deleted file mode 100644 index 66b6452..0000000 --- a/server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[name]` on the table `Category` will be added. If there are existing duplicate values, this will fail. - -*/ --- CreateIndex -CREATE UNIQUE INDEX `Category_name_key` ON `Category`(`name`); diff --git a/server/prisma/migrations/20250426025921_init/migration.sql b/server/prisma/migrations/20250426025921_init/migration.sql new file mode 100644 index 0000000..e59a207 --- /dev/null +++ b/server/prisma/migrations/20250426025921_init/migration.sql @@ -0,0 +1,129 @@ +-- CreateTable +CREATE TABLE `Product` ( + `id` VARCHAR(191) NOT NULL, + `slug` VARCHAR(191) NOT NULL, + `title` VARCHAR(191) NOT NULL, + `mainImage` VARCHAR(191) NOT NULL, + `price` INTEGER NOT NULL DEFAULT 0, + `rating` INTEGER NOT NULL DEFAULT 0, + `description` VARCHAR(191) NOT NULL, + `manufacturer` VARCHAR(191) NOT NULL, + `inStock` INTEGER NOT NULL DEFAULT 1, + `categoryId` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `Product_slug_key`(`slug`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Image` ( + `imageID` VARCHAR(191) NOT NULL, + `productID` VARCHAR(191) NOT NULL, + `image` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`imageID`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `User` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `password` VARCHAR(191) NULL, + `role` VARCHAR(191) NULL DEFAULT 'user', + + UNIQUE INDEX `User_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Customer_order` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `lastname` VARCHAR(191) NOT NULL, + `phone` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `company` VARCHAR(191) NOT NULL, + `adress` VARCHAR(191) NOT NULL, + `apartment` VARCHAR(191) NOT NULL, + `postalCode` VARCHAR(191) NOT NULL, + `dateTime` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), + `status` VARCHAR(191) NOT NULL, + `city` VARCHAR(191) NOT NULL, + `country` VARCHAR(191) NOT NULL, + `orderNotice` VARCHAR(191) NULL, + `total` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `customer_order_product` ( + `id` VARCHAR(191) NOT NULL, + `customerOrderId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Category` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `Category_name_key`(`name`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Wishlist` ( + `id` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Project` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `itemCount` INTEGER NOT NULL DEFAULT 0, + `contractorId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Material` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `description` VARCHAR(191) NULL, + `quantity` INTEGER NOT NULL, + `unit` VARCHAR(191) NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Material` ADD CONSTRAINT `Material_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index cff4598..6f51b47 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -41,6 +41,7 @@ model User { password String? role String? @default("user") Wishlist Wishlist[] + Project Project[] } model Customer_order { @@ -84,3 +85,23 @@ model Wishlist { user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String } + +model Project { + id String @id @default(uuid()) + name String + createdAt DateTime @default(now()) + itemCount Int @default(0) + contractor User @relation(fields: [contractorId], references: [id]) + contractorId String + materials Material[] +} + +model Material { + id String @id @default(uuid()) + name String + description String? + quantity Int + unit String + project Project @relation(fields: [projectId], references: [id]) + projectId String +} From 1f485f4eba0b76453dc6e8d3333dc56fddfae522 Mon Sep 17 00:00:00 2001 From: mehakseedat63 Date: Sun, 27 Apr 2025 03:33:45 +0500 Subject: [PATCH 2/5] Fixed migrations --- .../projects/[projectId]/materials/route.ts | 20 +++++- app/api/contractor/projects/route.ts | 6 +- app/projects/[projectId]/materials/page.tsx | 25 ++++++- .../migration.sql | 39 +++++----- .../20250426025921_init => }/migration.sql | 71 +++++++++---------- .../migration.sql | 35 +++++++++ .../migration.sql | 17 +++++ .../migration.sql | 10 +++ .../migration.sql | 10 +++ .../migration.sql | 2 + .../migration.sql | 26 +++++++ .../migration.sql | 15 ++++ .../migration.sql | 11 +++ .../migration.sql | 14 ++++ .../migration.sql | 5 ++ .../migration.sql | 5 ++ .../migration.sql | 5 ++ .../migration.sql | 8 +++ .../migration.sql | 21 ++++-- server/prisma/schema.prisma | 55 +++++++------- server/utills/db.ts | 25 +++---- 21 files changed, 310 insertions(+), 115 deletions(-) rename server/{prisma/migrations/20250426025921_init => }/migration.sql (81%) create mode 100644 server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql create mode 100644 server/prisma/migrations/20240413064716_added_order_table/migration.sql create mode 100644 server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql create mode 100644 server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql create mode 100644 server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql create mode 100644 server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql create mode 100644 server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql create mode 100644 server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql create mode 100644 server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql create mode 100644 server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql create mode 100644 server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql create mode 100644 server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql create mode 100644 server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql rename {prisma/migrations/20250427000000_replace_materials_with_project_products => server/prisma/migrations/20250426042000_add_project_and_material_models}/migration.sql (54%) diff --git a/app/api/contractor/projects/[projectId]/materials/route.ts b/app/api/contractor/projects/[projectId]/materials/route.ts index 888c21e..7a5f8fb 100644 --- a/app/api/contractor/projects/[projectId]/materials/route.ts +++ b/app/api/contractor/projects/[projectId]/materials/route.ts @@ -36,7 +36,7 @@ type ProjectProductWithProduct = { price: number; mainImage: string; }; -}; +} // GET handler: Fetch all materials for a project export async function GET( @@ -62,12 +62,19 @@ export async function GET( title: true, price: true, mainImage: true, + description: true, }, }, }, }); - return NextResponse.json(materials); + // Transform the data to include name property + const transformedMaterials = materials.map((material: ProjectProductWithProduct) => ({ + ...material, + name: material.product.title, + })); + + return NextResponse.json(transformedMaterials); } catch (error) { console.error("Error fetching materials:", error); return new NextResponse("Internal Server Error", { status: 500 }); @@ -118,12 +125,19 @@ export async function POST( title: true, price: true, mainImage: true, + description: true, }, }, }, }); - return NextResponse.json(material); + // Add name property to response + const transformedMaterial = { + ...material, + name: material.product.title, + }; + + return NextResponse.json(transformedMaterial); } catch (error) { console.error("Error creating material:", error); return new NextResponse("Internal Server Error", { status: 500 }); diff --git a/app/api/contractor/projects/route.ts b/app/api/contractor/projects/route.ts index ee9712a..b2cf09a 100644 --- a/app/api/contractor/projects/route.ts +++ b/app/api/contractor/projects/route.ts @@ -1,6 +1,6 @@ import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; -import prisma from "@/utils/db"; +import prisma from "@/server/utills/db"; export async function GET() { try { @@ -18,7 +18,7 @@ export async function GET() { return new NextResponse("Unauthorized", { status: 401 }); } - const projects = await prisma.project.findMany({ + const projects = await prisma.Project.findMany({ where: { contractorId: user.id }, orderBy: { createdAt: "desc" }, }); @@ -57,7 +57,7 @@ export async function POST(req: Request) { } console.log("[PROJECTS_POST] Creating project with:", { name, contractorId: user.id }); - const project = await prisma.project.create({ + const project = await prisma.Project.create({ data: { name, contractorId: user.id, diff --git a/app/projects/[projectId]/materials/page.tsx b/app/projects/[projectId]/materials/page.tsx index 51d53cf..896087b 100644 --- a/app/projects/[projectId]/materials/page.tsx +++ b/app/projects/[projectId]/materials/page.tsx @@ -24,6 +24,13 @@ interface Material { quantity: number; unit: string; productId: string; + product?: { + id: string; + title: string; + description?: string; + price: number; + mainImage: string; + }; } interface Product { @@ -58,7 +65,14 @@ export default function MaterialsPage() { throw new Error("Failed to fetch materials"); } const materialsData = await materialsResponse.json(); - setMaterials(materialsData); + + // Ensure each material has a name property, using product.title as fallback + const processedMaterials = materialsData.map((material: any) => ({ + ...material, + name: material.name || (material.product?.title || "Unknown Material") + })); + + setMaterials(processedMaterials); // Fetch available products catalog const productsResponse = await fetch('/api/products'); @@ -99,7 +113,14 @@ export default function MaterialsPage() { // Update local state with new material const newMaterial = await response.json(); - setMaterials([...materials, newMaterial]); + + // Make sure the material has a name property derived from the product if needed + const materialWithName = { + ...newMaterial, + name: newMaterial.name || (newMaterial.product?.title || "Unknown Material") + }; + + setMaterials([...materials, materialWithName]); // Update project item count await fetch(`/api/contractor/projects/${params.projectId}/recalculate`, { diff --git a/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql b/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql index ab828cb..1f0b622 100644 --- a/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql +++ b/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql @@ -6,10 +6,10 @@ */ -- DropTable -DROP TABLE `customer_order`; +-- DROP TABLE `customer_order`; -- DropTable -DROP TABLE `user`; +-- DROP TABLE `user`; -- CreateTable CREATE TABLE `User` ( @@ -56,28 +56,14 @@ CREATE TABLE `Project` ( ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CreateTable -CREATE TABLE `Material` ( +CREATE TABLE `ProjectProduct` ( `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `description` VARCHAR(191) NULL, `quantity` INTEGER NOT NULL, - `unit` VARCHAR(191) NOT NULL, `projectId` VARCHAR(191) NOT NULL, - `templateId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, - INDEX `Material_projectId_fkey`(`projectId`), - INDEX `Material_templateId_fkey`(`templateId`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateTable -CREATE TABLE `MaterialTemplate` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `description` VARCHAR(191) NULL, - `unit` VARCHAR(191) NOT NULL, - - UNIQUE INDEX `MaterialTemplate_name_key`(`name`), + INDEX `ProjectProduct_projectId_fkey`(`projectId`), + INDEX `ProjectProduct_productId_fkey`(`productId`), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -91,7 +77,14 @@ ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userI ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE `Material` ADD CONSTRAINT `Material_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` + FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE `Material` ADD CONSTRAINT `Material_templateId_fkey` FOREIGN KEY (`templateId`) REFERENCES `MaterialTemplate`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` + FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Alter the customer_order table instead of recreating it +-- Add any necessary ALTER TABLE statements here to modify the customer_order table as needed + +-- Alter the user table instead of recreating it +-- Add any necessary ALTER TABLE statements here to modify the user table as needed diff --git a/server/prisma/migrations/20250426025921_init/migration.sql b/server/migration.sql similarity index 81% rename from server/prisma/migrations/20250426025921_init/migration.sql rename to server/migration.sql index e59a207..daa8ebe 100644 --- a/server/prisma/migrations/20250426025921_init/migration.sql +++ b/server/migration.sql @@ -1,37 +1,37 @@ -- CreateTable -CREATE TABLE `Product` ( +CREATE TABLE `User` ( `id` VARCHAR(191) NOT NULL, - `slug` VARCHAR(191) NOT NULL, - `title` VARCHAR(191) NOT NULL, - `mainImage` VARCHAR(191) NOT NULL, - `price` INTEGER NOT NULL DEFAULT 0, - `rating` INTEGER NOT NULL DEFAULT 0, - `description` VARCHAR(191) NOT NULL, - `manufacturer` VARCHAR(191) NOT NULL, - `inStock` INTEGER NOT NULL DEFAULT 1, - `categoryId` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `password` VARCHAR(191) NULL, + `role` VARCHAR(191) NULL DEFAULT 'user', - UNIQUE INDEX `Product_slug_key`(`slug`), + UNIQUE INDEX `User_email_key`(`email`), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CreateTable -CREATE TABLE `Image` ( - `imageID` VARCHAR(191) NOT NULL, - `productID` VARCHAR(191) NOT NULL, - `image` VARCHAR(191) NOT NULL, +CREATE TABLE `Category` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, - PRIMARY KEY (`imageID`) + UNIQUE INDEX `Category_name_key`(`name`), + PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CreateTable -CREATE TABLE `User` ( +CREATE TABLE `Product` ( `id` VARCHAR(191) NOT NULL, - `email` VARCHAR(191) NOT NULL, - `password` VARCHAR(191) NULL, - `role` VARCHAR(191) NULL DEFAULT 'user', + `title` VARCHAR(191) NOT NULL, + `price` INTEGER NOT NULL, + `rating` INTEGER NOT NULL DEFAULT 0, + `description` TEXT NOT NULL, + `mainImage` VARCHAR(191) NULL, + `slug` VARCHAR(191) NOT NULL, + `manufacturer` VARCHAR(191) NULL, + `inStock` INTEGER NOT NULL DEFAULT 0, + `categoryId` VARCHAR(191) NOT NULL, - UNIQUE INDEX `User_email_key`(`email`), + UNIQUE INDEX `Product_slug_key`(`slug`), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -66,20 +66,11 @@ CREATE TABLE `customer_order_product` ( PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; --- CreateTable -CREATE TABLE `Category` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - - UNIQUE INDEX `Category_name_key`(`name`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - -- CreateTable CREATE TABLE `Wishlist` ( `id` VARCHAR(191) NOT NULL, - `productId` VARCHAR(191) NOT NULL, `userId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -96,13 +87,11 @@ CREATE TABLE `Project` ( ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- CreateTable -CREATE TABLE `Material` ( +CREATE TABLE `ProjectProduct` ( `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `description` VARCHAR(191) NULL, `quantity` INTEGER NOT NULL, - `unit` VARCHAR(191) NOT NULL, `projectId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -111,19 +100,23 @@ CREATE TABLE `Material` ( ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE `Material` ADD CONSTRAINT `Material_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql b/server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql new file mode 100644 index 0000000..1eb93a9 --- /dev/null +++ b/server/prisma/migrations/20240320142857_podesavanje_prizme/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE `Product` ( + `id` VARCHAR(191) NOT NULL, + `slug` VARCHAR(191) NOT NULL, + `title` VARCHAR(191) NOT NULL, + `mainImage` VARCHAR(191) NOT NULL, + `price` INTEGER NOT NULL DEFAULT 0, + `rating` INTEGER NOT NULL DEFAULT 0, + `description` VARCHAR(191) NOT NULL, + `manufacturer` VARCHAR(191) NOT NULL, + `category` VARCHAR(191) NOT NULL, + `inStock` INTEGER NOT NULL DEFAULT 1, + + UNIQUE INDEX `Product_slug_key`(`slug`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Image` ( + `imageID` VARCHAR(191) NOT NULL, + `productID` VARCHAR(191) NOT NULL, + `image` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`imageID`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `User` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `password` VARCHAR(191) NULL, + + UNIQUE INDEX `User_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240413064716_added_order_table/migration.sql b/server/prisma/migrations/20240413064716_added_order_table/migration.sql new file mode 100644 index 0000000..defac89 --- /dev/null +++ b/server/prisma/migrations/20240413064716_added_order_table/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE `Order` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `lastname` VARCHAR(191) NOT NULL, + `phone` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `company` VARCHAR(191) NULL, + `adress` VARCHAR(191) NOT NULL, + `apartment` VARCHAR(191) NULL, + `city` VARCHAR(191) NOT NULL, + `country` VARCHAR(191) NOT NULL, + `postalCode` INTEGER NOT NULL, + + UNIQUE INDEX `Order_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql b/server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql new file mode 100644 index 0000000..049dac1 --- /dev/null +++ b/server/prisma/migrations/20240414064137_added_category_table_and_added_role_column/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE `user` ADD COLUMN `role` VARCHAR(191) NULL DEFAULT 'user'; + +-- CreateTable +CREATE TABLE `Category` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql b/server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql new file mode 100644 index 0000000..e85be98 --- /dev/null +++ b/server/prisma/migrations/20240415100000_added_category_id_field_in_product_table/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `category` on the `product` table. All the data in the column will be lost. + - Added the required column `categoryId` to the `Product` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `product` DROP COLUMN `category`, + ADD COLUMN `categoryId` VARCHAR(191) NOT NULL; diff --git a/server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql b/server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql new file mode 100644 index 0000000..d7b7ef2 --- /dev/null +++ b/server/prisma/migrations/20240415130405_added_relationship_between_product_table_and_category_table/migration.sql @@ -0,0 +1,2 @@ +-- AddForeignKey +ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql b/server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql new file mode 100644 index 0000000..fc5dc39 --- /dev/null +++ b/server/prisma/migrations/20240418151340_added_new_customer_order_table/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the `order` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE `order`; + +-- CreateTable +CREATE TABLE `Customer_order` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `lastname` VARCHAR(191) NOT NULL, + `phone` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `company` VARCHAR(191) NOT NULL, + `adress` VARCHAR(191) NOT NULL, + `apartment` VARCHAR(191) NOT NULL, + `postalCode` VARCHAR(191) NOT NULL, + `dateTime` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), + `status` VARCHAR(191) NOT NULL, + `total` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql b/server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql new file mode 100644 index 0000000..54a3ba7 --- /dev/null +++ b/server/prisma/migrations/20240512145715_bojan_update_za_customer_order_product/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE `customer_order_product` ( + `id` VARCHAR(191) NOT NULL, + `customerOrderId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + `quantity` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql b/server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql new file mode 100644 index 0000000..05eea93 --- /dev/null +++ b/server/prisma/migrations/20240515154444_added_necessary_fields_for_customer_order_table/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `city` to the `Customer_order` table without a default value. This is not possible if the table is not empty. + - Added the required column `country` to the `Customer_order` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `customer_order` ADD COLUMN `city` VARCHAR(191) NOT NULL, + ADD COLUMN `country` VARCHAR(191) NOT NULL, + ADD COLUMN `orderNotice` VARCHAR(191) NULL; diff --git a/server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql b/server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql new file mode 100644 index 0000000..78be224 --- /dev/null +++ b/server/prisma/migrations/20240602092804_added_wishlist_table/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE `Wishlist` ( + `id` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, + `userId` VARCHAR(191) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql b/server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql new file mode 100644 index 0000000..334b201 --- /dev/null +++ b/server/prisma/migrations/20240607074201_added_cascade_delete_in_wishlist_table/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE `wishlist` DROP FOREIGN KEY `Wishlist_userId_fkey`; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql b/server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql new file mode 100644 index 0000000..feb9041 --- /dev/null +++ b/server/prisma/migrations/20240607075549_added_cascade_delete_for_categories_in_product_table/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE `product` DROP FOREIGN KEY `Product_categoryId_fkey`; + +-- AddForeignKey +ALTER TABLE `Product` ADD CONSTRAINT `Product_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql b/server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql new file mode 100644 index 0000000..198ff4a --- /dev/null +++ b/server/prisma/migrations/20240607083528_added_cascade_delete_for_wishlist_in_product_table/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE `wishlist` DROP FOREIGN KEY `Wishlist_productId_fkey`; + +-- AddForeignKey +ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql b/server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql new file mode 100644 index 0000000..66b6452 --- /dev/null +++ b/server/prisma/migrations/20240607111047_added_unique_constraint_to_name_column_in_the_category_table/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name]` on the table `Category` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX `Category_name_key` ON `Category`(`name`); diff --git a/prisma/migrations/20250427000000_replace_materials_with_project_products/migration.sql b/server/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql similarity index 54% rename from prisma/migrations/20250427000000_replace_materials_with_project_products/migration.sql rename to server/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql index 6541d07..8a5ec66 100644 --- a/prisma/migrations/20250427000000_replace_materials_with_project_products/migration.sql +++ b/server/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql @@ -1,8 +1,19 @@ --- Drop the old tables -DROP TABLE IF EXISTS `Material`; -DROP TABLE IF EXISTS `MaterialTemplate`; +-- CreateTable +CREATE TABLE `Project` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `itemCount` INTEGER NOT NULL DEFAULT 0, + `contractorId` VARCHAR(191) NOT NULL, + + INDEX `Project_contractorId_fkey`(`contractorId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; --- Create the new ProjectProduct table +-- CreateTable CREATE TABLE `ProjectProduct` ( `id` VARCHAR(191) NOT NULL, `quantity` INTEGER NOT NULL, @@ -14,7 +25,7 @@ CREATE TABLE `ProjectProduct` ( PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; --- Add foreign key constraints +-- AddForeignKey ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 6f51b47..8c0b251 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -1,9 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -27,6 +21,9 @@ model Product { category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) customerOrders customer_order_product[] Wishlist Wishlist[] + projects ProjectProduct[] + + @@index([categoryId], map: "Product_categoryId_fkey") } model Image { @@ -40,8 +37,8 @@ model User { email String @unique password String? role String? @default("user") + projects Project[] Wishlist Wishlist[] - Project Project[] } model Customer_order { @@ -65,43 +62,53 @@ model Customer_order { model customer_order_product { id String @id @default(uuid()) - customerOrder Customer_order @relation(fields: [customerOrderId], references: [id]) customerOrderId String - product Product @relation(fields: [productId], references: [id]) productId String quantity Int + customerOrder Customer_order @relation(fields: [customerOrderId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id]) + + @@index([customerOrderId], map: "customer_order_product_customerOrderId_fkey") + @@index([productId], map: "customer_order_product_productId_fkey") } model Category { id String @id @default(uuid()) name String @unique - products Product[] // Define a one-to-many relationship + products Product[] } model Wishlist { id String @id @default(uuid()) - product Product @relation(fields: [productId], references: [id], onDelete: Cascade) productId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([productId], map: "Wishlist_productId_fkey") + @@index([userId], map: "Wishlist_userId_fkey") } model Project { - id String @id @default(uuid()) - name String - createdAt DateTime @default(now()) - itemCount Int @default(0) - contractor User @relation(fields: [contractorId], references: [id]) + id String @id @default(uuid()) + name String + createdAt DateTime @default(now()) + itemCount Int @default(0) contractorId String - materials Material[] + products ProjectProduct[] + contractor User @relation(fields: [contractorId], references: [id], onDelete: Cascade) + + @@index([contractorId], map: "Project_contractorId_fkey") } -model Material { +model ProjectProduct { id String @id @default(uuid()) - name String - description String? quantity Int - unit String - project Project @relation(fields: [projectId], references: [id]) projectId String -} + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + product Product @relation(fields: [productId], references: [id], onDelete: Restrict) + productId String + + @@index([projectId], map: "ProjectProduct_projectId_fkey") + @@index([productId], map: "ProjectProduct_productId_fkey") +} \ No newline at end of file diff --git a/server/utills/db.ts b/server/utills/db.ts index 36be3ac..6990dac 100644 --- a/server/utills/db.ts +++ b/server/utills/db.ts @@ -1,18 +1,11 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaClient } from "@prisma/client"; -const prismaClientSingleton = () => { - return new PrismaClient(); -} +const prisma = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL + }, + }, +}); -type PrismaClientSingleton = ReturnType; - -const globalForPrisma = globalThis as unknown as { - prisma: PrismaClientSingleton | undefined; -} - -const prisma = globalForPrisma.prisma ?? prismaClientSingleton(); - - -export default prisma; - -if(process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; \ No newline at end of file +export default prisma; \ No newline at end of file From e1ddf5800cfe20d5d4feba3aefe41a5c95f8efe1 Mon Sep 17 00:00:00 2001 From: Mehak Date: Wed, 30 Apr 2025 05:09:59 +0500 Subject: [PATCH 3/5] upload file functionlity --- .../projects/upload-material/route.ts | 43 ++ .../projects/uploaded-files/route.ts | 22 + app/api/upload/route.ts | 39 ++ app/projects/[projectId]/materials/page.tsx | 394 +++++++++++------- app/projects/upload/page.tsx | 99 ++++- package.json | 1 + server/package.json | 5 +- 7 files changed, 435 insertions(+), 168 deletions(-) create mode 100644 app/api/contractor/projects/upload-material/route.ts create mode 100644 app/api/contractor/projects/uploaded-files/route.ts create mode 100644 app/api/upload/route.ts diff --git a/app/api/contractor/projects/upload-material/route.ts b/app/api/contractor/projects/upload-material/route.ts new file mode 100644 index 0000000..f928f58 --- /dev/null +++ b/app/api/contractor/projects/upload-material/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/server/utills/db"; + +interface Material { + productId: string; + quantity: number; +} + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { projectId, filePath } = await req.json(); + + if (!projectId || !filePath) { + return new NextResponse("Project ID and file path are required", { + status: 400, + }); + } + + try { + const materials: Material[] = []; + + for (const material of materials) { + await prisma.projectProduct.create({ + data: { + projectId: projectId, + productId: material.productId, + quantity: material.quantity, + }, + }); + } + + return NextResponse.json({ message: "Materials uploaded successfully" }); + } catch (error) { + console.error("Error uploading materials:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/app/api/contractor/projects/uploaded-files/route.ts b/app/api/contractor/projects/uploaded-files/route.ts new file mode 100644 index 0000000..f262fae --- /dev/null +++ b/app/api/contractor/projects/uploaded-files/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; + +export async function GET() { + const uploadDir = path.join(process.cwd(), "server", "uploads"); + + try { + const files = await fs.promises.readdir(uploadDir); + const pdfFiles = files + .filter((file) => file.endsWith(".pdf")) + .map((file) => ({ + name: file, + path: `uploads/${file}`, + })); + + return NextResponse.json(pdfFiles); + } catch (error) { + console.error("Error reading upload directory:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..46385c3 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { v4 as uuidv4 } from "uuid"; +import { promises as fs } from "fs"; +import path from "path"; + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const formData = await req.formData(); + const file = formData.get("uploadedFile") as File; + + if (!file) { + return new NextResponse("No file uploaded", { status: 400 }); + } + + if (file.type !== "application/pdf") { + return new NextResponse("Only PDF files are allowed", { status: 400 }); + } + + const uniqueFileName = `${uuidv4()}-${file.name}`; + const uploadPath = path.join( + process.cwd(), + "server", + "uploads", + uniqueFileName + ); + + await fs.mkdir(path.dirname(uploadPath), { recursive: true }); + + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(uploadPath, Uint8Array.from(buffer)); + + return NextResponse.json({ filePath: `uploads/${uniqueFileName}` }); +} diff --git a/app/projects/[projectId]/materials/page.tsx b/app/projects/[projectId]/materials/page.tsx index 896087b..106df17 100644 --- a/app/projects/[projectId]/materials/page.tsx +++ b/app/projects/[projectId]/materials/page.tsx @@ -42,45 +42,54 @@ interface Product { } export default function MaterialsPage() { - // State management for materials and UI const [materials, setMaterials] = useState([]); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); const [selectedProduct, setSelectedProduct] = useState(""); const [newQuantity, setNewQuantity] = useState(1); const [editingMaterial, setEditingMaterial] = useState(null); - - // Routing and authentication hooks + const [uploadedFiles, setUploadedFiles] = useState< + { name: string; path: string }[] + >([]); + const [selectedFile, setSelectedFile] = useState(""); + const params = useParams(); const router = useRouter(); const { data: session } = useSession(); - // Fetch materials and products on component mount useEffect(() => { const fetchData = async () => { try { - // Fetch project materials - const materialsResponse = await fetch(`/api/contractor/projects/${params.projectId}/materials`); + const materialsResponse = await fetch( + `/api/contractor/projects/${params.projectId}/materials` + ); if (!materialsResponse.ok) { throw new Error("Failed to fetch materials"); } const materialsData = await materialsResponse.json(); - - // Ensure each material has a name property, using product.title as fallback + const processedMaterials = materialsData.map((material: any) => ({ ...material, - name: material.name || (material.product?.title || "Unknown Material") + name: material.name || material.product?.title || "Unknown Material", })); - + setMaterials(processedMaterials); - // Fetch available products catalog - const productsResponse = await fetch('/api/products'); + const productsResponse = await fetch("/api/products"); if (!productsResponse.ok) { throw new Error("Failed to fetch products"); } const productsData = await productsResponse.json(); setProducts(productsData); + + const filesResponse = await fetch( + "/api/contractor/projects/uploaded-files" + ); + if (!filesResponse.ok) { + throw new Error("Failed to fetch uploaded files"); + } + const filesData = await filesResponse.json(); + setUploadedFiles(filesData); } catch (error) { console.error("Error fetching data:", error); toast.error("Failed to load materials"); @@ -92,42 +101,40 @@ export default function MaterialsPage() { fetchData(); }, [params.projectId]); - // Handle adding new material to project const handleAddMaterial = async () => { try { - // Create new material entry - const response = await fetch(`/api/contractor/projects/${params.projectId}/materials`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - productId: selectedProduct, - quantity: newQuantity, - }), - }); + const response = await fetch( + `/api/contractor/projects/${params.projectId}/materials`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + productId: selectedProduct, + quantity: newQuantity, + }), + } + ); if (!response.ok) { throw new Error("Failed to add material"); } - // Update local state with new material const newMaterial = await response.json(); - - // Make sure the material has a name property derived from the product if needed + const materialWithName = { ...newMaterial, - name: newMaterial.name || (newMaterial.product?.title || "Unknown Material") + name: + newMaterial.name || newMaterial.product?.title || "Unknown Material", }; - + setMaterials([...materials, materialWithName]); - - // Update project item count + await fetch(`/api/contractor/projects/${params.projectId}/recalculate`, { method: "POST", }); - // Reset form and show success message toast.success("Material added successfully"); setSelectedProduct(""); setNewQuantity(1); @@ -137,30 +144,35 @@ export default function MaterialsPage() { } }; - // Handle updating material quantity - const handleUpdateQuantity = async (materialId: string, newQuantity: number) => { + const handleUpdateQuantity = async ( + materialId: string, + newQuantity: number + ) => { try { - // Update material quantity in database - const response = await fetch(`/api/contractor/projects/${params.projectId}/materials/${materialId}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - quantity: newQuantity, - }), - }); + const response = await fetch( + `/api/contractor/projects/${params.projectId}/materials/${materialId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + quantity: newQuantity, + }), + } + ); if (!response.ok) { throw new Error("Failed to update quantity"); } - // Update local state with new quantity - setMaterials(materials.map(material => - material.id === materialId - ? { ...material, quantity: newQuantity } - : material - )); + setMaterials( + materials.map((material) => + material.id === materialId + ? { ...material, quantity: newQuantity } + : material + ) + ); toast.success("Quantity updated successfully"); } catch (error) { console.error("Error updating quantity:", error); @@ -168,22 +180,21 @@ export default function MaterialsPage() { } }; - // Handle removing material from project const handleDeleteMaterial = async (materialId: string) => { try { - // Delete material from database - const response = await fetch(`/api/contractor/projects/${params.projectId}/materials/${materialId}`, { - method: "DELETE", - }); + const response = await fetch( + `/api/contractor/projects/${params.projectId}/materials/${materialId}`, + { + method: "DELETE", + } + ); if (!response.ok) { throw new Error("Failed to delete material"); } - // Remove material from local state - setMaterials(materials.filter(material => material.id !== materialId)); - - // Update project item count + setMaterials(materials.filter((material) => material.id !== materialId)); + await fetch(`/api/contractor/projects/${params.projectId}/recalculate`, { method: "POST", }); @@ -195,14 +206,49 @@ export default function MaterialsPage() { } }; - // Loading state display + const handleUploadMaterial = async () => { + if (!selectedFile) { + toast.error("Please select a file to upload"); + return; + } + + try { + const response = await fetch(`/api/contractor/projects/upload-material`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + projectId: params.projectId, + filePath: selectedFile, + }), + }); + + if (!response.ok) { + throw new Error("Failed to upload material"); + } + + const result = await response.json(); + toast.success("Material uploaded successfully"); + } catch (error) { + console.error("Error uploading material:", error); + toast.error("Failed to upload material"); + } + }; + if (loading) { - return
Loading...
; + return ( +
+
+
+ ); } return ( -
-
+
+

Project Materials

+ +

Add New Material

setEditingMaterial({ - ...editingMaterial, - quantity: parseInt(e.target.value) - })} - className="block w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" - /> - ) : ( - material.quantity - )} - - - {material.unit} - - - {editingMaterial?.id === material.id ? ( - <> - - - - ) : ( - <> - - - - )} - - - ))} - - -
-
+
+

Upload Material from PDF

+
+ +
+ +
+ + + + + + + + + + + + {materials.map((material) => ( + + + + + + + + ))} + +
+ Material Name + + Description + + Quantity + + Unit + + Actions +
+ {material.name} + + {material.description || "-"} + + {editingMaterial?.id === material.id ? ( + + setEditingMaterial({ + ...editingMaterial, + quantity: parseInt(e.target.value), + }) + } + className="block w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + /> + ) : ( + material.quantity + )} + + {material.unit} + + {editingMaterial?.id === material.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
); -} \ No newline at end of file +} diff --git a/app/projects/upload/page.tsx b/app/projects/upload/page.tsx index d41eed8..6c20a26 100644 --- a/app/projects/upload/page.tsx +++ b/app/projects/upload/page.tsx @@ -3,30 +3,76 @@ // Purpose: Creates new projects and handles initial file upload // Features: // - Project name input -// - File upload interface (for future implementation) +// - File upload interface with progress and validation // - Form validation // - Automatic navigation to materials page after creation // ********************* "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; import { toast } from "react-hot-toast"; export default function UploadProjectPage() { - // Routing and state management const router = useRouter(); const [projectName, setProjectName] = useState(""); const [loading, setLoading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadMessage, setUploadMessage] = useState(""); + const fileInputRef = useRef(null); + + const handleFileUpload = async (file: File) => { + if (file.type !== "application/pdf") { + toast.error("Only PDF files are allowed."); + return false; + } + if (file.size > 20 * 1024 * 1024) { + toast.error("File size exceeds 20MB limit."); + return false; + } + + const formData = new FormData(); + formData.append("uploadedFile", file); + + try { + const uploadResponse = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error("Failed to upload file"); + } + + const uploadData = await uploadResponse.json(); + setUploadMessage("File uploaded successfully."); + setUploadProgress(100); + return uploadData.filePath; + } catch (error) { + console.error("Error uploading file:", error); + setUploadMessage("Failed to upload file."); + setUploadProgress(0); + return false; + } + }; - // Handle form submission and project creation const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); + const file = fileInputRef.current?.files?.[0]; + let filePath = null; + + if (file) { + filePath = await handleFileUpload(file); + if (!filePath) { + setLoading(false); + return; + } + } + try { - // Create new project in database const response = await fetch("/api/contractor/projects", { method: "POST", headers: { @@ -34,6 +80,7 @@ export default function UploadProjectPage() { }, body: JSON.stringify({ name: projectName, + filePath: filePath, }), }); @@ -41,7 +88,6 @@ export default function UploadProjectPage() { throw new Error("Failed to create project"); } - // Navigate to materials page on success const data = await response.json(); toast.success("Project created successfully!"); router.push(`/projects/${data.id}/materials`); @@ -55,11 +101,15 @@ export default function UploadProjectPage() { return (
-

Upload

+

+ Upload Project +

- {/* Project name input field */}
-
- {/* File upload input (prepared for future implementation) */}
-
- {/* Submit button with loading state */}
); -} \ No newline at end of file +} diff --git a/package.json b/package.json index b4acfba..382674a 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.0.1", "daisyui": "^4.7.2", "eslint": "^8", diff --git a/server/package.json b/server/package.json index 4c9d124..ea1d739 100644 --- a/server/package.json +++ b/server/package.json @@ -2,9 +2,10 @@ "name": "razvoj_veb_aplikacija", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "start": "nodemon app.js" }, "keywords": [], "author": "", From 1039bd6e4a45fd27f09c3e1ea094385787d42bc2 Mon Sep 17 00:00:00 2001 From: mehakseedat63 Date: Wed, 30 Apr 2025 23:42:21 +0500 Subject: [PATCH 4/5] Updated UI --- .../projects/[projectId]/files/route.ts | 63 +++ .../materials/[materialId]/route.ts | 7 +- .../projects/[projectId]/materials/route.ts | 6 +- .../projects/[projectId]/recalculate/route.ts | 2 +- app/api/contractor/projects/route.ts | 84 ++- .../projects/upload-material/route.ts | 46 +- .../projects/uploaded-files/route.ts | 15 +- app/api/projects/upload-material/route.ts | 77 +++ app/api/projects/uploaded-files/route.ts | 44 ++ app/api/upload/route.ts | 12 +- app/projects/[projectId]/materials/page.tsx | 502 ++++++++++------- app/projects/page.tsx | 1 + app/projects/upload/page.tsx | 182 +++++-- package-lock.json | 7 + server/app.js | 31 +- server/controllers/projects.js | 506 ++++++++++++++++++ server/controllers/uploads.js | 78 +++ server/package-lock.json | 15 +- server/package.json | 3 +- .../migration.sql | 20 + .../migration.sql | 29 + server/prisma/schema.prisma | 99 ++-- server/routes/projects.js | 343 ++++++++++++ server/routes/uploads.js | 11 + 24 files changed, 1845 insertions(+), 338 deletions(-) create mode 100644 app/api/contractor/projects/[projectId]/files/route.ts create mode 100644 app/api/projects/upload-material/route.ts create mode 100644 app/api/projects/uploaded-files/route.ts create mode 100644 server/controllers/projects.js create mode 100644 server/controllers/uploads.js create mode 100644 server/prisma/migrations/20250429000001_add_project_file_table/migration.sql create mode 100644 server/prisma/migrations/20250430173330_add_project_file_table/migration.sql create mode 100644 server/routes/projects.js create mode 100644 server/routes/uploads.js diff --git a/app/api/contractor/projects/[projectId]/files/route.ts b/app/api/contractor/projects/[projectId]/files/route.ts new file mode 100644 index 0000000..b4cd2d3 --- /dev/null +++ b/app/api/contractor/projects/[projectId]/files/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; + +export async function GET( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Get project files from database + const files = await prisma.projectfile.findMany({ + where: { + projectId: params.projectId + }, + orderBy: { + uploadedAt: 'desc' + } + }); + + return NextResponse.json(files); + } catch (error) { + console.error("Error fetching project files:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + +export async function POST( + request: Request, + { params }: { params: { projectId: string } } +) { + try { + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const body = await request.json(); + const { filename, originalname, path, mimetype, size } = body; + + // Create file record in database + const file = await prisma.projectfile.create({ + data: { + projectId: params.projectId, + filename, + originalname, + path, + mimetype, + size, + } + }); + + return NextResponse.json(file); + } catch (error) { + console.error("Error creating project file:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts b/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts index 72420f2..745a469 100644 --- a/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts +++ b/app/api/contractor/projects/[projectId]/materials/[materialId]/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; -import prisma from "@/utils/db"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import prisma from "@/utils/db"; -// PUT /api/contractor/projects/[projectId]/materials/[materialId] +// PUT handler: Update a material export async function PUT( request: Request, { params }: { params: { projectId: string; materialId: string } } @@ -25,6 +25,7 @@ export async function PUT( return new NextResponse("Project not found", { status: 404 }); } + // Parse the request body const body = await request.json(); const { quantity } = body; @@ -58,7 +59,7 @@ export async function PUT( } } -// DELETE /api/contractor/projects/[projectId]/materials/[materialId] +// DELETE handler: Delete a material export async function DELETE( request: Request, { params }: { params: { projectId: string; materialId: string } } diff --git a/app/api/contractor/projects/[projectId]/materials/route.ts b/app/api/contractor/projects/[projectId]/materials/route.ts index 7a5f8fb..a0499f2 100644 --- a/app/api/contractor/projects/[projectId]/materials/route.ts +++ b/app/api/contractor/projects/[projectId]/materials/route.ts @@ -69,9 +69,10 @@ export async function GET( }); // Transform the data to include name property - const transformedMaterials = materials.map((material: ProjectProductWithProduct) => ({ + const transformedMaterials = materials.map((material: any) => ({ ...material, - name: material.product.title, + name: material.product?.title || "Unknown Material", + unit: "piece", // Default unit })); return NextResponse.json(transformedMaterials); @@ -135,6 +136,7 @@ export async function POST( const transformedMaterial = { ...material, name: material.product.title, + unit: "piece", }; return NextResponse.json(transformedMaterial); diff --git a/app/api/contractor/projects/[projectId]/recalculate/route.ts b/app/api/contractor/projects/[projectId]/recalculate/route.ts index a5a9fcb..88d213f 100644 --- a/app/api/contractor/projects/[projectId]/recalculate/route.ts +++ b/app/api/contractor/projects/[projectId]/recalculate/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from "next-auth"; import prisma from "@/utils/db"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -// POST /api/contractor/projects/[projectId]/recalculate +// POST handler: Recalculate project item count export async function POST( request: Request, { params }: { params: { projectId: string } } diff --git a/app/api/contractor/projects/route.ts b/app/api/contractor/projects/route.ts index b2cf09a..8a2951d 100644 --- a/app/api/contractor/projects/route.ts +++ b/app/api/contractor/projects/route.ts @@ -1,6 +1,9 @@ import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; import prisma from "@/server/utills/db"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { promises as fs } from "fs"; +import path from "path"; export async function GET() { try { @@ -18,7 +21,7 @@ export async function GET() { return new NextResponse("Unauthorized", { status: 401 }); } - const projects = await prisma.Project.findMany({ + const projects = await prisma.project.findMany({ where: { contractorId: user.id }, orderBy: { createdAt: "desc" }, }); @@ -32,44 +35,69 @@ export async function GET() { export async function POST(req: Request) { try { - const session = await getServerSession(); - console.log("[PROJECTS_POST] Session:", session); - - if (!session?.user?.email) { + const session = await getServerSession(authOptions); + if (!session) { return new NextResponse("Unauthorized", { status: 401 }); } - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, + const { name, filePaths } = await req.json(); + + // Create project using backend API + const projectResponse = await fetch("http://localhost:3001/api/projects", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + contractorId: session.user.id, + }), }); - console.log("[PROJECTS_POST] User:", user); - if (!user) { - return new NextResponse("Unauthorized", { status: 401 }); + if (!projectResponse.ok) { + throw new Error("Failed to create project"); } - const body = await req.json(); - console.log("[PROJECTS_POST] Request body:", body); - const { name } = body; + const project = await projectResponse.json(); - if (!name) { - return new NextResponse("Name is required", { status: 400 }); - } + // If there are files, move them from temp to project directory + if (filePaths && filePaths.length > 0) { + // Create project directory + const projectDir = path.join(process.cwd(), "server", "uploads", project.id); + await fs.mkdir(projectDir, { recursive: true }); - console.log("[PROJECTS_POST] Creating project with:", { name, contractorId: user.id }); - const project = await prisma.Project.create({ - data: { - name, - contractorId: user.id, - }, - }); - console.log("[PROJECTS_POST] Created project:", project); + // Move each file and create file records + for (const tempPath of filePaths) { + const fileName = path.basename(tempPath); + const sourcePath = path.join(process.cwd(), "server", tempPath); + const targetPath = path.join(projectDir, fileName); + + // Move file from temp to project directory + await fs.rename(sourcePath, targetPath); + + // Create file record in database + await fetch(`http://localhost:3001/api/projects/${project.id}/files`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + filename: fileName, + originalname: fileName.substring(37), // Remove UUID prefix + path: `uploads/${project.id}/${fileName}`, + mimetype: "application/pdf", + size: (await fs.stat(targetPath)).size, + }), + }); + } + } return NextResponse.json(project); } catch (error) { - console.error("[PROJECTS_POST] Error details:", error); - // Return more specific error message if available - const errorMessage = error instanceof Error ? error.message : "Internal error"; - return new NextResponse(errorMessage, { status: 500 }); + console.error("Error creating project:", error); + return new NextResponse( + error instanceof Error ? error.message : "Internal Server Error", + { status: 500 } + ); } } \ No newline at end of file diff --git a/app/api/contractor/projects/upload-material/route.ts b/app/api/contractor/projects/upload-material/route.ts index f928f58..3132c4f 100644 --- a/app/api/contractor/projects/upload-material/route.ts +++ b/app/api/contractor/projects/upload-material/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import fs from "fs"; +import path from "path"; import prisma from "@/server/utills/db"; interface Material { @@ -23,19 +25,41 @@ export async function POST(req: Request) { } try { - const materials: Material[] = []; - - for (const material of materials) { - await prisma.projectProduct.create({ - data: { - projectId: projectId, - productId: material.productId, - quantity: material.quantity, - }, - }); + // Verify the project exists and belongs to the user + const project = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Check if file exists + const fullFilePath = path.join(process.cwd(), filePath); + const fileExists = await fs.promises.access(fullFilePath) + .then(() => true) + .catch(() => false); + + if (!fileExists) { + return new NextResponse("File not found", { status: 404 }); } - return NextResponse.json({ message: "Materials uploaded successfully" }); + // In a real implementation, you would parse the PDF here + // and extract materials list + + // Create a placeholder material for demo purposes + const newMaterial = await prisma.projectProduct.create({ + data: { + projectId, + productId: "placeholder-product-id", // You would determine this from the file + quantity: 1, + }, + }); + + return NextResponse.json({ + message: "Materials uploaded successfully", + material: newMaterial + }); } catch (error) { console.error("Error uploading materials:", error); return new NextResponse("Internal Server Error", { status: 500 }); diff --git a/app/api/contractor/projects/uploaded-files/route.ts b/app/api/contractor/projects/uploaded-files/route.ts index f262fae..94cf0b8 100644 --- a/app/api/contractor/projects/uploaded-files/route.ts +++ b/app/api/contractor/projects/uploaded-files/route.ts @@ -6,6 +6,18 @@ export async function GET() { const uploadDir = path.join(process.cwd(), "server", "uploads"); try { + // Create the uploads directory if it doesn't exist + await fs.promises.mkdir(uploadDir, { recursive: true }); + + // Check if directory exists before reading + const dirExists = await fs.promises.access(uploadDir) + .then(() => true) + .catch(() => false); + + if (!dirExists) { + return NextResponse.json([]); + } + const files = await fs.promises.readdir(uploadDir); const pdfFiles = files .filter((file) => file.endsWith(".pdf")) @@ -17,6 +29,7 @@ export async function GET() { return NextResponse.json(pdfFiles); } catch (error) { console.error("Error reading upload directory:", error); - return new NextResponse("Internal Server Error", { status: 500 }); + // Return empty array instead of error when directory doesn't exist or can't be read + return NextResponse.json([]); } } diff --git a/app/api/projects/upload-material/route.ts b/app/api/projects/upload-material/route.ts new file mode 100644 index 0000000..249c311 --- /dev/null +++ b/app/api/projects/upload-material/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import fs from "fs"; +import path from "path"; +import prisma from "@/utils/db"; + +export async function POST(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Parse request body + const { projectId, filePath } = await request.json(); + + if (!projectId || !filePath) { + return new NextResponse("Project ID and file path are required", { + status: 400, + }); + } + + // Verify the project exists + const project = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + return new NextResponse("Project not found", { status: 404 }); + } + + // Check if file exists + const fullFilePath = path.join(process.cwd(), filePath); + const fileExists = await fs.promises.access(fullFilePath) + .then(() => true) + .catch(() => false); + + if (!fileExists) { + return new NextResponse("File not found", { status: 404 }); + } + + // Create a placeholder material for demo purposes + const newMaterial = await prisma.projectProduct.create({ + data: { + projectId, + productId: "default-product-id", // Use a default product ID + quantity: 1, + }, + include: { + product: true, + }, + }); + + // Update the project's item count + await prisma.project.update({ + where: { id: projectId }, + data: { itemCount: { increment: 1 } }, + }); + + return NextResponse.json({ + message: "Materials uploaded successfully", + material: { + id: newMaterial.id, + name: newMaterial.product.title, + description: newMaterial.product.description, + quantity: newMaterial.quantity, + unit: "piece", + productId: newMaterial.product.id + } + }); + } catch (error) { + console.error("Error uploading material from file:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/projects/uploaded-files/route.ts b/app/api/projects/uploaded-files/route.ts new file mode 100644 index 0000000..686a264 --- /dev/null +++ b/app/api/projects/uploaded-files/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import fs from "fs"; +import path from "path"; + +export async function GET(request: Request) { + try { + // Verify user authentication + const session = await getServerSession(authOptions); + if (!session) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Create uploads directory if it doesn't exist + const uploadDir = path.join(process.cwd(), "server", "uploads"); + await fs.promises.mkdir(uploadDir, { recursive: true }); + + // Check if directory exists before reading + const dirExists = await fs.promises.access(uploadDir) + .then(() => true) + .catch(() => false); + + if (!dirExists) { + return NextResponse.json([]); + } + + // Read the directory + const files = await fs.promises.readdir(uploadDir); + + // Filter for PDF files + const pdfFiles = files + .filter((file) => file.endsWith(".pdf")) + .map((file) => ({ + name: file, + path: `uploads/${file}`, + })); + + return NextResponse.json(pdfFiles); + } catch (error) { + console.error("Error fetching uploaded files:", error); + return NextResponse.json([]); // Return empty array instead of error + } +} \ No newline at end of file diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 46385c3..18526f3 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -23,17 +23,27 @@ export async function POST(req: Request) { } const uniqueFileName = `${uuidv4()}-${file.name}`; + + // Create temp uploads directory const uploadPath = path.join( process.cwd(), "server", "uploads", + "temp", uniqueFileName ); + // Ensure temp directory exists await fs.mkdir(path.dirname(uploadPath), { recursive: true }); const buffer = Buffer.from(await file.arrayBuffer()); await fs.writeFile(uploadPath, Uint8Array.from(buffer)); - return NextResponse.json({ filePath: `uploads/${uniqueFileName}` }); + return NextResponse.json({ + filePath: `uploads/temp/${uniqueFileName}`, + fileName: uniqueFileName, + originalName: file.name, + size: buffer.length, + mimeType: file.type + }); } diff --git a/app/projects/[projectId]/materials/page.tsx b/app/projects/[projectId]/materials/page.tsx index 106df17..3685e55 100644 --- a/app/projects/[projectId]/materials/page.tsx +++ b/app/projects/[projectId]/materials/page.tsx @@ -41,6 +41,16 @@ interface Product { inStock: number; } +interface ProjectFile { + id: string; + filename: string; + originalname: string; + path: string; + mimetype: string; + size: number; + uploadedAt: string; +} + export default function MaterialsPage() { const [materials, setMaterials] = useState([]); const [products, setProducts] = useState([]); @@ -48,10 +58,9 @@ export default function MaterialsPage() { const [selectedProduct, setSelectedProduct] = useState(""); const [newQuantity, setNewQuantity] = useState(1); const [editingMaterial, setEditingMaterial] = useState(null); - const [uploadedFiles, setUploadedFiles] = useState< - { name: string; path: string }[] - >([]); + const [uploadedFiles, setUploadedFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(""); + const [selectedFilesForImport, setSelectedFilesForImport] = useState([]); const params = useParams(); const router = useRouter(); @@ -75,15 +84,16 @@ export default function MaterialsPage() { setMaterials(processedMaterials); - const productsResponse = await fetch("/api/products"); + const productsResponse = await fetch(`/api/products`); if (!productsResponse.ok) { throw new Error("Failed to fetch products"); } const productsData = await productsResponse.json(); setProducts(productsData); + // Fetch uploaded files const filesResponse = await fetch( - "/api/contractor/projects/uploaded-files" + `/api/projects/uploaded-files` ); if (!filesResponse.ok) { throw new Error("Failed to fetch uploaded files"); @@ -206,33 +216,111 @@ export default function MaterialsPage() { } }; - const handleUploadMaterial = async () => { - if (!selectedFile) { - toast.error("Please select a file to upload"); + const handleFileChange = async (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + + const file = e.target.files[0]; + if (file.type !== 'application/pdf') { + toast.error('Only PDF files are allowed'); return; } + const formData = new FormData(); + formData.append('files', file); + try { - const response = await fetch(`/api/contractor/projects/upload-material`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - projectId: params.projectId, - filePath: selectedFile, - }), + const response = await fetch(`http://localhost:3001/api/projects/${params.projectId}/files`, { + method: 'POST', + body: formData, }); if (!response.ok) { - throw new Error("Failed to upload material"); + throw new Error('Failed to upload file'); } const result = await response.json(); - toast.success("Material uploaded successfully"); + + // Refresh the file list from backend + const filesResponse = await fetch(`http://localhost:3001/api/projects/${params.projectId}/files`); + if (!filesResponse.ok) { + throw new Error('Failed to fetch uploaded files'); + } + const filesData = await filesResponse.json(); + setUploadedFiles(filesData); + toast.success('File uploaded successfully'); } catch (error) { - console.error("Error uploading material:", error); - toast.error("Failed to upload material"); + console.error('Error uploading file:', error); + toast.error('Failed to upload file'); + } + }; + + const toggleFileSelection = (filePath: string) => { + console.log("Current selection:", selectedFilesForImport); + console.log("Toggling file:", filePath); + + setSelectedFilesForImport(prev => { + const newSelection = prev.includes(filePath) + ? prev.filter(path => path !== filePath) + : [...prev, filePath]; + + console.log("New selection:", newSelection); + return newSelection; + }); + }; + + const handleImportList = async () => { + if (selectedFilesForImport.length === 0) { + toast.error("Please select at least one file to import"); + return; + } + + try { + setLoading(true); + + // Process each selected file + for (const filePath of selectedFilesForImport) { + const response = await fetch(`/api/projects/upload-material`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + projectId: params.projectId, + filePath: filePath, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to import from file: ${filePath}`); + } + } + + // Refresh the materials list + const materialsResponse = await fetch( + `/api/contractor/projects/${params.projectId}/materials` + ); + + if (!materialsResponse.ok) { + throw new Error("Failed to refresh materials list"); + } + + const materialsData = await materialsResponse.json(); + const processedMaterials = materialsData.map((material: any) => ({ + ...material, + name: material.name || material.product?.title || "Unknown Material", + })); + + setMaterials(processedMaterials); + + // Clear the selection + setSelectedFilesForImport([]); + + toast.success("Materials imported successfully"); + } catch (error) { + console.error("Error importing materials:", error); + toast.error("Failed to import materials"); + } finally { + setLoading(false); } }; @@ -248,173 +336,221 @@ export default function MaterialsPage() {

Project Materials

-
-

Add New Material

-
- - setNewQuantity(parseInt(e.target.value))} - className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" - placeholder="Quantity" - /> - -
-
- -
-

Upload Material from PDF

-
- - +
+ {/* Left sidebar - File list */} +
+
+
+

Files

+ +
+ + {uploadedFiles.length === 0 ? ( +

No files uploaded yet.

+ ) : ( +
+ {uploadedFiles.map((file) => ( +
+ { + if (e.target.checked) { + setSelectedFilesForImport([...selectedFilesForImport, file.path]); + } else { + setSelectedFilesForImport(selectedFilesForImport.filter(path => path !== file.path)); + } + }} + className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" + /> + +
+ ))} +
+ )} +
-
-
- - - - - - - - - - - - {materials.map((material) => ( - - - - - - - - ))} - -
+
+

Add New Material

+
+
Select Material + {products.map((product) => ( + + ))} + + setNewQuantity(parseInt(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + placeholder="Quantity" + /> + + + + +
+

Upload Material from PDF

+
+ +
- Unit - - Actions -
- {material.name} - - {material.description || "-"} - - {editingMaterial?.id === material.id ? ( - - setEditingMaterial({ - ...editingMaterial, - quantity: parseInt(e.target.value), - }) - } - className="block w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" - /> - ) : ( - material.quantity - )} - - {material.unit} - - {editingMaterial?.id === material.id ? ( - <> - - - - ) : ( - <> - - - - )} -
+ Upload PDF + +
+
+ +
+ + + + + + + + + + + + {materials.map((material) => ( + + + + + + + + ))} + +
+ Material Name + + Description + + Quantity + + Unit + + Actions +
+ {material.name} + + {material.description || "-"} + + {editingMaterial?.id === material.id ? ( + + setEditingMaterial({ + ...editingMaterial, + quantity: parseInt(e.target.value), + }) + } + className="block w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + /> + ) : ( + material.quantity + )} + + {material.unit} + + {editingMaterial?.id === material.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
); diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 02ccd85..4ca66e2 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -58,6 +58,7 @@ export default function ProjectsPage() { useEffect(() => { const fetchProjects = async () => { try { + setLoading(true); const response = await fetch("/api/contractor/projects"); const data = await response.json(); setProjects(data); diff --git a/app/projects/upload/page.tsx b/app/projects/upload/page.tsx index 6c20a26..2dd22a3 100644 --- a/app/projects/upload/page.tsx +++ b/app/projects/upload/page.tsx @@ -20,67 +20,114 @@ export default function UploadProjectPage() { const [loading, setLoading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadMessage, setUploadMessage] = useState(""); + const [selectedFiles, setSelectedFiles] = useState([]); const fileInputRef = useRef(null); - const handleFileUpload = async (file: File) => { - if (file.type !== "application/pdf") { - toast.error("Only PDF files are allowed."); - return false; + const handleFileChange = (e: React.ChangeEvent) => { + setUploadProgress(0); + setUploadMessage(""); + + if (e.target.files && e.target.files.length > 0) { + const newFiles = Array.from(e.target.files); + + // Validate all files + const invalidFiles = newFiles.filter(file => + file.type !== "application/pdf" || file.size > 20 * 1024 * 1024 + ); + + if (invalidFiles.length > 0) { + toast.error("Some files are invalid. Only PDF files up to 20MB are allowed."); + return; + } + + setSelectedFiles(newFiles); + setUploadMessage(`${newFiles.length} file(s) selected`); } - if (file.size > 20 * 1024 * 1024) { - toast.error("File size exceeds 20MB limit."); - return false; + }; + + const removeFile = (index: number) => { + setSelectedFiles(prevFiles => { + const newFiles = [...prevFiles]; + newFiles.splice(index, 1); + + // Update the upload message with the new count + if (newFiles.length === 0) { + setUploadMessage(""); + } else { + setUploadMessage(`${newFiles.length} file(s) selected`); + } + + return newFiles; + }); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; } + }; - const formData = new FormData(); - formData.append("uploadedFile", file); + const handleFilesUpload = async (files: File[]) => { + const uploadedFilePaths = []; + let progress = 0; + + for (const file of files) { + const formData = new FormData(); + formData.append("uploadedFile", file); - try { - const uploadResponse = await fetch("/api/upload", { - method: "POST", - body: formData, - }); + try { + const uploadResponse = await fetch("/api/upload", { + method: "POST", + body: formData, + }); - if (!uploadResponse.ok) { - throw new Error("Failed to upload file"); - } + if (!uploadResponse.ok) { + throw new Error(`Failed to upload file: ${file.name}`); + } - const uploadData = await uploadResponse.json(); - setUploadMessage("File uploaded successfully."); - setUploadProgress(100); - return uploadData.filePath; - } catch (error) { - console.error("Error uploading file:", error); - setUploadMessage("Failed to upload file."); - setUploadProgress(0); - return false; + const uploadData = await uploadResponse.json(); + uploadedFilePaths.push(uploadData.filePath); + + // Update progress + progress = Math.round(((uploadedFilePaths.length) / files.length) * 100); + setUploadProgress(progress); + + } catch (error) { + console.error("Error uploading file:", error); + toast.error(`Failed to upload ${file.name}`); + } + } + + if (uploadedFilePaths.length > 0) { + setUploadMessage(`${uploadedFilePaths.length} of ${files.length} files uploaded successfully.`); + return uploadedFilePaths; } + + return null; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); - const file = fileInputRef.current?.files?.[0]; - let filePath = null; + let filePaths = null; - if (file) { - filePath = await handleFileUpload(file); - if (!filePath) { + if (selectedFiles.length > 0) { + filePaths = await handleFilesUpload(selectedFiles); + if (!filePaths || filePaths.length === 0) { setLoading(false); return; } } try { - const response = await fetch("/api/contractor/projects", { + const response = await fetch("http://localhost:3001/api/projects", { method: "POST", headers: { "Content-Type": "application/json", }, + credentials: 'include', // Include cookies for authentication body: JSON.stringify({ name: projectName, - filePath: filePath, + filePaths: filePaths, }), }); @@ -128,27 +175,60 @@ export default function UploadProjectPage() {
-
- setUploadProgress(0)} - className="block w-full text-sm text-gray-500 - file:mr-4 file:py-2 file:px-4 - file:rounded-md file:border-0 - file:text-sm file:font-semibold - file:bg-indigo-50 file:text-indigo-700 - hover:file:bg-indigo-100" - /> +
+ + + {selectedFiles.length > 0 + ? `${selectedFiles.length} file(s)` + : "No file chosen"} +
+ + {selectedFiles.length > 0 && ( +
+
+ {selectedFiles.map((file, index) => ( +
+
+ {file.name} +
+ +
+ ))} +
+
+ )} + {uploadProgress > 0 && (
@@ -162,7 +242,7 @@ export default function UploadProjectPage() {

)} - {uploadMessage && ( + {uploadMessage && !selectedFiles.length && (

{uploadMessage}

)}
diff --git a/package-lock.json b/package-lock.json index 89c67d4..554b375 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.0.1", "daisyui": "^4.7.2", "eslint": "^8", @@ -1920,6 +1921,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", diff --git a/server/app.js b/server/app.js index 7ee21d2..e257790 100644 --- a/server/app.js +++ b/server/app.js @@ -1,6 +1,8 @@ const express = require("express"); const bcrypt = require('bcryptjs'); const fileUpload = require("express-fileupload"); +const path = require('path'); +const fs = require('fs'); const productsRouter = require("./routes/products"); const productImagesRouter = require("./routes/productImages"); const categoryRouter = require("./routes/category"); @@ -11,19 +13,27 @@ const orderRouter = require("./routes/customer_orders"); const slugRouter = require("./routes/slugs"); const orderProductRouter = require('./routes/customer_order_product'); const wishlistRouter = require('./routes/wishlist'); -var cors = require("cors"); +const projectsRouter = require('./routes/projects'); +const uploadsRouter = require('./routes/uploads'); const app = express(); +// Ensure uploads directory exists at server startup +const uploadsDir = path.join(process.cwd(), 'server', 'uploads'); +fs.promises.mkdir(uploadsDir, { recursive: true }) + .then(() => console.log('Uploads directory created/verified')) + .catch(err => console.error('Error creating uploads directory:', err)); + app.use(express.json()); -app.use( - cors({ - origin: "*", - methods: ["GET", "POST", "PUT", "DELETE"], - allowedHeaders: ["Content-Type", "Authorization"], - }) -); -app.use(fileUpload()); +app.use(fileUpload({ + createParentPath: true, + limits: { + fileSize: 20 * 1024 * 1024 // 20MB max file size + }, +})); + +// Serve uploaded files statically +app.use('/uploads', express.static(path.join(process.cwd(), 'server', 'uploads'))); app.use("/api/products", productsRouter); app.use("/api/categories", categoryRouter); @@ -35,7 +45,8 @@ app.use("/api/orders", orderRouter); app.use('/api/order-product', orderProductRouter); app.use("/api/slugs", slugRouter); app.use("/api/wishlist", wishlistRouter); - +app.use("/api/projects", projectsRouter); +app.use("/api/uploads", uploadsRouter); const PORT = process.env.PORT || 3001; app.listen(PORT, () => { diff --git a/server/controllers/projects.js b/server/controllers/projects.js new file mode 100644 index 0000000..d8f7e58 --- /dev/null +++ b/server/controllers/projects.js @@ -0,0 +1,506 @@ +const { PrismaClient } = require('@prisma/client'); +const fs = require('fs'); +const path = require('path'); +const prisma = new PrismaClient(); + +// Helper function to ensure upload directory exists +const ensureUploadDirExists = async (projectId) => { + // Create main uploads directory if it doesn't exist + const mainUploadDir = path.join(process.cwd(), 'server', 'uploads'); + await fs.promises.mkdir(mainUploadDir, { recursive: true }); + + // Create project-specific directory + const projectUploadDir = path.join(mainUploadDir, projectId); + await fs.promises.mkdir(projectUploadDir, { recursive: true }); + + return projectUploadDir; +}; + +// Get all projects for the authenticated user +const getAllProjects = async (req, res) => { + try { + // Get user ID from request (authentication will be handled by middleware) + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const projects = await prisma.project.findMany({ + where: { contractorId: userId }, + orderBy: { createdAt: 'desc' }, + }); + + res.status(200).json(projects); + } catch (error) { + console.error('Error getting projects:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get a specific project +const getProject = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const project = await prisma.project.findUnique({ + where: { id }, + include: { + products: { + include: { + product: true, + } + } + } + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Check if user owns the project + if (project.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + res.status(200).json(project); + } catch (error) { + console.error('Error getting project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Create a new project with optional file uploads +const createProject = async (req, res) => { + try { + const { name } = req.body; + const userId = req.user?.id; + + if (!name) { + return res.status(400).json({ message: 'Project name is required' }); + } + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Create the project + const project = await prisma.project.create({ + data: { + name, + contractorId: userId, + }, + }); + + // Handle file uploads if any + if (req.files) { + const uploadDir = await ensureUploadDirExists(project.id); + const files = Array.isArray(req.files.files) ? req.files.files : [req.files.files]; + + for (const file of files) { + const fileExtension = path.extname(file.name); + const fileName = `${Date.now()}-${file.name}`; + const filePath = path.join('uploads', project.id, fileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + + // Move file to project directory + await file.mv(fullPath); + + // Create ProjectFile record + await prisma.projectFile.create({ + data: { + projectId: project.id, + filename: fileName, + originalname: file.name, + mimetype: file.mimetype, + size: file.size, + path: filePath, + } + }); + } + } + + // Return project with files + const projectWithFiles = await prisma.project.findUnique({ + where: { id: project.id }, + include: { + files: true + } + }); + + res.status(201).json(projectWithFiles); + } catch (error) { + console.error('Error creating project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Update a project +const updateProject = async (req, res) => { + try { + const { id } = req.params; + const { name } = req.body; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Update the project + const project = await prisma.project.update({ + where: { id }, + data: { name }, + }); + + res.status(200).json(project); + } catch (error) { + console.error('Error updating project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Delete a project +const deleteProject = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Delete the project (this will cascade delete projectfiles due to the foreign key constraint) + await prisma.project.delete({ + where: { id }, + }); + + // Remove associated files from filesystem + const uploadDir = path.join(process.cwd(), 'server', 'uploads', id); + if (fs.existsSync(uploadDir)) { + fs.rmSync(uploadDir, { recursive: true, force: true }); + } + + res.status(200).json({ message: 'Project deleted successfully' }); + } catch (error) { + console.error('Error deleting project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Helper function to handle file uploads +const handleFileUploads = async (files, projectId) => { + const uploadDir = await ensureUploadDirExists(projectId); + const uploadedFiles = []; + + // Handle multiple files in an array + if (Array.isArray(files.files)) { + for (const file of files.files) { + const fileName = `${Date.now()}-${file.name}`; + const filePath = path.join('uploads', projectId, fileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + + await file.mv(fullPath); + + // Save file information in projectfile table + const projectFile = await prisma.projectFile.create({ + data: { + projectId: projectId, + filename: fileName, + originalname: file.name, + mimetype: file.mimetype, + size: file.size, + path: filePath, + } + }); + + uploadedFiles.push(projectFile); + } + } + // Handle single file upload + else if (files.files) { + const file = files.files; + const fileName = `${Date.now()}-${file.name}`; + const filePath = path.join('uploads', projectId, fileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + + await file.mv(fullPath); + + // Save file information in projectfile table + const projectFile = await prisma.projectFile.create({ + data: { + projectId: projectId, + filename: fileName, + originalname: file.name, + mimetype: file.mimetype, + size: file.size, + path: filePath, + } + }); + + uploadedFiles.push(projectFile); + } + + return uploadedFiles; +}; + +// Upload files for a project +const uploadFiles = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Check if files are provided + if (!req.files || Object.keys(req.files).length === 0) { + return res.status(400).json({ message: 'No files uploaded' }); + } + + // Handle the file uploads + const uploadedFiles = await handleFileUploads(req.files, id); + + // For each file, create a placeholder ProjectProduct + // Note: In a real implementation, you might want to parse the files and create products accordingly + for (const file of uploadedFiles) { + await prisma.projectProduct.create({ + data: { + projectId: id, + productId: "placeholder-id", // You would need to determine the actual product ID + quantity: 1, + }, + }); + } + + res.status(200).json({ message: 'Files uploaded successfully', files: uploadedFiles }); + } catch (error) { + console.error('Error uploading files:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get uploaded files for a project +const getProjectFiles = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Get files from projectfile table + const files = await prisma.projectFile.findMany({ + where: { + projectId: id + }, + orderBy: { + uploadedAt: 'desc' + } + }); + + res.status(200).json(files); + } catch (error) { + console.error('Error getting project files:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Create a project file record +const createProjectFile = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + const { filename, originalname, path, mimetype, size } = req.body; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Check if project exists and belongs to user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Create file record + const file = await prisma.projectFile.create({ + data: { + projectId: id, + filename, + originalname, + path, + mimetype, + size, + } + }); + + res.status(201).json(file); + } catch (error) { + console.error('Error creating project file:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Upload materials from a PDF file +const uploadMaterialFromFile = async (req, res) => { + try { + const { projectId, filePath } = req.body; + + if (!projectId || !filePath) { + return res.status(400).json({ message: 'Project ID and file path are required' }); + } + + // Verify the project exists and belongs to the user + const existingProject = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (existingProject.contractorId !== req.user?.id) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Check if file exists + const fullFilePath = path.join(process.cwd(), 'server', filePath); + const fileExists = await fs.promises.access(fullFilePath) + .then(() => true) + .catch(() => false); + + if (!fileExists) { + return res.status(404).json({ message: 'File not found' }); + } + + // In a real implementation, you would parse the PDF and extract materials + // For now, we'll create a placeholder material + const newMaterial = await prisma.projectProduct.create({ + data: { + projectId, + productId: "default-product-id", // You would need to determine the actual product ID + quantity: 1, + }, + }); + + // Update the project's item count + await prisma.project.update({ + where: { id: projectId }, + data: { itemCount: { increment: 1 } }, + }); + + return res.status(200).json({ message: 'Material uploaded successfully', material: newMaterial }); + } catch (error) { + console.error('Error uploading material from file:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get all uploaded files +const getUploadedFiles = async (req, res) => { + try { + // Create uploads directory if it doesn't exist + const uploadDir = path.join(process.cwd(), 'server', 'uploads'); + await fs.promises.mkdir(uploadDir, { recursive: true }); + + // Read the directory + const files = await fs.promises.readdir(uploadDir) + .catch(error => { + console.error(`Error reading directory: ${error.message}`); + return []; // Return empty array if directory can't be read + }); + + // Filter for PDF files + const pdfFiles = files + .filter(file => file.endsWith('.pdf')) + .map(file => ({ + name: file, + path: `uploads/${file}` + })); + + return res.status(200).json(pdfFiles); + } catch (error) { + console.error('Error getting uploaded files:', error); + // Return empty array instead of error + return res.status(200).json([]); + } +}; + +module.exports = { + getAllProjects, + getProject, + createProject, + updateProject, + deleteProject, + uploadFiles, + getProjectFiles, + createProjectFile, + uploadMaterialFromFile, + getUploadedFiles +}; \ No newline at end of file diff --git a/server/controllers/uploads.js b/server/controllers/uploads.js new file mode 100644 index 0000000..be2615e --- /dev/null +++ b/server/controllers/uploads.js @@ -0,0 +1,78 @@ +const fs = require('fs'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); + +// Ensure the uploads directory exists +const ensureUploadDirExists = async () => { + const uploadDir = path.join(process.cwd(), 'server', 'uploads'); + await fs.promises.mkdir(uploadDir, { recursive: true }); + return uploadDir; +}; + +// Upload a new file +const uploadFile = async (req, res) => { + try { + if (!req.files || Object.keys(req.files).length === 0) { + return res.status(400).json({ message: 'No file uploaded' }); + } + + const uploadedFile = req.files.uploadedFile; + + // Validate file type + if (uploadedFile.mimetype !== 'application/pdf') { + return res.status(400).json({ message: 'Only PDF files are allowed' }); + } + + // Create uploads directory if it doesn't exist + const uploadDir = await ensureUploadDirExists(); + + // Create a unique filename + const uniqueFileName = `${uuidv4()}-${uploadedFile.name}`; + const filePath = path.join(uploadDir, uniqueFileName); + + // Move the file to the uploads directory + await uploadedFile.mv(filePath); + + return res.status(200).json({ + filePath: `uploads/${uniqueFileName}`, + message: 'File uploaded successfully' + }); + } catch (error) { + console.error('Error uploading file:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +// Get all uploaded files +const getUploadedFiles = async (req, res) => { + try { + // Create uploads directory if it doesn't exist + const uploadDir = await ensureUploadDirExists(); + + // Check if directory exists before reading + const files = await fs.promises.readdir(uploadDir) + .catch(error => { + console.error(`Error reading directory: ${error.message}`); + return []; // Return empty array if directory can't be read + }); + + // Filter for PDF files + const pdfFiles = files + .filter(file => file.endsWith('.pdf')) + .map(file => ({ + name: file.substring(file.indexOf('-') + 1), // Remove UUID prefix + path: `uploads/${file}` + })); + + return res.status(200).json(pdfFiles); + } catch (error) { + console.error('Error getting uploaded files:', error); + // Return empty array instead of error + return res.status(200).json([]); + } +}; + +module.exports = { + uploadFile, + getUploadedFiles +}; \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 26634f8..58989ca 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15,7 +15,8 @@ "express": "^4.18.3", "mysql": "^2.18.1", "nanoid": "^5.0.6", - "prisma": "^5.12.1" + "prisma": "^5.12.1", + "uuid": "^11.1.0" } }, "node_modules/@prisma/client": { @@ -889,6 +890,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/server/package.json b/server/package.json index ea1d739..b711c5b 100644 --- a/server/package.json +++ b/server/package.json @@ -17,6 +17,7 @@ "express": "^4.18.3", "mysql": "^2.18.1", "nanoid": "^5.0.6", - "prisma": "^5.12.1" + "prisma": "^5.12.1", + "uuid": "^11.1.0" } } diff --git a/server/prisma/migrations/20250429000001_add_project_file_table/migration.sql b/server/prisma/migrations/20250429000001_add_project_file_table/migration.sql new file mode 100644 index 0000000..97534e7 --- /dev/null +++ b/server/prisma/migrations/20250429000001_add_project_file_table/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE `projectfile` ( + `id` VARCHAR(191) NOT NULL, + `projectId` VARCHAR(191) NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `originalname` VARCHAR(255) NOT NULL, + `mimetype` VARCHAR(100) NOT NULL, + `size` BIGINT NOT NULL, + `path` VARCHAR(500) NOT NULL, + `uploadedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `projectfile_projectId_idx` ON `projectfile`(`projectId`); + +-- AddForeignKey +ALTER TABLE `projectfile` ADD CONSTRAINT `projectfile_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `electronics_shop`.`Project` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/server/prisma/migrations/20250430173330_add_project_file_table/migration.sql b/server/prisma/migrations/20250430173330_add_project_file_table/migration.sql new file mode 100644 index 0000000..ceb1cd1 --- /dev/null +++ b/server/prisma/migrations/20250430173330_add_project_file_table/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the `projectfile` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE `projectfile` DROP FOREIGN KEY `projectfile_projectId_fkey`; + +-- DropTable +DROP TABLE `projectfile`; + +-- CreateTable +CREATE TABLE `ProjectFile` ( + `id` VARCHAR(191) NOT NULL, + `filename` VARCHAR(191) NOT NULL, + `originalname` VARCHAR(191) NOT NULL, + `mimetype` VARCHAR(191) NOT NULL, + `size` INTEGER NOT NULL, + `path` VARCHAR(191) NOT NULL, + `uploadedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `projectId` VARCHAR(191) NOT NULL, + + INDEX `ProjectFile_projectId_fkey`(`projectId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ProjectFile` ADD CONSTRAINT `ProjectFile_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8c0b251..5f25ac1 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -19,9 +19,9 @@ model Product { inStock Int @default(1) categoryId String category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) - customerOrders customer_order_product[] - Wishlist Wishlist[] projects ProjectProduct[] + Wishlist Wishlist[] + customerOrders customer_order_product[] @@index([categoryId], map: "Product_categoryId_fkey") } @@ -32,41 +32,12 @@ model Image { image String } -model User { - id String @id @default(uuid()) - email String @unique - password String? - role String? @default("user") - projects Project[] - Wishlist Wishlist[] -} - -model Customer_order { - id String @id @default(uuid()) - name String - lastname String - phone String - email String - company String - adress String - apartment String - postalCode String - dateTime DateTime? @default(now()) - status String - city String - country String - orderNotice String? - total Int - products customer_order_product[] -} - model customer_order_product { - id String @id @default(uuid()) + id String @id @default(uuid()) customerOrderId String productId String quantity Int - customerOrder Customer_order @relation(fields: [customerOrderId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id]) + product Product @relation(fields: [productId], references: [id]) @@index([customerOrderId], map: "customer_order_product_customerOrderId_fkey") @@index([productId], map: "customer_order_product_productId_fkey") @@ -83,32 +54,70 @@ model Wishlist { productId String userId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([productId], map: "Wishlist_productId_fkey") @@index([userId], map: "Wishlist_userId_fkey") } model Project { - id String @id @default(uuid()) + id String @id @default(uuid()) name String - createdAt DateTime @default(now()) - itemCount Int @default(0) + createdAt DateTime @default(now()) + itemCount Int @default(0) contractorId String products ProjectProduct[] - contractor User @relation(fields: [contractorId], references: [id], onDelete: Cascade) + files ProjectFile[] @@index([contractorId], map: "Project_contractorId_fkey") } model ProjectProduct { - id String @id @default(uuid()) - quantity Int - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Restrict) - productId String + id String @id @default(uuid()) + quantity Int + projectId String + productId String + product Product @relation(fields: [productId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@index([projectId], map: "ProjectProduct_projectId_fkey") @@index([productId], map: "ProjectProduct_productId_fkey") -} \ No newline at end of file +} + +model ProjectFile { + id String @id @default(uuid()) + filename String + originalname String + mimetype String + size Int + path String + uploadedAt DateTime @default(now()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId], map: "ProjectFile_projectId_fkey") +} + +model customer_order { + id String @id + name String + lastname String + phone String + email String + company String + adress String + apartment String + postalCode String + dateTime DateTime? @default(now()) + status String + total Int + city String + country String + orderNotice String? +} + +model user { + id String @id + email String @unique(map: "User_email_key") + password String? + role String? @default("user") +} diff --git a/server/routes/projects.js b/server/routes/projects.js new file mode 100644 index 0000000..634d10e --- /dev/null +++ b/server/routes/projects.js @@ -0,0 +1,343 @@ +const express = require('express'); +const router = express.Router(); +const projectController = require('../controllers/projects'); +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); +const path = require('path'); +const fs = require('fs'); + +// Get all projects for the current user +router.get('/', projectController.getAllProjects); + +// Create a new project +router.post('/', async (req, res) => { + try { + const { name } = req.body; + const userId = req.user?.id; + + if (!name) { + return res.status(400).json({ message: 'Project name is required' }); + } + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Create the project + const project = await prisma.project.create({ + data: { + name, + contractorId: userId, + }, + }); + + res.status(201).json(project); + } catch (error) { + console.error('Error creating project:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Get all uploaded files (no project context) +router.get('/uploaded-files', projectController.getUploadedFiles); + +// Upload material from a PDF file +router.post('/upload-material', projectController.uploadMaterialFromFile); + +// Get a specific project +router.get('/:id', projectController.getProject); + +// Update a project +router.put('/:id', projectController.updateProject); + +// Delete a project +router.delete('/:id', projectController.deleteProject); + +// Upload files for a project +router.post('/:id/files', projectController.uploadFiles); + +// Get uploaded files for a project +router.get('/:id/files', projectController.getProjectFiles); + +// Materials endpoints +// Get all materials for a project +router.get('/:id/materials', async (req, res) => { + try { + const { id } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Verify the project exists and belongs to the user + const existingProject = await prisma.project.findUnique({ + where: { id }, + }); + + if (!existingProject) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Note: We're not checking project ownership here for simplicity + + // Fetch materials with associated product data + const materials = await prisma.projectProduct.findMany({ + where: { + projectId: id, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + description: true, + }, + }, + }, + }); + + // Transform the data to include name property + const transformedMaterials = materials.map((material) => ({ + ...material, + name: material.product?.title || "Unknown Material", + unit: "piece", // Default unit + })); + + res.status(200).json(transformedMaterials); + } catch (error) { + console.error("Error fetching materials:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Add a new material to a project +router.post('/:id/materials', async (req, res) => { + try { + const { id } = req.params; + const { productId, quantity } = req.body; + + // Validate required fields + if (!productId || !quantity) { + return res.status(400).json({ message: 'Missing required fields' }); + } + + // Verify product exists + const product = await prisma.product.findUnique({ + where: { id: productId }, + }); + + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + // Create new project product entry + const material = await prisma.projectProduct.create({ + data: { + projectId: id, + productId, + quantity, + }, + include: { + product: { + select: { + id: true, + title: true, + price: true, + mainImage: true, + description: true, + }, + }, + }, + }); + + // Add name property to response + const transformedMaterial = { + ...material, + name: material.product.title, + }; + + // Update project item count + await prisma.project.update({ + where: { id }, + data: { itemCount: { increment: 1 } }, + }); + + res.status(201).json(transformedMaterial); + } catch (error) { + console.error("Error creating material:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Update a material +router.put('/:id/materials/:materialId', async (req, res) => { + try { + const { id, materialId } = req.params; + const { quantity } = req.body; + + // Update the material + const updatedMaterial = await prisma.projectProduct.update({ + where: { + id: materialId, + projectId: id, + }, + data: { + quantity, + }, + include: { + product: true, + }, + }); + + // Transform to match expected format + const material = { + id: updatedMaterial.id, + name: updatedMaterial.product.title, + description: updatedMaterial.product.description, + quantity: updatedMaterial.quantity, + unit: "piece", + productId: updatedMaterial.product.id + }; + + res.status(200).json(material); + } catch (error) { + console.error("Error updating material:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Delete a material +router.delete('/:id/materials/:materialId', async (req, res) => { + try { + const { id, materialId } = req.params; + + // First verify that the project exists and get current itemCount + const project = await prisma.project.findUnique({ + where: { + id, + }, + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Use a transaction to ensure both operations succeed or fail together + await prisma.$transaction([ + prisma.projectProduct.delete({ + where: { + id: materialId, + projectId: id, + }, + }), + prisma.project.update({ + where: { + id, + }, + data: { + // Ensure itemCount never goes below 0 + itemCount: Math.max(0, (project.itemCount || 0) - 1), + }, + }), + ]); + + res.status(204).send(); + } catch (error) { + console.error("Error deleting material:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Recalculate project item count +router.post('/:id/recalculate', async (req, res) => { + try { + const { id } = req.params; + + // First verify that the project exists + const project = await prisma.project.findUnique({ + where: { + id, + }, + include: { + products: true, + }, + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + // Count the actual number of materials + const actualCount = project.products.length; + + // Update the project with the correct count + const updatedProject = await prisma.project.update({ + where: { + id, + }, + data: { + itemCount: actualCount, + }, + }); + + res.status(200).json({ itemCount: updatedProject.itemCount }); + } catch (error) { + console.error("Error recalculating project:", error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +// Delete a project file +router.delete('/:id/files/:fileId', async (req, res) => { + try { + const { id, fileId } = req.params; + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + // Verify project exists and belongs to user + const project = await prisma.project.findUnique({ + where: { id }, + }); + + if (!project) { + return res.status(404).json({ message: 'Project not found' }); + } + + if (project.contractorId !== userId) { + return res.status(403).json({ message: 'Forbidden' }); + } + + // Get file details + const file = await prisma.projectFile.findUnique({ + where: { id: fileId }, + }); + + if (!file || file.projectId !== id) { + return res.status(404).json({ message: 'File not found' }); + } + + // Delete file from filesystem + const fullPath = path.join(process.cwd(), 'server', file.path); + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + } + + // Delete file record from database + await prisma.projectFile.delete({ + where: { id: fileId }, + }); + + res.status(200).json({ message: 'File deleted successfully' }); + } catch (error) { + console.error('Error deleting file:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/server/routes/uploads.js b/server/routes/uploads.js new file mode 100644 index 0000000..d9b3f22 --- /dev/null +++ b/server/routes/uploads.js @@ -0,0 +1,11 @@ +const express = require('express'); +const router = express.Router(); +const uploadsController = require('../controllers/uploads'); + +// Upload a new file +router.post('/', uploadsController.uploadFile); + +// Get all uploaded files +router.get('/', uploadsController.getUploadedFiles); + +module.exports = router; \ No newline at end of file From 9e0c52f97e1c0bc890e1c5f00b06ce5904a107da Mon Sep 17 00:00:00 2001 From: mehakseedat63 Date: Thu, 1 May 2025 00:41:41 +0500 Subject: [PATCH 5/5] fixed broken file upload in project creation --- .../projects/[projectId]/files/route.ts | 50 ++++++++-- app/api/contractor/projects/route.ts | 82 +++++---------- app/api/upload/route.ts | 18 ++-- app/projects/[projectId]/materials/page.tsx | 75 ++++++++------ app/projects/upload/page.tsx | 48 ++++----- .../migration.sql | 90 ----------------- .../migration.sql | 33 ++----- .../migration.sql | 0 .../migration.sql | 29 ++++++ prisma/schema.prisma | 99 ++++++++++--------- server/app.js | 8 ++ 11 files changed, 248 insertions(+), 284 deletions(-) delete mode 100644 prisma/migrations/20250426034535_add_project_and_material_models/migration.sql rename {server/prisma => prisma}/migrations/20250429000001_add_project_file_table/migration.sql (100%) create mode 100644 prisma/migrations/20250430173330_add_project_file_table/migration.sql diff --git a/app/api/contractor/projects/[projectId]/files/route.ts b/app/api/contractor/projects/[projectId]/files/route.ts index b4cd2d3..cd721a4 100644 --- a/app/api/contractor/projects/[projectId]/files/route.ts +++ b/app/api/contractor/projects/[projectId]/files/route.ts @@ -2,6 +2,9 @@ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import prisma from "@/utils/db"; +import { v4 as uuidv4 } from "uuid"; +import { promises as fs } from "fs"; +import path from "path"; export async function GET( request: Request, @@ -14,7 +17,7 @@ export async function GET( } // Get project files from database - const files = await prisma.projectfile.findMany({ + const files = await prisma.ProjectFile.findMany({ where: { projectId: params.projectId }, @@ -40,22 +43,49 @@ export async function POST( return new NextResponse("Unauthorized", { status: 401 }); } - const body = await request.json(); - const { filename, originalname, path, mimetype, size } = body; + const formData = await request.formData(); + const file = formData.get("files") as File; + + if (!file) { + return new NextResponse("No file uploaded", { status: 400 }); + } + + if (file.type !== "application/pdf") { + return new NextResponse("Only PDF files are allowed", { status: 400 }); + } + + // Create unique filename + const uniqueFileName = `${Date.now()}-${file.name}`; + + // Create project-specific directory + const projectUploadPath = path.join( + process.cwd(), + "server", + "uploads", + params.projectId + ); + + await fs.mkdir(projectUploadPath, { recursive: true }); + + // Save file to disk + const filePath = path.join('uploads', params.projectId, uniqueFileName); + const fullPath = path.join(process.cwd(), 'server', filePath); + const buffer = await file.arrayBuffer(); + await fs.writeFile(fullPath, new Uint8Array(buffer)); // Create file record in database - const file = await prisma.projectfile.create({ + const projectFile = await prisma.ProjectFile.create({ data: { projectId: params.projectId, - filename, - originalname, - path, - mimetype, - size, + filename: uniqueFileName, + originalname: file.name, + mimetype: file.type, + size: buffer.byteLength, + path: filePath, } }); - return NextResponse.json(file); + return NextResponse.json(projectFile); } catch (error) { console.error("Error creating project file:", error); return new NextResponse("Internal Server Error", { status: 500 }); diff --git a/app/api/contractor/projects/route.ts b/app/api/contractor/projects/route.ts index 8a2951d..c2beee6 100644 --- a/app/api/contractor/projects/route.ts +++ b/app/api/contractor/projects/route.ts @@ -1,9 +1,6 @@ import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; import prisma from "@/server/utills/db"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { promises as fs } from "fs"; -import path from "path"; export async function GET() { try { @@ -35,69 +32,44 @@ export async function GET() { export async function POST(req: Request) { try { - const session = await getServerSession(authOptions); - if (!session) { + const session = await getServerSession(); + console.log("[PROJECTS_POST] Session:", session); + + if (!session?.user?.email) { return new NextResponse("Unauthorized", { status: 401 }); } - const { name, filePaths } = await req.json(); - - // Create project using backend API - const projectResponse = await fetch("http://localhost:3001/api/projects", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - contractorId: session.user.id, - }), + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, }); + console.log("[PROJECTS_POST] User:", user); - if (!projectResponse.ok) { - throw new Error("Failed to create project"); + if (!user) { + return new NextResponse("Unauthorized", { status: 401 }); } - const project = await projectResponse.json(); - - // If there are files, move them from temp to project directory - if (filePaths && filePaths.length > 0) { - // Create project directory - const projectDir = path.join(process.cwd(), "server", "uploads", project.id); - await fs.mkdir(projectDir, { recursive: true }); - - // Move each file and create file records - for (const tempPath of filePaths) { - const fileName = path.basename(tempPath); - const sourcePath = path.join(process.cwd(), "server", tempPath); - const targetPath = path.join(projectDir, fileName); - - // Move file from temp to project directory - await fs.rename(sourcePath, targetPath); + const body = await req.json(); + console.log("[PROJECTS_POST] Request body:", body); + const { name } = body; - // Create file record in database - await fetch(`http://localhost:3001/api/projects/${project.id}/files`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - filename: fileName, - originalname: fileName.substring(37), // Remove UUID prefix - path: `uploads/${project.id}/${fileName}`, - mimetype: "application/pdf", - size: (await fs.stat(targetPath)).size, - }), - }); - } + if (!name) { + return new NextResponse("Name is required", { status: 400 }); } + console.log("[PROJECTS_POST] Creating project with:", { name, contractorId: user.id }); + const project = await prisma.Project.create({ + data: { + name, + contractorId: user.id, + }, + }); + console.log("[PROJECTS_POST] Created project:", project); + return NextResponse.json(project); } catch (error) { - console.error("Error creating project:", error); - return new NextResponse( - error instanceof Error ? error.message : "Internal Server Error", - { status: 500 } - ); + console.error("[PROJECTS_POST] Error details:", error); + // Return more specific error message if available + const errorMessage = error instanceof Error ? error.message : "Internal error"; + return new NextResponse(errorMessage, { status: 500 }); } } \ No newline at end of file diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 18526f3..d03adc2 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -13,34 +13,38 @@ export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get("uploadedFile") as File; + const projectId = formData.get("projectId") as string; if (!file) { return new NextResponse("No file uploaded", { status: 400 }); } + if (!projectId) { + return new NextResponse("Project ID is required", { status: 400 }); + } + if (file.type !== "application/pdf") { return new NextResponse("Only PDF files are allowed", { status: 400 }); } const uniqueFileName = `${uuidv4()}-${file.name}`; - // Create temp uploads directory - const uploadPath = path.join( + // Create project-specific directory + const projectUploadPath = path.join( process.cwd(), "server", "uploads", - "temp", - uniqueFileName + projectId ); - // Ensure temp directory exists - await fs.mkdir(path.dirname(uploadPath), { recursive: true }); + await fs.mkdir(projectUploadPath, { recursive: true }); + const uploadPath = path.join(projectUploadPath, uniqueFileName); const buffer = Buffer.from(await file.arrayBuffer()); await fs.writeFile(uploadPath, Uint8Array.from(buffer)); return NextResponse.json({ - filePath: `uploads/temp/${uniqueFileName}`, + filePath: `uploads/${projectId}/${uniqueFileName}`, fileName: uniqueFileName, originalName: file.name, size: buffer.length, diff --git a/app/projects/[projectId]/materials/page.tsx b/app/projects/[projectId]/materials/page.tsx index 3685e55..2ff2d11 100644 --- a/app/projects/[projectId]/materials/page.tsx +++ b/app/projects/[projectId]/materials/page.tsx @@ -93,7 +93,7 @@ export default function MaterialsPage() { // Fetch uploaded files const filesResponse = await fetch( - `/api/projects/uploaded-files` + `/api/contractor/projects/${params.projectId}/files` ); if (!filesResponse.ok) { throw new Error("Failed to fetch uploaded files"); @@ -102,7 +102,7 @@ export default function MaterialsPage() { setUploadedFiles(filesData); } catch (error) { console.error("Error fetching data:", error); - toast.error("Failed to load materials"); + toast.error("Failed to load data"); } finally { setLoading(false); } @@ -219,38 +219,47 @@ export default function MaterialsPage() { const handleFileChange = async (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; - const file = e.target.files[0]; - if (file.type !== 'application/pdf') { - toast.error('Only PDF files are allowed'); - return; + const files = e.target.files; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.size > 20 * 1024 * 1024) { + toast.error('File size exceeds 20MB'); + return; + } + if (file.type !== 'application/pdf') { + toast.error('Only PDF files are allowed'); + return; + } } const formData = new FormData(); - formData.append('files', file); + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } try { - const response = await fetch(`http://localhost:3001/api/projects/${params.projectId}/files`, { + const response = await fetch(`/api/contractor/projects/${params.projectId}/files`, { method: 'POST', body: formData, }); if (!response.ok) { - throw new Error('Failed to upload file'); + throw new Error('Failed to upload files'); } const result = await response.json(); - // Refresh the file list from backend - const filesResponse = await fetch(`http://localhost:3001/api/projects/${params.projectId}/files`); + // Refresh the file list + const filesResponse = await fetch(`/api/contractor/projects/${params.projectId}/files`); if (!filesResponse.ok) { throw new Error('Failed to fetch uploaded files'); } const filesData = await filesResponse.json(); setUploadedFiles(filesData); - toast.success('File uploaded successfully'); + toast.success('Files uploaded successfully'); } catch (error) { - console.error('Error uploading file:', error); - toast.error('Failed to upload file'); + console.error('Error uploading files:', error); + toast.error('Failed to upload files'); } }; @@ -358,27 +367,24 @@ export default function MaterialsPage() { {uploadedFiles.map((file) => (
{ - if (e.target.checked) { - setSelectedFilesForImport([...selectedFilesForImport, file.path]); - } else { - setSelectedFilesForImport(selectedFilesForImport.filter(path => path !== file.path)); - } - }} + onChange={() => toggleFileSelection(file.path)} className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded" /> + + {new Date(file.uploadedAt).toLocaleDateString()} +
))}
@@ -423,20 +429,29 @@ export default function MaterialsPage() {

Upload Material from PDF

-
+
- +
+ + + Only PDF files up to 20MB are allowed + +
diff --git a/app/projects/upload/page.tsx b/app/projects/upload/page.tsx index 2dd22a3..6fc8c01 100644 --- a/app/projects/upload/page.tsx +++ b/app/projects/upload/page.tsx @@ -65,18 +65,19 @@ export default function UploadProjectPage() { } }; - const handleFilesUpload = async (files: File[]) => { - const uploadedFilePaths = []; + const handleFilesUpload = async (files: File[], projectId: string) => { + const uploadedFiles = []; let progress = 0; for (const file of files) { const formData = new FormData(); - formData.append("uploadedFile", file); + formData.append("files", file); // This matches the backend's expected field name try { - const uploadResponse = await fetch("/api/upload", { + const uploadResponse = await fetch(`/api/contractor/projects/${projectId}/files`, { method: "POST", body: formData, + credentials: 'include', // Include cookies for authentication }); if (!uploadResponse.ok) { @@ -84,10 +85,10 @@ export default function UploadProjectPage() { } const uploadData = await uploadResponse.json(); - uploadedFilePaths.push(uploadData.filePath); + uploadedFiles.push(uploadData); // Update progress - progress = Math.round(((uploadedFilePaths.length) / files.length) * 100); + progress = Math.round(((uploadedFiles.length) / files.length) * 100); setUploadProgress(progress); } catch (error) { @@ -96,9 +97,9 @@ export default function UploadProjectPage() { } } - if (uploadedFilePaths.length > 0) { - setUploadMessage(`${uploadedFilePaths.length} of ${files.length} files uploaded successfully.`); - return uploadedFilePaths; + if (uploadedFiles.length > 0) { + setUploadMessage(`${uploadedFiles.length} of ${files.length} files uploaded successfully.`); + return uploadedFiles; } return null; @@ -108,18 +109,9 @@ export default function UploadProjectPage() { e.preventDefault(); setLoading(true); - let filePaths = null; - - if (selectedFiles.length > 0) { - filePaths = await handleFilesUpload(selectedFiles); - if (!filePaths || filePaths.length === 0) { - setLoading(false); - return; - } - } - try { - const response = await fetch("http://localhost:3001/api/projects", { + // First create the project + const createResponse = await fetch("/api/contractor/projects", { method: "POST", headers: { "Content-Type": "application/json", @@ -127,17 +119,25 @@ export default function UploadProjectPage() { credentials: 'include', // Include cookies for authentication body: JSON.stringify({ name: projectName, - filePaths: filePaths, }), }); - if (!response.ok) { + if (!createResponse.ok) { throw new Error("Failed to create project"); } - const data = await response.json(); + const projectData = await createResponse.json(); + + // Then upload files if any + if (selectedFiles.length > 0) { + const uploadedFiles = await handleFilesUpload(selectedFiles, projectData.id); + if (!uploadedFiles || uploadedFiles.length === 0) { + toast.error("Failed to upload files"); + } + } + toast.success("Project created successfully!"); - router.push(`/projects/${data.id}/materials`); + router.push(`/projects/${projectData.id}/materials`); } catch (error) { console.error("Error creating project:", error); toast.error("Failed to create project. Please try again."); diff --git a/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql b/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql deleted file mode 100644 index 1f0b622..0000000 --- a/prisma/migrations/20250426034535_add_project_and_material_models/migration.sql +++ /dev/null @@ -1,90 +0,0 @@ -/* - Warnings: - - - You are about to drop the `customer_order` table. If the table is not empty, all the data it contains will be lost. - - You are about to drop the `user` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropTable --- DROP TABLE `customer_order`; - --- DropTable --- DROP TABLE `user`; - --- CreateTable -CREATE TABLE `User` ( - `id` VARCHAR(191) NOT NULL, - `email` VARCHAR(191) NOT NULL, - `password` VARCHAR(191) NULL, - `role` VARCHAR(191) NULL DEFAULT 'user', - - UNIQUE INDEX `User_email_key`(`email`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateTable -CREATE TABLE `Customer_order` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `lastname` VARCHAR(191) NOT NULL, - `phone` VARCHAR(191) NOT NULL, - `email` VARCHAR(191) NOT NULL, - `company` VARCHAR(191) NOT NULL, - `adress` VARCHAR(191) NOT NULL, - `apartment` VARCHAR(191) NOT NULL, - `postalCode` VARCHAR(191) NOT NULL, - `dateTime` DATETIME(3) NULL DEFAULT CURRENT_TIMESTAMP(3), - `status` VARCHAR(191) NOT NULL, - `city` VARCHAR(191) NOT NULL, - `country` VARCHAR(191) NOT NULL, - `orderNotice` VARCHAR(191) NULL, - `total` INTEGER NOT NULL, - - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateTable -CREATE TABLE `Project` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - `itemCount` INTEGER NOT NULL DEFAULT 0, - `contractorId` VARCHAR(191) NOT NULL, - - INDEX `Project_contractorId_fkey`(`contractorId`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- CreateTable -CREATE TABLE `ProjectProduct` ( - `id` VARCHAR(191) NOT NULL, - `quantity` INTEGER NOT NULL, - `projectId` VARCHAR(191) NOT NULL, - `productId` VARCHAR(191) NOT NULL, - - INDEX `ProjectProduct_projectId_fkey`(`projectId`), - INDEX `ProjectProduct_productId_fkey`(`productId`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - --- AddForeignKey -ALTER TABLE `customer_order_product` ADD CONSTRAINT `customer_order_product_customerOrderId_fkey` FOREIGN KEY (`customerOrderId`) REFERENCES `Customer_order`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `Wishlist` ADD CONSTRAINT `Wishlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` - FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` - FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; - --- Alter the customer_order table instead of recreating it --- Add any necessary ALTER TABLE statements here to modify the customer_order table as needed - --- Alter the user table instead of recreating it --- Add any necessary ALTER TABLE statements here to modify the user table as needed diff --git a/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql b/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql index 0fd1dd9..8a5ec66 100644 --- a/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql +++ b/prisma/migrations/20250426042000_add_project_and_material_models/migration.sql @@ -10,37 +10,24 @@ CREATE TABLE `Project` ( PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; --- CreateTable -CREATE TABLE `MaterialTemplate` ( - `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `description` VARCHAR(191) NULL, - `unit` VARCHAR(191) NOT NULL, - - UNIQUE INDEX `MaterialTemplate_name_key`(`name`), - PRIMARY KEY (`id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +-- AddForeignKey +ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; -- CreateTable -CREATE TABLE `Material` ( +CREATE TABLE `ProjectProduct` ( `id` VARCHAR(191) NOT NULL, - `name` VARCHAR(191) NOT NULL, - `description` VARCHAR(191) NULL, `quantity` INTEGER NOT NULL, - `unit` VARCHAR(191) NOT NULL, `projectId` VARCHAR(191) NOT NULL, - `templateId` VARCHAR(191) NOT NULL, + `productId` VARCHAR(191) NOT NULL, - INDEX `Material_projectId_fkey`(`projectId`), - INDEX `Material_templateId_fkey`(`templateId`), + INDEX `ProjectProduct_projectId_fkey`(`projectId`), + INDEX `ProjectProduct_productId_fkey`(`productId`), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- AddForeignKey -ALTER TABLE `Project` ADD CONSTRAINT `Project_contractorId_fkey` FOREIGN KEY (`contractorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE `Material` ADD CONSTRAINT `Material_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_projectId_fkey` + FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE `Material` ADD CONSTRAINT `Material_templateId_fkey` FOREIGN KEY (`templateId`) REFERENCES `MaterialTemplate`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file +ALTER TABLE `ProjectProduct` ADD CONSTRAINT `ProjectProduct_productId_fkey` + FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; \ No newline at end of file diff --git a/server/prisma/migrations/20250429000001_add_project_file_table/migration.sql b/prisma/migrations/20250429000001_add_project_file_table/migration.sql similarity index 100% rename from server/prisma/migrations/20250429000001_add_project_file_table/migration.sql rename to prisma/migrations/20250429000001_add_project_file_table/migration.sql diff --git a/prisma/migrations/20250430173330_add_project_file_table/migration.sql b/prisma/migrations/20250430173330_add_project_file_table/migration.sql new file mode 100644 index 0000000..ceb1cd1 --- /dev/null +++ b/prisma/migrations/20250430173330_add_project_file_table/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - You are about to drop the `projectfile` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE `projectfile` DROP FOREIGN KEY `projectfile_projectId_fkey`; + +-- DropTable +DROP TABLE `projectfile`; + +-- CreateTable +CREATE TABLE `ProjectFile` ( + `id` VARCHAR(191) NOT NULL, + `filename` VARCHAR(191) NOT NULL, + `originalname` VARCHAR(191) NOT NULL, + `mimetype` VARCHAR(191) NOT NULL, + `size` INTEGER NOT NULL, + `path` VARCHAR(191) NOT NULL, + `uploadedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `projectId` VARCHAR(191) NOT NULL, + + INDEX `ProjectFile_projectId_fkey`(`projectId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ProjectFile` ADD CONSTRAINT `ProjectFile_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c0b251..5f25ac1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,9 +19,9 @@ model Product { inStock Int @default(1) categoryId String category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) - customerOrders customer_order_product[] - Wishlist Wishlist[] projects ProjectProduct[] + Wishlist Wishlist[] + customerOrders customer_order_product[] @@index([categoryId], map: "Product_categoryId_fkey") } @@ -32,41 +32,12 @@ model Image { image String } -model User { - id String @id @default(uuid()) - email String @unique - password String? - role String? @default("user") - projects Project[] - Wishlist Wishlist[] -} - -model Customer_order { - id String @id @default(uuid()) - name String - lastname String - phone String - email String - company String - adress String - apartment String - postalCode String - dateTime DateTime? @default(now()) - status String - city String - country String - orderNotice String? - total Int - products customer_order_product[] -} - model customer_order_product { - id String @id @default(uuid()) + id String @id @default(uuid()) customerOrderId String productId String quantity Int - customerOrder Customer_order @relation(fields: [customerOrderId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id]) + product Product @relation(fields: [productId], references: [id]) @@index([customerOrderId], map: "customer_order_product_customerOrderId_fkey") @@index([productId], map: "customer_order_product_productId_fkey") @@ -83,32 +54,70 @@ model Wishlist { productId String userId String product Product @relation(fields: [productId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([productId], map: "Wishlist_productId_fkey") @@index([userId], map: "Wishlist_userId_fkey") } model Project { - id String @id @default(uuid()) + id String @id @default(uuid()) name String - createdAt DateTime @default(now()) - itemCount Int @default(0) + createdAt DateTime @default(now()) + itemCount Int @default(0) contractorId String products ProjectProduct[] - contractor User @relation(fields: [contractorId], references: [id], onDelete: Cascade) + files ProjectFile[] @@index([contractorId], map: "Project_contractorId_fkey") } model ProjectProduct { - id String @id @default(uuid()) - quantity Int - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - product Product @relation(fields: [productId], references: [id], onDelete: Restrict) - productId String + id String @id @default(uuid()) + quantity Int + projectId String + productId String + product Product @relation(fields: [productId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@index([projectId], map: "ProjectProduct_projectId_fkey") @@index([productId], map: "ProjectProduct_productId_fkey") -} \ No newline at end of file +} + +model ProjectFile { + id String @id @default(uuid()) + filename String + originalname String + mimetype String + size Int + path String + uploadedAt DateTime @default(now()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId], map: "ProjectFile_projectId_fkey") +} + +model customer_order { + id String @id + name String + lastname String + phone String + email String + company String + adress String + apartment String + postalCode String + dateTime DateTime? @default(now()) + status String + total Int + city String + country String + orderNotice String? +} + +model user { + id String @id + email String @unique(map: "User_email_key") + password String? + role String? @default("user") +} diff --git a/server/app.js b/server/app.js index e257790..bca11bc 100644 --- a/server/app.js +++ b/server/app.js @@ -15,6 +15,7 @@ const orderProductRouter = require('./routes/customer_order_product'); const wishlistRouter = require('./routes/wishlist'); const projectsRouter = require('./routes/projects'); const uploadsRouter = require('./routes/uploads'); +var cors = require("cors"); const app = express(); @@ -25,6 +26,13 @@ fs.promises.mkdir(uploadsDir, { recursive: true }) .catch(err => console.error('Error creating uploads directory:', err)); app.use(express.json()); +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "PUT", "DELETE"], + allowedHeaders: ["Content-Type", "Authorization"], + }) +); app.use(fileUpload({ createParentPath: true, limits: {