Saltar al contenido

4. La página del admin ​

Un plugin del panel no trae su propia interfaz: reutiliza el kit de componentes del panel, sus tiendas y sus estilos. Eso es lo que hace que sea indistinguible de las pantallas del núcleo.

Lo que puedes importar ​

  • vue y vue-router.
  • @/components/ui/* (el kit: catálogo), @/composables/*, @/lib/*, @/stores/*, @/api/http, @/i18n, @/app/*.
  • No @/modules/… (salvo tres rutas concretas), y nunca import * as. La lista exacta: Módulos del admin.

admin/index.ts ​

El que trae la plantilla ya vale. Su forma:

ts
export default {
  feature: "notas",
  routes: [{ path: "/notas", component: () => import("./pages/NotasPage.vue"), meta: { title: t("Notas"), permission: ["notas.view"] } }],
  nav,
};
  • feature es la clave del módulo: si está apagado, el panel oculta la entrada y bloquea las rutas.
  • meta.permission restringe quién entra; la API vuelve a comprobarlo siempre.
  • nav es una entrada de menú plana (sección local); el runtime la aplana con PROMOTE, como las del núcleo.
  • Opcionalmente tour (tutorial del módulo), settingsLink (enlace en Ajustes) y slots (componentes en pantallas del núcleo).

La pantalla ​

Sustituye admin/pages/NotasPage.vue. Usa AppPage, UiStats/UiStat, UiCard, UiTable con StatusBadge, UiField/UiInput, UiEmpty y UiLoadError; carga datos con useResource; avisa con useFeedback y comprueba permisos con useSession:

vue
<script setup lang="ts">
import { computed, ref } from "vue";
import { t } from "@/i18n";
import { http } from "@/api/http";
import AppPage from "@/components/ui/AppPage.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiCard from "@/components/ui/UiCard.vue";
import UiEmpty from "@/components/ui/UiEmpty.vue";
import UiField from "@/components/ui/UiField.vue";
import UiInput from "@/components/ui/UiInput.vue";
import UiLoadError from "@/components/ui/UiLoadError.vue";
import UiStat from "@/components/ui/UiStat.vue";
import UiStats from "@/components/ui/UiStats.vue";
import UiTable from "@/components/ui/UiTable.vue";
import type { Column } from "@/components/ui/types";
import { useResource } from "@/composables/useResource";
import { dateTime } from "@/lib/format";
import { useFeedback } from "@/stores/feedback";
import { useSession } from "@/stores/session";

type Note = { id: string; name: string; note: string | null; pinned: boolean; createdAt: string };

const feedback = useFeedback();
const session = useSession();
const notes = useResource(() => http.get<{ items: Note[] }>("/notas/items"));
const summary = useResource(() => http.get<{ total: number; pinned: number }>("/notas/summary"));
const name = ref("");
const saving = ref(false);

const columns = computed<Column[]>(() => [
  { key: "name", label: t("Nota"), mobile: "title" },
  { key: "pinned", label: t("Estado") },
  { key: "createdAt", label: t("Creada"), nowrap: true },
  { key: "actions", label: "", align: "right" },
]);
const STATE = computed(() => ({ true: { label: t("Fijada"), tone: "accent" as const }, false: { label: t("Normal"), tone: "neutral" as const } }));

async function reload(): Promise<void> {
  await Promise.all([notes.reload(), summary.reload()]);
}

async function add(): Promise<void> {
  if (!name.value.trim()) return;
  saving.value = true;
  try {
    await http.post("/notas/items", { name: name.value });
    name.value = "";
    feedback.toast(t("Nota creada"));
    await reload();
  } catch (err) {
    feedback.error(err);
  } finally {
    saving.value = false;
  }
}

async function togglePin(note: Note): Promise<void> {
  try {
    await http.patch(`/notas/items/${note.id}/pin`, { pinned: !note.pinned });
    await reload();
  } catch (err) {
    feedback.error(err);
  }
}

async function remove(note: Note): Promise<void> {
  if (!(await feedback.confirm({ title: t("¿Borrar «{name}»?", { name: note.name }), tone: "danger", confirmLabel: t("Borrar") }))) return;
  try {
    await http.del(`/notas/items/${note.id}`);
    await reload();
  } catch (err) {
    feedback.error(err);
  }
}
</script>

<template>
  <AppPage :title="t('Notas')" :description="t('Tablón de notas del local con notas fijadas y aviso diario.')">
    <UiStats min="180px">
      <UiStat :label="t('Notas')" :value="String(summary.data.value?.total ?? 0)" :loading="summary.loading.value && !summary.data.value" />
      <UiStat :label="t('Fijadas')" :value="String(summary.data.value?.pinned ?? 0)" :loading="summary.loading.value && !summary.data.value" />
    </UiStats>

    <UiCard v-if="session.can('notas.edit')" :title="t('Nueva nota')">
      <form class="form" @submit.prevent="add">
        <UiField :label="t('Texto de la nota')" required>
          <UiInput v-model="name" :maxlength="80" />
        </UiField>
        <UiButton type="submit" variant="primary" :loading="saving">{{ t("Añadir") }}</UiButton>
      </form>
    </UiCard>

    <UiLoadError v-if="notes.error.value" :error="notes.error.value" @retry="reload" />
    <UiCard :title="t('Tablón')" flush>
      <UiTable :columns="columns" :rows="notes.data.value?.items ?? []" row-key="id" :loading="notes.loading.value && !notes.data.value" bare>
        <template #cell-pinned="{ row }"><StatusBadge :of="STATE" :value="row.pinned" dot /></template>
        <template #cell-createdAt="{ row }">{{ dateTime(row.createdAt) }}</template>
        <template #cell-actions="{ row }">
          <UiButton v-if="session.can('notas.pin')" variant="ghost" size="sm" :icon="row.pinned ? 'x' : 'pin'" @click="togglePin(row)">{{ row.pinned ? t("Soltar") : t("Fijar") }}</UiButton>
          <UiButton v-if="session.can('notas.edit')" variant="danger-ghost" size="sm" icon="trash" :label="t('Borrar')" @click="remove(row)" />
        </template>
        <template #empty><UiEmpty :title="t('Todavía no hay notas')" :text="t('Crea la primera para el equipo.')" compact /></template>
      </UiTable>
    </UiCard>
  </AppPage>
</template>

<style scoped>
.form {
  display: grid;
  gap: var(--s-3);
  max-width: 420px;
}
</style>

Puntos a fijarse:

  • Todos los textos van en t("…"): el español es la clave de traducción.
  • Cero colores, radios o tamaños literales: el CSS usa var(--s-3). Ver Tokens.
  • Los estados completos: carga (:loading), vacío (#empty con UiEmpty) y error (UiLoadError con reintento).
  • Lo destructivo se confirma con feedback.confirm({ tone: "danger" }); el resultado se avisa con feedback.toast(); un error de la API, con feedback.error(err).
  • session.can("notas.pin") oculta el botón a quien no tiene el permiso. Ocultar no protege: la API lo exige igualmente.
  • UiTable se convierte en tarjetas en móvil; mobile: "title" marca la columna que hace de título.

Comprueba los tipos y las pruebas:

bash
npm run typecheck
npm test

vue-tsc pasa, pero npm test falla: has añadido textos con t() que aún no están en en.json, ca.json y fr.json, y el test de traducciones de la plantilla lo detecta («faltan en en.json»). Es el comportamiento esperado, y se arregla en el siguiente paso.

Siguiente: idiomas →

Contrato con el host: apiVersion 2. Sitio estático: no recoge datos ni usa servicios de terceros.