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:
@@ -68,6 +68,7 @@ model Location {
|
|||||||
country String
|
country String
|
||||||
city String
|
city String
|
||||||
district String @default("")
|
district String @default("")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
isActive Int @default(1) @map("is_active")
|
isActive Int @default(1) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ model Category {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
locationId Int @map("location_id")
|
locationId Int @map("location_id")
|
||||||
name String
|
name String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
isActive Int @default(1) @map("is_active")
|
isActive Int @default(1) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ model Subcategory {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
categoryId Int @map("category_id")
|
categoryId Int @map("category_id")
|
||||||
name String
|
name String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
isActive Int @default(1) @map("is_active")
|
isActive Int @default(1) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getAuth } from '@/lib/auth-middleware';
|
import { getAuth } from '@/lib/auth-middleware';
|
||||||
|
import { ensureSortOrderColumns } from '@/lib/ensure-sort-order';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const auth = getAuth(request);
|
const auth = getAuth(request);
|
||||||
if ('status' in auth) return auth;
|
if ('status' in auth) return auth;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensureSortOrderColumns();
|
||||||
|
|
||||||
const [locations, categories, subcategories] = await Promise.all([
|
const [locations, categories, subcategories] = await Promise.all([
|
||||||
db.location.findMany({
|
db.location.findMany({
|
||||||
orderBy: { id: 'desc' },
|
orderBy: [{ sortOrder: 'asc' }, { country: 'asc' }, { city: 'asc' }, { district: 'asc' }],
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { categories: true, products: true },
|
select: { categories: true, products: true },
|
||||||
@@ -17,7 +20,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.category.findMany({
|
db.category.findMany({
|
||||||
orderBy: { id: 'desc' },
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
include: {
|
include: {
|
||||||
location: { select: { id: true, country: true, city: true, district: true } },
|
location: { select: { id: true, country: true, city: true, district: true } },
|
||||||
_count: {
|
_count: {
|
||||||
@@ -26,7 +29,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.subcategory.findMany({
|
db.subcategory.findMany({
|
||||||
orderBy: { id: 'desc' },
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||||
include: {
|
include: {
|
||||||
category: { select: { id: true, name: true, locationId: true } },
|
category: { select: { id: true, name: true, locationId: true } },
|
||||||
_count: {
|
_count: {
|
||||||
|
|||||||
55
admin-next/src/app/api/categories/[id]/sort/route.ts
Normal file
55
admin-next/src/app/api/categories/[id]/sort/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
57
admin-next/src/app/api/locations/[id]/sort/route.ts
Normal file
57
admin-next/src/app/api/locations/[id]/sort/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
admin-next/src/app/api/locations/by-city/route.ts
Normal file
50
admin-next/src/app/api/locations/by-city/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
41
admin-next/src/app/api/locations/rename-city/route.ts
Normal file
41
admin-next/src/app/api/locations/rename-city/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
55
admin-next/src/app/api/subcategories/[id]/sort/route.ts
Normal file
55
admin-next/src/app/api/subcategories/[id]/sort/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,8 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Tag,
|
Tag,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
X,
|
X,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -212,6 +214,7 @@ export function CatalogPage() {
|
|||||||
type: string;
|
type: string;
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
extra?: { country: string; city: string };
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<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 () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
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 typePath = deleteTarget.type === "product" ? "products" : `${deleteTarget.type}s`;
|
||||||
const res = await fetch(`/api/${typePath}/${deleteTarget.id}`, { method: "DELETE" });
|
const res = await fetch(`/api/${typePath}/${deleteTarget.id}`, { method: "DELETE" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -436,16 +470,32 @@ export function CatalogPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let body: Record<string, string> = {};
|
|
||||||
if (type === "location") {
|
if (type === "location") {
|
||||||
const loc = tree?.locations.find((l) => l.id === id);
|
const loc = tree?.locations.find((l) => l.id === id);
|
||||||
body = { country: loc?.country || "", city: loc?.city || "", district: renameValue.trim() };
|
if (!loc) { setRenamingId(null); return; }
|
||||||
} else {
|
if (loc.district && loc.district !== "") {
|
||||||
body = { name: renameValue.trim() };
|
// Rename district
|
||||||
}
|
const body = { country: loc.country, city: loc.city, district: renameValue.trim() };
|
||||||
const typePath = `${type}s`;
|
const res = await fetch(`/api/locations/${id}`, {
|
||||||
const res = await fetch(`/api/${typePath}/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error();
|
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 {
|
||||||
|
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();
|
||||||
|
}
|
||||||
toast.success("Renamed");
|
toast.success("Renamed");
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
fetchTree();
|
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 () => {
|
const handleAdd = async () => {
|
||||||
if (!addMode) return;
|
if (!addMode) return;
|
||||||
try {
|
try {
|
||||||
@@ -795,8 +859,34 @@ export function CatalogPage() {
|
|||||||
{cities.map((city) => {
|
{cities.map((city) => {
|
||||||
const cityEntry = countryEntry.cityMap.get(city)!;
|
const cityEntry = countryEntry.cityMap.get(city)!;
|
||||||
const districts = Array.from(cityEntry.districtMap.keys()).sort();
|
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 (
|
return (
|
||||||
<div key={city} className="ml-2">
|
<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">
|
<Accordion type="multiple" className="w-full">
|
||||||
{districts.map((district) => {
|
{districts.map((district) => {
|
||||||
const { location, categories } = cityEntry.districtMap.get(district)!;
|
const { location, categories } = cityEntry.districtMap.get(district)!;
|
||||||
@@ -814,7 +904,7 @@ export function CatalogPage() {
|
|||||||
>
|
>
|
||||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{city}{isDistrict ? ` > ${district}` : ""}
|
{isDistrict ? district : city}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={location.isActive === 1 ? "default" : "secondary"} className="text-[10px] px-1.5 py-0 ml-auto mr-1">
|
<Badge variant={location.isActive === 1 ? "default" : "secondary"} className="text-[10px] px-1.5 py-0 ml-auto mr-1">
|
||||||
{totalCount}
|
{totalCount}
|
||||||
@@ -826,6 +916,22 @@ export function CatalogPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
<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
|
<Switch
|
||||||
checked={location.isActive === 1}
|
checked={location.isActive === 1}
|
||||||
onCheckedChange={() => handleToggleActive("location", location.id)}
|
onCheckedChange={() => handleToggleActive("location", location.id)}
|
||||||
@@ -942,6 +1048,22 @@ export function CatalogPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
<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
|
<Switch
|
||||||
checked={cat.isActive === 1}
|
checked={cat.isActive === 1}
|
||||||
onCheckedChange={() => handleToggleActive("category", cat.id)}
|
onCheckedChange={() => handleToggleActive("category", cat.id)}
|
||||||
@@ -1066,6 +1188,22 @@ export function CatalogPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
|
<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
|
<Switch
|
||||||
checked={sub.isActive === 1}
|
checked={sub.isActive === 1}
|
||||||
onCheckedChange={() => handleToggleActive("subcategory", sub.id)}
|
onCheckedChange={() => handleToggleActive("subcategory", sub.id)}
|
||||||
@@ -1593,7 +1731,7 @@ export function CatalogPage() {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
{deleteError && deleteTarget && deleteTarget.type !== "product" && (
|
{deleteError && deleteTarget && deleteTarget.type !== "product" && deleteTarget.type !== "city" && (
|
||||||
<Button variant="outline" onClick={handleDeactivateFromCatalog}>
|
<Button variant="outline" onClick={handleDeactivateFromCatalog}>
|
||||||
🔕 Deactivate
|
🔕 Deactivate
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
21
admin-next/src/lib/ensure-sort-order.ts
Normal file
21
admin-next/src/lib/ensure-sort-order.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user