Añadir una pantalla al admin
Crear una vista nueva en el dashboard del laboratorio — page, server action, validator y UI con shadcn/ui.
El admin (apps/admin) es Next.js 16 (App Router). Las pantallas viven bajo route groups
en apps/admin/src/app/. La regla de oro: sin fetch del lado del cliente — la página
es un Server Component que llama a una server action,
y los formularios escriben mediante otra action.
Los route groups
| Grupo | Contenido |
|---|---|
(auth)/ | login / register (sin shell del OS). |
(admin)/ | gestión de tenants (super-admin). |
(dashboard)/ | el workspace del laboratorio — aquí va tu pantalla nueva. |
(portal)/ | redirección pública por QR (/r/[qr]). |
(public)/ | vistas sin auth: kiosko, pantalla de turnos, ticket. |
El layout.tsx de (dashboard)/ ya envuelve todo en el shell del Lab OS (statusbar +
dock + ventanas) y redirige a /login si no hay sesión. No repliques ese layout: tu
página solo renderiza su contenido.
Pasos
1. Crea la carpeta de la ruta
Para una pantalla en /widgets:
mkdir -p "apps/admin/src/app/(dashboard)/widgets"Una subruta como "nuevo" o "detalle" es otra carpeta anidada:
mkdir -p "apps/admin/src/app/(dashboard)/widgets/new"
mkdir -p "apps/admin/src/app/(dashboard)/widgets/[id]"2. La server action (datos)
Antes de la pantalla, asegúrate de tener la action. Ver la guía completa en Escribir una server action.
// apps/admin/src/actions/widgets.ts
"use server";
import { apiGet, apiPost } from "@/lib/api-client";
import { revalidatePath } from "next/cache";
export async function getWidgets() {
return apiGet<Widget[]>("/api/widgets");
}
export async function createWidget(data: { name: string; color?: string }) {
const widget = await apiPost<Widget>("/api/widgets", data);
revalidatePath("/widgets");
return widget;
}3. La página (Server Component) — lista + tabla
La página es async, llama a la action en el servidor y pasa los datos a la tabla. Sigue
el patrón de apps/admin/src/app/(dashboard)/inventory/page.tsx:
// apps/admin/src/app/(dashboard)/widgets/page.tsx
import Link from "next/link";
import { getWidgets } from "@/actions/widgets";
import { CatalogTable } from "@/components/tables/catalog-table";
import { widgetColumns } from "@/components/tables/widget-columns";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
export default async function WidgetsPage() {
const items = await getWidgets();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Widgets</h1>
<Link href="/widgets/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Nuevo widget
</Button>
</Link>
</div>
<CatalogTable
columns={widgetColumns}
data={items}
detailBase="/widgets"
searchPlaceholder="Buscar por nombre..."
entityLabel="Widget"
/>
</div>
);
}Nota: Las tablas usan
@tanstack/react-table. Las columnas se definen enapps/admin/src/components/tables/<dominio>-columns.tsx.
4. El validator Zod
Los formularios validan con Zod en apps/admin/src/lib/validators/<dominio>.ts, y el tipo
se deriva con z.infer. Sigue el estilo de
apps/admin/src/lib/validators/inventory.ts:
// apps/admin/src/lib/validators/widget.ts
import { z } from "zod";
export const widgetSchema = z.object({
name: z.string().min(1, "Nombre requerido"),
color: z.string().optional(),
});
export type WidgetInput = z.infer<typeof widgetSchema>;5. El formulario (Client Component)
El formulario es un Client Component ("use client") que llama a la action de escritura.
El stack es shadcn/ui (Input, Button, Select, Textarea, Label) + sonner para
toasts + next/navigation para redirigir. Patrón con useActionState como en
apps/admin/src/components/forms/inventory-item-form.tsx:
// apps/admin/src/components/forms/widget-form.tsx
"use client";
import { useActionState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createWidget } from "@/actions/widgets";
export function WidgetForm() {
const router = useRouter();
const action = async (_prev: unknown, formData: FormData) => {
const result: any = await createWidget({
name: String(formData.get("name") ?? ""),
color: String(formData.get("color") ?? "") || undefined,
});
if (result?.error) return result;
toast.success("Widget creado");
router.push("/widgets");
return result;
};
const [state, formAction, pending] = useActionState(action, null);
return (
<form action={formAction} className="space-y-6 max-w-3xl">
{state?.error && <p className="text-sm text-destructive">{state.error}</p>}
<div className="space-y-2">
<Label htmlFor="name">Nombre</Label>
<Input id="name" name="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input id="color" name="color" />
</div>
<Button type="submit" disabled={pending}>
{pending ? "Guardando..." : "Crear"}
</Button>
</form>
);
}Algunos formularios más complejos usan react-hook-form con
@hookform/resolvers/zod + el validator del paso 4 — elige según la complejidad del form,
pero la validación siempre se apoya en el mismo schema Zod.
6. La página "nuevo" que monta el formulario
// apps/admin/src/app/(dashboard)/widgets/new/page.tsx
import { WidgetForm } from "@/components/forms/widget-form";
export default function NewWidgetPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Nuevo widget</h1>
<WidgetForm />
</div>
);
}Checklist
- Carpeta de ruta bajo
(dashboard)/. - Server action(s) en
apps/admin/src/actions/<dominio>.ts. - Validator Zod en
apps/admin/src/lib/validators/<dominio>.ts. page.tsx(Server Componentasync) que llama a la action de lectura.- Columnas de tabla (
@tanstack/react-table) si listas datos. - Formulario Client Component (shadcn/ui + sonner) que llama a la action de escritura.
- Cero fetch desde el cliente — todo por server actions.
Anterior: Escribir una server action · Inicio de Guías.