feat(applications): drag-to-reorder on the public site

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:04:10 -05:00
parent fbfffb28d9
commit afcaf991b5
6 changed files with 72 additions and 10 deletions
@@ -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");
+4
View File
@@ -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])
}
// ------------------------------------------------------
+1 -1
View File
@@ -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) {
@@ -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 ...
@@ -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<any[]>([]);
const [dashboardMetrics, setDashboardMetrics] = useState<any[]>([]);
const [draggedSlug, setDraggedSlug] = useState<string | null>(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() {
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div>
<h1 className="text-3xl font-light text-white flex items-center gap-3"><Layers className="text-purple-400" /> Knowledge Base</h1>
<p className="text-[#86868B] mt-2">Manage the technical literature and general specifications of your application categories.</p>
<p className="text-[#86868B] mt-2">Manage the technical literature and specifications. Drag rows by the handle to reorder how applications appear on the site.</p>
</div>
<button onClick={() => setIsCreateModalOpen(true)} className="flex items-center gap-2 bg-purple-500/10 text-purple-400 border border-purple-500/20 px-5 py-2.5 rounded-xl font-medium hover:bg-purple-500 hover:text-white transition-all"><Plus size={18} /> New Application</button>
</div>
@@ -308,14 +328,24 @@ export default function ApplicationsManager() {
<div className="bg-[#111] border border-white/5 rounded-3xl overflow-hidden shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<thead><tr className="border-b border-white/5 text-[10px] uppercase tracking-widest text-[#86868B] bg-black/40"><th className="p-6 font-semibold w-10"></th><th className="p-6 font-semibold">Application Sector</th><th className="p-6 font-semibold">Status</th><th className="p-6 font-semibold">Visibility</th><th className="p-6 font-semibold text-right">Actions</th></tr></thead>
<tbody>
{isLoading ? (
<tr><td colSpan={4} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]"><Loader2 className="animate-spin mx-auto mb-2" size={24} /> Loading database...</td></tr>
) : apps.length === 0 ? (
<tr><td colSpan={5} className="p-8 text-center text-[#86868B]">No applications yet.</td></tr>
) : apps.map((app) => {
const isPopulated = app.heroDescription && app.heroDescription.length > 10;
return (
<tr key={app.slug} className={`border-b border-white/5 transition-colors group ${app.isActive ? 'hover:bg-white/[0.02]' : 'opacity-50'}`}>
<tr
key={app.slug}
draggable
onDragStart={() => 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'}`}
>
<td className="p-6"><span className="cursor-grab text-[#86868B] hover:text-white inline-flex" title="Drag to reorder"><GripVertical size={16} /></span></td>
<td className="p-6"><p className="font-medium text-white">{app.title}</p><p className="text-xs text-[#86868B] font-mono mt-1">/{app.slug}</p></td>
<td className="p-6">{isPopulated ? <span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit font-semibold"><CheckCircle2 size={12} /> Populated</span> : <span className="bg-white/5 text-[#86868B] border border-white/10 px-3 py-1 rounded-full text-[10px] uppercase tracking-wider flex items-center gap-1.5 w-fit"><AlertCircle size={12} /> Pending Setup</span>}</td>
<td className="p-6"><button onClick={() => {toggleApplication(app.slug, app.isActive); fetchApps();}} className={`text-xs font-medium px-3 py-1 rounded-full flex items-center gap-1.5 transition-colors ${app.isActive ? 'bg-purple-500/10 text-purple-400' : 'bg-red-500/10 text-red-400'}`}>{app.isActive ? <><Eye size={12} /> Visible</> : <><EyeOff size={12} /> Hidden</>}</button></td>
+1 -1
View File
@@ -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));