From afcaf991b544907c8167a055681ee1075d0d54f0 Mon Sep 17 00:00:00 2001 From: DavidHerran Date: Fri, 5 Jun 2026 12:04:10 -0500 Subject: [PATCH] feat(applications): drag-to-reorder on the public site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editors can now control the order applications appear in, the same way they already reorder Hero slides. - Application gains an `order Int @default(0)` column (additive migration 20260605120000_add_application_order, IF NOT EXISTS, safe for deploy) plus an (isActive, order) index. - New reorderApplications(orderedSlugs) server action — single $transaction renumbering, mirrors reorderHeroSlides. - HQ applications panel: rows are now draggable by a grip handle (HTML5 DnD, optimistic local reorder, persisted on drop, toast feedback). - All public-facing queries now order by [order asc, createdAt asc]: home ApplicationsDashboard + GlobalOperations, the footer apps list, and the HQ list itself. Existing rows default to 0 so current order is preserved until the editor drags something. Verified: production build compiles, TypeScript clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 10 +++++ prisma/schema.prisma | 4 ++ src/app/[locale]/page.tsx | 2 +- .../dashboard/applications/actions.ts | 20 ++++++++- .../dashboard/applications/page.tsx | 44 ++++++++++++++++--- src/components/layout/Footer.tsx | 2 +- 6 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260605120000_add_application_order/migration.sql diff --git a/prisma/migrations/20260605120000_add_application_order/migration.sql b/prisma/migrations/20260605120000_add_application_order/migration.sql new file mode 100644 index 0000000..7a7927e --- /dev/null +++ b/prisma/migrations/20260605120000_add_application_order/migration.sql @@ -0,0 +1,10 @@ +-- ───────────────────────────────────────────────────────────────────────── +-- ADDITIVE MIGRATION — adds a manual `order` column to Application so the +-- editor can drag-to-reorder applications on the public site (same pattern +-- as HeroSlide). Existing rows default to 0 and keep their creation order +-- as a tiebreaker. Safe for `migrate deploy`. Idempotent. +-- ───────────────────────────────────────────────────────────────────────── + +ALTER TABLE "Application" ADD COLUMN IF NOT EXISTS "order" INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS "Application_isActive_order_idx" ON "Application" ("isActive", "order"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5784d08..0b92ca1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -89,6 +89,9 @@ model Application { dashboardMetricsJson String? @default("[]") isActive Boolean @default(true) // 🔥 NUEVO: Para poder ocultarlas + // 🔥 NUEVO: Orden manual para drag-to-reorder en el frontend (como HeroSlide) + order Int @default(0) + // 🌍 MOTOR DE TRADUCCIONES translationsJson String? @default("{}") @@ -97,6 +100,7 @@ model Application { @@index([isActive]) @@index([category]) + @@index([isActive, order]) } // ------------------------------------------------------ diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 6f4c9c8..d69c562 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -150,7 +150,7 @@ export default async function Home({ params }: { params: Promise<{ locale: strin shortDescription: true, heroDescription: true, dashboardMetricsJson: true, isActive: true, translationsJson: true }, - orderBy: { createdAt: "asc" } + orderBy: [{ order: "asc" }, { createdAt: "asc" }] }); dbApps = rawApps.map((app: any) => getLocalizedData(app as any, locale)); } catch (error) { diff --git a/src/app/hq-command/dashboard/applications/actions.ts b/src/app/hq-command/dashboard/applications/actions.ts index ce910f3..85fd5af 100644 --- a/src/app/hq-command/dashboard/applications/actions.ts +++ b/src/app/hq-command/dashboard/applications/actions.ts @@ -18,7 +18,7 @@ export async function getApplications() { noStore(); try { const apps = await prisma.application.findMany({ - orderBy: { createdAt: "asc" } + orderBy: [{ order: "asc" }, { createdAt: "asc" }] }); return { success: true, apps }; } catch (error) { @@ -161,6 +161,24 @@ export async function deleteApplication(slug: string) { } } +// 6b. REORDENAR APLICACIONES (drag-to-reorder, mismo patrón que HeroSlide) +// Recibe la lista de slugs en el nuevo orden y renumera el campo `order` +// en una sola transacción atómica. +export async function reorderApplications(orderedSlugs: string[]) { + try { + await prisma.$transaction( + orderedSlugs.map((slug, idx) => + prisma.application.update({ where: { slug }, data: { order: idx } }) + ) + ); + revalidatePath("/hq-command/dashboard/applications"); + revalidatePath("/[locale]", "layout"); + return { success: true }; + } catch (e) { + return { error: "Failed to reorder applications." }; + } +} + // 7. LA SEMILLA: AUTO-POBLAR LA BASE DE DATOS (Intacto) export async function seedInitialApplications() { // ... Tu código actual de la semilla se queda exactamente igual ... diff --git a/src/app/hq-command/dashboard/applications/page.tsx b/src/app/hq-command/dashboard/applications/page.tsx index 1e2cd78..4ab791a 100644 --- a/src/app/hq-command/dashboard/applications/page.tsx +++ b/src/app/hq-command/dashboard/applications/page.tsx @@ -6,10 +6,10 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowLeft, Layers, DatabaseZap, Loader2, X, CheckCircle2, FileText, LayoutTemplate, AlignLeft, Plus, Trash2, AlertCircle, Eye, EyeOff, Sparkles, - Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw, - Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check + Bold, Italic, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Table, Minus, Image as ImageIcon, Video, Box, Type, Code, RotateCcw, RotateCw, + Maximize2, Minimize2, ChevronDown, FolderOpen, Upload, FolderPlus, ChevronRight, File, ArrowUpFromLine, Search, Grid3X3, LayoutList, Copy, Check, GripVertical } from "lucide-react"; -import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication } from "./actions"; +import { getApplications, createApplication, updateApplicationData, toggleApplication, deleteApplication, reorderApplications } from "./actions"; import { useHqUi } from "@/components/hq/Toast"; @@ -258,9 +258,29 @@ export default function ApplicationsManager() { const [sections, setSections] = useState([]); const [dashboardMetrics, setDashboardMetrics] = useState([]); + const [draggedSlug, setDraggedSlug] = useState(null); + const fetchApps = async () => { setIsLoading(true); const res = await getApplications(); if (res.success && res.apps) setApps(res.apps); setIsLoading(false); }; useEffect(() => { fetchApps(); }, []); + // Drag-to-reorder — same pattern as the Hero panel. Optimistic local + // reorder, then persist the new order to the DB. + const onDropApp = async (targetSlug: string) => { + if (!draggedSlug || draggedSlug === targetSlug) return; + const slugs = apps.map((a) => a.slug); + const from = slugs.indexOf(draggedSlug); + const to = slugs.indexOf(targetSlug); + if (from < 0 || to < 0) return; + const reordered = [...slugs]; + reordered.splice(from, 1); + reordered.splice(to, 0, draggedSlug); + setApps((prev) => reordered.map((s) => prev.find((a) => a.slug === s)!)); + setDraggedSlug(null); + const res = await reorderApplications(reordered); + if (res?.error) { ui.toast(res.error, "error"); fetchApps(); } + else ui.toast("Order updated.", "success"); + }; + const openEditModal = (app: any) => { setEditingApp(app); setActiveTab("basic"); try { setAdvantages(JSON.parse(app.advantagesJson || "[]")); } catch { setAdvantages([]); } @@ -299,7 +319,7 @@ export default function ApplicationsManager() {

Knowledge Base

-

Manage the technical literature and general specifications of your application categories.

+

Manage the technical literature and specifications. Drag rows by the handle to reorder how applications appear on the site.

@@ -308,14 +328,24 @@ export default function ApplicationsManager() {
- + {isLoading ? ( - + + ) : apps.length === 0 ? ( + ) : apps.map((app) => { const isPopulated = app.heroDescription && app.heroDescription.length > 10; return ( - + setDraggedSlug(app.slug)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => onDropApp(app.slug)} + className={`border-b border-white/5 transition-colors group ${draggedSlug === app.slug ? 'opacity-40' : ''} ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`} + > + diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 2362109..4d27646 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -22,7 +22,7 @@ export default async function Footer({ locale }: { locale: string }) { try { const rawApps = await prisma.application.findMany({ where: { isActive: true }, - orderBy: { createdAt: "asc" }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }], take: 4, }); activeApps = rawApps.map((app: any) => getLocalizedData(app, locale));
Application SectorStatusVisibilityActions
Application SectorStatusVisibilityActions
Loading database...
Loading database...
No applications yet.

{app.title}

/{app.slug}

{isPopulated ? Populated : Pending Setup}