fix(admin): issue #147 — Catalog Tree city edit/delete, sort order, subcategory ops

- 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
This commit is contained in:
NW
2026-08-10 10:55:12 +01:00
parent 1b7050002e
commit 9ac7afa36e
9 changed files with 434 additions and 11 deletions

View File

@@ -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: {

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<string | null>(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<string, string> = {};
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 (
<div key={city} className="ml-2">
{/* City header with delete */}
<div className="flex items-center gap-1 py-1 px-2 group hover:bg-muted/30 rounded">
<span className="text-xs font-medium truncate flex-1">{city}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">{cityTotal}</Badge>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
onClick={() =>
setDeleteTarget({
type: "city",
id: 0,
name: `${country} > ${city}`,
extra: { country, city },
})
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<Accordion type="multiple" className="w-full">
{districts.map((district) => {
const { location, categories } = cityEntry.districtMap.get(district)!;
@@ -814,7 +904,7 @@ export function CatalogPage() {
>
<ChevronRight className="h-3 w-3 shrink-0" />
<span className="truncate">
{city}{isDistrict ? ` > ${district}` : ""}
{isDistrict ? district : city}
</span>
<Badge variant={location.isActive === 1 ? "default" : "secondary"} className="text-[10px] px-1.5 py-0 ml-auto mr-1">
{totalCount}
@@ -826,6 +916,22 @@ export function CatalogPage() {
)}
</div>
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleSort("location", location.id, "up")}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleSort("location", location.id, "down")}
>
<ChevronDown className="h-3 w-3" />
</Button>
<Switch
checked={location.isActive === 1}
onCheckedChange={() => handleToggleActive("location", location.id)}
@@ -942,6 +1048,22 @@ export function CatalogPage() {
)}
</div>
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleSort("category", cat.id, "up")}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleSort("category", cat.id, "down")}
>
<ChevronDown className="h-3 w-3" />
</Button>
<Switch
checked={cat.isActive === 1}
onCheckedChange={() => handleToggleActive("category", cat.id)}
@@ -1066,6 +1188,22 @@ export function CatalogPage() {
</Badge>
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleSort("subcategory", sub.id, "up")}
>
<ChevronUp className="h-2.5 w-2.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleSort("subcategory", sub.id, "down")}
>
<ChevronDown className="h-2.5 w-2.5" />
</Button>
<Switch
checked={sub.isActive === 1}
onCheckedChange={() => handleToggleActive("subcategory", sub.id)}
@@ -1593,7 +1731,7 @@ export function CatalogPage() {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
{deleteError && deleteTarget && deleteTarget.type !== "product" && (
{deleteError && deleteTarget && deleteTarget.type !== "product" && deleteTarget.type !== "city" && (
<Button variant="outline" onClick={handleDeactivateFromCatalog}>
🔕 Deactivate
</Button>

View File

@@ -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<void> {
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
}
}
}