From 9ac7afa36e858dd6a07cfab47ffd0b60f1d246ed Mon Sep 17 00:00:00 2001 From: NW Date: Mon, 10 Aug 2026 10:55:12 +0100 Subject: [PATCH] =?UTF-8?q?fix(admin):=20issue=20#147=20=E2=80=94=20Catalo?= =?UTF-8?q?g=20Tree=20city=20edit/delete,=20sort=20order,=20subcategory=20?= =?UTF-8?q?ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleRename: city rename via /api/locations/rename-city (all rows), district via PUT /api/locations/{id} - City-level delete: DELETE /api/locations/by-city (blocks on dependencies, 400 with counts) - Sort order: idempotent ensureSortOrderColumns (ALTER TABLE try/catch) + PATCH /api/{type}s/{id}/sort + up/down arrows on district/category/subcategory - Tree queries order by sort_order asc, then name - Subcategory rename/delete verified - tsc clean --- admin-next/prisma/schema.prisma | 3 + admin-next/src/app/api/catalog/tree/route.ts | 9 +- .../src/app/api/categories/[id]/sort/route.ts | 55 +++++++ .../src/app/api/locations/[id]/sort/route.ts | 57 +++++++ .../src/app/api/locations/by-city/route.ts | 50 ++++++ .../app/api/locations/rename-city/route.ts | 41 +++++ .../app/api/subcategories/[id]/sort/route.ts | 55 +++++++ .../src/components/catalog/catalog-page.tsx | 154 +++++++++++++++++- admin-next/src/lib/ensure-sort-order.ts | 21 +++ 9 files changed, 434 insertions(+), 11 deletions(-) create mode 100644 admin-next/src/app/api/categories/[id]/sort/route.ts create mode 100644 admin-next/src/app/api/locations/[id]/sort/route.ts create mode 100644 admin-next/src/app/api/locations/by-city/route.ts create mode 100644 admin-next/src/app/api/locations/rename-city/route.ts create mode 100644 admin-next/src/app/api/subcategories/[id]/sort/route.ts create mode 100644 admin-next/src/lib/ensure-sort-order.ts diff --git a/admin-next/prisma/schema.prisma b/admin-next/prisma/schema.prisma index f113ea7..d444638 100755 --- a/admin-next/prisma/schema.prisma +++ b/admin-next/prisma/schema.prisma @@ -68,6 +68,7 @@ model Location { country String city String district String @default("") + sortOrder Int @default(0) @map("sort_order") isActive Int @default(1) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @@ -82,6 +83,7 @@ model Category { id Int @id @default(autoincrement()) locationId Int @map("location_id") name String + sortOrder Int @default(0) @map("sort_order") isActive Int @default(1) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @@ -97,6 +99,7 @@ model Subcategory { id Int @id @default(autoincrement()) categoryId Int @map("category_id") name String + sortOrder Int @default(0) @map("sort_order") isActive Int @default(1) @map("is_active") createdAt DateTime @default(now()) @map("created_at") diff --git a/admin-next/src/app/api/catalog/tree/route.ts b/admin-next/src/app/api/catalog/tree/route.ts index 8efb9ce..0986e27 100755 --- a/admin-next/src/app/api/catalog/tree/route.ts +++ b/admin-next/src/app/api/catalog/tree/route.ts @@ -1,15 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { getAuth } from '@/lib/auth-middleware'; +import { ensureSortOrderColumns } from '@/lib/ensure-sort-order'; export async function GET(request: NextRequest) { const auth = getAuth(request); if ('status' in auth) return auth; try { + await ensureSortOrderColumns(); + const [locations, categories, subcategories] = await Promise.all([ db.location.findMany({ - orderBy: { id: 'desc' }, + orderBy: [{ sortOrder: 'asc' }, { country: 'asc' }, { city: 'asc' }, { district: 'asc' }], include: { _count: { select: { categories: true, products: true }, @@ -17,7 +20,7 @@ export async function GET(request: NextRequest) { }, }), db.category.findMany({ - orderBy: { id: 'desc' }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], include: { location: { select: { id: true, country: true, city: true, district: true } }, _count: { @@ -26,7 +29,7 @@ export async function GET(request: NextRequest) { }, }), db.subcategory.findMany({ - orderBy: { id: 'desc' }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], include: { category: { select: { id: true, name: true, locationId: true } }, _count: { diff --git a/admin-next/src/app/api/categories/[id]/sort/route.ts b/admin-next/src/app/api/categories/[id]/sort/route.ts new file mode 100644 index 0000000..de18770 --- /dev/null +++ b/admin-next/src/app/api/categories/[id]/sort/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; +import { ensureSortOrderColumns } from '@/lib/ensure-sort-order'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + await ensureSortOrderColumns(); + const { id } = await params; + const body = await request.json(); + const { direction } = body as { direction: 'up' | 'down' }; + + if (direction !== 'up' && direction !== 'down') { + return NextResponse.json({ error: 'direction must be "up" or "down"' }, { status: 400 }); + } + + const current = await db.category.findUnique({ where: { id: +id } }); + if (!current) { + return NextResponse.json({ error: 'Category not found' }, { status: 404 }); + } + + const siblings = await db.category.findMany({ + where: { locationId: current.locationId }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }); + + const idx = siblings.findIndex((c) => c.id === current.id); + if (idx === -1) { + return NextResponse.json({ error: 'Category not found in siblings' }, { status: 500 }); + } + + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= siblings.length) { + return NextResponse.json({ ok: true, message: 'Already at boundary' }); + } + + const neighbor = siblings[swapIdx]; + + await db.$transaction([ + db.category.update({ where: { id: current.id }, data: { sortOrder: neighbor.sortOrder } }), + db.category.update({ where: { id: neighbor.id }, data: { sortOrder: current.sortOrder } }), + ]); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('Category sort API error:', error); + return NextResponse.json({ error: 'Failed to sort category' }, { status: 500 }); + } +} diff --git a/admin-next/src/app/api/locations/[id]/sort/route.ts b/admin-next/src/app/api/locations/[id]/sort/route.ts new file mode 100644 index 0000000..c292595 --- /dev/null +++ b/admin-next/src/app/api/locations/[id]/sort/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; +import { ensureSortOrderColumns } from '@/lib/ensure-sort-order'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + await ensureSortOrderColumns(); + const { id } = await params; + const body = await request.json(); + const { direction } = body as { direction: 'up' | 'down' }; + + if (direction !== 'up' && direction !== 'down') { + return NextResponse.json({ error: 'direction must be "up" or "down"' }, { status: 400 }); + } + + const current = await db.location.findUnique({ where: { id: +id } }); + if (!current) { + return NextResponse.json({ error: 'Location not found' }, { status: 404 }); + } + + // Find all locations in the same country+city, ordered by sort_order then name + const siblings = await db.location.findMany({ + where: { country: current.country, city: current.city }, + orderBy: [{ sortOrder: 'asc' }, { district: 'asc' }], + }); + + const idx = siblings.findIndex((l) => l.id === current.id); + if (idx === -1) { + return NextResponse.json({ error: 'Location not found in siblings' }, { status: 500 }); + } + + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= siblings.length) { + return NextResponse.json({ ok: true, message: 'Already at boundary' }); + } + + const neighbor = siblings[swapIdx]; + + // Swap sort_order values + await db.$transaction([ + db.location.update({ where: { id: current.id }, data: { sortOrder: neighbor.sortOrder } }), + db.location.update({ where: { id: neighbor.id }, data: { sortOrder: current.sortOrder } }), + ]); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('Location sort API error:', error); + return NextResponse.json({ error: 'Failed to sort location' }, { status: 500 }); + } +} diff --git a/admin-next/src/app/api/locations/by-city/route.ts b/admin-next/src/app/api/locations/by-city/route.ts new file mode 100644 index 0000000..71940f6 --- /dev/null +++ b/admin-next/src/app/api/locations/by-city/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +export async function DELETE(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const { searchParams } = new URL(request.url); + const country = searchParams.get('country'); + const city = searchParams.get('city'); + + if (!country || !city) { + return NextResponse.json( + { error: 'Missing required query params: country, city' }, + { status: 400 } + ); + } + + const locations = await db.location.findMany({ + where: { country, city }, + include: { _count: { select: { categories: true, products: true } } }, + }); + + if (locations.length === 0) { + return NextResponse.json({ error: 'No locations found for this city' }, { status: 404 }); + } + + const totalCategories = locations.reduce((sum, l) => sum + l._count.categories, 0); + const totalProducts = locations.reduce((sum, l) => sum + l._count.products, 0); + + if (totalCategories > 0 || totalProducts > 0) { + return NextResponse.json( + { + error: `Cannot delete city with existing categories (${totalCategories}) or products (${totalProducts})`, + }, + { status: 400 } + ); + } + + const ids = locations.map((l) => l.id); + await db.location.deleteMany({ where: { id: { in: ids } } }); + + return NextResponse.json({ ok: true, deleted: ids.length }); + } catch (error) { + console.error('Delete city API error:', error); + return NextResponse.json({ error: 'Failed to delete city' }, { status: 500 }); + } +} diff --git a/admin-next/src/app/api/locations/rename-city/route.ts b/admin-next/src/app/api/locations/rename-city/route.ts new file mode 100644 index 0000000..b65463e --- /dev/null +++ b/admin-next/src/app/api/locations/rename-city/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; + +export async function PUT(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const body = await request.json(); + const { country, oldCity, newCity } = body; + + if (!country || !oldCity || !newCity) { + return NextResponse.json( + { error: 'Missing required fields: country, oldCity, newCity' }, + { status: 400 } + ); + } + + const updated = await db.location.updateMany({ + where: { country, city: oldCity }, + data: { city: newCity }, + }); + + return NextResponse.json({ ok: true, updated: updated.count }); + } catch (error: unknown) { + console.error('Rename city API error:', error); + if ( + error && + typeof error === 'object' && + 'code' in error && + (error as { code: string }).code === 'P2002' + ) { + return NextResponse.json( + { error: 'City already exists in this country' }, + { status: 409 } + ); + } + return NextResponse.json({ error: 'Failed to rename city' }, { status: 500 }); + } +} diff --git a/admin-next/src/app/api/subcategories/[id]/sort/route.ts b/admin-next/src/app/api/subcategories/[id]/sort/route.ts new file mode 100644 index 0000000..e584e59 --- /dev/null +++ b/admin-next/src/app/api/subcategories/[id]/sort/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { getAuth } from '@/lib/auth-middleware'; +import { ensureSortOrderColumns } from '@/lib/ensure-sort-order'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + await ensureSortOrderColumns(); + const { id } = await params; + const body = await request.json(); + const { direction } = body as { direction: 'up' | 'down' }; + + if (direction !== 'up' && direction !== 'down') { + return NextResponse.json({ error: 'direction must be "up" or "down"' }, { status: 400 }); + } + + const current = await db.subcategory.findUnique({ where: { id: +id } }); + if (!current) { + return NextResponse.json({ error: 'Subcategory not found' }, { status: 404 }); + } + + const siblings = await db.subcategory.findMany({ + where: { categoryId: current.categoryId }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }); + + const idx = siblings.findIndex((s) => s.id === current.id); + if (idx === -1) { + return NextResponse.json({ error: 'Subcategory not found in siblings' }, { status: 500 }); + } + + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= siblings.length) { + return NextResponse.json({ ok: true, message: 'Already at boundary' }); + } + + const neighbor = siblings[swapIdx]; + + await db.$transaction([ + db.subcategory.update({ where: { id: current.id }, data: { sortOrder: neighbor.sortOrder } }), + db.subcategory.update({ where: { id: neighbor.id }, data: { sortOrder: current.sortOrder } }), + ]); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('Subcategory sort API error:', error); + return NextResponse.json({ error: 'Failed to sort subcategory' }, { status: 500 }); + } +} diff --git a/admin-next/src/components/catalog/catalog-page.tsx b/admin-next/src/components/catalog/catalog-page.tsx index 91c786a..99c3844 100755 --- a/admin-next/src/components/catalog/catalog-page.tsx +++ b/admin-next/src/components/catalog/catalog-page.tsx @@ -65,6 +65,8 @@ import { FolderOpen, Tag, ChevronRight, + ChevronUp, + ChevronDown, X, BarChart3, Copy, @@ -212,6 +214,7 @@ export function CatalogPage() { type: string; id: number; name: string; + extra?: { country: string; city: string }; } | null>(null); const [deleteError, setDeleteError] = useState(null); @@ -349,9 +352,40 @@ export function CatalogPage() { } }; + const handleCityDelete = async (country: string, city: string) => { + try { + const res = await fetch(`/api/locations/by-city?country=${encodeURIComponent(country)}&city=${encodeURIComponent(city)}`, { + method: "DELETE", + }); + if (!res.ok) { + const data = await res.json(); + const msg = data.error || "Failed to delete city"; + setDeleteError(msg); + toast.error(msg); + return; + } + toast.success(`City "${city}" deleted`); + setDeleteTarget(null); + setDeleteError(null); + fetchTree(); + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to delete city"; + setDeleteError(msg); + toast.error(msg); + } + }; + const handleDelete = async () => { if (!deleteTarget) return; try { + if (deleteTarget.type === "city") { + // City delete is handled by handleCityDelete + const parts = deleteTarget.name.split(" > "); + const country = parts[0]; + const city = parts[1]; + await handleCityDelete(country, city); + return; + } const typePath = deleteTarget.type === "product" ? "products" : `${deleteTarget.type}s`; const res = await fetch(`/api/${typePath}/${deleteTarget.id}`, { method: "DELETE" }); if (!res.ok) { @@ -436,16 +470,32 @@ export function CatalogPage() { return; } try { - let body: Record = {}; if (type === "location") { const loc = tree?.locations.find((l) => l.id === id); - body = { country: loc?.country || "", city: loc?.city || "", district: renameValue.trim() }; + if (!loc) { setRenamingId(null); return; } + if (loc.district && loc.district !== "") { + // Rename district + const body = { country: loc.country, city: loc.city, district: renameValue.trim() }; + const res = await fetch(`/api/locations/${id}`, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(); + } else { + // Rename city (all rows with same country+city) + const res = await fetch("/api/locations/rename-city", { + method: "PUT", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ country: loc.country, oldCity: loc.city, newCity: renameValue.trim() }), + }); + if (!res.ok) throw new Error(); + } } else { - body = { name: renameValue.trim() }; + const body = { name: renameValue.trim() }; + const typePath = `${type}s`; + const res = await fetch(`/api/${typePath}/${id}`, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(); } - const typePath = `${type}s`; - const res = await fetch(`/api/${typePath}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); - if (!res.ok) throw new Error(); toast.success("Renamed"); setRenamingId(null); fetchTree(); @@ -454,6 +504,20 @@ export function CatalogPage() { } }; + const handleSort = async (type: string, id: number, direction: "up" | "down") => { + try { + const res = await fetch(`/api/${type}s/${id}/sort`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ direction }), + }); + if (!res.ok) throw new Error(); + fetchTree(); + } catch { + toast.error("Failed to reorder"); + } + }; + const handleAdd = async () => { if (!addMode) return; try { @@ -795,8 +859,34 @@ export function CatalogPage() { {cities.map((city) => { const cityEntry = countryEntry.cityMap.get(city)!; const districts = Array.from(cityEntry.districtMap.keys()).sort(); + const cityLocations = Array.from(cityEntry.districtMap.values()); + const cityTotalCategories = cityLocations.reduce((sum, d) => sum + d.location.categoryCount, 0); + const cityTotalProducts = cityLocations.reduce((sum, d) => sum + d.location.productCount, 0); + const cityTotal = cityTotalCategories + cityTotalProducts; return (
+ {/* City header with delete */} +
+ {city} + {cityTotal} +
+ +
+
{districts.map((district) => { const { location, categories } = cityEntry.districtMap.get(district)!; @@ -814,7 +904,7 @@ export function CatalogPage() { > - {city}{isDistrict ? ` > ${district}` : ""} + {isDistrict ? district : city} {totalCount} @@ -826,6 +916,22 @@ export function CatalogPage() { )}
e.stopPropagation()}> + + handleToggleActive("location", location.id)} @@ -942,6 +1048,22 @@ export function CatalogPage() { )}
e.stopPropagation()}> + + handleToggleActive("category", cat.id)} @@ -1066,6 +1188,22 @@ export function CatalogPage() { )}
e.stopPropagation()}> + + handleToggleActive("subcategory", sub.id)} @@ -1593,7 +1731,7 @@ export function CatalogPage() { Cancel - {deleteError && deleteTarget && deleteTarget.type !== "product" && ( + {deleteError && deleteTarget && deleteTarget.type !== "product" && deleteTarget.type !== "city" && ( diff --git a/admin-next/src/lib/ensure-sort-order.ts b/admin-next/src/lib/ensure-sort-order.ts new file mode 100644 index 0000000..881094d --- /dev/null +++ b/admin-next/src/lib/ensure-sort-order.ts @@ -0,0 +1,21 @@ +import { db } from '@/lib/db'; + +/** + * Idempotently adds sort_order columns to locations, categories, and subcategories. + * Uses raw SQL with try/catch since SQLite doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN. + */ +export async function ensureSortOrderColumns(): Promise { + const migrations = [ + 'ALTER TABLE locations ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE categories ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE subcategories ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0', + ]; + + for (const sql of migrations) { + try { + await db.$executeRawUnsafe(sql); + } catch { + // Column already exists — ignore + } + } +}