ixiclinicDocs
DesarrolladoresGuías

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

GrupoContenido
(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 en apps/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

  1. Carpeta de ruta bajo (dashboard)/.
  2. Server action(s) en apps/admin/src/actions/<dominio>.ts.
  3. Validator Zod en apps/admin/src/lib/validators/<dominio>.ts.
  4. page.tsx (Server Component async) que llama a la action de lectura.
  5. Columnas de tabla (@tanstack/react-table) si listas datos.
  6. Formulario Client Component (shadcn/ui + sonner) que llama a la action de escritura.
  7. Cero fetch desde el cliente — todo por server actions.

Anterior: Escribir una server action · Inicio de Guías.

On this page