Rediseño visual UABC + Mulish + auditoría de BD (P0/P1)

Rediseño visual:
- Nueva identidad UABC/editorial (DESIGN.md), tipografía Mulish, tokens
  verde/dorado/negro sobre bone-white, patrón de puntos verde en canvas,
  radios 8/12/30/9999, sin sombras, hairline 1px, tarjetas con leve
  transparencia + blur suave + borde verde muy sutil.
- 45 archivos .astro/.tsx alineados al nuevo sistema (Track A/B/C/D/E).
- Limpieza post-critique: eliminado triple CTA en home alumno, .card
  anidados en admin, aria-live decorativo, 26 aliases legacy en global.css,
  border-l verde en nav (patrón AI-slop), classList.remove obsoletos.

Auditoría de llamadas a BD (para 20-50 usuarios concurrentes):
- P0-1: middleware con cache in-memory de profile (30s) + is_banned (30s)
  por uid; exclusión de assets estáticos (/_astro/*, /audio/*, favicon).
  Invalidación en signout, PATCH /profile, baneos.
- P0-2: eliminado count pendientes duplicado en AppLayout; badge se
  hidrata client-side (BadgeSolicitudes con fetch inicial).
- P0-3: migración 0008 REPLICA IDENTITY FULL en solicitudes + delta
  preciso en BadgeSolicitudes (con fallback a refetch).
- P1: admin/index dedupe (4 queries → 2 con recientes.filter), catalogo
  reusa profile del middleware (3 queries → 1), inventario paralelo,
  estadisticas con cache 60s en RAM (stats-cache.ts), migración 0009
  con 3 índices (fecha, vencidos parcial, audit compuesto).

Fixes P0 de UI post-critique:
- estadisticas.astro: ReferenceError DIAS restaurado.
- solicitudes (3 vistas): error rendering sanitizado (no expone mensaje
  Postgres crudo al admin).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
This commit is contained in:
2026-09-21 09:42:12 -07:00
parent a41d50ca62
commit ac3e7061ac
53 changed files with 2132 additions and 1576 deletions
+234 -259
View File
@@ -1,228 +1,189 @@
# Ease Health — Style Reference
> Botanical greenhouse on cream paper
# UABC — Style Reference
> Identidad editorial institucional en clave monoespaciada
**Theme:** light
**Theme:** mixed
Ease Health uses a clinical-botanical language: a warm cream canvas layered with soft sage, keylime, and slate-blue panels that feel like a greenhouse rendered on paper. Typography carries the entire personality — weight 300 serif headlines (Faire Octave) paired against a humanist sans (Suisse Intl) create a hushed, editorial authority unusual for healthtech. A single deep forest green (#0f3e17) anchors every action and heading, reading like ink on watercolor. Surfaces are flat and shadowless; depth comes from layered tinted panels and generous padding, not elevation. Components are rounded (~14px) and pill-shaped for tags, keeping the whole system soft and approachable without losing clinical seriousness.
El sistema conserva la estructura editorial del original —bandas oscuras documentales alternadas con secciones claras de lectura— pero reemplaza por completo la paleta cromática por la colorimetría institucional de la UABC (verde principal, dorados y acentos complementarios) y unifica toda la tipografía en una sola familia monoespaciada, Azeret Mono, que trata la página como una hoja de especificación técnica: encabezados, cuerpo y controles de UI comparten la misma fuente y se diferencian solo por peso y tamaño. El Verde Institucional (#00723F) actúa como el único interruptor de acción —CTAs, enlaces, estados activos— mientras el resto de la paleta (verde claro, dorado, amarillo, verde jardín, azul) aporta soporte cromático secundario sin competir con el verde principal. Los componentes siguen siendo deliberadamente delgados: pills de radio completo, nav flotante translúcido, bordes de 1px, casi sin sombra — la interfaz se retira para que el contenido institucional lleve el peso visual.
## Tokens — Colors
| Name | Value | Token | Role |
|------|-------|-------|------|
| Forest Ink | `#0f3e17` | `--color-forest-ink` | Primary action buttons, headings, links, icon strokes — deep botanical green reads as ink, the only saturated dark in the system |
| Sage Mist | `#b1dbb8` | `--color-sage-mist` | Mid-tone card backgrounds and decorative washes, panel-level surfaces |
| Keylime Wash | `#e1f4df` | `--color-keylime-wash` | Hero panel background, light card surfaces — the dominant tinted canvas tone |
| Mint Veil | `#cfe7d3` | `--color-mint-veil` | Accent card surfaces, soft section dividers between sage and keylime layers |
| Slate Hush | `#b6ced5` | `--color-slate-hush` | Cool counter-balance panel for product preview and feature blocks — breaks the green monotony with desaturated blue-gray |
| Cream Paper | `#fffefc` | `--color-cream-paper` | Page background, badge backgrounds, button text — warm off-white that softens every green |
| Charcoal | `#222222` | `--color-charcoal` | Navigation text and minor UI strokes — restrained secondary ink |
| Border Mist | `#efeeeb` | `--color-border-mist` | Hairline dividers and subtle borders throughout the layout |
| Forest Shadow | `#0c2f10` | `--color-forest-shadow` | Supporting palette color for small decorative accents when the core palette needs contrast. Do not promote it to the primary CTA color |
| Verde Institucional | `#00723F` | `--color-verde-institucional` | Color institucional principal (Pantone 356) — CTAs primarios, nav activo, íconos, acentos de enlace; el único color con función de "interruptor" en la UI |
| Verde Claro | `#50B848` | `--color-verde-claro` | Estados hover, contornos de acento suaves, eco desaturado del Verde Institucional (Pantone 369) |
| Dorado UABC | `#DD971A` | `--color-dorado-uabc` | Acento cálido secundario — bordes de tags, highlights, contraste cálido frente al verde (Pantone 131) |
| Amarillo Dorado | `#FEBE10` | `--color-amarillo-dorado` | Badges y estados destacados sobre fondo oscuro (Pantone 124) |
| Verde Jardín | `#024731` | `--color-verde-jardin` | Superficie oscura alterna, bordes fuertes sobre fondo oscuro (Pantone 3435) |
| Dorado Oscuro | `#B08208` | `--color-dorado-oscuro` | Fondo translúcido de nav, bordes de fuerza media, superficies UI mudas (Pantone 132) |
| Azul Institucional | `#20419A` | `--color-azul-institucional` | Acento informativo/enlaces alternos — distingue contenido secundario de las acciones primarias en verde (Pantone Reflex Blue) |
| Bone White | `#fffefb` | `--color-bone-white` | Lienzo de página en secciones claras, color de texto en secciones oscuras |
| Linen | `#f3f2ee` | `--color-linen` | Superficie secundaria cálida, fondo de tarjetas elevadas |
| Hairline Gray | `#e5e7eb` | `--color-hairline-gray` | Bordes finos en tarjetas, imágenes, divisores y campos de formulario |
| Graphite | `#333833` | `--color-graphite` | Texto principal y encabezados sobre superficies claras |
| Negro Institucional | `#000000` | `--color-negro-institucional` | Lienzos de sección oscura (Pantone Process Black), texto de máximo contraste, scrims sobre imagen |
## Tokens — Typography
### Faire Octave — Display serif for all section headings and hero copy · `--font-faire-octave`
- **Substitute:** Cormorant Garamond, Playfair Display
- **Weights:** 300
- **Sizes:** 40px, 56px, 74px
- **Line height:** 1.05–1.35
- **Letter spacing:** -0.01em at 40px, -0.03em at 56–74px
- **Role:** Display serif for all section headings and hero copy
### Suisse Intl — Body, navigation, UI labels, buttons, captions — humanist sans at whisper weight for feature lists and normal weight for copy · `--font-suisse-intl`
- **Substitute:** Inter, Söhne, Helvetica Neue
- **Weights:** 300, 400, 600
- **Sizes:** 10px, 11px, 12px, 14px, 18px, 23px, 28px, 56px
- **Line height:** 1.00–1.61
- **Letter spacing:** 0.08em uppercase at 11px/600 (eyebrow labels); natural tracking otherwise
- **Role:** Body, navigation, UI labels, buttons, captions — humanist sans at whisper weight for feature lists and normal weight for copy
### Azeret Mono — Fuente única del sistema: encabezados, cuerpo y controles de UI. Al ser monoespaciada en todos los roles, la jerarquía se construye solo con peso y tamaño, reforzando la lectura de "hoja de especificación técnica" en toda la interfaz. · `--font-azeret-mono`
- **Fuente:** Fontsource (`@fontsource/azeret-mono` / fontsource.org/fonts/azeret-mono)
- **Substitute:** JetBrains Mono, IBM Plex Mono, o monoespacio del sistema
- **Weights:** 300 (encabezados y cuerpo), 500 (nav, botones, controles de UI)
- **Sizes:** 10px, 12px, 14px, 16px, 24px, 32px, 72px
- **Line height:** 1.10–1.83 según rol
- **Letter spacing:** -1.44px a 72px, -0.64px a 32px, -0.48px a 24px, -0.32px a 16px (cuerpo), +0.5px a 10px, +0.6px a 12px, +0.7px a 14px (micro/caption)
- **Role — Display/Heading:** peso 300, tamaños 24/32/72px. El peso liviano en tamaños grandes es no negociable — mantiene la autoridad editorial por contención en vez de volumen.
- **Role — Body:** peso 300, tamaños 14–16px, line-height 1.57–1.83. Trata el párrafo como salida de terminal, no como folleto de marketing.
- **Role — UI/Nav/Buttons:** peso 500, tamaños 12–16px. El salto de peso (300 → 500) es la única señal que distingue controles interactivos del texto editorial, ya que comparten la misma fuente.
### Type Scale
| Role | Size | Line Height | Letter Spacing | Token |
|------|------|-------------|----------------|-------|
| caption | 10px | 1.61 | — | `--text-caption` |
| body | 14px | 1.5 | — | `--text-body` |
| subheading | 18px | 1.3 | — | `--text-subheading` |
| heading-sm | 23px | 1.5 | — | `--text-heading-sm` |
| heading | 40px | 1.35 | -0.4px | `--text-heading` |
| heading-lg | 56px | 1.2 | -1.68px | `--text-heading-lg` |
| display | 74px | 1.05 | -2.22px | `--text-display` |
| Role | Size | Line Height | Letter Spacing | Weight | Token |
|------|------|-------------|----------------|--------|-------|
| micro | 10px | 1.6 | 0.5px | 300 | `--text-micro` |
| caption | 12px | 1.63 | 0.6px | 300 | `--text-caption` |
| body-sm | 14px | 1.63 | 0.7px | 300 | `--text-body-sm` |
| body | 16px | 1.57 | -0.32px | 300 | `--text-body` |
| ui | 16px | 1.83 | -0.017em | 500 | `--text-ui` |
| heading | 24px | 1.3 | -0.48px | 300 | `--text-heading` |
| heading-lg | 32px | 1.3 | -0.64px | 300 | `--text-heading-lg` |
| display | 72px | 1.1 | -1.44px | 300 | `--text-display` |
## Tokens — Spacing & Shapes
**Base unit:** 8px
**Density:** comfortable
### Spacing Scale
| Name | Value | Token |
|------|-------|-------|
| 4 | 4px | `--spacing-4` |
| 7 | 7px | `--spacing-7` |
| 9 | 9px | `--spacing-9` |
| 11 | 11px | `--spacing-11` |
| 14 | 14px | `--spacing-14` |
| 18 | 18px | `--spacing-18` |
| 21 | 21px | `--spacing-21` |
| 28 | 28px | `--spacing-28` |
| 35 | 35px | `--spacing-35` |
| 42 | 42px | `--spacing-42` |
| 49 | 49px | `--spacing-49` |
| 56 | 56px | `--spacing-56` |
| 70 | 70px | `--spacing-70` |
| 76 | 76px | `--spacing-76` |
| 99 | 99px | `--spacing-99` |
| 156 | 156px | `--spacing-156` |
| 8 | 8px | `--spacing-8` |
| 16 | 16px | `--spacing-16` |
| 24 | 24px | `--spacing-24` |
| 32 | 32px | `--spacing-32` |
| 64 | 64px | `--spacing-64` |
| 120 | 120px | `--spacing-120` |
| 128 | 128px | `--spacing-128` |
| 152 | 152px | `--spacing-152` |
### Border Radius
| Element | Value |
|---------|-------|
| nav | 7px |
| cards | 14px |
| badges | 999px |
| buttons | 14px |
| sections | 9999px |
| nav | 30px |
| tags | 8px |
| cards | 12px |
| buttons | 9999px |
### Layout
- **Page max-width:** 1200px
- **Section gap:** 64-96px
- **Card padding:** 28-42px
- **Element gap:** 14-21px
- **Section gap:** 64px
- **Card padding:** 24px
- **Element gap:** 8px
## Components
### Primary Forest Button
**Role:** Filled CTA for demos, explores, and form submits
### Verde Pill CTA
**Role:** Botón de llamada a la acción primaria
Background #0f3e17, text #fffefc, 14px border-radius, padding 14px 21px, Suisse Intl 14px/400. Arrow icon in white at trailing edge. Hover transitions to #0c2f10.
Relleno con #00723F, texto blanco en Azeret Mono peso 500, 16px, 9999px de border-radius, 10px de padding vertical / 24px horizontal. Etiqueta de una sola línea. Sin sombra. La forma de pill es el único elemento con curvatura completa — hace que el CTA sea inconfundible como el interruptor de acción de la página.
### Large Forest Button
**Role:** Hero-scale CTA with generous breathing room
### Nav Flotante Translúcido
**Role:** Navegación superior de página
Same Forest Ink fill and white text, but padding scales to 42px all sides for hero placement. Rounded 14px corners. Used sparingly for maximum-weight calls to action.
Contenedor tipo pill a 30px de border-radius, Dorado Oscuro (#B08208) a baja opacidad sobre la página, conteniendo los enlaces en Azeret Mono peso 500, 12px.
### Pill Badge / Tag
**Role:** Category labels, CRM/EHR/RCM module tags, status indicators
### Mono Display Heading
**Role:** Encabezados de hero y de sección
Background #fffefc, text Forest Ink #0f3e17, 999px border-radius (full pill), padding 9px 14px, Suisse Intl 14px/400. Inverted cream-on-green relationship makes tags feel like paper labels.
Azeret Mono peso 300, 72px en hero, 32–24px en títulos de sección. Line-height 1.10–1.30. Letter-spacing según escala. Color Bone White (#fffefb) sobre secciones oscuras, Graphite (#333833) sobre secciones claras. El peso liviano es no negociable — engrosarlo rompe el efecto editorial.
### Sage Feature Card
**Role:** Heavier-tinted content panel for feature highlights
### Mono Body Block
**Role:** Copy de párrafo en secciones de contenido
Background Sage Mist #b1dbb8, 14px border-radius, 42px padding all sides, no shadow. Hosts illustration or product preview content. Flat surface — depth comes from color not elevation.
Azeret Mono peso 300, 14–16px, line-height 1.63–1.83, letter-spacing -0.32px a 16px. Color Graphite (#333833) sobre superficies claras, Bone White (#fffefb) sobre oscuras. Max-width ~480px para preservar la medida de "lectura de terminal".
### Keylime Soft Card
**Role:** Light-tinted content panel for secondary features
### Two-Column Editorial Section
**Role:** Layout de contenido: encabezado izquierda, cuerpo derecha
Background Keylime Wash #e1f4df, 14px border-radius, 28px padding, no shadow. Tighter padding than sage variant for compact content blocks.
Grid lado a lado con 48–64px de gap. Columna izquierda contiene el Mono Display Heading a 32px. Columna derecha contiene el Mono Body Block a 14–16px. Ambas alineadas arriba. Fondo: Bone White o Linen. Padding de sección: 64px horizontal, 64px vertical.
### Slate Product Panel
**Role:** Cool-toned container for product UI screenshots and demos
### Full-Bleed Documentary Image
**Role:** Banda de imagen atmosférica entre secciones de contenido
Background Slate Hush #b6ced5, 14px border-radius, 42px padding, no shadow. Provides cool visual counterpoint to warm green panels — the only non-green tinted surface.
Fotografía a sangre completa sobre fondo Negro Institucional (#000000). Scrim oscuro opcional (rgba(0,0,0,0.3–0.5)) para contraste de texto. Texto superpuesto en Bone White, Azeret Mono peso 300. Las imágenes son documentales/candid, no de stock pulido.
### Product Preview Card (Inner)
**Role:** White floating card within Slate Product Panel
### Vertical Sidebar Label
**Role:** Indicador de sección en el margen de la página
Background Cream Paper #fffefc, 14px border-radius, 28px padding, no border or shadow. Represents a product screen mockup (CRM, EHR, RCM). Sits on the Slate panel like a printed screenshot.
Texto rotado 90° en el margen izquierdo, posicionado 24–49px del borde. Azeret Mono 10–12px, letter-spacing 0.05em, mayúsculas o tal cual. Color: Graphite sobre secciones claras, Hairline Gray (#e5e7eb) o Bone White sobre oscuras. Funciona como marcador de sección, no como navegación.
### Navigation Bar
**Role:** Top-level site navigation
### Ghost Text Link
**Role:** Enlaces en línea dentro del cuerpo de texto
Transparent over cream canvas, text Charcoal #222222, Suisse Intl 14px/400, 7px radius on interactive items. 'Book a Demo' CTA pill on the right uses Forest Ink fill.
Sin fondo, subrayado de 1px en #00723F o Graphite según contexto. Azeret Mono a 14–16px. El hover aumenta opacidad o cambia a Verde Claro (#50B848).
### Eyebrow Label
**Role:** Section category tag above headings
### Hairline Divider
**Role:** Separador de sección
Suisse Intl 11px/600, letter-spacing 0.08em, uppercase, Forest Ink color. Tiny typographic marker that labels each section's theme (e.g. 'QUICK ANSWER').
1px sólido en #e5e7eb o #B08208, a todo el ancho o contenido. Sin ornamentación, sin degradado. La división entre secciones se implica por espaciado, no por líneas visuales — esto es un respaldo.
### FAQ Row
**Role:** Two-column question/answer layout
### Image Frame Card
**Role:** Tarjeta de contenido con imagen
Left column: Faire Octave 40px/300 in Forest Ink for question. Right column: Suisse Intl 14px/400 in Charcoal for answer. 1px Border Mist divider between rows.
Borde 1px sólido #e5e7eb, 12px de border-radius, interior Linen (#f3f2ee), 24px de padding. Imagen arriba con borde 1px en #e5e7eb. Texto de cuerpo en Azeret Mono 14px debajo. Elevación mínima — sin drop shadow.
### Investor Logo Strip
**Role:** Social proof row of VC logos
### Compact Pill Tag
**Role:** Badge de categoría o estado
Single horizontal row, monochrome treatment in Charcoal #222222 on cream canvas, generous vertical padding (42px+). No decorative borders — relies on whitespace for rhythm.
8px de border-radius (pill sutil, no curvatura completa), 4–8px de padding vertical / 8–12px horizontal, Azeret Mono peso 500 a 12px. Borde 1px en #e5e7eb o #DD971A. Fondo: Bone White o transparente. Color: Graphite, Verde Institucional o Dorado UABC.
## Do's and Don'ts
### Do
- Use Forest Ink (#0f3e17) for every CTA, heading, and primary link — it is the only saturated dark and must remain singular
- Set all display headings in Faire Octave weight 300 with tight tracking (-0.03em at 56–74px) — never bold the serif
- Pair serif questions with sans-serif answers in FAQ and feature blocks to create typographic contrast
- Layer surfaces using cream → keylime → mint → sage → slate in order to create depth without shadows
- Use 14px border-radius on all cards and buttons, 999px on all badges and tags — consistency is non-negotiable
- Apply 0.08em letter-spacing with uppercase and 600 weight for eyebrow labels only — nowhere else
- Keep the Slate Hush panel as the exclusive home for product screenshots — it provides the cool counterbalance to warm green panels
- Configura todos los CTAs primarios a 9999px de border-radius con relleno #00723F y texto Bone White en Azeret Mono peso 500, 16px.
- Usa Azeret Mono peso 300 para todo encabezado a 24px, 32px y 72px — nunca lo pongas en negrita.
- Escribe el copy de cuerpo en Azeret Mono peso 300 a 14–16px con line-height 1.63–1.83.
- Separa secciones con 64px de padding vertical, no con divisores ni solo cambios de color de fondo.
- Aplica bordes 1px sólidos #e5e7eb a todas las tarjetas e imágenes — nunca uses drop shadows para elevación.
- Usa #fffefb para lienzos claros y #000000 para bandas oscuras; deja que la fotografía llene las bandas oscuras a sangre completa.
- Reserva el Verde Institucional (#00723F) como el único color con función de acción primaria — el resto de la paleta (verde claro, dorado, amarillo, azul) es soporte cromático secundario, nunca compite con él en un mismo CTA.
### Don't
- Never add box-shadow to cards — the system communicates elevation through tinted layers only
- Don't use Forest Ink as a background tint — it is too dark for surface washes; reserve for text and action fills only
- Avoid mixing multiple heading weights — everything is weight 300 in Faire Octave; bold would break the hushed editorial tone
- Don't place colored borders on cards — depth comes from fill contrast, not strokes
- Never use more than one serif at a time — Faire Octave owns display, Suisse Intl owns everything else
- Don't introduce new accent hues — the palette is strictly botanical greens + slate; any other color breaks the greenhouse metaphor
- Avoid tight padding under 21px on cards — generous 28–42px padding is essential to the soft, breathing-room aesthetic
- Don't use the sage or keylime tones for text — they are surface colors only and fail contrast for copy
- No uses pesos negrita (600–700) en encabezados Azeret Mono — el peso 300 es la firma del sistema.
- No introduzcas drop shadows para elevar tarjetas; usa el color de superficie Linen (#f3f2ee) y bordes hairline en su lugar.
- No mezcles otra familia tipográfica proporcional o serif en ningún rol — la unificación en una sola mono es la decisión que define el sistema.
- No apliques el Verde Institucional a fondos, bandas de sección o elementos decorativos — es solo de acción.
- No uses valores de radio fuera de los cuatro tokens: 8px (tags), 12px (cards), 30px (nav), 9999px (buttons).
- No rompas el ritmo de lienzo de dos tonos — no insertes un tercer color de fondo de sección (además de Bone White y Negro Institucional).
- No uses fotografía de stock, ilustraciones o renders 3D — la imaginería debe ser documental y candid.
## Surfaces
| Level | Name | Value | Purpose |
|-------|------|-------|---------|
| 0 | Cream Paper | `#fffefc` | Base page background — warm off-white dominates the canvas |
| 1 | Keylime Wash | `#e1f4df` | Primary tinted panel — hero left column, light feature blocks |
| 2 | Mint Veil | `#cfe7d3` | Mid-tone section surface between keylime and sage |
| 3 | Sage Mist | `#b1dbb8` | Heavier tinted panels — hero accents, feature highlights |
| 4 | Slate Hush | `#b6ced5` | Cool counter-panel for product showcases and visual contrast |
| 1 | Bone Canvas | `#fffefb` | Secciones claras de lectura, fondo de página por defecto |
| 2 | Linen Card | `#f3f2ee` | Bloques de contenido elevados, paneles secundarios |
| 3 | Dorado Nav | `#B08208` | Superficie de navegación flotante translúcida |
| 4 | Negro Institucional | `#000000` | Secciones documentales oscuras, lienzos de banda de imagen |
## Elevation
The system deliberately avoids shadows entirely — boxShadow=none on all card variants. Depth and hierarchy are communicated through layered tinted surfaces (cream → keylime → sage → slate) rather than elevation. This keeps the interface feeling like watercolor paper rather than glassmorphic UI, matching the botanical-greenhouse metaphor.
El sistema evita deliberadamente los drop shadows. La elevación se expresa mediante cambios de color de superficie (Bone White → Linen) y bordes hairline (#e5e7eb a 1px). El único valor cercano a sombra es un box-shadow tenue teñido con #f3f2ee en botones — apenas perceptible. La interfaz debe sentirse impresa, no flotando.
## Imagery
Imagery is product-first and UI-native: floating product screenshots (CRM cards, EHR patient views, RCM revenue graphs) rendered as crisp white cards on Slate Hush panels. No lifestyle photography, no stock imagery. Iconography is minimal stroke-based, rendered in Forest Ink. Illustrations, where present, use the same sage/keylime palette to stay on-system. The visual density is text-dominant with product mockups serving as proof, not decoration.
Fotografía documental y candid: espacios universitarios, actividad académica real en lugar de stock corporativo escenificado. Las imágenes corren a sangre completa como bandas de sección, a menudo con un scrim oscuro para superposición de texto. Llevan un grado de color apagado y ligeramente desaturado que combina con la paleta institucional. Sin ilustraciones, sin renders 3D, sin capturas de producto — la fotografía ES el contenido visual. La iconografía es mínima: íconos de línea de peso mono en Graphite o Verde Institucional, nunca decorativos.
## Layout
Max-width 1200px centered container with 64–96px vertical section gaps. Hero is a two-column split: left panel tinted Keylime Wash with serif headline + body + CTA; right panel tinted Slate Hush housing three product preview cards (CRM, EHR, RCM) in a row. Below the hero, sections alternate between cream canvas and tinted panels in a botanical rhythm. FAQ uses a two-column text grid (serif question left, sans answer right). The investor logo strip is a single full-width row on cream. Navigation is minimal — left-aligned logo, centered text links, right-aligned CTA pill — no sticky behavior or mega-menu. The overall page rhythm is generous and editorial rather than dense.
La página alterna entre dos estados de lienzo: bandas documentales oscuras a sangre completa (Negro Institucional #000000 con foto) y secciones claras de lectura (Bone White #fffefb). El contenido está centrado a 1200px de max-width. El hero es una imagen oscura a pantalla completa con encabezado Azeret Mono centrado y CTA verde. Las secciones siguientes usan un split editorial de dos columnas — encabezado izquierda, cuerpo mono derecha — con gaps generosos de 64px. Las etiquetas verticales de sidebar se ubican en el margen izquierdo de la página como marcadores de sección. La navegación es una cápsula pill flotante, no una barra de ancho completo. La densidad general es espaciosa; la página está dominada por texto y fotografía, no por grillas de tarjetas.
## Agent Prompt Guide
**Quick Color Reference**
- Text/Heading: #0f3e17 (Forest Ink)
- Page background: #fffefc (Cream Paper)
- Hero/light panel: #e1f4df (Keylime Wash)
- Feature panel: #b1dbb8 (Sage Mist)
- Product showcase panel: #b6ced5 (Slate Hush)
- Border: #efeeeb
- primary action: #0f3e17 (filled action)
**Example Component Prompts**
1. Create a Primary Action Button: #0f3e17 background, #fffefc text, 9999px radius, compact pill padding. Use this filled treatment for the main CTA.
2. **Feature card**: Sage Mist #b1dbb8 background, 14px border-radius, 42px padding all sides, no shadow. Eyebrow label in Suisse Intl 11px/600 uppercase with 0.08em tracking, Forest Ink color. Heading in Faire Octave 40px/300, Forest Ink. Body in Suisse Intl 14px/400, Charcoal #222222.
3. **Pill badge**: Background Cream Paper #fffefc, text Forest Ink #0f3e17, 999px border-radius, padding 9px 14px, Suisse Intl 14px/400. Place above section headings as a category tag.
4. **FAQ row**: Two-column grid. Left: Faire Octave 40px/300 question in Forest Ink. Right: Suisse Intl 14px/400 answer in Charcoal #222222. Separator: 1px solid Border Mist #efeeeb below each row.
## Type Pairing Philosophy
The serif/sans split is the system's defining typographic move. Faire Octave (or Cormorant Garamond as substitute) handles ALL headings at weight 300 — never bold. Suisse Intl (or Inter) handles ALL UI and body text. The whisper-weight serif creates editorial calm unusual in healthtech, where most competitors use bold geometric sans headlines. Tracking tightens as size grows: -0.01em at 40px, -0.03em at 56–74px. Body text uses natural tracking. The only uppercase treatment is 11px/600 eyebrows at 0.08em — these are navigational markers, not decorative caps.
## Similar Brands
- **Headspace** — Same botanical palette approach with sage greens and soft tinted panels, plus serif-leaning editorial calm and generous breathing room
- **Calm** — Nature-inspired muted greens against cream, generous whitespace, flat surfaces without shadows, and a serene clinical-adjacent tone
- **Mercury** — Deep single-color ink (#0f3e17 forest vs Mercury's near-black) against a warm off-white canvas with editorial serif display type at weight 300
- **Spring Health** — Behavioral health branding with green-dominant palette, soft panel layering, and humanist sans body type paired with serif accents
- **Grow Therapy** — Healthcare SaaS using sage and mint greens against cream, with rounded components and a botanical rather than clinical visual mood
Quick Color Reference:\n- text: #333833 (light) / #fffefb (dark)\n- background: #fffefb (light) / #000000 (dark)\n- border: #e5e7eb (hairline) / #B08208 (medium)\n- accent primario: #00723F (Verde Institucional — CTAs, enlaces, estados activos)\n- accent secundario: #DD971A (Dorado UABC — tags, highlights)\n- primary action: #00723F (acción rellena)
## Quick Start
@@ -231,150 +192,164 @@ The serif/sans split is the system's defining typographic move. Faire Octave (or
```css
:root {
/* Colors */
--color-forest-ink: #0f3e17;
--color-sage-mist: #b1dbb8;
--color-keylime-wash: #e1f4df;
--color-mint-veil: #cfe7d3;
--color-slate-hush: #b6ced5;
--color-cream-paper: #fffefc;
--color-charcoal: #222222;
--color-border-mist: #efeeeb;
--color-forest-shadow: #0c2f10;
--color-verde-institucional: #00723F;
--color-verde-claro: #50B848;
--color-dorado-uabc: #DD971A;
--color-amarillo-dorado: #FEBE10;
--color-verde-jardin: #024731;
--color-dorado-oscuro: #B08208;
--color-azul-institucional: #20419A;
--color-bone-white: #fffefb;
--color-linen: #f3f2ee;
--color-hairline-gray: #e5e7eb;
--color-graphite: #333833;
--color-negro-institucional: #000000;
/* Typography — Font Families */
--font-faire-octave: 'Faire Octave', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-suisse-intl: 'Suisse Intl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Typography — Font Family (única) */
--font-azeret-mono: 'Azeret Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Typography — Scale */
--text-caption: 10px;
--leading-caption: 1.61;
--text-body: 14px;
--leading-body: 1.5;
--text-subheading: 18px;
--leading-subheading: 1.3;
--text-heading-sm: 23px;
--leading-heading-sm: 1.5;
--text-heading: 40px;
--leading-heading: 1.35;
--tracking-heading: -0.4px;
--text-heading-lg: 56px;
--leading-heading-lg: 1.2;
--tracking-heading-lg: -1.68px;
--text-display: 74px;
--leading-display: 1.05;
--tracking-display: -2.22px;
--text-micro: 10px;
--leading-micro: 1.6;
--tracking-micro: 0.5px;
--text-caption: 12px;
--leading-caption: 1.63;
--tracking-caption: 0.6px;
--text-body-sm: 14px;
--leading-body-sm: 1.63;
--tracking-body-sm: 0.7px;
--text-body: 16px;
--leading-body: 1.57;
--tracking-body: -0.32px;
--text-ui: 16px;
--leading-ui: 1.83;
--tracking-ui: -0.017em;
--text-heading: 24px;
--leading-heading: 1.3;
--tracking-heading: -0.48px;
--text-heading-lg: 32px;
--leading-heading-lg: 1.3;
--tracking-heading-lg: -0.64px;
--text-display: 72px;
--leading-display: 1.1;
--tracking-display: -1.44px;
/* Typography — Weights */
--font-weight-light: 300;
--font-weight-regular: 400;
--font-weight-semibold: 600;
--font-weight-ui: 500;
/* Spacing */
--spacing-4: 4px;
--spacing-7: 7px;
--spacing-9: 9px;
--spacing-11: 11px;
--spacing-14: 14px;
--spacing-18: 18px;
--spacing-21: 21px;
--spacing-28: 28px;
--spacing-35: 35px;
--spacing-42: 42px;
--spacing-49: 49px;
--spacing-56: 56px;
--spacing-70: 70px;
--spacing-76: 76px;
--spacing-99: 99px;
--spacing-156: 156px;
--spacing-unit: 8px;
--spacing-8: 8px;
--spacing-16: 16px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-64: 64px;
--spacing-120: 120px;
--spacing-128: 128px;
--spacing-152: 152px;
/* Layout */
--page-max-width: 1200px;
--section-gap: 64-96px;
--card-padding: 28-42px;
--element-gap: 14-21px;
--section-gap: 64px;
--card-padding: 24px;
--element-gap: 8px;
/* Border Radius */
--radius-lg: 7.04px;
--radius-xl: 14.08px;
--radius-full: 999px;
--radius-full-2: 9999px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-3xl: 30px;
--radius-full: 9999px;
/* Named Radii */
--radius-nav: 7px;
--radius-cards: 14px;
--radius-badges: 999px;
--radius-buttons: 14px;
--radius-sections: 9999px;
--radius-nav: 30px;
--radius-tags: 8px;
--radius-cards: 12px;
--radius-buttons: 9999px;
/* Surfaces */
--surface-cream-paper: #fffefc;
--surface-keylime-wash: #e1f4df;
--surface-mint-veil: #cfe7d3;
--surface-sage-mist: #b1dbb8;
--surface-slate-hush: #b6ced5;
--surface-bone-canvas: #fffefb;
--surface-linen-card: #f3f2ee;
--surface-dorado-nav: #B08208;
--surface-negro-institucional: #000000;
}
```
### Fontsource — instalación de Azeret Mono
```bash
npm install @fontsource/azeret-mono
```
```javascript
// Importa los pesos usados por el sistema (300 y 500)
import '@fontsource/azeret-mono/300.css';
import '@fontsource/azeret-mono/500.css';
```
Si el proyecto no usa un bundler con soporte npm, se puede enlazar directamente el CSS servido por Fontsource (`https://cdn.jsdelivr.net/fontsource/fonts/azeret-mono@latest/latin-300-normal.css` y su equivalente `-500-`) en el `<head>` del documento.
### Tailwind v4
```css
@theme {
/* Colors */
--color-forest-ink: #0f3e17;
--color-sage-mist: #b1dbb8;
--color-keylime-wash: #e1f4df;
--color-mint-veil: #cfe7d3;
--color-slate-hush: #b6ced5;
--color-cream-paper: #fffefc;
--color-charcoal: #222222;
--color-border-mist: #efeeeb;
--color-forest-shadow: #0c2f10;
--color-verde-institucional: #00723F;
--color-verde-claro: #50B848;
--color-dorado-uabc: #DD971A;
--color-amarillo-dorado: #FEBE10;
--color-verde-jardin: #024731;
--color-dorado-oscuro: #B08208;
--color-azul-institucional: #20419A;
--color-bone-white: #fffefb;
--color-linen: #f3f2ee;
--color-hairline-gray: #e5e7eb;
--color-graphite: #333833;
--color-negro-institucional: #000000;
/* Typography */
--font-faire-octave: 'Faire Octave', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-suisse-intl: 'Suisse Intl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-azeret-mono: 'Azeret Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Typography — Scale */
--text-caption: 10px;
--leading-caption: 1.61;
--text-body: 14px;
--leading-body: 1.5;
--text-subheading: 18px;
--leading-subheading: 1.3;
--text-heading-sm: 23px;
--leading-heading-sm: 1.5;
--text-heading: 40px;
--leading-heading: 1.35;
--tracking-heading: -0.4px;
--text-heading-lg: 56px;
--leading-heading-lg: 1.2;
--tracking-heading-lg: -1.68px;
--text-display: 74px;
--leading-display: 1.05;
--tracking-display: -2.22px;
--text-micro: 10px;
--leading-micro: 1.6;
--tracking-micro: 0.5px;
--text-caption: 12px;
--leading-caption: 1.63;
--tracking-caption: 0.6px;
--text-body-sm: 14px;
--leading-body-sm: 1.63;
--tracking-body-sm: 0.7px;
--text-body: 16px;
--leading-body: 1.57;
--tracking-body: -0.32px;
--text-ui: 16px;
--leading-ui: 1.83;
--tracking-ui: -0.017em;
--text-heading: 24px;
--leading-heading: 1.3;
--tracking-heading: -0.48px;
--text-heading-lg: 32px;
--leading-heading-lg: 1.3;
--tracking-heading-lg: -0.64px;
--text-display: 72px;
--leading-display: 1.1;
--tracking-display: -1.44px;
/* Spacing */
--spacing-4: 4px;
--spacing-7: 7px;
--spacing-9: 9px;
--spacing-11: 11px;
--spacing-14: 14px;
--spacing-18: 18px;
--spacing-21: 21px;
--spacing-28: 28px;
--spacing-35: 35px;
--spacing-42: 42px;
--spacing-49: 49px;
--spacing-56: 56px;
--spacing-70: 70px;
--spacing-76: 76px;
--spacing-99: 99px;
--spacing-156: 156px;
--spacing-8: 8px;
--spacing-16: 16px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-64: 64px;
--spacing-120: 120px;
--spacing-128: 128px;
--spacing-152: 152px;
/* Border Radius */
--radius-lg: 7.04px;
--radius-xl: 14.08px;
--radius-full: 999px;
--radius-full-2: 9999px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-3xl: 30px;
--radius-full: 9999px;
}
```
+18 -20
View File
@@ -3,17 +3,20 @@ import type { ToastPayload, ToastKind } from '@/lib/toast';
type Item = ToastPayload & { id: number; leaving?: boolean };
const KIND_META: Record<ToastKind, { color: string; d: string }> = {
const KIND_META: Record<ToastKind, { bg: string; text: string; d: string }> = {
success: {
color: 'var(--color-positive)',
bg: 'bg-verde-institucional',
text: 'text-bone-white',
d: 'M4.5 12.75l6 6 9-13.5',
},
error: {
color: 'var(--color-danger-text)',
bg: 'bg-negro-institucional',
text: 'text-bone-white',
d: 'M6 18L18 6M6 6l12 12',
},
info: {
color: 'var(--color-secondary)',
bg: 'bg-azul-institucional',
text: 'text-bone-white',
d: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
},
};
@@ -50,7 +53,7 @@ export default function Toaster() {
function dismiss(id: number, animate = false) {
if (animate) {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, leaving: true } : it)));
setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 250);
setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 200);
} else {
setItems((prev) => prev.filter((it) => it.id !== id));
}
@@ -71,29 +74,23 @@ export default function Toaster() {
onClick={() => dismiss(t.id, true)}
aria-live="polite"
className={
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[2px] px-4 py-3 bg-white ' +
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-[12px] px-4 py-3 bg-bone-white text-graphite border border-hairline-gray shadow-none ' +
'motion-safe:transition-[opacity,transform] motion-safe:duration-200 ' +
(t.leaving ? 'toast-leave' : 'toast-enter')
}
style={{
border: '2px solid var(--color-ink)',
boxShadow: 'var(--shadow-hard)',
color: 'var(--color-ink)',
}}
>
<span
aria-hidden="true"
className="shrink-0 grid place-items-center w-9 h-9 rounded-[2px]"
style={{ background: meta.color, border: '2px solid var(--color-ink)' }}
className={`shrink-0 grid place-items-center w-8 h-8 rounded-full ${meta.bg} ${meta.text}`}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d={meta.d} />
</svg>
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-sm leading-snug">{t.title}</span>
<span className="block font-medium text-sm text-graphite leading-snug">{t.title}</span>
{t.description && (
<span className="block text-xs opacity-70 mt-0.5 leading-snug">{t.description}</span>
<span className="block text-xs text-graphite/70 mt-0.5 leading-snug">{t.description}</span>
)}
</span>
</button>
@@ -102,18 +99,19 @@ export default function Toaster() {
<style>{`
.toast-enter {
animation: toast-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation: toast-in 200ms ease-out both;
}
.toast-leave {
opacity: 0;
transform: translateY(-8px) scale(0.98);
transform: translateY(-4px);
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.toast-enter { animation: none; }
.toast-leave { opacity: 0; }
}
`}</style>
</div>
+59 -7
View File
@@ -4,11 +4,17 @@ import { toast } from '@/lib/toast';
function setBadge(n: number) {
const v = Math.max(0, n | 0);
// Badge pill visual (nav): oculto si 0.
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
el.textContent = String(v);
if (v > 0) el.removeAttribute('hidden');
else el.setAttribute('hidden', '');
el.setAttribute('aria-label', `${v} solicitudes pendientes`);
el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full');
});
// Contadores de texto plano (KPIs) — visibles siempre, sin estilo pill.
document.querySelectorAll<HTMLElement>('[data-count="pendientes"]').forEach((el) => {
el.textContent = String(v);
});
}
@@ -18,18 +24,26 @@ function readBadge(): number {
return Number.isFinite(n) ? n : 0;
}
export default function BadgeSolicitudes() {
export default function BadgeSolicitudes({ count, className }: { count?: number; className?: string } = {}) {
useEffect(() => {
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full');
});
const supabase = browserClient();
const refetch = async () => {
const { count } = await supabase
const { count: c } = await supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('estado', 'pendiente');
setBadge(count ?? 0);
setBadge(c ?? 0);
};
// Fetch inicial: el SSR ya no envía count (se removió de AppLayout para
// ahorrar 1 query/pageview). El badge arranca en 0 y se hidrata acá.
refetch();
const channel = supabase
.channel('solicitudes-pendientes')
.on(
@@ -43,11 +57,26 @@ export default function BadgeSolicitudes() {
},
)
.on(
// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default;
// upgrade a delta cuando la tabla tenga replica identity full.
// Delta preciso: REPLICA IDENTITY FULL (migración 0008) hace que
// payload.old traiga el estado anterior — evitamos el refetch en cada UPDATE.
'postgres_changes',
{ event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' },
() => { refetch(); },
(payload) => {
const oldEstado = (payload.old as { estado?: string } | null)?.estado;
const newEstado = (payload.new as { estado?: string } | null)?.estado;
// Fallback: si el WAL no envió estado en `old` (tabla aún en REPLICA IDENTITY DEFAULT),
// caemos al refetch original — un poco más caro pero correcto.
if (oldEstado === undefined) {
refetch();
return;
}
if (oldEstado === 'pendiente' && newEstado !== 'pendiente') {
setBadge(Math.max(0, readBadge() - 1));
} else if (oldEstado !== 'pendiente' && newEstado === 'pendiente') {
setBadge(readBadge() + 1);
}
// Si ninguno de los dos es 'pendiente', el UPDATE no afecta al count — no hacer nada.
},
)
.subscribe();
@@ -56,5 +85,28 @@ export default function BadgeSolicitudes() {
};
}, []);
return null;
if (count !== undefined) {
return (
<span
data-badge="pendientes"
hidden={count === 0}
aria-label={`${count} solicitudes pendientes`}
className={`inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full font-sans font-medium text-[11px] leading-none bg-verde-institucional text-bone-white ${className ?? ''}`}
>
{count}
</span>
);
}
return (
<style>{`
[data-badge="pendientes"] {
background-color: var(--color-verde-institucional) !important;
color: var(--color-bone-white) !important;
border-radius: 9999px !important;
min-width: 18px !important;
height: 18px !important;
}
`}</style>
);
}
+109 -52
View File
@@ -21,50 +21,67 @@ type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?:
type Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number };
type Props = Barras | Linea | Donut;
const INK = '#383838';
const PENCIL = '#a1a1a1';
const PRIMARY = '#00723F';
const SECONDARY = '#DD971A';
const DANGER = '#a83224';
const POSITIVE = '#38c1b0';
const ATTENTION = '#ffde00';
// PALETA UABC (TRACK E):
// Primario: #00723F (verde-institucional)
// Secundarios: #DD971A (dorado-uabc), #50B848 (verde-claro), #20419A (azul-institucional), #024731 (verde-jardin)
// Alerta: #000000 (negro-institucional)
// Superficie: #fffefb (bone-white)
// Texto: #333833 (graphite)
// Bordes: #e5e7eb (hairline-gray)
const MONO: React.CSSProperties = {
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
fontSize: 11,
color: INK,
};
const VERDE_INSTITUCIONAL = '#00723F';
const DORADO_UABC = '#DD971A';
const VERDE_CLARO = '#50B848';
const AZUL_INSTITUCIONAL = '#20419A';
const VERDE_JARDIN = '#024731';
const NEGRO_INSTITUCIONAL = '#000000';
const BONE_WHITE = '#fffefb';
const GRAPHITE = '#333833';
const HAIRLINE_GRAY = '#e5e7eb';
const PRIMARY = VERDE_INSTITUCIONAL;
const ALERT = NEGRO_INSTITUCIONAL;
// Paleta rotativa para el donut. Coloreado por label cuando aplique.
const DONUT_BY_LABEL: Record<string, string> = {
pendiente: ATTENTION,
aprobado: PRIMARY,
activo: PRIMARY,
devuelto: POSITIVE,
rechazado: PENCIL,
vencido: DANGER,
pendiente: DORADO_UABC,
aprobado: VERDE_INSTITUCIONAL,
activo: VERDE_CLARO,
devuelto: VERDE_JARDIN,
rechazado: GRAPHITE,
vencido: ALERT,
};
const DONUT_FALLBACK = [PRIMARY, SECONDARY, POSITIVE, ATTENTION, PENCIL, DANGER];
const DONUT_FALLBACK = [
VERDE_INSTITUCIONAL,
DORADO_UABC,
VERDE_CLARO,
AZUL_INSTITUCIONAL,
VERDE_JARDIN,
ALERT,
];
function donutColor(label: string, i: number): string {
return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length];
}
const TOOLTIP_CONTENT_STYLE: React.CSSProperties = {
backgroundColor: '#fffefb',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: '8px 12px',
color: '#333833',
boxShadow: 'none',
fontFamily: "'Azeret Mono', monospace",
fontSize: 12,
};
function TooltipBox({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
return (
<div
style={{
background: 'white',
border: `2px solid ${INK}`,
borderRadius: 2,
padding: '6px 10px',
...MONO,
}}
>
{label !== undefined && <div style={{ fontWeight: 600 }}>{label}</div>}
<div style={TOOLTIP_CONTENT_STYLE}>
{label !== undefined && <div style={{ fontWeight: 500, color: VERDE_INSTITUCIONAL, marginBottom: 4 }}>{label}</div>}
{payload.map((p: any, i: number) => (
<div key={i}>
<div key={i} style={{ fontVariantNumeric: 'tabular-nums' }}>
{p.name}: <strong>{p.value}</strong>
</div>
))}
@@ -86,8 +103,11 @@ function usePrefersReducedMotion(): boolean {
function fmtFechaCorta(iso: string): string {
// 'YYYY-MM-DD' → 'DD/MM'
const [, m, d] = iso.split('-');
return `${d}/${m}`;
const parts = iso.split('-');
if (parts.length >= 3) {
return `${parts[2]}/${parts[1]}`;
}
return iso;
}
export default function Chart(props: Props) {
@@ -103,21 +123,40 @@ export default function Chart(props: Props) {
);
return (
<ResponsiveContainer width="100%" height={alto}>
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" horizontal={false} />
<XAxis type="number" allowDecimals={false} stroke={INK} tick={MONO as any} />
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 28, left: 8, bottom: 8 }}>
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" horizontal={false} />
<XAxis
type="number"
allowDecimals={false}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
<YAxis
type="category"
dataKey="label"
width={100}
stroke={INK}
tick={{ ...MONO, fontSize: 10 } as any}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace" } as any}
interval={0}
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
/>
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
<LabelList dataKey="valor" position="right" style={MONO as any} />
<Tooltip
content={<TooltipBox />}
cursor={{ fill: 'rgba(0, 114, 63, 0.05)' }}
contentStyle={TOOLTIP_CONTENT_STYLE}
/>
<Bar
dataKey="valor"
fill={PRIMARY}
radius={[0, 8, 8, 0]}
animationDuration={anim}
name="Solicitado"
>
<LabelList
dataKey="valor"
position="right"
style={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
@@ -129,17 +168,30 @@ export default function Chart(props: Props) {
return (
<ResponsiveContainer width="100%" height={alto}>
<LineChart data={datos} margin={{ top: 8, right: 16, left: 0, bottom: 8 }}>
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" />
<XAxis dataKey="fechaCorta" stroke={INK} tick={MONO as any} interval="preserveStartEnd" />
<YAxis allowDecimals={false} stroke={INK} tick={MONO as any} />
<Tooltip content={<TooltipBox />} cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} />
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" />
<XAxis
dataKey="fechaCorta"
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
stroke="#e5e7eb"
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
/>
<Tooltip
content={<TooltipBox />}
cursor={{ stroke: '#e5e7eb', strokeWidth: 1, strokeDasharray: '2 3' }}
contentStyle={TOOLTIP_CONTENT_STYLE}
/>
<Line
type="monotone"
dataKey="valor"
stroke={PRIMARY}
strokeWidth={2}
dot={{ fill: PRIMARY, stroke: INK, strokeWidth: 1.5, r: 3 }}
activeDot={{ fill: SECONDARY, stroke: INK, strokeWidth: 1.5, r: 5 }}
dot={{ fill: PRIMARY, stroke: BONE_WHITE, strokeWidth: 1, r: 3 }}
activeDot={{ fill: DORADO_UABC, stroke: PRIMARY, strokeWidth: 1, r: 5 }}
animationDuration={anim}
name="Solicitudes"
/>
@@ -153,15 +205,15 @@ export default function Chart(props: Props) {
return (
<ResponsiveContainer width="100%" height={alto}>
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
<Tooltip content={<TooltipBox />} />
<Tooltip content={<TooltipBox />} contentStyle={TOOLTIP_CONTENT_STYLE} />
<Pie
data={datos}
dataKey="valor"
nameKey="label"
innerRadius="55%"
outerRadius="85%"
stroke={INK}
strokeWidth={1.5}
stroke={BONE_WHITE}
strokeWidth={1}
isAnimationActive={!reduced}
animationDuration={anim}
label={false}
@@ -175,11 +227,16 @@ export default function Chart(props: Props) {
layout="horizontal"
verticalAlign="bottom"
align="center"
iconType="square"
wrapperStyle={{ ...MONO, marginTop: 8 }}
iconType="circle"
wrapperStyle={{
fontFamily: "'Azeret Mono', monospace",
fontSize: 12,
color: '#333833',
marginTop: 12,
}}
formatter={(value: string, entry: any) => {
const v = entry?.payload?.valor;
return v != null ? `${value} (${v})` : value;
return <span style={{ color: '#333833', marginRight: 8, fontFamily: "'Azeret Mono', monospace" }}>{v != null ? `${value} (${v})` : value}</span>;
}}
/>
</PieChart>
@@ -81,20 +81,23 @@ export default function CategoriaForm({ mode, categoria }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,24rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nueva categoría' : `Editar: ${categoria?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -122,8 +125,7 @@ export default function CategoriaForm({ mode, categoria }: Props) {
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -54,17 +54,9 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
<>
<button
type="button"
className="btn btn-ghost transition-colors duration-150"
className="btn btn-ghost"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-danger)';
e.currentTarget.style.color = 'white';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-ink)';
}}
>
Eliminar
</button>
@@ -72,37 +64,37 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar categoría
</h2>
{bloqueado ? (
<>
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<p className="text-sm text-graphite">
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{materialesCount}</span>{' '}
material{materialesCount === 1 ? '' : 'es'} asociado
{materialesCount === 1 ? '' : 's'}. Reasígnalos primero.
</p>
<a
href={`/admin/inventario?cat=${id}`}
className="text-sm underline"
style={{ color: 'var(--color-primary)' }}
className="text-sm text-verde-institucional hover:text-verde-claro underline"
>
Ver materiales de esta categoría →
</a>
</>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -114,8 +106,7 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
@@ -55,8 +55,8 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
<button
type="button"
className={
'btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]' +
(compact ? ' w-full text-sm px-2' : '')
'btn btn-ghost' +
(compact ? ' w-full text-xs px-2' : '')
}
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={open}
@@ -67,22 +67,23 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,26rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar material
</h2>
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>? Esta acción no se puede deshacer.
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>? Esta acción no se puede deshacer.
</p>
{tienePrestamos && (
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p className="text-sm text-negro-institucional">
Este material tiene préstamos asociados y no podrá eliminarse.
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -92,8 +93,7 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
</button>
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
@@ -186,7 +186,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
const triggerClass =
(mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost') +
(compact ? ' w-full text-sm px-2' : '');
(compact ? ' w-full text-xs px-2' : '');
const triggerStyle = compact
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
: undefined;
@@ -202,18 +202,19 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(94vw,36rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(94vw,36rem)] max-h-[90vh] overflow-y-auto"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nuevo material' : `Editar: ${material?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
@@ -226,13 +227,12 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
<label className="label" htmlFor={`${titleId}-file`}>Foto</label>
<div className="flex items-start gap-3">
<div
className="w-24 h-24 shrink-0 grid place-items-center bg-[color:var(--color-chalk)]"
style={{ border: '2px solid var(--color-ink)' }}
className="w-24 h-24 shrink-0 grid place-items-center bg-linen rounded-[12px] border border-hairline-gray overflow-hidden"
>
{currentImg ? (
<img src={currentImg} alt="" className="w-full h-full object-cover" />
) : (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" className="stroke-graphite" strokeWidth="1.5" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
@@ -251,7 +251,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
{(file || imagenPath) && (
<button
type="button"
className="btn btn-ghost self-start"
className="btn btn-ghost self-start text-xs"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => {
setFile(null);
@@ -326,16 +326,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
/>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex items-start gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
checked={trackeado}
onChange={(e) => setTrackeado(e.target.checked)}
className="mt-1"
className="mt-1 accent-verde-institucional"
/>
<span>
<span className="font-medium">Rastrear cada unidad por separado</span>
<span className="block opacity-70 text-xs">
<span className="block text-graphite/70 text-xs">
Ej. Laptop-01, Laptop-02… El stock se calcula del número de unidades registradas abajo.
</span>
</span>
@@ -359,10 +359,10 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
{trackeado && (
<p className="text-xs opacity-70 mt-1">Se calcula del número de unidades.</p>
<p className="text-xs text-graphite/70 mt-1">Se calcula del número de unidades.</p>
)}
{!trackeado && mode === 'edit' && material && (
<p className="text-xs opacity-60 mt-1">
<p className="text-xs text-graphite/60 mt-1">
Prestados: {material.cantidad_total - material.cantidad_disponible}
</p>
)}
@@ -389,8 +389,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -408,10 +407,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
{/* Gestor de unidades — solo edit + trackeado */}
{mode === 'edit' && material && material.trackeado_por_unidad && (
<div
className="border-t-2 p-5 sm:p-6"
style={{ borderColor: 'var(--color-ink)', background: 'var(--color-chalk)' }}
>
<div className="border-t border-hairline-gray p-6 bg-linen rounded-b-[12px]">
<UnidadesManager materialId={material.id} />
</div>
)}
@@ -88,7 +88,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
return (
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3">Unidades individuales</h3>
<h3 className="eyebrow mb-3">Unidades individuales</h3>
<form onSubmit={crear} className="flex gap-2 mb-4">
<input
@@ -110,9 +110,9 @@ export default function UnidadesManager({ materialId }: { materialId: number })
</form>
{loading ? (
<p className="text-sm opacity-70">Cargando…</p>
<p className="text-sm text-graphite/70">Cargando…</p>
) : unidades.length === 0 ? (
<p className="text-sm opacity-70">Sin unidades registradas.</p>
<p className="text-sm text-graphite/70">Sin unidades registradas.</p>
) : (
<div className="flex flex-col gap-2">
{unidades.map((u) => {
@@ -120,7 +120,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
return (
<div
key={u.id}
className="card p-3 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center"
className="card-flat p-3.5 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center bg-bone-white border border-hairline-gray rounded-[12px]"
>
<input
type="text"
@@ -151,7 +151,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
</select>
<button
type="button"
className="btn btn-ghost"
className="btn btn-danger text-xs px-3"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
disabled={bloqueada}
onClick={() => eliminar(u)}
@@ -64,27 +64,28 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Eliminar maestro
</h2>
{bloqueado ? (
<p className="text-sm">
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
<p className="text-sm text-graphite">
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
</p>
) : (
<p className="text-sm">
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
<p className="text-sm text-graphite">
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
</p>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
@@ -96,8 +97,7 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
{!bloqueado && (
<button
type="button"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
onClick={submit}
disabled={loading}
>
+15 -12
View File
@@ -87,20 +87,23 @@ export default function MaestroForm({ mode, maestro }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,24rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
{mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@@ -122,23 +125,24 @@ export default function MaestroForm({ mode, maestro }: Props) {
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<label className="flex items-center gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
checked={activo}
onChange={(e) => setActivo(e.target.checked)}
className="accent-verde-institucional"
/>
Activo (visible en el catálogo de maestros)
<span>Activo <span className="text-graphite/70 text-xs">(visible en el catálogo de maestros)</span></span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex items-start gap-2 text-sm cursor-pointer text-graphite">
<input
type="checkbox"
className="mt-0.5"
className="mt-0.5 accent-verde-institucional"
checked={esTutor}
onChange={(e) => setEsTutor(e.target.checked)}
/>
<span>Es tutor <span className="opacity-70">(aparece en la lista de tutores del perfil de alumnos)</span></span>
<span>Es tutor <span className="text-graphite/70 text-xs">(aparece en la lista de tutores del perfil de alumnos)</span></span>
</label>
{error && (
@@ -147,8 +151,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
+34 -24
View File
@@ -107,7 +107,7 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
ref={inputRef}
id={inputId}
type="text"
className="input"
className="input rounded-[8px]"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
@@ -119,13 +119,18 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
setText(e.target.value);
if (value) setValue(''); // el usuario editó → invalidar la selección previa
}}
onFocus={() => items.length && setOpen(true)}
onFocus={() => items.length > 0 && setOpen(true)}
onKeyDown={onKey}
autoComplete="off"
spellCheck={false}
/>
{(text || value) && (
<button type="button" className="btn btn-ghost px-3" onClick={clear} aria-label="Limpiar">
<button
type="button"
className="btn btn-ghost px-3"
onClick={clear}
aria-label="Limpiar"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -136,32 +141,37 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
<ul
id={listId}
role="listbox"
className="absolute z-10 mt-1 w-full bg-white rounded-lg max-h-64 overflow-auto"
style={{ border: '1px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
className="absolute z-50 mt-1 w-full bg-bone-white border border-hairline-gray rounded-[8px] max-h-64 overflow-auto shadow-none p-1"
>
{loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Buscando…</li>
<li className="px-3 py-2 text-sm text-graphite opacity-70">Buscando…</li>
)}
{!loading && items.length === 0 && (
<li className="px-3 py-2 text-sm opacity-70">Sin resultados</li>
<li className="px-3 py-2 text-sm text-graphite opacity-70">Sin resultados</li>
)}
{items.map((it, i) => (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === active}
className="px-3 py-2 text-sm cursor-pointer transition-colors"
style={i === active ? { background: 'color-mix(in oklab, var(--color-primary) 10%, transparent)' } : undefined}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
))}
{items.map((it, i) => {
const isSelected = String(it.id) === value;
const isFocused = i === active;
return (
<li
key={it.id}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={isFocused || isSelected}
className={`px-3 py-2 text-sm cursor-pointer transition-colors rounded-[8px] ${
isFocused || isSelected
? 'bg-linen text-verde-institucional font-medium'
: 'text-graphite hover:bg-linen'
}`}
onMouseDown={(e) => {
e.preventDefault();
pick(it);
}}
>
{it.label}
</li>
);
})}
</ul>
)}
</div>
@@ -82,24 +82,24 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
return (
<>
<button type="button" className="btn btn-primary" onClick={dlg.open}>Aprobar</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
<dialog ref={dlg.ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Aprobar solicitud</h2>
<p className="text-sm text-graphite/70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:
</p>
<ul className="text-sm mt-1 list-disc pl-5">
<ul className="text-sm mt-2 list-disc pl-5 space-y-1">
{prestamo.items.map((i, idx) => (
<li key={idx}>
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
{(i.material?.cantidad_disponible ?? 0) < i.cantidad && (
<span style={{ color: 'var(--color-danger-text)' }}> — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
<span className="text-dorado-uabc font-medium"> — stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
)}
</li>
))}
</ul>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
Uno o más materiales no tienen stock suficiente.
</div>
)}
@@ -128,7 +128,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
@@ -184,18 +184,18 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<>
<button
type="button"
className="btn btn-ghost hover:!text-[color:var(--color-danger)]"
className="btn btn-danger"
onClick={dlg.open}
>
Rechazar
</button>
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
<dialog ref={dlg.ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Rechazar solicitud</h2>
<p className="text-sm text-graphite/70 mt-1">
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
</p>
<p className="text-sm opacity-70 mt-1">
<p className="text-sm text-graphite/70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>
<div className="mt-4">
@@ -213,7 +213,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
/>
</div>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
@@ -221,9 +221,8 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<button type="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
<button
type="submit"
className="btn"
className="btn btn-danger"
disabled={loading}
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
>
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
</button>
@@ -40,23 +40,23 @@ export default function MarcarDevuelto({ prestamoId, resumenMateriales }: { pres
return (
<>
<button type="button" className="btn btn-secondary" onClick={() => ref.current?.showModal()}>
<button type="button" className="btn btn-primary" onClick={() => ref.current?.showModal()}>
Marcar devuelto
</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Confirmar devolución</h2>
<p className="text-sm opacity-80 mt-2">
¿Confirmas la devolución de <strong>{resumenMateriales}</strong>? El material regresará al inventario disponible.
<dialog ref={ref} className="card rounded-[12px] max-w-md w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<form onSubmit={submit} className="p-0">
<h2 className="text-xl md:text-2xl font-light text-graphite">Confirmar devolución</h2>
<p className="text-sm text-graphite/80 mt-2">
¿Confirmas la devolución de <strong className="text-graphite">{resumenMateriales}</strong>? El material regresará al inventario disponible.
</p>
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} disabled={loading}>Cancelar</button>
<button type="submit" className="btn btn-secondary" disabled={loading}>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Registrando…' : 'Confirmar devolución'}
</button>
</div>
+109 -79
View File
@@ -94,8 +94,7 @@ function ReasignarUnidad({
return (
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[28px]"
onClick={abrir}
>
Cambiar unidad
@@ -103,10 +102,9 @@ function ReasignarUnidad({
);
}
return (
<div className="flex flex-wrap items-center gap-1 mt-1">
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<select
className="input"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="input text-xs py-1 px-2 min-h-[32px] w-auto"
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={loading || saving}
@@ -118,8 +116,7 @@ function ReasignarUnidad({
</select>
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-primary text-xs py-1 px-2.5 min-h-[32px]"
onClick={confirmar}
disabled={!selected || saving}
>
@@ -127,8 +124,7 @@ function ReasignarUnidad({
</button>
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[32px]"
onClick={() => setOpen(false)}
disabled={saving}
>
@@ -170,84 +166,118 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
const getChipCls = (st: string) => {
const map: Record<string, string> = {
pendiente: 'chip-pendiente',
aprobado: 'chip-aprobado',
activo: 'chip-activo',
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
return map[st] ?? 'chip-pendiente';
};
return (
<>
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-lg w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
<button type="button" className="rounded-lg p-1.5 hover:bg-black/5 transition-colors" onClick={() => ref.current?.close()} aria-label="Cerrar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button type="button" className="btn btn-ghost" onClick={open}>Ver detalles</button>
<dialog ref={ref} className="card rounded-[12px] max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<div className="flex items-start justify-between gap-4">
<h2 className="text-xl md:text-2xl font-light text-graphite">Detalle del préstamo</h2>
<button
type="button"
className="rounded-full p-2 text-graphite/70 hover:text-graphite hover:bg-linen transition-colors"
onClick={() => ref.current?.close()}
aria-label="Cerrar"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{loading && <p className="mt-4 text-sm text-graphite/70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
{loading && <p className="mt-4 text-sm opacity-70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
{error}
</div>
)}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Maestro</dt>
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt>
<dd className="col-span-2">
<ul className="list-disc pl-4 space-y-2">
{data.prestamo.items.map((i) => (
<li key={i.id}>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
{i.material_unidad?.etiqueta && (
<div className="text-xs">
<span className="opacity-70">Unidad: </span>
<span className="font-semibold">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-3 text-sm">
<dt className="text-graphite/70">Alumno</dt>
<dd className="col-span-2 text-graphite font-medium">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="text-graphite/70">Maestro</dt>
<dd className="col-span-2 break-words text-graphite">{data.prestamo.maestro_responsable}</dd>
<dt className="text-graphite/70">Materiales</dt>
<dd className="col-span-2">
<ul className="border-y border-hairline-gray my-2 divide-y divide-hairline-gray">
{data.prestamo.items.map((i) => (
<li key={i.id} className="py-2.5 first:pt-2 last:pb-2 border-b border-hairline-gray last:border-b-0">
<div className="flex items-baseline justify-between gap-2">
<div>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}×</strong>{' '}
<span className="font-medium text-graphite">{i.material?.nombre ?? '—'}</span>
<span className="text-xs text-graphite/60 ml-2">Inv. {i.material?.numero_inventario ?? '—'}</span>
</div>
{canReasignar && i.material_unidad && (
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
)}
</li>
))}
</ul>
</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm opacity-70">Sin cambios registrados.</p>
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>
<span className="opacity-80">
{(e.estado_anterior ?? '—')} → {(e.estado_nuevo ?? '—')}
</span>
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>}
</li>
);
})}
</div>
{i.material_unidad?.etiqueta && (
<div className="text-xs text-graphite/70 mt-1">
Unidad: <span className="font-medium text-graphite">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs text-graphite/60 mt-0.5">{i.descripcion}</div>}
</li>
))}
</ul>
)}
</>
)}
</div>
</dd>
<dt className="text-graphite/70">Solicitado</dt>
<dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="text-graphite/70">Aprobado</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="text-graphite/70">Devolver antes</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="text-graphite/70">Notas</dt><dd className="col-span-2 text-graphite whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-3 text-base font-medium text-graphite">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm text-graphite/70">Sin cambios registrados.</p>
) : (
<div className="relative pl-4 border-l border-hairline-gray space-y-3 my-2">
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
const estadoNuevo = e.estado_nuevo;
const estadoAnt = e.estado_anterior;
return (
<div key={i} className="relative">
<div className="absolute -left-[21px] top-1.5 w-2 h-2 rounded-full bg-verde-institucional" />
<div className="flex items-center justify-between gap-2 flex-wrap text-sm">
<span className="font-medium text-graphite capitalize">{e.accion ?? 'Cambio'}</span>
{fecha && <span className="text-xs text-graphite/60">{fmt.format(new Date(fecha))}</span>}
</div>
{(estadoAnt || estadoNuevo) && (
<div className="flex items-center gap-1.5 mt-1 text-xs">
{estadoAnt && (
<>
<span className={`chip ${getChipCls(estadoAnt)}`}>{estadoAnt}</span>
<span className="text-graphite/40">→</span>
</>
)}
{estadoNuevo && (
<span className={`chip ${getChipCls(estadoNuevo)}`}>{estadoNuevo}</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
</dialog>
</>
);
+41 -14
View File
@@ -11,6 +11,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [razon, setRazon] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -21,6 +22,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
if (adminSelf) return;
setError(null);
setRazon('');
setExpiresAt('');
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
@@ -54,10 +56,18 @@ export default function BanearForm({ profile, adminSelf }: Props) {
setLoading(true);
setError(null);
try {
const payload: { profile_id: string; razon: string; expires_at?: string } = {
profile_id: profile.id,
razon: trimmed,
};
if (expiresAt) {
payload.expires_at = new Date(expiresAt + 'T23:59:59').toISOString();
}
const res = await fetch('/api/admin/baneos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profile.id, razon: trimmed }),
body: JSON.stringify(payload),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear');
@@ -79,11 +89,10 @@ export default function BanearForm({ profile, adminSelf }: Props) {
<>
<button
type="button"
className="btn btn-ghost"
className="btn btn-danger text-xs px-3"
style={{
minHeight: '36px',
paddingBlock: '0.25rem',
color: adminSelf ? undefined : 'var(--color-danger-text)',
opacity: adminSelf ? 0.5 : 1,
cursor: adminSelf ? 'not-allowed' : undefined,
}}
@@ -98,24 +107,27 @@ export default function BanearForm({ profile, adminSelf }: Props) {
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,28rem)]"
style={{ boxShadow: 'none' }}
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<form onSubmit={submit} className="p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<h2 id={titleId} className="text-xl text-graphite font-normal">
Banear a {label}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
className="rounded-[8px] p-1.5 hover:bg-linen text-graphite transition-colors leading-none"
>
<span aria-hidden="true">×</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm" style={{ color: 'var(--color-pencil)' }}>
<p className="text-sm text-graphite/70">
El usuario no podrá acceder al sistema hasta ser desbaneado.
</p>
@@ -137,19 +149,35 @@ export default function BanearForm({ profile, adminSelf }: Props) {
aria-describedby={error ? `${titleId}-err` : undefined}
placeholder="Explica el motivo — el usuario lo verá en su pantalla de bloqueo."
/>
<p className="text-xs mt-1" style={{ color: 'var(--color-pencil)' }}>
<p className="text-xs text-graphite/60 mt-1">
{razon.length}/500
</p>
</div>
<div>
<label className="label" htmlFor={`${titleId}-expires`}>
Fecha de expiración (opcional)
</label>
<input
id={`${titleId}-expires`}
type="date"
className="input"
value={expiresAt}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setExpiresAt(e.target.value)}
/>
<p className="text-xs text-graphite/60 mt-1">
Opcional. Si se omite, el baneo será indefinido hasta desbanear manualmente.
</p>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
className="text-sm text-negro-institucional"
>
{error}
</p>
@@ -161,8 +189,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
</button>
<button
type="submit"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
className="btn btn-danger"
disabled={loading}
>
{loading ? 'Baneando…' : 'Banear'}
@@ -37,7 +37,7 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
<>
<button
type="button"
className="btn btn-primary"
className="btn btn-secondary text-xs px-3"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={submit}
disabled={loading}
@@ -48,17 +48,18 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
<dialog
ref={errorDialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
className="card bg-bone-white rounded-[12px] p-0 border border-hairline-gray backdrop:bg-negro-institucional/40 w-[min(92vw,26rem)]"
style={{ boxShadow: 'none' }}
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
<div className="p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-xl text-graphite font-normal">
No se pudo desbanear
</h2>
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p className="text-sm text-negro-institucional">
{error}
</p>
<div className="flex justify-end pt-2">
<button type="button" className="btn btn-primary" onClick={() => errorDialogRef.current?.close()}>
<button type="button" className="btn btn-ghost" onClick={() => errorDialogRef.current?.close()}>
Entendido
</button>
</div>
+42 -9
View File
@@ -6,32 +6,65 @@ type Props = {
};
export default function AgregarMaterial({ material }: Props) {
const { addItem, has } = useCart();
const { items, addItem, updateCantidad, removeItem, has } = useCart();
const sinStock = material.cantidad_disponible === 0;
const enCarrito = has(material.id);
if (enCarrito) {
const item = items.find((i) => i.material_id === material.id);
const cantidad = item?.cantidad ?? 1;
return (
<button type="button" className="btn btn-secondary w-full" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4.5 12.75l6 6 9-13.5" />
</svg>
En tu solicitud
</button>
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-1">
<button
type="button"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
aria-label={`Disminuir cantidad de ${material.nombre}`}
onClick={() => {
if (cantidad > 1) {
updateCantidad(material.id, cantidad - 1);
} else {
removeItem(material.id);
toast({ kind: 'info', title: 'Eliminado de tu solicitud', description: material.nombre });
}
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<span className="font-sans text-sm font-medium text-graphite min-w-[1.75rem] text-center" style={{ fontVariantNumeric: 'tabular-nums' }}>
{cantidad}
</span>
<button
type="button"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
disabled={cantidad >= material.cantidad_disponible}
aria-label={`Aumentar cantidad de ${material.nombre}`}
onClick={() => updateCantidad(material.id, cantidad + 1)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<span className="text-xs text-graphite/70 truncate">En tu solicitud</span>
</div>
);
}
return (
<button
type="button"
className="btn btn-primary w-full"
className="btn btn-secondary w-full"
disabled={sinStock}
onClick={() => {
addItem(material);
toast({ kind: 'success', title: 'Agregado a tu solicitud', description: material.nombre });
}}
>
{sinStock ? 'Sin stock' : 'Agregar'}
{sinStock ? 'Sin stock' : 'Agregar al carrito'}
</button>
);
}
+10 -5
View File
@@ -82,25 +82,30 @@ export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
<label className="label sr-only" htmlFor="buscador-catalogo">
Buscar material
</label>
<div className="relative">
<div className="relative flex items-center">
<span className="absolute left-3.5 pointer-events-none text-graphite/50 flex items-center" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="stroke-graphite">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
id="buscador-catalogo"
className="input"
className="input pl-11 pr-10 rounded-[8px]"
type="search"
placeholder="Buscar por nombre o número de inventario…"
value={q}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
style={{ paddingRight: q ? '2.5rem' : undefined }}
/>
{q && (
<button
type="button"
onClick={clear}
aria-label="Limpiar búsqueda"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-[2px] hover:bg-black/5 transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-full hover:bg-linen text-graphite transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
+2 -9
View File
@@ -50,13 +50,8 @@ export default function FiltroCategorias({ categorias }: Props) {
const chipClass = (v: string) =>
v === active
? 'btn'
: 'btn btn-ghost';
const chipStyle = (v: string): React.CSSProperties =>
v === active
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
: { minHeight: '40px', paddingBlock: '0.375rem' };
? 'bg-verde-institucional text-bone-white rounded-[8px] px-3 py-1 border border-verde-institucional inline-flex items-center whitespace-nowrap text-xs font-medium cursor-pointer transition-colors'
: 'chip cursor-pointer hover:bg-linen hover:border-dorado-uabc transition-colors';
return (
<div
@@ -70,7 +65,6 @@ export default function FiltroCategorias({ categorias }: Props) {
role="tab"
aria-selected={active === ALL}
className={chipClass(ALL)}
style={chipStyle(ALL)}
data-cat-value={ALL}
data-cat-active={active === ALL ? 'true' : undefined}
onClick={() => pick(ALL)}
@@ -86,7 +80,6 @@ export default function FiltroCategorias({ categorias }: Props) {
role="tab"
aria-selected={active === v}
className={chipClass(v)}
style={chipStyle(v)}
data-cat-value={v}
data-cat-active={active === v ? 'true' : undefined}
onClick={() => pick(v)}
+39 -51
View File
@@ -14,14 +14,6 @@ type Material = {
categoria: { id: number; nombre: string } | null;
};
// El catálogo sólo lista materiales con estado 'disponible' (ver catalogo.astro),
// así que el chip siempre coincide con el mismo estilo que usa el inventario del admin.
const DISPONIBLE_BADGE_STYLE = {
background: 'color-mix(in oklab, var(--color-positive) 18%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-positive)',
} as const;
type CartItem = {
material_id: number;
nombre: string;
@@ -238,7 +230,7 @@ export default function CartProvider({
return (
<CartContext.Provider value={value}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{materiales.map((m) => {
const src = imgUrl(m.imagen_path);
return (
@@ -249,34 +241,32 @@ export default function CartProvider({
data-nombre={m.nombre}
data-numero-inventario={m.numero_inventario ?? ''}
>
<div
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style={{ borderBottom: '2px solid var(--color-ink)' }}
>
<div className="relative aspect-square w-full rounded-t-[12px] overflow-hidden bg-linen flex items-center justify-center border-b border-hairline-gray">
{src ? (
<img src={src} alt={m.nombre} className="w-full h-full object-cover" loading="lazy" />
<img src={src} alt={m.nombre} className="w-full h-full aspect-square object-cover rounded-t-[12px]" loading="lazy" />
) : (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="text-graphite/40" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
<span
className="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]"
style={DISPONIBLE_BADGE_STYLE}
>
<span className="absolute bottom-2 right-2 badge">
Disponible
</span>
</div>
<div className="p-4 flex flex-col gap-3 flex-1">
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
<div className="p-6 flex flex-col gap-2 flex-1">
<span className="eyebrow text-dorado-uabc">{m.categoria?.nombre ?? 'Sin categoría'}</span>
<h3 className="text-[16px] font-medium text-graphite leading-snug">
{m.nombre}
</h3>
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
{m.descripcion && (
<p className="text-sm text-graphite/75 line-clamp-2">{m.descripcion}</p>
)}
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
<dl className="text-xs text-graphite/70 grid grid-cols-2 gap-1 my-2">
<dt className="opacity-60">Inventario</dt>
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt className="opacity-60">Disponibles</dt>
@@ -285,7 +275,7 @@ export default function CartProvider({
</dd>
</dl>
<div className="mt-auto">
<div className="mt-auto pt-2">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
</div>
</div>
@@ -297,8 +287,7 @@ export default function CartProvider({
{items.length > 0 && (
<button
type="button"
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40"
style={{ boxShadow: 'var(--shadow-hard)' }}
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40 px-6 py-3"
onClick={openCheckout}
>
Ver solicitud ({items.length})
@@ -308,44 +297,43 @@ export default function CartProvider({
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
className="card bg-bone-white rounded-[12px] p-6 text-graphite border border-hairline-gray backdrop:bg-negro-institucional/60 w-[min(92vw,32rem)] m-auto shadow-none"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
<form onSubmit={submit} className="flex flex-col gap-5">
<div className="flex items-center justify-between gap-3 pb-3 border-b border-hairline-gray">
<h2 id={titleId} className="text-xl font-normal text-graphite">
Tu solicitud
</h2>
<button
type="button"
onClick={closeCheckout}
aria-label="Cerrar"
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{items.length === 0 ? (
<p className="text-sm opacity-70">Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.</p>
<p className="text-sm text-graphite/70">Tu solicitud está vacía. Cierra esta ventana y agrega material del catálogo.</p>
) : (
<ul className="flex flex-col gap-3 max-h-64 overflow-y-auto -mx-1 px-1">
{items.map((item) => (
<li
key={item.material_id}
className="flex items-start gap-2 pb-3"
style={{ borderBottom: '1.5px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
className="flex items-start gap-3 pb-3 border-b border-hairline-gray"
>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.nombre}</p>
<p className="font-sans font-medium text-sm text-graphite truncate">{item.nombre}</p>
<div className="flex items-center gap-2 mt-1.5">
<label className="sr-only" htmlFor={`cant-cart-${item.material_id}`}>
Cantidad de {item.nombre}
</label>
<input
id={`cant-cart-${item.material_id}`}
className="input"
className="input rounded-[8px]"
type="number"
min={1}
max={item.cantidad_disponible}
@@ -354,14 +342,14 @@ export default function CartProvider({
onChange={(e) => updateCantidad(item.material_id, Number(e.target.value) || 1)}
style={{ width: '4.5rem', minHeight: '36px', paddingBlock: '0.25rem', fontVariantNumeric: 'tabular-nums' }}
/>
<span className="text-xs opacity-60">de {item.cantidad_disponible}</span>
<span className="text-xs text-graphite/70">de {item.cantidad_disponible}</span>
</div>
<label className="sr-only" htmlFor={`desc-cart-${item.material_id}`}>
Nota para {item.nombre}
</label>
<input
id={`desc-cart-${item.material_id}`}
className="input mt-2"
className="input rounded-[8px] mt-2"
type="text"
maxLength={200}
value={item.descripcion}
@@ -374,9 +362,9 @@ export default function CartProvider({
type="button"
onClick={() => removeItem(item.material_id)}
aria-label={`Quitar ${item.nombre}`}
className="rounded-lg p-1.5 hover:bg-black/5 transition-colors shrink-0"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
@@ -391,7 +379,7 @@ export default function CartProvider({
<select
ref={firstFieldRef}
id="maestro-select"
className="input"
className="input rounded-[8px]"
value={maestroId}
onChange={(e) => setMaestroId(e.target.value)}
aria-describedby="maestro-hint"
@@ -403,7 +391,7 @@ export default function CartProvider({
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p id="maestro-hint" className="text-xs opacity-70 mt-1">
<p id="maestro-hint" className="text-xs text-graphite/70 mt-1">
{tutorNombre
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
: 'Debes elegir un maestro; no tienes tutor guardado.'}
@@ -415,7 +403,7 @@ export default function CartProvider({
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
<textarea
id="notas-cart"
className="input"
className="input rounded-[8px]"
rows={3}
maxLength={100}
value={notas}
@@ -424,25 +412,25 @@ export default function CartProvider({
style={{ minHeight: '96px' }}
aria-describedby="notas-cart-hint"
/>
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
<p id="notas-cart-hint" className="text-xs text-graphite/70 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
</p>
</div>
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error} · Ajusta la solicitud e intenta de nuevo.
</p>
)}
{ok && (
<p role="status" className="text-sm" style={{ color: 'var(--color-positive)' }}>
<p role="status" className="text-sm text-verde-institucional font-medium">
Solicitud enviada. Actualizando…
</p>
)}
{!perfilCompleto ? (
<div className="card p-3">
<p className="text-sm mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
<div className="card p-4 rounded-[12px] bg-linen border border-hairline-gray">
<p className="text-sm text-graphite mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
</div>
) : (
+10 -15
View File
@@ -6,31 +6,26 @@ type Props = {
className?: string;
};
// Contraste rápido: fondo HSL con L=55% → texto blanco pasa AA con casi todos los hues.
// Mantenemos blanco fijo para consistencia visual.
export default function Avatar({ info, size = 40, className = '' }: Props) {
const style: React.CSSProperties = {
width: size,
height: size,
border: '2px solid var(--color-ink)',
borderRadius: 2,
display: 'grid',
placeItems: 'center',
overflow: 'hidden',
background: info.src ? 'white' : info.hueColor,
color: 'white',
fontWeight: 700,
fontSize: Math.max(11, Math.floor(size * 0.4)),
lineHeight: 1,
flexShrink: 0,
fontSize: Math.max(12, Math.floor(size * 0.38)),
};
return (
<div className={className} style={style} aria-hidden="true">
<div
className={`rounded-full overflow-hidden shrink-0 grid place-items-center border border-hairline-gray select-none leading-none ${
info.src ? 'bg-bone-white' : 'bg-linen text-verde-institucional font-medium'
} ${className}`}
style={style}
aria-hidden="true"
>
{info.src ? (
<img
src={info.src}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
className="w-full h-full object-cover rounded-full"
referrerPolicy="no-referrer"
/>
) : (
+30 -19
View File
@@ -123,35 +123,46 @@ export default function PerfilForm({
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
<div>
<label className="label" htmlFor="p-foto">Foto</label>
<div className="flex items-center gap-3">
<Avatar info={previewInfo} size={72} />
<div className="flex-1 flex flex-col gap-2">
<div className="flex items-center gap-4">
<Avatar info={previewInfo} size={96} className="w-24 h-24 rounded-full" />
<div className="flex-1 flex flex-col items-start gap-2">
<input
ref={fileInputRef}
id="p-foto"
className="input"
className="hidden"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{(file || fotoPath) && (
<div className="flex gap-2 flex-wrap items-center">
<button
type="button"
className="btn btn-ghost self-start"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setFotoPath(null);
}}
className="btn btn-ghost"
onClick={() => fileInputRef.current?.click()}
>
Quitar foto
Subir foto
</button>
{(file || fotoPath) && (
<button
type="button"
className="btn btn-ghost text-negro-institucional"
onClick={() => {
setFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setFotoPath(null);
}}
>
Quitar foto
</button>
)}
</div>
{file && (
<span className="text-xs text-graphite/70 truncate max-w-xs">{file.name}</span>
)}
</div>
</div>
{!file && !fotoPath && googleAvatarUrl && (
<p className="text-xs opacity-70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
<p className="text-xs text-graphite/70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
)}
</div>
@@ -159,7 +170,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
<input
id="p-mat"
className="input"
className="input rounded-[8px]"
type="text"
value={matricula}
onChange={(e) => setMatricula(e.target.value)}
@@ -174,7 +185,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-sem">Semestre</label>
<select
id="p-sem"
className="input"
className="input rounded-[8px]"
value={semestre}
onChange={(e) => setSemestre(e.target.value)}
>
@@ -191,7 +202,7 @@ export default function PerfilForm({
<label className="label" htmlFor="p-tutor">Tutor</label>
<select
id="p-tutor"
className="input"
className="input rounded-[8px]"
value={tutorId}
onChange={(e) => setTutorId(e.target.value)}
>
@@ -200,12 +211,12 @@ export default function PerfilForm({
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
))}
</select>
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
<p className="text-xs text-graphite/70 mt-1">Se rellenará por defecto al crear vales.</p>
</div>
)}
{error && (
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
<p role="alert" className="text-sm text-negro-institucional">
{error}
</p>
)}
+38 -47
View File
@@ -32,14 +32,10 @@ const avatar = profile
const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id);
let pendientesCount = 0;
if (isAdmin) {
const { count } = await Astro.locals.supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('estado', 'pendiente');
pendientesCount = count ?? 0;
}
// El count de pendientes lo hidrata BadgeSolicitudes (client-side) al montarse.
// Renderizar 0 en SSR evita 1 hit a DB por cada pageview de admin — el badge
// aparece ~200ms después de la carga, trade-off aceptable.
const pendientesCount = 0;
const path = Astro.url.pathname;
const isActive = (href: string) => path === href || path.startsWith(href + '/');
@@ -94,14 +90,14 @@ const activeCheck = (item: NavItem) => {
<div class="min-h-screen flex flex-col md:block">
<!-- Sidebar desktop -->
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-white text-[color:var(--color-ink)] md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out" style="border-right: 2px solid var(--color-ink);">
<a href="/" class="p-6 flex items-center gap-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)]" aria-label="LabPréstamos — Inicio" style="border-bottom: 2px solid var(--color-ink);">
<div class="w-9 h-9 rounded-[2px] bg-[color:var(--color-primary)] grid place-items-center p-2" style="border: 2px solid var(--color-ink);" aria-hidden="true">
<BrandMark class="w-full h-full text-white" />
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-bone-white text-graphite border-r border-hairline-gray md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out">
<a href="/" class="p-6 flex items-center gap-3 border-b border-hairline-gray focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional" aria-label="LabPréstamos — Inicio">
<div class="w-9 h-9 rounded-[8px] bg-linen text-verde-institucional grid place-items-center p-2" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<div>
<div class="font-semibold leading-tight uppercase tracking-wide text-sm">LabPréstamos</div>
<div class="text-xs" style="color: var(--color-pencil);">UABC · Lab. Sistemas</div>
<div class="font-medium leading-tight text-graphite text-sm">LabPréstamos</div>
<div class="text-xs text-graphite/60">UABC · Lab. Sistemas</div>
</div>
</a>
<nav class="flex-1 p-3 flex flex-col gap-1.5" aria-label="Navegación principal">
@@ -109,10 +105,11 @@ const activeCheck = (item: NavItem) => {
<a
href={item.href}
class:list={[
'relative flex items-center gap-3 px-3 py-2.5 rounded-[2px] text-sm font-medium uppercase tracking-wide whitespace-nowrap transition-colors',
activeCheck(item) ? 'bg-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
'relative flex items-center gap-3 px-3 py-2.5 rounded-[8px] text-sm whitespace-nowrap motion-safe:transition-colors motion-safe:duration-150',
activeCheck(item)
? 'bg-linen text-verde-institucional font-medium'
: 'text-graphite hover:bg-linen hover:text-verde-institucional'
]}
style={activeCheck(item) ? 'border: 2px solid var(--color-ink);' : 'border: 2px solid transparent;'}
aria-current={activeCheck(item) ? 'page' : undefined}
>
<span class="relative inline-grid place-items-center">
@@ -124,8 +121,7 @@ const activeCheck = (item: NavItem) => {
data-badge="pendientes"
hidden={pendientesCount === 0}
aria-label={`${pendientesCount} solicitudes pendientes`}
class="absolute -top-2 -right-3 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
class="absolute -top-2 -right-3 min-w-[18px] h-[18px] px-1.5 grid place-items-center rounded-full font-sans font-medium text-[11px] leading-none bg-verde-institucional text-bone-white"
>{pendientesCount}</span>
)}
</span>
@@ -133,26 +129,25 @@ const activeCheck = (item: NavItem) => {
</a>
))}
</nav>
<div class="p-3" style="border-top: 2px solid var(--color-ink);">
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[2px] hover:bg-[color:var(--color-chalk)] transition-colors">
<div class="p-3 border-t border-hairline-gray">
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[8px] hover:bg-linen transition-colors">
{avatar && <Avatar client:load info={avatar} size={36} />}
<div class="min-w-0 flex-1">
<div class="text-sm font-medium truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-xs uppercase tracking-wide flex items-center gap-1.5" style="color: var(--color-pencil);">
<div class="text-sm font-medium text-graphite truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-xs text-graphite/60 flex items-center gap-1.5">
<span>Ver perfil</span>
{perfilIncompleto && (
<span
aria-label="Perfil incompleto"
title="Perfil incompleto"
class="inline-block w-2 h-2 rounded-full motion-safe:animate-pulse"
style="background: var(--color-danger);"
class="inline-block w-2 h-2 rounded-full bg-negro-institucional motion-safe:animate-pulse"
></span>
)}
</div>
</div>
</a>
<form method="POST" action="/api/auth/signout" class="mt-1">
<button type="submit" class="w-full text-left px-3 py-2 rounded-[2px] text-sm uppercase tracking-wide hover:bg-[color:var(--color-chalk)] transition-colors flex items-center gap-2">
<form method="POST" action="/api/auth/signout" class="mt-2">
<button type="submit" class="btn btn-ghost w-full justify-start text-xs">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS.logout} />
</svg>
@@ -163,20 +158,19 @@ const activeCheck = (item: NavItem) => {
</aside>
<!-- Header mobile mínimo -->
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-white" style="border-bottom: 2px solid var(--color-ink);">
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)] rounded-[2px]" aria-label="LabPréstamos — Inicio">
<div class="w-8 h-8 rounded-[2px] bg-[color:var(--color-primary)] grid place-items-center p-1.5" style="border: 2px solid var(--color-ink);" aria-hidden="true">
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-bone-white border-b border-hairline-gray">
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional rounded-[8px]" aria-label="LabPréstamos — Inicio">
<div class="w-8 h-8 rounded-[8px] bg-linen text-verde-institucional grid place-items-center p-1.5" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span>
<span class="font-medium text-sm text-graphite">LabPréstamos</span>
</a>
<a href="/perfil" class="relative flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)] rounded-[2px]" aria-label="Mi perfil">
<a href="/perfil" class="relative flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional rounded-full" aria-label="Mi perfil">
{avatar && <Avatar client:load info={avatar} size={36} />}
{perfilIncompleto && (
<span
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full motion-safe:animate-pulse"
style="background: var(--color-danger); border: 1.5px solid var(--color-ink);"
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-negro-institucional motion-safe:animate-pulse"
></span>
)}
</a>
@@ -189,8 +183,7 @@ const activeCheck = (item: NavItem) => {
id="sidebar-toggle"
aria-label="Alternar menú lateral"
aria-controls="app-sidebar"
class="hidden md:inline-flex items-center justify-center w-10 h-10 rounded-[2px] mb-4 hover:bg-[color:var(--color-chalk)] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-ink)]"
style="border: 2px solid var(--color-ink);"
class="hidden md:inline-flex items-center justify-center w-10 h-10 rounded-[8px] mb-4 border border-hairline-gray bg-bone-white text-graphite hover:bg-linen hover:text-verde-institucional transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-verde-institucional"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS.menu} />
@@ -199,10 +192,10 @@ const activeCheck = (item: NavItem) => {
<slot />
</main>
<!-- Dock mobile: barra inferior fija, plana (sin sombra, como el nav de MotherDuck) -->
<!-- Dock móvil: barra inferior fija, plana (sin sombra, sin blur) -->
<nav
class="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white flex items-stretch justify-around"
style="border-top: 2px solid var(--color-ink); padding-bottom: env(safe-area-inset-bottom);"
class="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-bone-white border-t border-hairline-gray flex items-stretch justify-around"
style="padding-bottom: env(safe-area-inset-bottom);"
aria-label="Navegación principal"
>
{nav.map((item) => {
@@ -212,14 +205,13 @@ const activeCheck = (item: NavItem) => {
href={item.href}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
class="relative flex-1 grid place-items-center py-2.5"
class="relative flex-1 grid place-items-center py-2"
>
<span
class:list={[
'relative grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
active ? 'bg-[color:var(--color-primary)] text-white' : ''
'relative grid place-items-center rounded-[8px] w-10 h-10 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
active ? 'text-verde-institucional font-medium bg-linen' : 'text-graphite hover:bg-linen hover:text-verde-institucional'
]}
style={active ? 'border: 2px solid var(--color-ink);' : ''}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
@@ -229,8 +221,7 @@ const activeCheck = (item: NavItem) => {
data-badge="pendientes"
hidden={pendientesCount === 0}
aria-label={`${pendientesCount} solicitudes pendientes`}
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-[2px] font-mono font-bold text-[11px] leading-none"
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 grid place-items-center rounded-full font-sans font-medium text-[11px] leading-none bg-verde-institucional text-bone-white"
>{pendientesCount}</span>
)}
</span>
@@ -241,9 +232,9 @@ const activeCheck = (item: NavItem) => {
<button
type="submit"
aria-label="Cerrar sesión"
class="w-full grid place-items-center py-2.5"
class="w-full grid place-items-center py-2"
>
<span class="grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95 hover:bg-[color:var(--color-danger)]/15">
<span class="grid place-items-center rounded-[8px] w-10 h-10 text-graphite hover:bg-linen hover:text-verde-institucional transition-[background-color,transform] duration-100 motion-safe:active:scale-95">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS.logout} />
</svg>
+15 -17
View File
@@ -11,28 +11,26 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f4efea" />
<meta name="theme-color" content="#00723F" />
<meta name="color-scheme" content="light" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<!--
THESIS: Trade the institutional-green bitácora look for MotherDuck's crayon-terminal-on-cream
neobrutalism, per a fully pinned user brief — hard edges and offset shadow instead of soft,
rounded institutional polish.
OWN-WORLD: Cream Paper canvas, Frost White cards, Charcoal Ink borders/text everywhere, Sky
Crayon as the sole filled-action color, Coral Sketch for danger, Mint Sketch for positive
status, Duck Bill Orange as sparse accent, JetBrains Mono throughout, 2px radius everywhere,
-6px/6px hard offset shadow with press-in hover on buttons and modals.
STORY: alumno/admin still request, approve, and return lab equipment; the interface now reads
like a terminal readout instead of a soft green form.
FIRST VIEWPORT: login card — cream page, white bordered card, charcoal offset shadow, uppercase
mono headline, sky-blue press button.
FORM: user-pinned brief (MotherDuck DESIGN.md) — concept roll skipped per "the brief wins."
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the
verdict, DESIGN.md, and every shipping raster carrying its provenance.
THESIS: Identidad editorial institucional UABC en clave monoespaciada — verde institucional
#00723F como único interruptor de acción, tipografía Azeret Mono unificada (300/500), dos
lienzos alternos (Bone White / Negro Institucional), sin sombras, hairlines 1px.
OWN-WORLD: Bone White (#fffefb) canvas, Verde Institucional (#00723F) para CTAs y estados,
Dorado UABC (#DD971A) como acento secundario, radios 8/12/30/9999 solamente, ninguna otra
familia tipográfica.
STORY: mismo sistema de préstamos — la interfaz se lee como una hoja de especificación
técnica en vez de un panel de marketing.
FORM: user-pinned brief (UABC DESIGN.md) — concept roll skipped, brief gana.
-->
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 btn btn-primary z-50">
Saltar al contenido
@@ -48,14 +46,14 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
<style is:global>
#page-loader {
position: fixed; inset: 0; z-index: 9999;
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
background: color-mix(in oklab, var(--color-bone-white) 60%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: grid; place-items: center;
}
#page-loader[hidden] { display: none; }
.page-loader-spinner {
color: var(--color-ink);
color: var(--color-graphite);
animation: labre-spin 0.9s linear infinite;
}
@keyframes labre-spin { to { transform: rotate(360deg); } }
+144
View File
@@ -0,0 +1,144 @@
// Cache in-memory de las estadísticas admin — 60s TTL.
// Motivo: /admin/estadisticas dispara 9 queries por visita; a 20-50 usuarios
// concurrentes con un admin abriendo la pestaña varias veces al día, absorbemos
// >90% de los hits con este cache.
// El cache es por día (isoDate) — cuando cambia el día, la key cambia y se re-computa.
import type { SupabaseClient } from '@supabase/supabase-js';
const TTL_MS = 60_000;
export type EstadisticasBundle = {
kpis: {
nSolicitadas: number;
nAprobadas: number;
nRechazadas: number;
nDevueltas: number;
nEnPrestamo: number;
nVencidos: number;
};
topMateriales: { label: string; valor: number }[];
solicitudesPorDia: { fecha: string; valor: number }[];
distribEstados: { label: string; valor: number }[];
};
type Entry = { at: number; data: EstadisticasBundle };
const cache = new Map<string, Entry>();
export function invalidateEstadisticas() {
cache.clear();
}
export async function getEstadisticas(
supabase: SupabaseClient,
dia: { isoDate: string; startUTC: Date; endUTC: Date },
): Promise<EstadisticasBundle> {
const key = dia.isoDate;
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.at < TTL_MS) return hit.data;
const countHead = { count: 'exact' as const, head: true };
const startISO = dia.startUTC.toISOString();
const endISO = dia.endUTC.toISOString();
const DIAS = 30;
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
// 9 queries en paralelo — mismo shape que la implementación inline anterior.
const [
{ count: nSolicitadas },
{ count: nAprobadas },
{ count: nRechazadas },
{ count: nDevueltas },
{ count: nEnPrestamo },
{ count: nVencidos },
{ data: topMaterialesRaw },
{ data: solicitudesUlt30 },
{ data: estadosRaw },
] = await Promise.all([
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
supabase
.from('solicitud_items')
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
.gte('solicitud.fecha_solicitud', desde),
supabase
.from('solicitudes')
.select('fecha_solicitud')
.gte('fecha_solicitud', desde)
.order('fecha_solicitud', { ascending: true }),
supabase.from('solicitudes').select('estado'),
]);
// Top 10 materiales (agrupado en memoria).
const topMapa = new Map<string, number>();
for (const r of (topMaterialesRaw ?? []) as any[]) {
const nom = r.material?.nombre ?? '—';
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
}
const topMateriales = [...topMapa.entries()]
.map(([label, valor]) => ({ label, valor }))
.sort((a, b) => b.valor - a.valor)
.slice(0, 10);
// Solicitudes por día (30 días, rellena huecos con 0).
function toIsoDateMX(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Tijuana',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
const conteoPorDia = new Map<string, number>();
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
const k = toIsoDateMX(new Date(r.fecha_solicitud));
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
}
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
for (let i = 0; i < DIAS; i++) {
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
const iso = toIsoDateMX(d);
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
}
// Distribución de estados.
const estadoLabelMap: Record<string, string> = {
pendiente: 'Pendiente',
aprobado: 'Aprobado',
activo: 'Activo',
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const estadoMapa = new Map<string, number>();
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
if (!r.estado) continue;
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
}
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
label: estadoLabelMap[k] ?? k,
valor: v,
}));
const data: EstadisticasBundle = {
kpis: {
nSolicitadas: nSolicitadas ?? 0,
nAprobadas: nAprobadas ?? 0,
nRechazadas: nRechazadas ?? 0,
nDevueltas: nDevueltas ?? 0,
nEnPrestamo: nEnPrestamo ?? 0,
nVencidos: nVencidos ?? 0,
},
topMateriales,
solicitudesPorDia,
distribEstados,
};
cache.set(key, { at: now, data });
return data;
}
+64 -19
View File
@@ -6,7 +6,33 @@ const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api
// Rutas siempre accesibles para un user baneado (sino, loop de rewrite)
const BANNED_ALLOWED = ['/banned', '/api/auth/signout'];
// Caches en memoria del proceso — TTL corto, mono-container. Se invalidan en signout
// y al actualizar profile. Reducen 2-3 hits a Postgres por request a ~0.05 en promedio.
const PROFILE_TTL_MS = 30_000;
const BANNED_TTL_MS = 30_000;
type CachedProfile = { profile: any; exp: number };
type CachedBanned = { banned: boolean; exp: number };
const profileCache = new Map<string, CachedProfile>();
const bannedCache = new Map<string, CachedBanned>();
export function invalidateUserCaches(uid: string) {
profileCache.delete(uid);
bannedCache.delete(uid);
}
export const onRequest = defineMiddleware(async (context, next) => {
// Skip assets estáticos y audio — Astro los sirve por el mismo pipeline y no necesitan auth.
const path = context.url.pathname;
if (
path.startsWith('/_astro/') ||
path.startsWith('/audio/') ||
path === '/favicon.svg' ||
path === '/favicon.ico' ||
path === '/robots.txt'
) {
return next();
}
const supabase = serverClient(context.cookies);
context.locals.supabase = supabase;
@@ -21,27 +47,36 @@ export const onRequest = defineMiddleware(async (context, next) => {
context.locals.profile = null;
if (user) {
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
let { data: profile } = await supabase
.from('profiles')
.select(select)
.eq('id', user.id)
.maybeSingle();
const now = Date.now();
const cached = profileCache.get(user.id);
let profile: any = cached && cached.exp > now ? cached.profile : null;
// Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro
// trigger del proyecto vecino en auth.users y no siempre dispara. Si el
// user existe pero no hay profile, lo creamos aquí con los datos del OAuth.
if (!profile && user.email?.toLowerCase().endsWith(UABC_DOMAIN)) {
const nombre =
(user.user_metadata?.full_name as string | undefined) ??
(user.user_metadata?.name as string | undefined) ??
null;
const { data: creado } = await supabase
if (!profile) {
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
const { data } = await supabase
.from('profiles')
.upsert({ id: user.id, email: user.email, nombre }, { onConflict: 'id' })
.select(select)
.eq('id', user.id)
.maybeSingle();
profile = creado ?? null;
profile = data ?? null;
// Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro
// trigger del proyecto vecino en auth.users y no siempre dispara. Si el
// user existe pero no hay profile, lo creamos aquí con los datos del OAuth.
if (!profile && user.email?.toLowerCase().endsWith(UABC_DOMAIN)) {
const nombre =
(user.user_metadata?.full_name as string | undefined) ??
(user.user_metadata?.name as string | undefined) ??
null;
const { data: creado } = await supabase
.from('profiles')
.upsert({ id: user.id, email: user.email, nombre }, { onConflict: 'id' })
.select(select)
.maybeSingle();
profile = creado ?? null;
}
if (profile) profileCache.set(user.id, { profile, exp: now + PROFILE_TTL_MS });
}
context.locals.profile = profile ?? null;
@@ -60,9 +95,19 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Ban guard: si el user está baneado (y no es admin), toda ruta cae a /banned
// salvo la propia /banned y el signout. RPC is_banned respeta expires_at.
// Cache 30s por uid — se invalida al banear/desbanear desde los endpoints admin.
if (user && context.locals.profile && context.locals.profile.rol !== 'admin') {
const { data: banned } = await supabase.rpc('is_banned', { p_uid: user.id });
if (banned === true && !BANNED_ALLOWED.includes(pathname)) {
const now = Date.now();
const cached = bannedCache.get(user.id);
let banned: boolean;
if (cached && cached.exp > now) {
banned = cached.banned;
} else {
const { data } = await supabase.rpc('is_banned', { p_uid: user.id });
banned = data === true;
bannedCache.set(user.id, { banned, exp: now + BANNED_TTL_MS });
}
if (banned && !BANNED_ALLOWED.includes(pathname)) {
return context.rewrite('/banned');
}
}
+10 -6
View File
@@ -6,13 +6,17 @@ Astro.response.status = 403;
---
<Layout title="Acceso denegado — LabPréstamos">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] mb-4 p-4" style="background: var(--color-danger); border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full" style="color: var(--color-ink);" />
<div class="w-full max-w-md">
<div class="card text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional mb-4 p-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="mb-2">Acceso denegado</h1>
<p class="text-sm text-graphite/70">Esta sección es solo para administradores del laboratorio.</p>
<div class="mt-6">
<a href="/" class="btn btn-primary inline-flex">Volver al inicio</a>
</div>
</div>
<h1 class="text-2xl font-semibold uppercase tracking-wide">Acceso denegado</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Esta sección es solo para administradores del laboratorio.</p>
<a href="/" class="btn btn-primary mt-6 inline-flex">Volver al inicio</a>
</div>
</main>
</Layout>
+10 -6
View File
@@ -6,13 +6,17 @@ Astro.response.status = 404;
---
<Layout title="Página no encontrada — LabPréstamos">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] mb-4 p-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full text-white" />
<div class="w-full max-w-md">
<div class="card text-center">
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional mb-4 p-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="mb-2">Página no encontrada</h1>
<p class="text-sm text-graphite/70">La dirección no existe o cambió de lugar.</p>
<div class="mt-6">
<a href="/" class="btn btn-primary inline-flex">Volver al inicio</a>
</div>
</div>
<h1 class="text-2xl font-semibold uppercase tracking-wide">Página no encontrada</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">La dirección no existe o cambió de lugar.</p>
<a href="/" class="btn btn-primary mt-6 inline-flex">Volver al inicio</a>
</div>
</main>
</Layout>
+43 -116
View File
@@ -2,161 +2,88 @@
import AppLayout from '@/layouts/AppLayout.astro';
import Chart from '@/components/admin/estadisticas/Chart.tsx';
import { todayMX } from '@/lib/date';
import { getEstadisticas } from '@/lib/stats-cache';
// El middleware ya garantiza rol admin en /admin/*.
const supabase = Astro.locals.supabase;
const dia = todayMX();
const countHead = { count: 'exact' as const, head: true };
const startISO = dia.startUTC.toISOString();
const endISO = dia.endUTC.toISOString();
// KPIs del día — misma lógica que /admin/inventario legacy.
const [
{ count: nSolicitadas },
{ count: nAprobadas },
{ count: nRechazadas },
{ count: nDevueltas },
{ count: nEnPrestamo },
{ count: nVencidos },
] = await Promise.all([
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
]);
// Cache 60s en RAM (getEstadisticas absorbe >90% de visitas repetidas).
const { kpis: k, topMateriales, solicitudesPorDia, distribEstados } = await getEstadisticas(supabase, dia);
const kpis = [
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
];
const toneStyle = (tone: string) => {
if (tone === 'primary') return 'color: var(--color-primary);';
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
if (tone === 'danger') return 'color: var(--color-danger-text);';
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
};
// Ventana 30 días (rango inclusivo del día actual).
// Debe coincidir con la ventana en stats-cache.ts (rango de las 3 gráficas).
const DIAS = 30;
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
// (1) Top 10 materiales solicitados últimos 30 días.
const { data: topMaterialesRaw } = await supabase
.from('solicitud_items')
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
.gte('solicitud.fecha_solicitud', desde);
const topMapa = new Map<string, number>();
for (const r of (topMaterialesRaw ?? []) as any[]) {
const nom = r.material?.nombre ?? '—';
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
}
const topMateriales = [...topMapa.entries()]
.map(([label, valor]) => ({ label, valor }))
.sort((a, b) => b.valor - a.valor)
.slice(0, 10);
// (2) Solicitudes por día últimos 30 días. Rellena huecos con 0.
const { data: solicitudesUlt30 } = await supabase
.from('solicitudes')
.select('fecha_solicitud')
.gte('fecha_solicitud', desde)
.order('fecha_solicitud', { ascending: true });
// Genera todas las fechas de la ventana en formato YYYY-MM-DD (zona MX).
function toIsoDateMX(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Tijuana',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
const conteoPorDia = new Map<string, number>();
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
const k = toIsoDateMX(new Date(r.fecha_solicitud));
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
}
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
for (let i = 0; i < DIAS; i++) {
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
const iso = toIsoDateMX(d);
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
}
// (3) Distribución de estados de todas las solicitudes.
const { data: estadosRaw } = await supabase.from('solicitudes').select('estado');
const estadoLabelMap: Record<string, string> = {
pendiente: 'Pendiente',
aprobado: 'Aprobado',
activo: 'Activo',
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const estadoMapa = new Map<string, number>();
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
if (!r.estado) continue;
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
}
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
label: estadoLabelMap[k] ?? k,
valor: v,
}));
const vencidosCount = k.nVencidos;
const kpis = [
{ label: 'Solicitadas', value: k.nSolicitadas, total: false, isVencidos: false },
{ label: 'Aprobadas', value: k.nAprobadas, total: false, isVencidos: false },
{ label: 'Rechazadas', value: k.nRechazadas, total: false, isVencidos: false },
{ label: 'Devoluciones', value: k.nDevueltas, total: false, isVencidos: false },
{ label: 'En préstamo', value: k.nEnPrestamo, total: true, isVencidos: false },
{ label: 'Vencidos', value: vencidosCount, total: true, isVencidos: true },
];
---
<AppLayout title="Estadísticas — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
<p class="text-sm mt-1 first-letter:uppercase" style="color: var(--color-pencil);">
<h1 class="text-3xl md:text-4xl">Estadísticas</h1>
<p class="text-sm mt-1 opacity-70">
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
</p>
</header>
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{kpis.map((k) => (
<div class="card p-4">
<div class="text-xs" style="color: var(--color-pencil);">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
{k.value}
{kpis.map((k) => {
const isVencidosAlert = k.isVencidos && k.value > 0;
return (
<div
class:list={[
'flex flex-col justify-between',
isVencidosAlert ? 'card-dark' : 'card',
]}
>
<div class:list={['eyebrow', isVencidosAlert ? 'text-amarillo-dorado' : 'text-dorado-uabc']}>
{k.label}{k.total ? ' (total)' : ' hoy'}
</div>
<div
class:list={[
'text-3xl font-light mt-2 leading-none tabular-nums',
isVencidosAlert ? 'text-bone-white' : 'text-graphite',
]}
>
{k.value}
</div>
</div>
</div>
))}
);
})}
</section>
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Solicitudes por día · últimos {DIAS}</h3>
<h3 class="mb-4">Solicitudes por día · últimos {DIAS} días</h3>
{solicitudesPorDia.some((d) => d.valor > 0) ? (
<Chart tipo="linea" datos={solicitudesPorDia} client:idle />
) : (
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Sin actividad en los últimos {DIAS} días.</p>
<p class="text-sm py-8 text-center text-graphite/60">Sin actividad en los últimos {DIAS} días.</p>
)}
</div>
<div class="card">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Distribución de estados</h3>
<h3 class="mb-4">Distribución de estados</h3>
{distribEstados.length > 0 ? (
<Chart tipo="donut" datos={distribEstados} client:idle />
) : (
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Aún no hay solicitudes registradas.</p>
<p class="text-sm py-8 text-center text-graphite/60">Aún no hay solicitudes registradas.</p>
)}
</div>
<div class="card lg:col-span-2">
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Top 10 materiales · últimos {DIAS} días</h3>
<h3 class="mb-4">Top 10 materiales · últimos {DIAS} días</h3>
{topMateriales.length > 0 ? (
<Chart tipo="barras" datos={topMateriales} alto={320} client:idle />
) : (
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Ningún material solicitado en los últimos {DIAS} días.</p>
<p class="text-sm py-8 text-center text-graphite/60">Ningún material solicitado en los últimos {DIAS} días.</p>
)}
</div>
</section>
+57 -58
View File
@@ -23,8 +23,13 @@ type Sol = {
items: Item[];
};
const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
// 2 queries en paralelo (antes eran 4):
// - count vencidos: KPI del header.
// - "recientes": trae las 8 más recientes; alimenta a la vez "Requieren tu atención" (pendientes)
// y "Actividad reciente" (todas las últimas 5). Trade-off: si en las últimas 8 hay <2 pendientes,
// se muestran las que haya — dashboard, no auditoría.
// El count de pendientes lo hidrata BadgeSolicitudes client-side (KPI del header lo lee del DOM del badge).
const [vencidos, recientes] = await Promise.all([
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
@@ -33,18 +38,13 @@ const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
.eq('estado', 'pendiente')
.order('fecha_solicitud', { ascending: false })
.limit(2),
supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
.order('fecha_solicitud', { ascending: false })
.limit(5),
.limit(8),
]);
const pendientesData = (pendientes2.data ?? []) as unknown as Sol[];
const ultimasData = (ultimas.data ?? []) as unknown as Sol[];
const recientesData = (recientes.data ?? []) as unknown as Sol[];
const pendientesData = recientesData.filter((s) => s.estado === 'pendiente').slice(0, 2);
const ultimasData = recientesData.slice(0, 5);
const estadoLabel: Record<Estado, string> = {
pendiente: 'Pendiente',
@@ -54,16 +54,14 @@ const estadoLabel: Record<Estado, string> = {
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const estadoColor: Record<Estado, string> = {
pendiente: 'var(--color-ink)',
aprobado: 'var(--color-positive)',
activo: 'var(--color-positive)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
const estadoChipClass: Record<Estado, string> = {
pendiente: 'chip-pendiente',
aprobado: 'chip-aprobado',
activo: 'chip-activo',
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
const resumenItems = (items: Item[]) => {
const preview = items.slice(0, 3).map((i) => `${i.cantidad}× ${i.material?.nombre ?? '—'}`).join(', ');
@@ -71,50 +69,53 @@ const resumenItems = (items: Item[]) => {
return extra > 0 ? `${preview} (+${extra})` : preview;
};
const kpiPend = pend.count ?? 0;
const kpiVenc = vencidos.count ?? 0;
---
<AppLayout title="Panel — LabPréstamos">
<div class="max-w-5xl mx-auto">
<header class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
<h1>
{saludo}, {nombreCorto}
</h1>
<div class="flex gap-3 shrink-0">
<a href="/admin/solicitudes" class="card-flat px-4 py-3 text-center">
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiPend > 0 ? 'var(--color-primary)' : 'var(--color-ink)'};`}>{kpiPend}</div>
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Pendientes</div>
<a href="/admin/solicitudes" class="card text-center block min-w-[120px] transition-colors hover:border-dorado-uabc">
<div data-count="pendientes" class="text-3xl md:text-4xl font-light" style="font-variant-numeric: tabular-nums;">—</div>
<div class="mt-1 text-xs eyebrow text-dorado-uabc">Pendientes</div>
</a>
<a href="/admin/solicitudes/activos" class="card-flat px-4 py-3 text-center">
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiVenc > 0 ? 'var(--color-danger)' : 'var(--color-ink)'};`}>{kpiVenc}</div>
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Vencidos</div>
<a
href="/admin/solicitudes/activos"
class:list={[
'text-center block min-w-[120px] transition-colors',
kpiVenc > 0 ? 'card-dark' : 'card'
]}
>
<div class="text-3xl md:text-4xl font-light" style="font-variant-numeric: tabular-nums;">{kpiVenc}</div>
<div class:list={['mt-1 text-xs eyebrow', kpiVenc > 0 ? 'text-bone-white' : 'text-graphite/70']}>Vencidos</div>
</a>
</div>
</header>
<section aria-labelledby="atencion-h" class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
<section aria-labelledby="atencion-h" class="card mb-10">
<div class="flex items-baseline justify-between mb-4">
<h2 id="atencion-h" class="text-xl md:text-2xl font-light">Requieren tu atención</h2>
{pendientesData.length > 0 && (
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
<a href="/admin/solicitudes" class="btn btn-ghost text-xs py-1 px-3 min-h-[36px]">Ver todas</a>
)}
</div>
{pendientesData.length === 0 ? (
<div class="card text-center">
<p class="text-sm opacity-70">Todo al día. No hay solicitudes pendientes.</p>
</div>
<p class="text-sm text-graphite/70 text-center py-6">Todo al día. No hay solicitudes pendientes.</p>
) : (
<div class="grid gap-3 md:grid-cols-2">
{pendientesData.map((s) => (
<a href="/admin/solicitudes" class="card-link">
<a href="/admin/solicitudes" class="card-link block">
<div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-semibold leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
<span class="badge shrink-0" style={badgeStyle(s.estado)}>{estadoLabel[s.estado]}</span>
<h3 class="text-base font-medium leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
<span class:list={['chip shrink-0', estadoChipClass[s.estado]]}>{estadoLabel[s.estado]}</span>
</div>
<p class="text-sm leading-snug mb-3" style="color: var(--color-pencil);">{resumenItems(s.items ?? [])}</p>
<div class="flex items-center justify-between text-xs">
<span style="color: var(--color-pencil);">{fmtFechaRelativa(s.fecha_solicitud)}</span>
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Revisar</span>
<p class="text-sm leading-snug mb-4 text-graphite/70">{resumenItems(s.items ?? [])}</p>
<div class="flex items-center justify-between text-xs pt-3 border-t border-hairline-gray">
<span class="text-graphite/60">{fmtFechaRelativa(s.fecha_solicitud)}</span>
<span class="btn btn-primary text-xs py-1 px-3 min-h-[32px]">Revisar</span>
</div>
</a>
))}
@@ -122,21 +123,19 @@ const kpiVenc = vencidos.count ?? 0;
)}
</section>
<section aria-labelledby="ultimas-h">
<div class="flex items-baseline justify-between mb-3">
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
<a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
<section aria-labelledby="ultimas-h" class="card">
<div class="flex items-baseline justify-between mb-4">
<h2 id="ultimas-h" class="text-xl md:text-2xl font-light">Actividad reciente</h2>
<a href="/admin/solicitudes/historial" class="btn btn-ghost text-xs py-1 px-3 min-h-[36px]">Historial</a>
</div>
{ultimasData.length === 0 ? (
<div class="card text-center">
<p class="text-sm opacity-70">Aún no hay solicitudes registradas.</p>
</div>
<p class="text-sm text-graphite/70 text-center py-6">Aún no hay solicitudes registradas.</p>
) : (
<>
<div class="hidden md:block card-flat p-0 overflow-hidden">
<div class="hidden md:block p-0 overflow-hidden rounded-[8px] border border-hairline-gray">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
@@ -146,8 +145,8 @@ const kpiVenc = vencidos.count ?? 0;
</thead>
<tbody>
{ultimasData.map((r) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
<tr class="border-b border-hairline-gray last:border-b-0">
<td class="p-3 font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
<td class="p-3">
<div class="flex flex-col gap-0.5">
{(r.items ?? []).map((i) => (
@@ -156,9 +155,9 @@ const kpiVenc = vencidos.count ?? 0;
</div>
</td>
<td class="p-3">
<span class="badge" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
<span class:list={['chip', estadoChipClass[r.estado]]}>{estadoLabel[r.estado]}</span>
</td>
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
<td class="p-3 text-graphite/80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
</tr>
))}
</tbody>
@@ -170,10 +169,10 @@ const kpiVenc = vencidos.count ?? 0;
<article class="card">
<div class="flex items-start justify-between gap-3 mb-2">
<div class="min-w-0">
<h3 class="font-semibold leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
<p class="text-xs mt-1" style="color: var(--color-pencil);">{fmtFechaRelativa(r.fecha_solicitud)}</p>
<h3 class="text-base font-medium leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
<p class="text-xs text-graphite/60 mt-1">{fmtFechaRelativa(r.fecha_solicitud)}</p>
</div>
<span class="badge shrink-0" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
<span class:list={['chip shrink-0', estadoChipClass[r.estado]]}>{estadoLabel[r.estado]}</span>
</div>
<div class="text-sm leading-snug">
{(r.items ?? []).map((i) => (
+32 -22
View File
@@ -24,27 +24,37 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
<div class="max-w-4xl">
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div>
<h1 class="text-2xl md:text-3xl font-semibold">Categorías</h1>
<p class="text-sm opacity-70 mt-1">Organiza los materiales por tipo.</p>
<h1 class="text-3xl">Categorías</h1>
<p class="text-sm text-graphite/70 mt-1">Organiza los materiales por tipo.</p>
</div>
<CategoriaForm mode="create" client:load />
</header>
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario">
<a
href="/admin/inventario"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Materiales
</a>
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
<a
href="/admin/inventario/categorias"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional"
aria-current="page"
>
Categorías
</a>
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<a
href="/admin/maestros"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Maestros
</a>
</nav>
{error && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar las categorías. Recarga la página.
</p>
</div>
@@ -52,8 +62,8 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
{!error && categorias.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin categorías aún</h2>
<p class="text-sm opacity-70">Crea la primera para agrupar tus materiales.</p>
<h2 class="text-lg mb-1">Sin categorías aún</h2>
<p class="text-sm text-graphite/70">Crea la primera para agrupar tus materiales.</p>
</div>
)}
@@ -61,24 +71,24 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
<>
<div class="hidden md:block card p-0 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen border-b border-hairline-gray">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium text-right"># Materiales</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
<th class="px-4 py-3 font-medium text-graphite text-right"># Materiales</th>
<th class="px-4 py-3 font-medium text-graphite text-right">Acciones</th>
</tr>
</thead>
<tbody>
{categorias.map((c) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3 font-medium">{c.nombre}</td>
<tr class="border-b border-hairline-gray last:border-b-0">
<td class="px-4 py-3 font-medium text-graphite">{c.nombre}</td>
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
{c.count > 0 ? (
<a href={`/admin/inventario?cat=${c.id}`} class="underline" style="color: var(--color-primary);">
<a href={`/admin/inventario?cat=${c.id}`} class="text-verde-institucional hover:text-verde-claro underline">
{c.count}
</a>
) : (
<span class="opacity-60">0</span>
<span class="text-graphite/50">0</span>
)}
</td>
<td class="px-4 py-3">
@@ -95,12 +105,12 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
<div class="md:hidden flex flex-col gap-3">
{categorias.map((c) => (
<article class="card flex items-start justify-between gap-3">
<article class="card flex items-start justify-between gap-3 p-4">
<div>
<h3 class="font-semibold">{c.nombre}</h3>
<p class="text-xs opacity-70 mt-1">
<h3 class="font-medium text-graphite">{c.nombre}</h3>
<p class="text-xs text-graphite/70 mt-1">
{c.count > 0 ? (
<a href={`/admin/inventario?cat=${c.id}`} class="underline" style="color: var(--color-primary);">
<a href={`/admin/inventario?cat=${c.id}`} class="text-verde-institucional hover:text-verde-claro underline">
{c.count} material{c.count === 1 ? '' : 'es'}
</a>
) : (
+37 -38
View File
@@ -23,39 +23,38 @@ const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
const estadoRaw = Astro.url.searchParams.get('estado') ?? '';
const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? estadoRaw : '';
let query = Astro.locals.supabase
let matQuery = Astro.locals.supabase
.from('materiales')
.select(
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, imagen_path, trackeado_por_unidad, categoria:categorias(id, nombre)',
);
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
if (catId != null) query = query.eq('categoria_id', catId);
if (estado) query = query.eq('estado', estado);
if (q) matQuery = matQuery.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
if (catId != null) matQuery = matQuery.eq('categoria_id', catId);
if (estado) matQuery = matQuery.eq('estado', estado);
const { data: matData, error: matError } = await query.order('nombre');
const materiales = (matData ?? []) as unknown as Material[];
const { data: catData } = await Astro.locals.supabase
.from('categorias')
.select('id, nombre')
.order('nombre');
const categorias = (catData ?? []) as { id: number; nombre: string }[];
const [matRes, catRes] = await Promise.all([
matQuery.order('nombre'),
Astro.locals.supabase.from('categorias').select('id, nombre').order('nombre'),
]);
const materiales = (matRes.data ?? []) as unknown as Material[];
const matError = matRes.error;
const categorias = (catRes.data ?? []) as { id: number; nombre: string }[];
const estadoBadge = (e: Material['estado']) => {
if (e === 'disponible')
return {
label: 'Disponible',
style: 'background: color-mix(in oklab, var(--color-positive) 18%, white); color: var(--color-ink); border: 1.5px solid var(--color-positive);',
className: 'bg-bone-white text-verde-institucional border border-verde-institucional',
};
if (e === 'mantenimiento')
return {
label: 'Mantenimiento',
style: 'background: color-mix(in oklab, var(--color-secondary) 18%, white); color: var(--color-ink); border: 1.5px solid var(--color-secondary);',
className: 'bg-bone-white text-dorado-uabc border border-dorado-uabc',
};
return {
label: 'Baja',
style: 'background: var(--color-chalk); color: var(--color-ink); border: 1.5px solid var(--color-graphite);',
className: 'bg-negro-institucional text-bone-white border border-negro-institucional',
};
};
---
@@ -63,19 +62,22 @@ const estadoBadge = (e: Material['estado']) => {
<div class="max-w-7xl">
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
<h1 class="text-3xl">Inventario</h1>
<p class="text-sm text-graphite/70 mt-1">Materiales del laboratorio.</p>
</div>
<MaterialForm mode="create" categorias={categorias} client:load />
</header>
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario">
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional" aria-current="page">
Materiales
</a>
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional">
Categorías
</a>
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional">
Maestros
</a>
</nav>
<form id="inv-filter-form" method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
@@ -110,8 +112,8 @@ const estadoBadge = (e: Material['estado']) => {
</form>
{matError && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudo cargar el inventario. Recarga la página.
</p>
</div>
@@ -119,8 +121,8 @@ const estadoBadge = (e: Material['estado']) => {
{!matError && materiales.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin resultados</h2>
<p class="text-sm opacity-70">
<h2 class="text-lg mb-1">Sin resultados</h2>
<p class="text-sm text-graphite/70">
{q || catId != null || estado
? 'Ningún material coincide con los filtros. Ajusta la búsqueda.'
: 'Aún no hay material registrado. Agrega uno para comenzar.'}
@@ -135,34 +137,31 @@ const estadoBadge = (e: Material['estado']) => {
const src = imgUrl(m.imagen_path);
return (
<article class="card p-0 flex flex-col overflow-hidden">
<div
class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style="border-bottom: 2px solid var(--color-ink);"
>
<div class="relative aspect-square w-full bg-linen rounded-t-[12px] overflow-hidden grid place-items-center border-b border-hairline-gray">
{src ? (
<img src={src} alt={m.nombre} class="w-full h-full object-cover" loading="lazy" />
<img src={src} alt={m.nombre} class="w-full h-full aspect-square object-cover rounded-t-[12px]" loading="lazy" />
) : (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" stroke-width="1.5" aria-hidden="true">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" class="stroke-graphite" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
<span class="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]" style={badge.style}>
<span class={`absolute bottom-2 right-2 text-xs px-2.5 py-0.5 rounded-[8px] font-medium whitespace-nowrap ${badge.className}`}>
{badge.label}
</span>
</div>
<div class="p-3 flex flex-col gap-2 flex-1">
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
<div class="p-4 flex flex-col gap-2 flex-1">
<h3 class="font-medium text-sm leading-snug line-clamp-2 text-graphite">{m.nombre}</h3>
{m.categoria && (
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
<p class="text-xs text-graphite/70 -mt-1">{m.categoria.nombre}</p>
)}
<p class="text-xs" style="font-variant-numeric: tabular-nums;">
<span class="font-semibold">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
{m.trackeado_por_unidad && <span class="opacity-70"> · por unidad</span>}
<p class="text-xs text-graphite" style="font-variant-numeric: tabular-nums;">
<span class="font-medium text-graphite">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
{m.trackeado_por_unidad && <span class="text-graphite/60"> · por unidad</span>}
</p>
{m.numero_inventario && (
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
<p class="text-xs text-graphite/60 font-mono truncate">{m.numero_inventario}</p>
)}
<div class="grid grid-cols-2 gap-2 mt-auto pt-2">
<MaterialForm
+47 -31
View File
@@ -28,27 +28,37 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
<div class="max-w-4xl">
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div>
<h1 class="text-2xl md:text-3xl font-semibold">Maestros</h1>
<p class="text-sm opacity-70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
<h1 class="text-3xl">Maestros</h1>
<p class="text-sm text-graphite/70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
</div>
<MaestroForm mode="create" client:load />
</header>
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<nav class="flex gap-2 mb-6 border-b border-hairline-gray" aria-label="Sub-navegación de inventario">
<a
href="/admin/inventario"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Materiales
</a>
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
<a
href="/admin/inventario/categorias"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-transparent text-graphite hover:text-verde-institucional"
>
Categorías
</a>
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
<a
href="/admin/maestros"
class="px-4 py-2 text-sm font-medium rounded-[8px] transition-colors border-b-2 -mb-px border-verde-institucional text-verde-institucional"
aria-current="page"
>
Maestros
</a>
</nav>
{error && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar los maestros. Recarga la página.
</p>
</div>
@@ -56,8 +66,8 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
{!error && maestros.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin maestros aún</h2>
<p class="text-sm opacity-70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
<h2 class="text-lg mb-1">Sin maestros aún</h2>
<p class="text-sm text-graphite/70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
</div>
)}
@@ -65,37 +75,35 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
<>
<div class="hidden md:block card p-0 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen border-b border-hairline-gray">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium">Estado</th>
<th class="px-4 py-3 font-medium text-center">Tutor</th>
<th class="px-4 py-3 font-medium text-right"># Alumnos</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
<th class="px-4 py-3 font-medium text-graphite">Estado</th>
<th class="px-4 py-3 font-medium text-graphite text-center">Tutor</th>
<th class="px-4 py-3 font-medium text-graphite text-right"># Alumnos</th>
<th class="px-4 py-3 font-medium text-graphite text-right">Acciones</th>
</tr>
</thead>
<tbody>
{maestros.map((m) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3 font-medium">{m.nombre}</td>
<tr class="border-b border-hairline-gray last:border-b-0">
<td class="px-4 py-3 font-medium text-graphite">{m.nombre}</td>
<td class="px-4 py-3">
{m.activo ? (
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
) : (
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</span>
)}
<span class={`badge text-xs rounded-[8px] ${m.activo ? 'bg-bone-white text-verde-institucional border-verde-institucional' : 'bg-linen text-graphite/60 border-hairline-gray'}`}>
{m.activo ? 'Activo' : 'Inactivo'}
</span>
</td>
<td class="px-4 py-3 text-center">
{m.es_tutor ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-label="Sí" role="img" style="display:inline-block; color: var(--color-primary);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" class="inline-block text-verde-institucional" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="Sí" role="img">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<span class="opacity-40" aria-label="No">—</span>
<span class="text-graphite/40" aria-label="No">—</span>
)}
</td>
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
{m.count > 0 ? m.count : <span class="opacity-60">0</span>}
{m.count > 0 ? m.count : <span class="text-graphite/50">0</span>}
</td>
<td class="px-4 py-3">
<div class="flex gap-2 justify-end">
@@ -111,12 +119,20 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
<div class="md:hidden flex flex-col gap-3">
{maestros.map((m) => (
<article class="card flex items-start justify-between gap-3">
<article class="card flex items-start justify-between gap-3 p-4">
<div>
<h3 class="font-semibold">{m.nombre}</h3>
<p class="text-xs opacity-70 mt-1">
{m.activo ? 'Activo' : 'Inactivo'} · {m.es_tutor ? 'Tutor' : 'No tutor'} · {m.count} alumno{m.count === 1 ? '' : 's'}
</p>
<h3 class="font-medium text-graphite">{m.nombre}</h3>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class={`badge text-xs rounded-[8px] ${m.activo ? 'bg-bone-white text-verde-institucional border-verde-institucional' : 'bg-linen text-graphite/60 border-hairline-gray'}`}>
{m.activo ? 'Activo' : 'Inactivo'}
</span>
{m.es_tutor && (
<span class="badge text-xs rounded-[8px] bg-linen text-dorado-uabc border-dorado-uabc">Tutor</span>
)}
<span class="text-xs text-graphite/70">
{m.count} alumno{m.count === 1 ? '' : 's'}
</span>
</div>
</div>
<div class="flex flex-col gap-2 shrink-0">
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo, es_tutor: m.es_tutor }} client:load />
+93 -88
View File
@@ -128,24 +128,34 @@ const exportFilename = `reporte-${today}.csv`;
<AppLayout title="Reportes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Reportes</h1>
<h1 class="text-3xl md:text-4xl">Reportes</h1>
<p class="text-sm opacity-70 mt-1">Historial filtrable de préstamos y lista de vencidos.</p>
</header>
<nav class="mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);" aria-label="Vistas de reportes">
<ul class="flex gap-1">
<nav class="mb-6 border-b border-hairline-gray" aria-label="Vistas de reportes">
<ul class="flex gap-2 -mb-px">
<li>
<a
href="?vista=historial"
aria-current={vista === 'historial' ? 'page' : undefined}
class:list={['tab', vista === 'historial' && 'tab-active']}
class:list={[
'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors',
vista === 'historial'
? 'text-verde-institucional border-b-2 border-verde-institucional'
: 'text-graphite hover:text-verde-institucional border-b-2 border-transparent',
]}
>Historial</a>
</li>
<li>
<a
href="?vista=vencidos"
aria-current={vista === 'vencidos' ? 'page' : undefined}
class:list={['tab', vista === 'vencidos' && 'tab-active']}
class:list={[
'inline-block px-4 py-2 text-sm font-medium rounded-[8px] transition-colors',
vista === 'vencidos'
? 'text-verde-institucional border-b-2 border-verde-institucional'
: 'text-graphite hover:text-verde-institucional border-b-2 border-transparent',
]}
>Vencidos</a>
</li>
</ul>
@@ -194,11 +204,11 @@ const exportFilename = `reporte-${today}.csv`;
<div class="mt-5 flex flex-col md:flex-row md:items-center gap-3 md:justify-between">
<div class="flex flex-col md:flex-row gap-2">
<button type="submit" class="btn btn-primary">Filtrar</button>
<button type="submit" class="btn btn-secondary">Filtrar</button>
<a href="/admin/reportes?vista=historial" class="btn btn-ghost">Limpiar</a>
</div>
<a
class="btn btn-secondary w-full md:w-auto"
class="btn btn-primary w-full md:w-auto"
href={exportHref}
download={exportFilename}
>
@@ -210,15 +220,14 @@ const exportFilename = `reporte-${today}.csv`;
{hitLimit && (
<div
role="status"
class="mb-4 rounded-lg p-3 text-sm"
style="background: color-mix(in oklab, var(--color-secondary) 12%, transparent); border: 1px solid color-mix(in oklab, var(--color-secondary) 30%, transparent);"
class="mb-4 rounded-[8px] p-4 text-sm bg-linen border border-hairline-gray text-graphite"
>
Se muestran los {HARD_LIMIT} más recientes — refina los filtros o exporta a CSV.
</div>
)}
{filas.length === 0 ? (
<div class="card text-center">
<div class="card text-center py-10">
<p class="opacity-75">Sin resultados para los filtros seleccionados.</p>
<p class="text-sm opacity-60 mt-1">Ajusta los filtros o exporta todo el historial.</p>
</div>
@@ -228,37 +237,39 @@ const exportFilename = `reporte-${today}.csv`;
<div class="card p-0 overflow-hidden hidden md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Fecha solicitud</th>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Maestro responsable</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Fecha devolución</th>
<th class="p-4 font-medium">Fecha solicitud</th>
<th class="p-4 font-medium">Alumno</th>
<th class="p-4 font-medium">Maestro responsable</th>
<th class="p-4 font-medium">Material</th>
<th class="p-4 font-medium">Estado</th>
<th class="p-4 font-medium">Fecha devolución</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<td class="p-3 opacity-90 whitespace-nowrap">{fmt(r.fecha_solicitud)}</td>
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
<tr class="border-b border-hairline-gray hover:bg-linen/50 transition-colors">
<td class="p-4 opacity-90 whitespace-nowrap tabular-nums">{fmt(r.fecha_solicitud)}</td>
<td class="p-4">
<div class="font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70 tabular-nums">{r.alumno.matricula}</div>}
</td>
<td class="p-3 opacity-90">{r.maestro_responsable ?? '—'}</td>
<td class="p-3">
<td class="p-4 opacity-90">{r.maestro_responsable ?? '—'}</td>
<td class="p-4">
{(r.items ?? []).map((i: any) => (
<div>
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-90 whitespace-nowrap">
<td class="p-4">
<span class={`chip chip-${r.estado}`}>{estadoLabel[r.estado] ?? r.estado}</span>
</td>
<td class="p-4 opacity-90 whitespace-nowrap tabular-nums">
{fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
{!r.fecha_devolucion_real && r.fecha_devolucion_estimada && (
<span class="text-xs opacity-60"> (est.)</span>
@@ -268,9 +279,9 @@ const exportFilename = `reporte-${today}.csv`;
))}
</tbody>
<tfoot>
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 15%, transparent); background: color-mix(in oklab, var(--color-ink) 3%, transparent);">
<td class="p-3 text-sm font-medium" colspan="6">
Total: <span style="font-variant-numeric: tabular-nums;">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
<tr class="bg-linen text-graphite border-t border-hairline-gray">
<td class="p-4 text-sm font-medium" colspan="6">
Total: <span class="tabular-nums font-medium">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
</td>
</tr>
</tfoot>
@@ -283,29 +294,37 @@ const exportFilename = `reporte-${today}.csv`;
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-3">
<span class="text-xs opacity-70">{fmt(r.fecha_solicitud)}</span>
<span class="text-xs font-medium">{estadoLabel[r.estado] ?? r.estado}</span>
<span class="text-xs opacity-70 tabular-nums">{fmt(r.fecha_solicitud)}</span>
<span class={`chip chip-${r.estado}`}>{estadoLabel[r.estado] ?? r.estado}</span>
</div>
<div class="mt-1 space-y-0.5">
<div class="mt-2 space-y-0.5">
{(r.items ?? []).map((i: any) => (
<div class="font-medium">
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<div class="font-medium text-graphite">
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</div>
<div class="text-sm opacity-80">
<div class="text-sm opacity-80 mt-1">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
{r.alumno?.matricula && <span class="opacity-60 tabular-nums"> · {r.alumno.matricula}</span>}
</div>
<div class="text-xs opacity-70">Maestro: {r.maestro_responsable ?? '—'}</div>
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable ?? '—'}</div>
<div class="mt-2 flex items-center justify-end text-sm">
<span class="opacity-80">
<span class="opacity-80 tabular-nums">
Dev.: {fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
{!r.fecha_devolucion_real && r.fecha_devolucion_estimada && (
<span class="text-xs opacity-60"> (est.)</span>
)}
</span>
</div>
</li>
))}
<li class="text-center text-sm opacity-70 pt-1">Total: {total} {total === 1 ? 'préstamo' : 'préstamos'}</li>
<li class="text-center text-sm opacity-70 pt-1">
Total: <span class="tabular-nums font-medium">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
</li>
</ul>
</>
)}
@@ -315,7 +334,7 @@ const exportFilename = `reporte-${today}.csv`;
{vista === 'vencidos' && (
<>
{vencidos.length === 0 ? (
<div class="card text-center">
<div class="card text-center py-10">
<p class="opacity-75">No hay préstamos vencidos. Todo al día.</p>
</div>
) : (
@@ -323,39 +342,38 @@ const exportFilename = `reporte-${today}.csv`;
<div class="card p-0 overflow-hidden hidden md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Debía devolver el</th>
<th class="p-3 font-medium">Días de atraso</th>
<th class="p-4 font-medium">Alumno</th>
<th class="p-4 font-medium">Material</th>
<th class="p-4 font-medium">Debía devolver el</th>
<th class="p-4 font-medium">Días de atraso</th>
</tr>
</thead>
<tbody>
{vencidos.map((r: any) => {
const dias = diasAtraso(r.fecha_devolucion_estimada);
return (
<tr
class="border-t"
style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); background: color-mix(in oklab, var(--color-danger) 8%, transparent);"
>
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
<tr class="border-b border-hairline-gray hover:bg-linen/50 transition-colors">
<td class="p-4">
<div class="font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
{r.alumno?.matricula && <div class="text-xs opacity-70 tabular-nums">{r.alumno.matricula}</div>}
</td>
<td class="p-3">
<td class="p-4">
{(r.items ?? []).map((i: any) => (
<div>
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</td>
<td class="p-3 whitespace-nowrap">{fmt(r.fecha_devolucion_estimada)}</td>
<td class="p-3 font-medium" style={`font-variant-numeric: tabular-nums; color: var(--color-danger-text);`}>
{dias} {dias === 1 ? 'día' : 'días'}
<td class="p-4 whitespace-nowrap tabular-nums">{fmt(r.fecha_devolucion_estimada)}</td>
<td class="p-4">
<span class="chip chip-vencido tabular-nums">
{dias} {dias === 1 ? 'día' : 'días'}
</span>
</td>
</tr>
);
@@ -369,23 +387,30 @@ const exportFilename = `reporte-${today}.csv`;
{vencidos.map((r: any) => {
const dias = diasAtraso(r.fecha_devolucion_estimada);
return (
<li class="card" style="background: color-mix(in oklab, var(--color-danger) 8%, transparent);">
<div class="space-y-0.5">
<li class="card">
<div class="flex items-baseline justify-between gap-3">
<span class="text-xs opacity-70">Atraso</span>
<span class="chip chip-vencido tabular-nums">
{dias} {dias === 1 ? 'día' : 'días'}
</span>
</div>
<div class="mt-2 space-y-0.5">
{(r.items ?? []).map((i: any) => (
<div class="font-medium">
<span style="font-variant-numeric: tabular-nums;">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
<div class="font-medium text-graphite">
<span class="tabular-nums">{i.cantidad}×</span> {i.material?.nombre ?? '—'}
{i.material?.numero_inventario && (
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
)}
</div>
))}
</div>
<div class="text-sm opacity-80">
<div class="text-sm opacity-80 mt-1">
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
{r.alumno?.matricula && <span class="opacity-60"> · {r.alumno.matricula}</span>}
{r.alumno?.matricula && <span class="opacity-60 tabular-nums"> · {r.alumno.matricula}</span>}
</div>
<div class="mt-2 flex items-center justify-between text-sm">
<span>Debía devolver el {fmt(r.fecha_devolucion_estimada)}</span>
<span class="font-medium" style="font-variant-numeric: tabular-nums; color: var(--color-danger-text);">
{dias} {dias === 1 ? 'día' : 'días'}
</span>
<span class="opacity-75">Debía devolver el</span>
<span class="tabular-nums font-medium">{fmt(r.fecha_devolucion_estimada)}</span>
</div>
</li>
);
@@ -397,23 +422,3 @@ const exportFilename = `reporte-${today}.csv`;
)}
</div>
</AppLayout>
<style>
@reference "tailwindcss";
.tab {
@apply inline-block px-4 py-2.5 text-sm font-medium rounded-t-lg
transition-[background-color,color,border-color] duration-150
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)];
color: color-mix(in oklab, var(--color-ink) 70%, transparent);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab:hover {
color: var(--color-ink);
background: color-mix(in oklab, var(--color-ink) 4%, transparent);
}
.tab-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+41 -45
View File
@@ -32,12 +32,11 @@ const tabs = [
---
<AppLayout title="Préstamos activos — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<header class="mb-6">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">En préstamo</h1>
<h1 class="text-2xl md:text-3xl font-light">En préstamo</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-positive); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
class="badge"
aria-label={`${filas.length} préstamos activos`}
>
{filas.length}
@@ -45,14 +44,16 @@ const tabs = [
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto">
{tabs.map((t) => {
const active = path === t.href;
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional'
]}
aria-current={active ? 'page' : undefined}
>
{t.label}
@@ -61,24 +62,25 @@ const tabs = [
})}
</nav>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, white); color: var(--color-ink); border: 1.5px solid var(--color-danger);">
Error cargando préstamos: {error.message}
{error ? (
<div role="alert" class="card text-center py-10">
<p class="font-medium text-graphite">No se pudo cargar la lista.</p>
<p class="text-sm text-graphite/70 mt-1">Recarga la página o contacta a soporte si persiste.</p>
</div>
)}
{filas.length === 0 ? (
) : filas.length === 0 ? (
<div class="card text-center py-12">
<p class="font-medium">No hay préstamos activos.</p>
<p class="font-medium text-graphite">No hay préstamos activos.</p>
<p class="text-sm text-graphite/70 mt-1">Todos los materiales están devueltos.</p>
</div>
) : (
<>
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Aprobado</th>
<th class="p-3 font-medium">Devolver antes de</th>
<th class="p-3 font-medium text-right">Acciones</th>
@@ -88,46 +90,40 @@ const tabs = [
{filas.map((r: any) => {
const vencido = isVencido(r.fecha_devolucion_estimada);
return (
<tr
class="border-t align-top"
style={`border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); ${vencido ? 'background: color-mix(in oklab, var(--color-danger) 8%, transparent);' : ''}`}
>
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs text-graphite/70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {i.material?.numero_inventario ?? '—'}</div>
<div class="text-xs text-graphite/70">Inv. {i.material?.numero_inventario ?? '—'}</div>
</div>
))}
</div>
{r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<div class="text-xs mt-1.5 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div>
)}
</td>
<td class="p-3 opacity-80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
<td class="p-3">
<span class:list={['chip', vencido ? 'chip-vencido' : 'chip-aprobado']}>
{vencido ? 'Vencido' : 'En préstamo'}
</span>
</td>
<td class="p-3 text-graphite/80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
<td class="p-3 text-graphite/80">
<span style="font-variant-numeric: tabular-nums;">
{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}
</span>
{vencido && (
<span
class="ml-2 inline-flex items-center rounded-[2px] px-2 py-0.5 text-xs font-semibold"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
>
Vencido
</span>
)}
</td>
<td class="p-3">
<div class="flex justify-end gap-2 flex-wrap">
<div class="flex items-center justify-end gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
</div>
@@ -143,30 +139,30 @@ const tabs = [
{filas.map((r: any) => {
const vencido = isVencido(r.fecha_devolucion_estimada);
return (
<li class="card" style={vencido ? 'background: color-mix(in oklab, var(--color-danger) 8%, white);' : ''}>
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
{vencido && (
<span class="inline-flex rounded-[2px] px-2 py-0.5 text-xs font-semibold" style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);">Vencido</span>
)}
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<span class:list={['chip shrink-0', vencido ? 'chip-vencido' : 'chip-aprobado']}>
{vencido ? 'Vencido' : 'En préstamo'}
</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="text-xs text-graphite/70 mt-0.5">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
<span class="opacity-70"> · Inv. {i.material?.numero_inventario ?? '—'}</span>
<span class="text-graphite/70"> · Inv. {i.material?.numero_inventario ?? '—'}</span>
</div>
))}
</div>
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
<div class="text-xs text-graphite/70 mt-2">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
{r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<p class="text-sm mt-2 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p>
)}
<div class="mt-3 flex gap-2 flex-wrap">
<div class="mt-3 pt-3 border-t border-hairline-gray flex items-center gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
</div>
+46 -32
View File
@@ -34,15 +34,20 @@ const estadoLabel: Record<string, string> = {
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const chipPorEstado: Record<string, string> = {
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
---
<AppLayout title="Historial de solicitudes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<header class="mb-6">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">Historial</h1>
<h1 class="text-2xl md:text-3xl font-light">Historial</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-chalk); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
class="badge"
aria-label={`${filas.length} registros`}
>
{filas.length}
@@ -50,14 +55,16 @@ const estadoLabel: Record<string, string> = {
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto">
{tabs.map((t) => {
const active = path === t.href;
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional'
]}
aria-current={active ? 'page' : undefined}
>
{t.label}
@@ -66,7 +73,7 @@ const estadoLabel: Record<string, string> = {
})}
</nav>
<form method="get" class="mb-4 flex items-end gap-3 flex-wrap">
<form method="get" class="mb-6 flex items-end gap-3 flex-wrap">
<div class="flex-1 min-w-[200px] max-w-xs">
<label class="label" for="estado">Filtrar por estado</label>
<select
@@ -86,21 +93,20 @@ const estadoLabel: Record<string, string> = {
</noscript>
</form>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, white); color: var(--color-ink); border: 1.5px solid var(--color-danger);">
Error cargando historial: {error.message}
{error ? (
<div role="alert" class="card text-center py-10">
<p class="font-medium text-graphite">No se pudo cargar la lista.</p>
<p class="text-sm text-graphite/70 mt-1">Recarga la página o contacta a soporte si persiste.</p>
</div>
)}
{filas.length === 0 ? (
) : filas.length === 0 ? (
<div class="card text-center py-12">
<p class="font-medium">No hay registros para este filtro.</p>
<p class="font-medium text-graphite">No hay registros para este filtro.</p>
</div>
) : (
<>
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
@@ -111,25 +117,29 @@ const estadoLabel: Record<string, string> = {
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs text-graphite/70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">Inv. {i.material?.numero_inventario ?? '—'}</div>
<div class="text-xs text-graphite/70">Inv. {i.material?.numero_inventario ?? '—'}</div>
</div>
))}
</div>
</td>
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3 opacity-80">{r.fecha_devolucion_real ? fmtFecha.format(new Date(r.fecha_devolucion_real)) : '—'}</td>
<td class="p-3">
<span class:list={['chip', chipPorEstado[r.estado] ?? 'chip-devuelto']}>
{estadoLabel[r.estado] ?? r.estado}
</span>
</td>
<td class="p-3 text-graphite/80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3 text-graphite/80">{r.fecha_devolucion_real ? fmtFecha.format(new Date(r.fecha_devolucion_real)) : '—'}</td>
</tr>
))}
</tbody>
@@ -140,18 +150,22 @@ const estadoLabel: Record<string, string> = {
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<span class="text-xs rounded-[2px] px-2 py-0.5" style="background: color-mix(in oklab, var(--color-ink) 10%, transparent);">{estadoLabel[r.estado] ?? r.estado}</span>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<span class:list={['chip shrink-0', chipPorEstado[r.estado] ?? 'chip-devuelto']}>
{estadoLabel[r.estado] ?? r.estado}
</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1 mt-2">
<div class="text-xs text-graphite/70 mt-0.5">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
</div>
))}
</div>
<div class="text-xs opacity-70 mt-2">Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}</div>
{r.fecha_devolucion_real && <div class="text-xs opacity-70">Devuelto {fmtFecha.format(new Date(r.fecha_devolucion_real))}</div>}
<div class="text-xs text-graphite/60 mt-2">Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}</div>
{r.fecha_devolucion_real && <div class="text-xs text-graphite/60 mt-0.5">Devuelto {fmtFecha.format(new Date(r.fecha_devolucion_real))}</div>}
</li>
))}
</ul>
+42 -32
View File
@@ -1,6 +1,7 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase;
@@ -23,12 +24,11 @@ const tabs = [
---
<AppLayout title="Solicitudes pendientes — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-4">
<header class="mb-6">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl md:text-3xl font-semibold">Solicitudes pendientes</h1>
<h1 class="text-2xl md:text-3xl font-light">Solicitudes pendientes</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-primary); color: white; border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
class="badge"
aria-label={`${filas.length} pendientes`}
>
{filas.length}
@@ -36,14 +36,16 @@ const tabs = [
</div>
</header>
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b flex gap-1 overflow-x-auto" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);">
<nav aria-label="Vistas de solicitudes" class="mb-6 border-b border-hairline-gray flex gap-2 overflow-x-auto">
{tabs.map((t) => {
const active = path === t.href || (t.href === '/admin/solicitudes' && path === '/admin/solicitudes/');
return (
<a
href={t.href}
class:list={['px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors -mb-px', active ? 'text-[color:var(--color-primary)]' : 'opacity-70 hover:opacity-100 border-transparent']}
style={active ? 'border-color: var(--color-primary);' : ''}
class:list={[
'px-4 py-2 text-sm font-medium whitespace-nowrap transition-colors border-b -mb-px',
active ? 'border-verde-institucional text-verde-institucional' : 'border-transparent text-graphite/70 hover:text-verde-institucional'
]}
aria-current={active ? 'page' : undefined}
>
{t.label}
@@ -52,61 +54,65 @@ const tabs = [
})}
</nav>
{error && (
<div role="alert" class="mb-4 rounded-lg p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, white); color: var(--color-ink); border: 1.5px solid var(--color-danger);">
Error cargando solicitudes: {error.message}
{error ? (
<div role="alert" class="card text-center py-10">
<p class="font-medium text-graphite">No se pudo cargar la lista.</p>
<p class="text-sm text-graphite/70 mt-1">Recarga la página o contacta a soporte si persiste.</p>
</div>
)}
{filas.length === 0 ? (
) : filas.length === 0 ? (
<div class="card text-center py-12">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-3" aria-hidden="true">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-3 text-verde-institucional" aria-hidden="true">
<path d="M4.5 12.75l6 6 9-13.5" />
</svg>
<p class="font-medium">No hay solicitudes por revisar.</p>
<p class="text-sm opacity-70 mt-1">Todo al día.</p>
<p class="font-medium text-graphite">No hay solicitudes por revisar.</p>
<p class="text-sm text-graphite/70 mt-1">Todo al día.</p>
</div>
) : (
<>
{/* Desktop: tabla */}
<div class="card p-0 overflow-hidden hidden md:block">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen text-graphite border-b border-hairline-gray">
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Estado</th>
<th class="p-3 font-medium">Solicitado</th>
<th class="p-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{filas.map((r: any) => (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs text-graphite/70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">
<div class="text-xs text-graphite/70">
Inv. {i.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{i.material?.cantidad_disponible ?? 0}</span>
</div>
</div>
))}
</div>
{r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<div class="text-xs mt-1.5 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div>
)}
</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3">
<div class="flex justify-end">
<span class="chip chip-pendiente">Pendiente</span>
</td>
<td class="p-3 text-graphite/80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3">
<div class="flex items-center justify-end gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<AccionesSolicitud client:load prestamo={r} />
</div>
</td>
@@ -121,25 +127,29 @@ const tabs = [
{filas.map((r: any) => (
<li class="card">
<div class="flex items-baseline justify-between gap-2">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
<span class="chip chip-pendiente">Pendiente</span>
</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="text-xs text-graphite/70 mt-0.5">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs text-graphite/70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
<span class="opacity-70"> · Inv. {i.material?.numero_inventario ?? '—'} · disp. {i.material?.cantidad_disponible ?? 0}</span>
<span class="text-graphite/70"> · Inv. {i.material?.numero_inventario ?? '—'} · disp. {i.material?.cantidad_disponible ?? 0}</span>
</div>
))}
</div>
{r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<p class="text-sm mt-2 text-graphite/80 border-l border-hairline-gray pl-2">
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p>
)}
<div class="mt-3">
<div class="text-xs text-graphite/60 mt-2">
Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}
</div>
<div class="mt-3 pt-3 border-t border-hairline-gray flex items-center gap-2 flex-wrap">
<VerDetalles client:load prestamoId={r.id} />
<AccionesSolicitud client:load prestamo={r} />
</div>
</li>
+30 -33
View File
@@ -53,13 +53,13 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
<AppLayout title="Usuarios — Admin">
<div class="max-w-5xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Usuarios</h1>
<p class="text-sm opacity-70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
<h1 class="text-3xl">Usuarios</h1>
<p class="text-sm text-graphite/70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
</header>
{perr && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
<div class="card mb-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar los usuarios. Recarga la página.
</p>
</div>
@@ -67,8 +67,8 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
{!perr && usuarios.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin usuarios aún</h2>
<p class="text-sm opacity-70">Se listarán aquí en cuanto alguien inicie sesión.</p>
<h2 class="text-lg mb-1">Sin usuarios aún</h2>
<p class="text-sm text-graphite/70">Se listarán aquí en cuanto alguien inicie sesión.</p>
</div>
)}
@@ -77,43 +77,41 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
{/* Desktop */}
<div class="hidden md:block card p-0 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<thead class="text-left bg-linen border-b border-hairline-gray">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Rol</th>
<th class="px-4 py-3 font-medium">Estado</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
<th class="px-4 py-3 font-medium text-graphite">Email</th>
<th class="px-4 py-3 font-medium text-graphite">Rol</th>
<th class="px-4 py-3 font-medium text-graphite">Estado</th>
<th class="px-4 py-3 font-medium text-graphite text-right">Acciones</th>
</tr>
</thead>
<tbody>
{usuarios.map((u) => {
const isSelf = u.id === meId;
return (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
<td class="px-4 py-3">
<div class="font-medium break-words">{u.nombre ?? '—'}</div>
{u.matricula && <div class="text-xs opacity-60 mt-0.5" style="font-variant-numeric: tabular-nums;">{u.matricula}</div>}
<div class="font-medium text-graphite break-words">{u.nombre ?? '—'}</div>
{u.matricula && <div class="text-xs text-graphite/60 mt-0.5 font-mono">{u.matricula}</div>}
</td>
<td class="px-4 py-3 break-all text-xs">{u.email}</td>
<td class="px-4 py-3 break-all text-xs text-graphite/80">{u.email}</td>
<td class="px-4 py-3">
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px]"
style={`border: 1.5px solid var(--color-ink); ${u.rol === 'admin' ? 'background: var(--color-secondary);' : ''}`}
class={`badge text-xs rounded-[8px] ${u.rol === 'admin' ? 'bg-linen text-graphite border-hairline-gray font-medium' : 'bg-bone-white text-graphite border-hairline-gray'}`}
>{rolLabel(u.rol)}</span>
</td>
<td class="px-4 py-3">
{u.baneo ? (
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-1 items-start">
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] w-fit"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
class="chip bg-negro-institucional text-bone-white rounded-[8px] text-xs px-2.5 py-0.5 font-medium border-0"
>Baneado</span>
<span class="text-xs opacity-70 break-words">{trunc(u.baneo.razon)}</span>
<span class="text-[11px] opacity-50">desde {fmtDate(u.baneo.banned_at)}</span>
<span class="text-xs text-graphite/70 break-words">{trunc(u.baneo.razon)}</span>
<span class="text-[11px] text-graphite/50">desde {fmtDate(u.baneo.banned_at)}</span>
</div>
) : (
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
<span class="badge bg-bone-white text-verde-institucional border-verde-institucional text-xs font-medium rounded-[8px]">Activo</span>
)}
</td>
<td class="px-4 py-3">
@@ -141,29 +139,28 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
{usuarios.map((u) => {
const isSelf = u.id === meId;
return (
<article class="card flex flex-col gap-3">
<article class="card flex flex-col gap-3 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="font-semibold break-words">{u.nombre ?? '—'}</h3>
<p class="text-xs opacity-70 break-all">{u.email}</p>
<p class="text-xs opacity-60 mt-1">
<h3 class="font-medium text-graphite break-words">{u.nombre ?? '—'}</h3>
<p class="text-xs text-graphite/70 break-all">{u.email}</p>
<p class="text-xs text-graphite/60 mt-1">
{rolLabel(u.rol)}{u.matricula ? ` · ${u.matricula}` : ''}
</p>
</div>
{u.baneo ? (
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] shrink-0"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
class="chip bg-negro-institucional text-bone-white rounded-[8px] text-xs px-2.5 py-0.5 font-medium shrink-0 border-0"
>Baneado</span>
) : (
<span class="text-xs uppercase tracking-wide shrink-0" style="color: var(--color-primary);">Activo</span>
<span class="badge bg-bone-white text-verde-institucional border-verde-institucional text-xs font-medium shrink-0 rounded-[8px]">Activo</span>
)}
</div>
{u.baneo && (
<div class="text-xs opacity-80 break-words">
<div class="text-xs text-graphite/80 break-words">
<strong>Razón:</strong> {trunc(u.baneo.razon, 120)}
<div class="opacity-60 mt-1">desde {fmtDate(u.baneo.banned_at)}</div>
<div class="text-graphite/60 mt-1">desde {fmtDate(u.baneo.banned_at)}</div>
</div>
)}
+18 -31
View File
@@ -7,33 +7,15 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
const qInitial = Astro.url.searchParams.get('q') ?? '';
const supabase = Astro.locals.supabase;
const userId = Astro.locals.user?.id;
const rol = Astro.locals.profile?.rol ?? 'alumno';
const profile = Astro.locals.profile;
const rol = profile?.rol ?? 'alumno';
const esDocente = rol === 'docente';
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
let matricula: string | null = null;
let tutorId: number | null = null;
if (userId) {
const { data: p } = await supabase
.from('profiles')
.select('matricula, tutor_id')
.eq('id', userId)
.maybeSingle();
matricula = p?.matricula ?? null;
tutorId = (p as { tutor_id?: number | null } | null)?.tutor_id ?? null;
}
// El middleware ya trae matricula y tutor_id en Astro.locals.profile — no requery.
const matricula = profile?.matricula ?? null;
const tutorId = profile?.tutor_id ?? null;
let tutorNombre: string | null = null;
if (tutorId) {
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
tutorNombre = data?.nombre ?? null;
}
// Docente: solo requiere matrícula (número de empleado). Alumno: matrícula + tutor.
const perfilCompleto = esDocente ? !!matricula : !!(matricula && tutorId);
// Lista completa de maestros activos para el combobox del checkout (solo alumno lo usa).
// Una sola query para maestros activos; el nombre del tutor sale del mismo array.
type MaestroOpt = { id: number; nombre: string };
let maestros: MaestroOpt[] = [];
if (!esDocente) {
@@ -44,6 +26,10 @@ if (!esDocente) {
.order('nombre');
maestros = (ms ?? []) as MaestroOpt[];
}
const tutorNombre = tutorId ? (maestros.find((m) => m.id === tutorId)?.nombre ?? null) : null;
// Docente: solo requiere matrícula (número de empleado). Alumno: matrícula + tutor.
const perfilCompleto = esDocente ? !!matricula : !!(matricula && tutorId);
type Material = {
id: number;
@@ -79,13 +65,14 @@ const categoriasFiltro = grupoList
<AppLayout title="Catálogo — LabPréstamos">
<div class="max-w-6xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Catálogo</h1>
<p class="text-sm opacity-70 mt-1">Material disponible en el Laboratorio de Sistemas.</p>
<span class="eyebrow mb-1">Inventario disponible</span>
<h1 class="text-3xl md:text-4xl">Catálogo</h1>
<p class="text-sm text-graphite/70 mt-1">Material disponible en el Laboratorio de Sistemas.</p>
</header>
{error && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
<div class="card mb-6 border border-hairline-gray" role="alert">
<p class="text-sm text-negro-institucional">
No se pudo cargar el catálogo. Recarga la página o intenta más tarde.
</p>
</div>
@@ -93,8 +80,8 @@ const categoriasFiltro = grupoList
{materiales.length === 0 && !error && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no hay material publicado</h2>
<p class="text-sm opacity-70">Vuelve pronto o contacta al encargado del laboratorio.</p>
<h2 class="text-xl mb-1">Aún no hay material publicado</h2>
<p class="text-sm text-graphite/70">Vuelve pronto o contacta al encargado del laboratorio.</p>
</div>
)}
@@ -107,7 +94,7 @@ const categoriasFiltro = grupoList
)}
</div>
<p id="catalogo-empty-filter" hidden class="text-sm opacity-70 mb-4">
<p id="catalogo-empty-filter" hidden class="text-sm text-graphite/70 mb-4">
Ningún material en esta categoría. Prueba con otra.
</p>
+106 -101
View File
@@ -48,37 +48,35 @@ const estadoLabel: Record<Estado, string> = {
vencido: 'Vencido',
};
const estadoColor: Record<Estado, string> = {
pendiente: 'var(--color-ink)',
aprobado: 'var(--color-positive)',
activo: 'var(--color-positive)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
const chipClass: Record<Estado, string> = {
pendiente: 'chip chip-pendiente',
aprobado: 'chip chip-aprobado',
activo: 'chip chip-activo',
devuelto: 'chip chip-devuelto',
rechazado: 'chip chip-rechazado',
vencido: 'chip chip-vencido',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
---
<AppLayout title="Mis préstamos — LabPréstamos">
<div class="max-w-4xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Mis préstamos</h1>
<p class="text-sm opacity-70 mt-1">Estado de tus solicitudes actuales e historial.</p>
<span class="eyebrow mb-1">Mis solicitudes</span>
<h1 class="text-3xl md:text-4xl">Mis préstamos</h1>
<p class="text-sm text-graphite/70 mt-1">Estado de tus solicitudes actuales e historial.</p>
</header>
{error && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
<div class="card mb-6 border border-hairline-gray p-6" role="alert">
<p class="text-sm text-negro-institucional">
No se pudieron cargar tus préstamos. Recarga la página o intenta más tarde.
</p>
</div>
)}
{!error && prestamos.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no has solicitado material</h2>
<p class="text-sm opacity-70 mb-4">Explora el catálogo y envía tu primera solicitud.</p>
<div class="card text-center p-8">
<h2 class="text-xl mb-1">Aún no has solicitado material</h2>
<p class="text-sm text-graphite/70 mb-4">Explora el catálogo y envía tu primera solicitud.</p>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
</div>
)}
@@ -86,106 +84,117 @@ const badgeStyle = (e: Estado) =>
{prestamos.length > 0 && (
<div class="space-y-8">
<section>
<h2 class="text-lg font-semibold mb-3">Activos <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({activos.length})</span></h2>
{activos.length === 0 ? (
<p class="text-sm opacity-70">Nada activo por ahora.</p>
) : (
<ul class="space-y-3">
{activos.map((p) => (
<li class="card">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.material_unidad?.etiqueta && (
<div class="text-xs opacity-70">Unidad: {it.material_unidad.etiqueta}</div>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_aprobacion && (
<>
<dt class="opacity-60">Aprobado</dt>
<dd class="text-right">{fmt(p.fecha_aprobacion)}</dd>
</>
)}
{p.fecha_devolucion_estimada && (
<>
<dt class="opacity-60">Devolver antes de</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_estimada)}</dd>
</>
)}
</dl>
{p.notas && (
<p class="mt-3 text-sm opacity-75 border-l-2 pl-3" style="border-color: color-mix(in oklab, var(--color-ink) 15%, transparent);">
{p.notas}
</p>
)}
</li>
))}
</ul>
)}
</section>
<section>
<details open={historial.length <= 5}>
<summary class="text-lg font-semibold cursor-pointer list-none flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<details open class="group">
<summary class="text-2xl cursor-pointer list-none flex items-center gap-2 select-none font-light">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="transition-transform duration-150 group-open:rotate-90">
<path d="M9 6l6 6-6 6" />
</svg>
Historial <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({historial.length})</span>
<span>
Activos <span class="text-base text-graphite/60 font-sans tabular-nums font-normal">({activos.length})</span>
</span>
</summary>
{historial.length === 0 ? (
<p class="text-sm opacity-70 mt-3">Sin historial todavía.</p>
{activos.length === 0 ? (
<div class="card p-6 text-sm text-graphite/70 mt-4">Nada activo por ahora.</div>
) : (
<ul class="space-y-3 mt-3">
{historial.map((p) => (
<li class="card" style="opacity: 0.92;">
<ul class="space-y-4 mt-4">
{activos.map((p) => (
<li class="card p-6">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
<span class="font-medium text-graphite tabular-nums">{it.cantidad}×</span>{' '}
<span class="font-medium text-graphite">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
<span class="text-xs text-graphite/60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.material_unidad?.etiqueta && (
<div class="text-xs text-graphite/70">Unidad: {it.material_unidad.etiqueta}</div>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
<p class="text-xs text-graphite/60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
<span class={chipClass[p.estado]}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
<dl class="mt-4 pt-3 border-t border-hairline-gray grid grid-cols-2 gap-y-1.5 text-sm">
<dt class="text-graphite/60">Maestro responsable</dt>
<dd class="text-right truncate font-medium text-graphite">{p.maestro_responsable}</dd>
<dt class="text-graphite/60">Solicitado</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_aprobacion && (
<>
<dt class="text-graphite/60">Aprobado</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_aprobacion)}</dd>
</>
)}
{p.fecha_devolucion_estimada && (
<>
<dt class="text-graphite/60">Devolver antes de</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_devolucion_estimada)}</dd>
</>
)}
</dl>
{p.notas && (
<p class="mt-3 text-sm text-graphite/80 border-l border-hairline-gray pl-3 italic bg-linen p-2 rounded-[8px]">
{p.notas}
</p>
)}
</li>
))}
</ul>
)}
</details>
</section>
<section>
<details open={historial.length <= 5} class="group">
<summary class="text-2xl cursor-pointer list-none flex items-center gap-2 select-none font-light">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="transition-transform duration-150 group-open:rotate-90">
<path d="M9 6l6 6-6 6" />
</svg>
<span>
Historial <span class="text-base text-graphite/60 font-sans tabular-nums font-normal">({historial.length})</span>
</span>
</summary>
{historial.length === 0 ? (
<p class="text-sm text-graphite/70 mt-3">Sin historial todavía.</p>
) : (
<ul class="space-y-4 mt-4">
{historial.map((p) => (
<li class="card p-6">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-medium text-graphite tabular-nums">{it.cantidad}×</span>{' '}
<span class="font-medium text-graphite">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs text-graphite/60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.descripcion && (
<p class="text-xs text-graphite/60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class={chipClass[p.estado]}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-4 pt-3 border-t border-hairline-gray grid grid-cols-2 gap-y-1.5 text-sm">
<dt class="text-graphite/60">Maestro responsable</dt>
<dd class="text-right truncate font-medium text-graphite">{p.maestro_responsable}</dd>
<dt class="text-graphite/60">Solicitado</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_devolucion_real && (
<>
<dt class="opacity-60">Devuelto</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_real)}</dd>
<dt class="text-graphite/60">Devuelto</dt>
<dd class="text-right text-graphite">{fmt(p.fecha_devolucion_real)}</dd>
</>
)}
</dl>
@@ -202,8 +211,4 @@ const badgeStyle = (e: Estado) =>
<style>
details > summary::-webkit-details-marker { display: none; }
details[open] > summary > svg { transform: rotate(90deg); }
details > summary > svg {
transition: transform 150ms ease-out;
}
</style>
+3 -1
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { invalidateUserCaches } from '@/middleware';
export const prerender = false;
@@ -17,7 +18,7 @@ export const POST: APIRoute = async ({ locals, params }) => {
.update({ unbanned_at: new Date().toISOString(), unbanned_by: locals.user!.id })
.eq('id', id)
.is('unbanned_at', null)
.select('id');
.select('id, profile_id');
if (error) {
return Response.json({ error: 'No se pudo desbanear' }, { status: 500 });
@@ -26,5 +27,6 @@ export const POST: APIRoute = async ({ locals, params }) => {
return Response.json({ error: 'Baneo no encontrado o ya inactivo' }, { status: 404 });
}
if (data[0]?.profile_id) invalidateUserCaches(data[0].profile_id);
return Response.json({ ok: true });
};
+2
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { invalidateUserCaches } from '@/middleware';
export const prerender = false;
@@ -63,5 +64,6 @@ export const POST: APIRoute = async ({ request, locals }) => {
return Response.json({ error: 'No se pudo crear el baneo' }, { status: 500 });
}
invalidateUserCaches(profile_id);
return Response.json({ id: data.id }, { status: 201 });
};
+4 -1
View File
@@ -1,7 +1,10 @@
import type { APIRoute } from 'astro';
import { serverClient } from '@/lib/supabase';
import { invalidateUserCaches } from '@/middleware';
export const POST: APIRoute = async ({ cookies, redirect }) => {
export const POST: APIRoute = async ({ cookies, redirect, locals }) => {
const uid = locals.user?.id;
await serverClient(cookies).auth.signOut();
if (uid) invalidateUserCaches(uid);
return redirect('/login');
};
+2
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro';
import { invalidateUserCaches } from '@/middleware';
export const prerender = false;
@@ -80,5 +81,6 @@ export const PATCH: APIRoute = async ({ request, locals }) => {
return Response.json({ error: 'no se pudo actualizar' }, { status: 500 });
}
invalidateUserCaches(user.id);
return Response.json({ ok: true, profile: data });
};
+14 -14
View File
@@ -39,40 +39,40 @@ Astro.response.status = 403;
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-lg">
<div class="text-center mb-6">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] p-4 mb-4" style="background: var(--color-danger); border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full" style="color: var(--color-ink);" />
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional p-4 mb-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Cuenta bloqueada</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Tu acceso al sistema de préstamos fue suspendido.</p>
<h1>Cuenta bloqueada</h1>
<p class="text-sm mt-2 text-graphite/70">Tu acceso al sistema de préstamos fue suspendido.</p>
</div>
<div class="card-raised">
<div class="card">
<dl class="flex flex-col gap-4 text-sm">
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Motivo</dt>
<dd class="whitespace-pre-wrap break-words">{ban.razon}</dd>
<dt class="eyebrow mb-1">Motivo</dt>
<dd class="whitespace-pre-wrap break-words text-graphite font-medium">{ban.razon}</dd>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Fecha del bloqueo</dt>
<dd>{bannedAtStr}</dd>
<dt class="eyebrow mb-1">Fecha del bloqueo</dt>
<dd class="text-graphite">{bannedAtStr}</dd>
</div>
{expiresStr && (
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Expira</dt>
<dd>{expiresStr}</dd>
<dt class="eyebrow mb-1">Expira</dt>
<dd class="text-graphite">{expiresStr}</dd>
</div>
)}
{byName && (
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Bloqueado por</dt>
<dd>{byName}</dd>
<dt class="eyebrow mb-1">Bloqueado por</dt>
<dd class="text-graphite">{byName}</dd>
</div>
)}
</div>
</dl>
<p class="text-sm mt-6" style="color: var(--color-pencil);">
<p class="text-sm mt-6 text-graphite/70">
Si crees que esto es un error, contacta al Laboratorio de Sistemas Computacionales.
</p>
+63 -38
View File
@@ -22,16 +22,40 @@ type Item = { cantidad: number; material: { nombre: string } | null };
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null;
let actual: Actual = null;
let activosCount = 0;
let pendientesCount = 0;
let devueltosCount = 0;
if (user) {
const { data } = await supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
.eq('alumno_id', user.id)
.in('estado', ['pendiente', 'aprobado', 'activo'])
.order('fecha_solicitud', { ascending: false })
.limit(1)
.maybeSingle();
actual = (data as unknown as Actual) ?? null;
const [actualRes, activosRes, pendRes, devRes] = await Promise.all([
supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
.eq('alumno_id', user.id)
.in('estado', ['pendiente', 'aprobado', 'activo'])
.order('fecha_solicitud', { ascending: false })
.limit(1)
.maybeSingle(),
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('alumno_id', user.id)
.in('estado', ['aprobado', 'activo']),
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('alumno_id', user.id)
.eq('estado', 'pendiente'),
supabase
.from('solicitudes')
.select('*', { count: 'exact', head: true })
.eq('alumno_id', user.id)
.eq('estado', 'devuelto'),
]);
actual = (actualRes.data as unknown as Actual) ?? null;
activosCount = activosRes.count ?? 0;
pendientesCount = pendRes.count ?? 0;
devueltosCount = devRes.count ?? 0;
}
const estadoLabel: Record<Estado, string> = {
@@ -42,16 +66,6 @@ const estadoLabel: Record<Estado, string> = {
devuelto: 'Devuelto',
vencido: 'Vencido',
};
const estadoColor: Record<Estado, string> = {
pendiente: 'var(--color-ink)',
aprobado: 'var(--color-positive)',
activo: 'var(--color-positive)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
const items = actual?.items ?? [];
const preview = items.slice(0, 3);
@@ -60,46 +74,57 @@ const extra = items.length - preview.length;
<AppLayout title="Inicio — LabPréstamos">
<div class="max-w-2xl mx-auto">
<header class="mb-8">
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
<h1>
{saludo}, {nombreCorto}
</h1>
</header>
<section class="card-raised mb-8">
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Nuevo préstamo</p>
<h2 class="text-xl md:text-2xl font-semibold mb-5">¿Qué vas a pedir hoy?</h2>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
</section>
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="card p-5">
<span class="eyebrow mb-1">Activos</span>
<div class="text-2xl md:text-3xl text-graphite font-light" style="font-variant-numeric: tabular-nums;">{activosCount}</div>
</div>
<div class="card p-5">
<span class="eyebrow mb-1 text-dorado-uabc">Pendientes</span>
<div class="text-2xl md:text-3xl text-graphite font-light" style="font-variant-numeric: tabular-nums;">{pendientesCount}</div>
</div>
<div class="card p-5">
<span class="eyebrow mb-1">Devueltos</span>
<div class="text-2xl md:text-3xl text-graphite font-light" style="font-variant-numeric: tabular-nums;">{devueltosCount}</div>
</div>
</div>
{actual ? (
<section aria-labelledby="actual-h">
<div class="flex items-center justify-between gap-3 mb-3">
<h2 id="actual-h" class="text-lg font-semibold uppercase tracking-wide">Tu préstamo actual</h2>
<span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
<h2 id="actual-h" class="text-xl">Tu préstamo actual</h2>
<span class:list={['chip', `chip-${actual.estado}`]}>{estadoLabel[actual.estado]}</span>
</div>
<a href="/alumno/mis-prestamos" class="card-link">
<ul class="space-y-1 mb-4">
<ul class="space-y-1.5 mb-4">
{preview.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
<li class="leading-snug text-graphite">
<span class="font-medium" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-medium text-graphite">{it.material?.nombre ?? 'Material eliminado'}</span>
</li>
))}
{extra > 0 && (
<li class="text-sm opacity-70">(y {extra} más)</li>
<li class="text-sm text-graphite/60">(y {extra} más)</li>
)}
</ul>
<div class="flex items-center justify-between gap-3 text-sm">
<span style="color: var(--color-pencil);">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Ver detalles</span>
<span class="text-graphite/60">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
<span class="text-verde-institucional font-medium">Ver detalles</span>
</div>
</a>
<div class="mt-6">
<a href="/alumno/catalogo" class="btn btn-primary">Nuevo préstamo</a>
</div>
</section>
) : (
<section class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no tienes préstamos activos</h2>
<p class="text-sm opacity-70 mb-5">¿Vamos por algo del laboratorio?</p>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
<section class="text-center py-8">
<p class="text-graphite/70 mb-5">Sin préstamos activos.</p>
<a href="/alumno/catalogo" class="btn btn-primary inline-flex">Nuevo préstamo</a>
</section>
)}
</div>
+11 -11
View File
@@ -16,33 +16,33 @@ const googleAuthUrl = '/api/auth/signin';
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] p-4 mb-4 motion-safe:animate-[fadein_.4s_ease-out]" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full text-white" />
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional p-4 mb-4 motion-safe:animate-[fadein_.4s_ease-out]" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Sistema de Préstamos</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Laboratorio de Sistemas Computacionales · UABC</p>
<h1>Sistema de Préstamos</h1>
<p class="text-sm mt-2 text-graphite/70">Laboratorio de Sistemas Computacionales · UABC</p>
</div>
<div class="card-raised motion-safe:animate-[fadein_.5s_ease-out]">
<h2 class="text-lg font-semibold mb-1 uppercase tracking-wide">Inicia sesión</h2>
<p class="text-sm mb-6" style="color: var(--color-pencil);">Accede con tu cuenta institucional.</p>
<div class="card motion-safe:animate-[fadein_.5s_ease-out]">
<h2 class="text-xl mb-1">Inicia sesión</h2>
<p class="text-sm mb-6 text-graphite/70">Accede con tu cuenta institucional.</p>
<a href={googleAuthUrl} class="btn btn-primary w-full">
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<path fill="white" d="M44.5 20H24v8.5h11.7C34.4 33.4 29.7 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c2.9 0 5.5 1 7.6 2.7l6.2-6.2C33.8 5.1 29.2 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21c11 0 20-8 20-21 0-1.4-.1-2.7-.5-4z"/>
<path fill="currentColor" d="M44.5 20H24v8.5h11.7C34.4 33.4 29.7 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c2.9 0 5.5 1 7.6 2.7l6.2-6.2C33.8 5.1 29.2 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21c11 0 20-8 20-21 0-1.4-.1-2.7-.5-4z"/>
</svg>
Continuar con Google
</a>
{errorMsg && (
<div role="alert" class="mt-4 rounded-[2px] p-3 text-sm" style="background: color-mix(in oklab, var(--color-danger) 20%, transparent); border: 1.5px solid var(--color-danger); color: var(--color-ink);">
<div role="alert" class="mt-4 rounded-[8px] p-4 text-sm bg-linen border border-negro-institucional text-graphite">
{errorMsg}
</div>
)}
</div>
<p class="text-center text-xs mt-6" style="color: var(--color-pencil);">
Acceso exclusivo con correo institucional <strong style="color: var(--color-ink);">@uabc.edu.mx</strong>
<p class="text-center text-xs mt-6 text-graphite/70">
Acceso exclusivo con correo institucional <strong class="text-graphite font-medium">@uabc.edu.mx</strong>
</p>
</div>
</main>
+5 -5
View File
@@ -29,18 +29,18 @@ const copyIntro =
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-lg">
<div class="text-center mb-6">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] p-4 mb-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full text-white" />
<div class="inline-grid place-items-center w-16 h-16 rounded-[12px] bg-linen text-verde-institucional p-4 mb-4" aria-hidden="true">
<BrandMark class="w-full h-full" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">
<h1>
Bienvenido, {primerNombre}
</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">
<p class="text-sm mt-2 text-graphite/70">
{copyIntro}
</p>
</div>
<div class="card-raised">
<div class="card">
<PerfilForm
client:load
mode="onboarding"
+10 -9
View File
@@ -31,22 +31,23 @@ const info = avatarInfo({
});
---
<AppLayout title="Mi perfil — LabPréstamos">
<div class="max-w-md mx-auto">
<header class="flex items-center gap-4 mb-6">
<Avatar client:load info={info} size={80} />
<div class="min-w-0">
<h1 class="text-xl md:text-2xl font-semibold uppercase tracking-wide leading-tight truncate">
<div class="max-w-xl mx-auto space-y-6">
<header class="card p-6 flex items-center gap-5">
<Avatar client:load info={info} size={80} className="w-20 h-20 rounded-full" />
<div class="min-w-0 flex-1">
<span class="eyebrow mb-1">Mi perfil</span>
<h1 class="text-2xl md:text-3xl leading-tight truncate">
{profile.nombre ?? profile.email.split('@')[0]}
</h1>
<p class="text-sm truncate" style="color: var(--color-pencil);">{profile.email}</p>
<span class="badge mt-2" style={`background: ${profile.rol === 'admin' ? 'var(--color-secondary)' : 'var(--color-chalk)'};`}>
<p class="text-sm text-graphite/70 truncate">{profile.email}</p>
<span class="badge mt-2">
{rolLabel}
</span>
</div>
</header>
<section class="card-raised">
<h2 class="text-sm uppercase tracking-wide font-semibold mb-4">Datos del perfil</h2>
<section class="card p-6">
<h2 class="text-xl md:text-2xl mb-4">Datos del perfil</h2>
<PerfilForm
client:load
userId={user.id}
+248 -95
View File
@@ -1,44 +1,49 @@
@import "tailwindcss";
@import "@fontsource-variable/jetbrains-mono";
@theme {
/* MotherDuck-derived system (shadow/radius/type mechanics); identity colors are UABC's own */
--color-cream: #f4efea;
--color-frost: #ffffff;
--color-chalk: #f8f8f7;
--color-ice: #ebf9ff;
/* UABC — Identidad editorial institucional (DESIGN.md) */
--color-verde-institucional: #00723F;
--color-verde-claro: #50B848;
--color-dorado-uabc: #DD971A;
--color-amarillo-dorado: #FEBE10;
--color-verde-jardin: #024731;
--color-dorado-oscuro: #B08208;
--color-azul-institucional: #20419A;
--color-bone-white: #fffefb;
--color-linen: #f3f2ee;
--color-hairline-gray: #e5e7eb;
--color-graphite: #333833;
--color-negro-institucional: #000000;
--color-ink: #383838;
--color-pencil: #a1a1a1;
--color-graphite: #818181;
--font-sans: 'Mulish', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'Mulish', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Mulish', ui-sans-serif, system-ui, sans-serif;
/* UABC institutional green/gold — dark enough that primary needs white text, not charcoal */
--color-primary: #00723F;
--color-primary-hover: #005f34;
--color-secondary: #DD971A;
--color-secondary-hover: #c78614;
--color-danger: #f38e84;
--color-danger-hover: #ec7469;
/* AA-contrast text variant of Coral Sketch — the fill color reads ~2:1 on white, too light for body text */
--color-danger-text: #a83224;
--color-positive: #38c1b0;
--color-attention: #ffde00;
--text-micro: 10px;
--text-caption: 12px;
--text-body-sm: 14px;
--text-body: 16px;
--text-ui: 16px;
--text-heading: 24px;
--text-heading-lg: 32px;
--text-display: 72px;
--color-surface: var(--color-cream);
/* Radii — tokens 8/12/30/9999 solamente */
--radius-tags: 8px;
--radius-cards: 12px;
--radius-nav: 30px;
--radius-pill: 9999px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 12px;
--radius-xl: 12px;
--radius-2xl: 30px;
--radius-3xl: 30px;
--radius-full: 9999px;
--font-sans: "JetBrains Mono Variable", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
--radius-card: 2px;
--shadow-hard: -6px 6px 0 0 var(--color-ink);
--shadow-hard-sm: -3px 3px 0 0 var(--color-ink);
/* Every Tailwind radius utility collapses to the system's 2px — non-negotiable per brief */
--radius-sm: 2px;
--radius-md: 2px;
--radius-lg: 2px;
--radius-xl: 2px;
--radius-2xl: 2px;
--radius-3xl: 2px;
/* Sin sombras — elevación por color de superficie + hairline border */
--shadow-hard: none;
--shadow-hard-sm: none;
}
@layer base {
@@ -46,116 +51,264 @@
color-scheme: light;
}
body {
background: var(--color-cream);
color: var(--color-ink);
background-color: var(--color-bone-white);
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 12%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
background-position: 0 0;
color: var(--color-graphite);
font-family: var(--font-sans);
letter-spacing: 0.02em;
font-weight: 700;
font-size: 16px;
line-height: 1.5;
letter-spacing: -0.1px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:focus-visible {
outline: 2px solid var(--color-ink);
outline: 2px solid var(--color-verde-institucional);
outline-offset: 2px;
}
::selection {
background: var(--color-primary);
color: white;
background: var(--color-verde-institucional);
color: var(--color-bone-white);
}
/* Tabular data keeps real square corners even where a nested control forgets to ask */
table {
font-variant-numeric: tabular-nums;
}
h1, h2, h3, h4 {
font-family: var(--font-sans);
font-weight: 700;
color: var(--color-graphite);
text-wrap: balance;
}
h1 { font-size: 32px; line-height: 1.25; letter-spacing: -0.3px; }
h2 { font-size: 24px; line-height: 1.25; letter-spacing: -0.2px; }
h3 { font-size: 16px; line-height: 1.5; letter-spacing: 0; font-weight: 700; }
h4 { font-size: 14px; line-height: 1.5; letter-spacing: 0; font-weight: 700; }
p { text-wrap: pretty; }
a { color: var(--color-verde-institucional); text-decoration: none; }
a:hover { color: var(--color-verde-claro); }
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-[2px] px-4 py-2.5 font-medium
motion-safe:transition-transform motion-safe:duration-100 motion-safe:ease-out
disabled:opacity-50 disabled:pointer-events-none disabled:translate-x-0 disabled:translate-y-0;
@apply inline-flex items-center justify-center gap-2 px-6 py-2.5
motion-safe:transition-colors motion-safe:duration-200 motion-safe:ease-out
disabled:opacity-50 disabled:pointer-events-none;
border-radius: var(--radius-pill);
min-height: 44px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
border: 2px solid var(--color-ink);
color: var(--color-ink);
box-shadow: var(--shadow-hard);
}
.btn:hover,
.btn:focus-visible {
transform: translate(-6px, 6px);
box-shadow: 0 0 0 0 transparent;
}
.btn:active {
transform: translate(-3px, 3px);
box-shadow: var(--shadow-hard-sm);
border: 1px solid transparent;
color: var(--color-graphite);
background: transparent;
font-family: var(--font-sans);
font-size: 16px;
font-weight: 700;
letter-spacing: 0;
line-height: 1.5;
box-shadow: none;
text-decoration: none;
}
.btn-primary {
background: var(--color-primary);
color: white;
background: var(--color-verde-institucional);
color: var(--color-bone-white);
border-color: var(--color-verde-institucional);
}
.btn-primary:hover,
.btn-primary:focus-visible {
background: var(--color-verde-claro);
border-color: var(--color-verde-claro);
}
.btn-secondary {
background: var(--color-secondary);
color: var(--color-ink);
background: transparent;
color: var(--color-graphite);
border-color: var(--color-hairline-gray);
}
.btn-secondary:hover,
.btn-secondary:focus-visible {
background: var(--color-linen);
border-color: var(--color-dorado-uabc);
}
.btn-ghost {
background: transparent;
color: var(--color-ink);
border-color: color-mix(in oklab, var(--color-ink) 35%, transparent);
box-shadow: none;
color: var(--color-graphite);
border-color: var(--color-hairline-gray);
}
.btn-ghost:hover,
.btn-ghost:focus-visible {
transform: none;
background: var(--color-chalk);
border-color: var(--color-ink);
background: var(--color-linen);
border-color: var(--color-hairline-gray);
}
.btn-danger {
background: var(--color-negro-institucional);
color: var(--color-bone-white);
border-color: var(--color-negro-institucional);
}
.btn-danger:hover,
.btn-danger:focus-visible {
background: var(--color-graphite);
border-color: var(--color-graphite);
}
.card {
@apply rounded-[2px] bg-white p-6;
border: 2px solid var(--color-ink);
}
.card-flat {
@apply rounded-[2px] bg-white p-6;
border: 1.5px solid var(--color-ink);
background: color-mix(in oklab, var(--color-linen) 72%, transparent);
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 18%, transparent);
border-radius: var(--radius-cards);
padding: 24px;
backdrop-filter: blur(6px) saturate(1.1);
-webkit-backdrop-filter: blur(6px) saturate(1.1);
}
.card-flat,
.card-raised {
@apply rounded-[2px] bg-white p-6;
border: 2px solid var(--color-ink);
box-shadow: var(--shadow-hard);
background: color-mix(in oklab, var(--color-linen) 82%, transparent);
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 22%, transparent);
border-radius: var(--radius-cards);
padding: 24px;
backdrop-filter: blur(8px) saturate(1.1);
-webkit-backdrop-filter: blur(8px) saturate(1.1);
}
.card-link {
@apply rounded-[2px] bg-white p-6 block
motion-safe:transition-transform motion-safe:duration-100 motion-safe:ease-out;
border: 2px solid var(--color-ink);
box-shadow: var(--shadow-hard);
@apply block motion-safe:transition-colors motion-safe:duration-200 motion-safe:ease-out;
background: color-mix(in oklab, var(--color-linen) 72%, transparent);
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 18%, transparent);
border-radius: var(--radius-cards);
padding: 24px;
text-decoration: none;
color: var(--color-graphite);
backdrop-filter: blur(6px) saturate(1.1);
-webkit-backdrop-filter: blur(6px) saturate(1.1);
}
.card-link:hover,
.card-link:focus-visible {
transform: translate(-6px, 6px);
box-shadow: 0 0 0 0 transparent;
background: color-mix(in oklab, var(--color-linen) 88%, transparent);
border-color: color-mix(in oklab, var(--color-verde-institucional) 40%, transparent);
}
/* Legacy tinted variants → collapse to Linen card */
.card-sage,
.card-keylime,
.card-mint,
.card-slate {
background: var(--color-linen);
border: 1px solid var(--color-hairline-gray);
border-radius: var(--radius-cards);
padding: 24px;
color: var(--color-graphite);
}
.card-dark {
background: var(--color-negro-institucional);
border: 1px solid var(--color-negro-institucional);
border-radius: var(--radius-cards);
padding: 24px;
color: var(--color-bone-white);
}
.input {
@apply w-full rounded-[2px] bg-white px-3 py-2.5
transition-[border-color] duration-100
focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-[color:var(--color-ink)];
border: 2px solid var(--color-ink);
@apply w-full px-4 py-3
transition-[border-color,box-shadow] duration-200
focus:outline-none;
background: var(--color-bone-white);
border: 1px solid var(--color-hairline-gray);
border-radius: var(--radius-tags);
color: var(--color-graphite);
min-height: 44px;
touch-action: manipulation;
font-family: var(--font-sans);
font-weight: 700;
font-size: 16px;
letter-spacing: -0.1px;
}
.input:focus {
border-color: var(--color-primary);
.input:focus,
.input:focus-visible {
border-color: var(--color-verde-institucional);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-verde-institucional) 25%, transparent);
}
.label {
@apply block text-sm font-medium mb-1 uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
color: var(--color-ink);
@apply block mb-2;
font-family: var(--font-sans);
font-size: 12px;
font-weight: 700;
color: var(--color-graphite);
letter-spacing: 0;
text-transform: none;
}
.eyebrow {
@apply inline-block;
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
color: var(--color-verde-institucional);
letter-spacing: 0.2px;
text-transform: uppercase;
}
.badge {
@apply inline-flex items-center rounded-[2px] px-2 py-0.5 text-xs font-semibold uppercase whitespace-nowrap;
letter-spacing: 0.03em;
border: 1.5px solid var(--color-ink);
@apply inline-flex items-center px-3 py-1 whitespace-nowrap;
border-radius: var(--radius-tags);
background: var(--color-bone-white);
color: var(--color-graphite);
border: 1px solid var(--color-hairline-gray);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
}
/* Status chips — tag pill (8px), no full pill; borde de acento por estado */
.chip {
@apply inline-flex items-center whitespace-nowrap;
border-radius: var(--radius-tags);
padding: 4px 10px;
font-size: 12px;
font-weight: 700;
font-family: var(--font-sans);
letter-spacing: 0;
background: var(--color-bone-white);
color: var(--color-graphite);
border: 1px solid var(--color-hairline-gray);
}
.chip-pendiente {
color: var(--color-dorado-uabc);
border-color: var(--color-dorado-uabc);
}
.chip-aprobado,
.chip-activo,
.chip-devuelto {
color: var(--color-verde-institucional);
border-color: var(--color-verde-institucional);
}
.chip-rechazado,
.chip-vencido {
color: var(--color-bone-white);
background: var(--color-negro-institucional);
border-color: var(--color-negro-institucional);
}
/* Patrones decorativos de círculos — utility para superficies puntuales */
.pattern-dots {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 18%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
}
.pattern-dots-dorado {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-dorado-uabc) 25%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
}
.pattern-dots-dense {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-verde-institucional) 22%, transparent) 2px, transparent 2px);
background-size: 14px 14px;
}
.pattern-dots-on-dark {
background-image: radial-gradient(circle, color-mix(in oklab, var(--color-bone-white) 18%, transparent) 1.5px, transparent 1.5px);
background-size: 22px 22px;
}
/* Nav flotante translúcido (patrón del brief) */
.nav-pill {
background: color-mix(in oklab, var(--color-dorado-oscuro) 18%, transparent);
border-radius: var(--radius-nav);
padding: 8px 16px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
h1, h2, h3 { text-wrap: balance; }
@@ -0,0 +1,21 @@
-- Migración 0008 — REPLICA IDENTITY FULL en prestamos.solicitudes
--
-- Motivo: el badge de solicitudes pendientes (client-side, componente
-- BadgeSolicitudes) escucha postgres_changes UPDATE. Con REPLICA IDENTITY
-- DEFAULT (default de Postgres) el WAL solo emite la PK en `payload.old`,
-- así que el cliente no puede saber el estado anterior y estaba obligado
-- a refetchear el count completo en cada UPDATE — costoso a 20-50 admins
-- concurrentes.
--
-- Con FULL, el WAL emite la fila completa `old`, y el cliente puede
-- aplicar el delta (+1/-1/no-op) sin ir a la BD. Trade-off: aumenta el
-- ancho de banda del WAL para replicación — irrelevante en esta tabla
-- (miles de filas, pocos updates/segundo).
--
-- Idempotente.
begin;
alter table prestamos.solicitudes replica identity full;
commit;
@@ -0,0 +1,30 @@
-- Migración 0009 — índices para queries frecuentes
--
-- Cubre 3 hot paths detectados en la auditoría:
--
-- 1) ORDER BY fecha_solicitud DESC — usado en admin/index, mis-prestamos,
-- estadisticas, reportes. Sin índice, a >5k filas Postgres hace sort en
-- memoria en cada request.
--
-- 2) "Vencidos": estado IN ('aprobado','activo') AND fecha_devolucion_estimada < today.
-- Índice parcial: solo indexa filas activas (10-100 filas típicas), evita
-- inflar el índice con solicitudes ya devueltas/rechazadas.
--
-- 3) audit_log filtrado por estado_nuevo + rango de at (estadisticas del día).
-- El compuesto acelera los 3 counts diarios (aprobadas/rechazadas/devueltas).
--
-- Todos idempotentes vía IF NOT EXISTS.
begin;
create index if not exists solicitudes_fecha_idx
on prestamos.solicitudes (fecha_solicitud desc);
create index if not exists solicitudes_vencidos_idx
on prestamos.solicitudes (fecha_devolucion_estimada)
where estado in ('aprobado', 'activo');
create index if not exists audit_estado_at_idx
on prestamos.audit_log (estado_nuevo, at desc);
commit;