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:
@@ -1,228 +1,189 @@
|
|||||||
# Ease Health — Style Reference
|
# UABC — Style Reference
|
||||||
> Botanical greenhouse on cream paper
|
> 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
|
## Tokens — Colors
|
||||||
|
|
||||||
| Name | Value | Token | Role |
|
| 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 |
|
| 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 |
|
||||||
| Sage Mist | `#b1dbb8` | `--color-sage-mist` | Mid-tone card backgrounds and decorative washes, panel-level surfaces |
|
| Verde Claro | `#50B848` | `--color-verde-claro` | Estados hover, contornos de acento suaves, eco desaturado del Verde Institucional (Pantone 369) |
|
||||||
| Keylime Wash | `#e1f4df` | `--color-keylime-wash` | Hero panel background, light card surfaces — the dominant tinted canvas tone |
|
| Dorado UABC | `#DD971A` | `--color-dorado-uabc` | Acento cálido secundario — bordes de tags, highlights, contraste cálido frente al verde (Pantone 131) |
|
||||||
| Mint Veil | `#cfe7d3` | `--color-mint-veil` | Accent card surfaces, soft section dividers between sage and keylime layers |
|
| Amarillo Dorado | `#FEBE10` | `--color-amarillo-dorado` | Badges y estados destacados sobre fondo oscuro (Pantone 124) |
|
||||||
| Slate Hush | `#b6ced5` | `--color-slate-hush` | Cool counter-balance panel for product preview and feature blocks — breaks the green monotony with desaturated blue-gray |
|
| Verde Jardín | `#024731` | `--color-verde-jardin` | Superficie oscura alterna, bordes fuertes sobre fondo oscuro (Pantone 3435) |
|
||||||
| Cream Paper | `#fffefc` | `--color-cream-paper` | Page background, badge backgrounds, button text — warm off-white that softens every green |
|
| Dorado Oscuro | `#B08208` | `--color-dorado-oscuro` | Fondo translúcido de nav, bordes de fuerza media, superficies UI mudas (Pantone 132) |
|
||||||
| Charcoal | `#222222` | `--color-charcoal` | Navigation text and minor UI strokes — restrained secondary ink |
|
| Azul Institucional | `#20419A` | `--color-azul-institucional` | Acento informativo/enlaces alternos — distingue contenido secundario de las acciones primarias en verde (Pantone Reflex Blue) |
|
||||||
| Border Mist | `#efeeeb` | `--color-border-mist` | Hairline dividers and subtle borders throughout the layout |
|
| Bone White | `#fffefb` | `--color-bone-white` | Lienzo de página en secciones claras, color de texto en secciones oscuras |
|
||||||
| 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 |
|
| 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
|
## Tokens — Typography
|
||||||
|
|
||||||
### Faire Octave — Display serif for all section headings and hero copy · `--font-faire-octave`
|
### 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`
|
||||||
- **Substitute:** Cormorant Garamond, Playfair Display
|
- **Fuente:** Fontsource (`@fontsource/azeret-mono` / fontsource.org/fonts/azeret-mono)
|
||||||
- **Weights:** 300
|
- **Substitute:** JetBrains Mono, IBM Plex Mono, o monoespacio del sistema
|
||||||
- **Sizes:** 40px, 56px, 74px
|
- **Weights:** 300 (encabezados y cuerpo), 500 (nav, botones, controles de UI)
|
||||||
- **Line height:** 1.05–1.35
|
- **Sizes:** 10px, 12px, 14px, 16px, 24px, 32px, 72px
|
||||||
- **Letter spacing:** -0.01em at 40px, -0.03em at 56–74px
|
- **Line height:** 1.10–1.83 según rol
|
||||||
- **Role:** Display serif for all section headings and hero copy
|
- **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.
|
||||||
### Suisse Intl — Body, navigation, UI labels, buttons, captions — humanist sans at whisper weight for feature lists and normal weight for copy · `--font-suisse-intl`
|
- **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.
|
||||||
- **Substitute:** Inter, Söhne, Helvetica Neue
|
- **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.
|
||||||
- **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
|
|
||||||
|
|
||||||
### Type Scale
|
### Type Scale
|
||||||
|
|
||||||
| Role | Size | Line Height | Letter Spacing | Token |
|
| Role | Size | Line Height | Letter Spacing | Weight | Token |
|
||||||
|------|------|-------------|----------------|-------|
|
|------|------|-------------|----------------|--------|-------|
|
||||||
| caption | 10px | 1.61 | — | `--text-caption` |
|
| micro | 10px | 1.6 | 0.5px | 300 | `--text-micro` |
|
||||||
| body | 14px | 1.5 | — | `--text-body` |
|
| caption | 12px | 1.63 | 0.6px | 300 | `--text-caption` |
|
||||||
| subheading | 18px | 1.3 | — | `--text-subheading` |
|
| body-sm | 14px | 1.63 | 0.7px | 300 | `--text-body-sm` |
|
||||||
| heading-sm | 23px | 1.5 | — | `--text-heading-sm` |
|
| body | 16px | 1.57 | -0.32px | 300 | `--text-body` |
|
||||||
| heading | 40px | 1.35 | -0.4px | `--text-heading` |
|
| ui | 16px | 1.83 | -0.017em | 500 | `--text-ui` |
|
||||||
| heading-lg | 56px | 1.2 | -1.68px | `--text-heading-lg` |
|
| heading | 24px | 1.3 | -0.48px | 300 | `--text-heading` |
|
||||||
| display | 74px | 1.05 | -2.22px | `--text-display` |
|
| heading-lg | 32px | 1.3 | -0.64px | 300 | `--text-heading-lg` |
|
||||||
|
| display | 72px | 1.1 | -1.44px | 300 | `--text-display` |
|
||||||
|
|
||||||
## Tokens — Spacing & Shapes
|
## Tokens — Spacing & Shapes
|
||||||
|
|
||||||
|
**Base unit:** 8px
|
||||||
|
|
||||||
**Density:** comfortable
|
**Density:** comfortable
|
||||||
|
|
||||||
### Spacing Scale
|
### Spacing Scale
|
||||||
|
|
||||||
| Name | Value | Token |
|
| Name | Value | Token |
|
||||||
|------|-------|-------|
|
|------|-------|-------|
|
||||||
| 4 | 4px | `--spacing-4` |
|
| 8 | 8px | `--spacing-8` |
|
||||||
| 7 | 7px | `--spacing-7` |
|
| 16 | 16px | `--spacing-16` |
|
||||||
| 9 | 9px | `--spacing-9` |
|
| 24 | 24px | `--spacing-24` |
|
||||||
| 11 | 11px | `--spacing-11` |
|
| 32 | 32px | `--spacing-32` |
|
||||||
| 14 | 14px | `--spacing-14` |
|
| 64 | 64px | `--spacing-64` |
|
||||||
| 18 | 18px | `--spacing-18` |
|
| 120 | 120px | `--spacing-120` |
|
||||||
| 21 | 21px | `--spacing-21` |
|
| 128 | 128px | `--spacing-128` |
|
||||||
| 28 | 28px | `--spacing-28` |
|
| 152 | 152px | `--spacing-152` |
|
||||||
| 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` |
|
|
||||||
|
|
||||||
### Border Radius
|
### Border Radius
|
||||||
|
|
||||||
| Element | Value |
|
| Element | Value |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| nav | 7px |
|
| nav | 30px |
|
||||||
| cards | 14px |
|
| tags | 8px |
|
||||||
| badges | 999px |
|
| cards | 12px |
|
||||||
| buttons | 14px |
|
| buttons | 9999px |
|
||||||
| sections | 9999px |
|
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
- **Page max-width:** 1200px
|
- **Page max-width:** 1200px
|
||||||
- **Section gap:** 64-96px
|
- **Section gap:** 64px
|
||||||
- **Card padding:** 28-42px
|
- **Card padding:** 24px
|
||||||
- **Element gap:** 14-21px
|
- **Element gap:** 8px
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### Primary Forest Button
|
### Verde Pill CTA
|
||||||
**Role:** Filled CTA for demos, explores, and form submits
|
**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
|
### Nav Flotante Translúcido
|
||||||
**Role:** Hero-scale CTA with generous breathing room
|
**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
|
### Mono Display Heading
|
||||||
**Role:** Category labels, CRM/EHR/RCM module tags, status indicators
|
**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
|
### Mono Body Block
|
||||||
**Role:** Heavier-tinted content panel for feature highlights
|
**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
|
### Two-Column Editorial Section
|
||||||
**Role:** Light-tinted content panel for secondary features
|
**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
|
### Full-Bleed Documentary Image
|
||||||
**Role:** Cool-toned container for product UI screenshots and demos
|
**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)
|
### Vertical Sidebar Label
|
||||||
**Role:** White floating card within Slate Product Panel
|
**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
|
### Ghost Text Link
|
||||||
**Role:** Top-level site navigation
|
**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
|
### Hairline Divider
|
||||||
**Role:** Section category tag above headings
|
**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
|
### Image Frame Card
|
||||||
**Role:** Two-column question/answer layout
|
**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
|
### Compact Pill Tag
|
||||||
**Role:** Social proof row of VC logos
|
**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's and Don'ts
|
||||||
|
|
||||||
### Do
|
### Do
|
||||||
- Use Forest Ink (#0f3e17) for every CTA, heading, and primary link — it is the only saturated dark and must remain singular
|
- Configura todos los CTAs primarios a 9999px de border-radius con relleno #00723F y texto Bone White en Azeret Mono peso 500, 16px.
|
||||||
- Set all display headings in Faire Octave weight 300 with tight tracking (-0.03em at 56–74px) — never bold the serif
|
- Usa Azeret Mono peso 300 para todo encabezado a 24px, 32px y 72px — nunca lo pongas en negrita.
|
||||||
- Pair serif questions with sans-serif answers in FAQ and feature blocks to create typographic contrast
|
- Escribe el copy de cuerpo en Azeret Mono peso 300 a 14–16px con line-height 1.63–1.83.
|
||||||
- Layer surfaces using cream → keylime → mint → sage → slate in order to create depth without shadows
|
- Separa secciones con 64px de padding vertical, no con divisores ni solo cambios de color de fondo.
|
||||||
- Use 14px border-radius on all cards and buttons, 999px on all badges and tags — consistency is non-negotiable
|
- Aplica bordes 1px sólidos #e5e7eb a todas las tarjetas e imágenes — nunca uses drop shadows para elevación.
|
||||||
- Apply 0.08em letter-spacing with uppercase and 600 weight for eyebrow labels only — nowhere else
|
- Usa #fffefb para lienzos claros y #000000 para bandas oscuras; deja que la fotografía llene las bandas oscuras a sangre completa.
|
||||||
- Keep the Slate Hush panel as the exclusive home for product screenshots — it provides the cool counterbalance to warm green panels
|
- 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
|
### Don't
|
||||||
- Never add box-shadow to cards — the system communicates elevation through tinted layers only
|
- No uses pesos negrita (600–700) en encabezados Azeret Mono — el peso 300 es la firma del sistema.
|
||||||
- Don't use Forest Ink as a background tint — it is too dark for surface washes; reserve for text and action fills only
|
- No introduzcas drop shadows para elevar tarjetas; usa el color de superficie Linen (#f3f2ee) y bordes hairline en su lugar.
|
||||||
- Avoid mixing multiple heading weights — everything is weight 300 in Faire Octave; bold would break the hushed editorial tone
|
- 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.
|
||||||
- Don't place colored borders on cards — depth comes from fill contrast, not strokes
|
- No apliques el Verde Institucional a fondos, bandas de sección o elementos decorativos — es solo de acción.
|
||||||
- Never use more than one serif at a time — Faire Octave owns display, Suisse Intl owns everything else
|
- No uses valores de radio fuera de los cuatro tokens: 8px (tags), 12px (cards), 30px (nav), 9999px (buttons).
|
||||||
- Don't introduce new accent hues — the palette is strictly botanical greens + slate; any other color breaks the greenhouse metaphor
|
- 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).
|
||||||
- Avoid tight padding under 21px on cards — generous 28–42px padding is essential to the soft, breathing-room aesthetic
|
- No uses fotografía de stock, ilustraciones o renders 3D — la imaginería debe ser documental y candid.
|
||||||
- Don't use the sage or keylime tones for text — they are surface colors only and fail contrast for copy
|
|
||||||
|
|
||||||
## Surfaces
|
## Surfaces
|
||||||
|
|
||||||
| Level | Name | Value | Purpose |
|
| Level | Name | Value | Purpose |
|
||||||
|-------|------|-------|---------|
|
|-------|------|-------|---------|
|
||||||
| 0 | Cream Paper | `#fffefc` | Base page background — warm off-white dominates the canvas |
|
| 1 | Bone Canvas | `#fffefb` | Secciones claras de lectura, fondo de página por defecto |
|
||||||
| 1 | Keylime Wash | `#e1f4df` | Primary tinted panel — hero left column, light feature blocks |
|
| 2 | Linen Card | `#f3f2ee` | Bloques de contenido elevados, paneles secundarios |
|
||||||
| 2 | Mint Veil | `#cfe7d3` | Mid-tone section surface between keylime and sage |
|
| 3 | Dorado Nav | `#B08208` | Superficie de navegación flotante translúcida |
|
||||||
| 3 | Sage Mist | `#b1dbb8` | Heavier tinted panels — hero accents, feature highlights |
|
| 4 | Negro Institucional | `#000000` | Secciones documentales oscuras, lienzos de banda de imagen |
|
||||||
| 4 | Slate Hush | `#b6ced5` | Cool counter-panel for product showcases and visual contrast |
|
|
||||||
|
|
||||||
## Elevation
|
## 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
|
||||||
|
|
||||||
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
|
## 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
|
## Agent Prompt Guide
|
||||||
|
|
||||||
**Quick Color Reference**
|
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)
|
||||||
- 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 Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -231,150 +192,164 @@ The serif/sans split is the system's defining typographic move. Faire Octave (or
|
|||||||
```css
|
```css
|
||||||
:root {
|
:root {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
--color-forest-ink: #0f3e17;
|
--color-verde-institucional: #00723F;
|
||||||
--color-sage-mist: #b1dbb8;
|
--color-verde-claro: #50B848;
|
||||||
--color-keylime-wash: #e1f4df;
|
--color-dorado-uabc: #DD971A;
|
||||||
--color-mint-veil: #cfe7d3;
|
--color-amarillo-dorado: #FEBE10;
|
||||||
--color-slate-hush: #b6ced5;
|
--color-verde-jardin: #024731;
|
||||||
--color-cream-paper: #fffefc;
|
--color-dorado-oscuro: #B08208;
|
||||||
--color-charcoal: #222222;
|
--color-azul-institucional: #20419A;
|
||||||
--color-border-mist: #efeeeb;
|
--color-bone-white: #fffefb;
|
||||||
--color-forest-shadow: #0c2f10;
|
--color-linen: #f3f2ee;
|
||||||
|
--color-hairline-gray: #e5e7eb;
|
||||||
|
--color-graphite: #333833;
|
||||||
|
--color-negro-institucional: #000000;
|
||||||
|
|
||||||
/* Typography — Font Families */
|
/* Typography — Font Family (única) */
|
||||||
--font-faire-octave: 'Faire Octave', 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;
|
||||||
--font-suisse-intl: 'Suisse Intl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
|
|
||||||
/* Typography — Scale */
|
/* Typography — Scale */
|
||||||
--text-caption: 10px;
|
--text-micro: 10px;
|
||||||
--leading-caption: 1.61;
|
--leading-micro: 1.6;
|
||||||
--text-body: 14px;
|
--tracking-micro: 0.5px;
|
||||||
--leading-body: 1.5;
|
--text-caption: 12px;
|
||||||
--text-subheading: 18px;
|
--leading-caption: 1.63;
|
||||||
--leading-subheading: 1.3;
|
--tracking-caption: 0.6px;
|
||||||
--text-heading-sm: 23px;
|
--text-body-sm: 14px;
|
||||||
--leading-heading-sm: 1.5;
|
--leading-body-sm: 1.63;
|
||||||
--text-heading: 40px;
|
--tracking-body-sm: 0.7px;
|
||||||
--leading-heading: 1.35;
|
--text-body: 16px;
|
||||||
--tracking-heading: -0.4px;
|
--leading-body: 1.57;
|
||||||
--text-heading-lg: 56px;
|
--tracking-body: -0.32px;
|
||||||
--leading-heading-lg: 1.2;
|
--text-ui: 16px;
|
||||||
--tracking-heading-lg: -1.68px;
|
--leading-ui: 1.83;
|
||||||
--text-display: 74px;
|
--tracking-ui: -0.017em;
|
||||||
--leading-display: 1.05;
|
--text-heading: 24px;
|
||||||
--tracking-display: -2.22px;
|
--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 */
|
/* Typography — Weights */
|
||||||
--font-weight-light: 300;
|
--font-weight-light: 300;
|
||||||
--font-weight-regular: 400;
|
--font-weight-ui: 500;
|
||||||
--font-weight-semibold: 600;
|
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--spacing-4: 4px;
|
--spacing-unit: 8px;
|
||||||
--spacing-7: 7px;
|
--spacing-8: 8px;
|
||||||
--spacing-9: 9px;
|
--spacing-16: 16px;
|
||||||
--spacing-11: 11px;
|
--spacing-24: 24px;
|
||||||
--spacing-14: 14px;
|
--spacing-32: 32px;
|
||||||
--spacing-18: 18px;
|
--spacing-64: 64px;
|
||||||
--spacing-21: 21px;
|
--spacing-120: 120px;
|
||||||
--spacing-28: 28px;
|
--spacing-128: 128px;
|
||||||
--spacing-35: 35px;
|
--spacing-152: 152px;
|
||||||
--spacing-42: 42px;
|
|
||||||
--spacing-49: 49px;
|
|
||||||
--spacing-56: 56px;
|
|
||||||
--spacing-70: 70px;
|
|
||||||
--spacing-76: 76px;
|
|
||||||
--spacing-99: 99px;
|
|
||||||
--spacing-156: 156px;
|
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
--page-max-width: 1200px;
|
--page-max-width: 1200px;
|
||||||
--section-gap: 64-96px;
|
--section-gap: 64px;
|
||||||
--card-padding: 28-42px;
|
--card-padding: 24px;
|
||||||
--element-gap: 14-21px;
|
--element-gap: 8px;
|
||||||
|
|
||||||
/* Border Radius */
|
/* Border Radius */
|
||||||
--radius-lg: 7.04px;
|
--radius-lg: 8px;
|
||||||
--radius-xl: 14.08px;
|
--radius-xl: 12px;
|
||||||
--radius-full: 999px;
|
--radius-3xl: 30px;
|
||||||
--radius-full-2: 9999px;
|
--radius-full: 9999px;
|
||||||
|
|
||||||
/* Named Radii */
|
/* Named Radii */
|
||||||
--radius-nav: 7px;
|
--radius-nav: 30px;
|
||||||
--radius-cards: 14px;
|
--radius-tags: 8px;
|
||||||
--radius-badges: 999px;
|
--radius-cards: 12px;
|
||||||
--radius-buttons: 14px;
|
--radius-buttons: 9999px;
|
||||||
--radius-sections: 9999px;
|
|
||||||
|
|
||||||
/* Surfaces */
|
/* Surfaces */
|
||||||
--surface-cream-paper: #fffefc;
|
--surface-bone-canvas: #fffefb;
|
||||||
--surface-keylime-wash: #e1f4df;
|
--surface-linen-card: #f3f2ee;
|
||||||
--surface-mint-veil: #cfe7d3;
|
--surface-dorado-nav: #B08208;
|
||||||
--surface-sage-mist: #b1dbb8;
|
--surface-negro-institucional: #000000;
|
||||||
--surface-slate-hush: #b6ced5;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### Tailwind v4
|
||||||
|
|
||||||
```css
|
```css
|
||||||
@theme {
|
@theme {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
--color-forest-ink: #0f3e17;
|
--color-verde-institucional: #00723F;
|
||||||
--color-sage-mist: #b1dbb8;
|
--color-verde-claro: #50B848;
|
||||||
--color-keylime-wash: #e1f4df;
|
--color-dorado-uabc: #DD971A;
|
||||||
--color-mint-veil: #cfe7d3;
|
--color-amarillo-dorado: #FEBE10;
|
||||||
--color-slate-hush: #b6ced5;
|
--color-verde-jardin: #024731;
|
||||||
--color-cream-paper: #fffefc;
|
--color-dorado-oscuro: #B08208;
|
||||||
--color-charcoal: #222222;
|
--color-azul-institucional: #20419A;
|
||||||
--color-border-mist: #efeeeb;
|
--color-bone-white: #fffefb;
|
||||||
--color-forest-shadow: #0c2f10;
|
--color-linen: #f3f2ee;
|
||||||
|
--color-hairline-gray: #e5e7eb;
|
||||||
|
--color-graphite: #333833;
|
||||||
|
--color-negro-institucional: #000000;
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
--font-faire-octave: 'Faire Octave', 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;
|
||||||
--font-suisse-intl: 'Suisse Intl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
|
|
||||||
/* Typography — Scale */
|
/* Typography — Scale */
|
||||||
--text-caption: 10px;
|
--text-micro: 10px;
|
||||||
--leading-caption: 1.61;
|
--leading-micro: 1.6;
|
||||||
--text-body: 14px;
|
--tracking-micro: 0.5px;
|
||||||
--leading-body: 1.5;
|
--text-caption: 12px;
|
||||||
--text-subheading: 18px;
|
--leading-caption: 1.63;
|
||||||
--leading-subheading: 1.3;
|
--tracking-caption: 0.6px;
|
||||||
--text-heading-sm: 23px;
|
--text-body-sm: 14px;
|
||||||
--leading-heading-sm: 1.5;
|
--leading-body-sm: 1.63;
|
||||||
--text-heading: 40px;
|
--tracking-body-sm: 0.7px;
|
||||||
--leading-heading: 1.35;
|
--text-body: 16px;
|
||||||
--tracking-heading: -0.4px;
|
--leading-body: 1.57;
|
||||||
--text-heading-lg: 56px;
|
--tracking-body: -0.32px;
|
||||||
--leading-heading-lg: 1.2;
|
--text-ui: 16px;
|
||||||
--tracking-heading-lg: -1.68px;
|
--leading-ui: 1.83;
|
||||||
--text-display: 74px;
|
--tracking-ui: -0.017em;
|
||||||
--leading-display: 1.05;
|
--text-heading: 24px;
|
||||||
--tracking-display: -2.22px;
|
--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 */
|
||||||
--spacing-4: 4px;
|
--spacing-8: 8px;
|
||||||
--spacing-7: 7px;
|
--spacing-16: 16px;
|
||||||
--spacing-9: 9px;
|
--spacing-24: 24px;
|
||||||
--spacing-11: 11px;
|
--spacing-32: 32px;
|
||||||
--spacing-14: 14px;
|
--spacing-64: 64px;
|
||||||
--spacing-18: 18px;
|
--spacing-120: 120px;
|
||||||
--spacing-21: 21px;
|
--spacing-128: 128px;
|
||||||
--spacing-28: 28px;
|
--spacing-152: 152px;
|
||||||
--spacing-35: 35px;
|
|
||||||
--spacing-42: 42px;
|
|
||||||
--spacing-49: 49px;
|
|
||||||
--spacing-56: 56px;
|
|
||||||
--spacing-70: 70px;
|
|
||||||
--spacing-76: 76px;
|
|
||||||
--spacing-99: 99px;
|
|
||||||
--spacing-156: 156px;
|
|
||||||
|
|
||||||
/* Border Radius */
|
/* Border Radius */
|
||||||
--radius-lg: 7.04px;
|
--radius-lg: 8px;
|
||||||
--radius-xl: 14.08px;
|
--radius-xl: 12px;
|
||||||
--radius-full: 999px;
|
--radius-3xl: 30px;
|
||||||
--radius-full-2: 9999px;
|
--radius-full: 9999px;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+18
-20
@@ -3,17 +3,20 @@ import type { ToastPayload, ToastKind } from '@/lib/toast';
|
|||||||
|
|
||||||
type Item = ToastPayload & { id: number; leaving?: boolean };
|
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: {
|
success: {
|
||||||
color: 'var(--color-positive)',
|
bg: 'bg-verde-institucional',
|
||||||
|
text: 'text-bone-white',
|
||||||
d: 'M4.5 12.75l6 6 9-13.5',
|
d: 'M4.5 12.75l6 6 9-13.5',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
color: 'var(--color-danger-text)',
|
bg: 'bg-negro-institucional',
|
||||||
|
text: 'text-bone-white',
|
||||||
d: 'M6 18L18 6M6 6l12 12',
|
d: 'M6 18L18 6M6 6l12 12',
|
||||||
},
|
},
|
||||||
info: {
|
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',
|
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) {
|
function dismiss(id: number, animate = false) {
|
||||||
if (animate) {
|
if (animate) {
|
||||||
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, leaving: true } : it)));
|
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 {
|
} else {
|
||||||
setItems((prev) => prev.filter((it) => it.id !== id));
|
setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
}
|
}
|
||||||
@@ -71,29 +74,23 @@ export default function Toaster() {
|
|||||||
onClick={() => dismiss(t.id, true)}
|
onClick={() => dismiss(t.id, true)}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className={
|
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 ' +
|
'motion-safe:transition-[opacity,transform] motion-safe:duration-200 ' +
|
||||||
(t.leaving ? 'toast-leave' : 'toast-enter')
|
(t.leaving ? 'toast-leave' : 'toast-enter')
|
||||||
}
|
}
|
||||||
style={{
|
|
||||||
border: '2px solid var(--color-ink)',
|
|
||||||
boxShadow: 'var(--shadow-hard)',
|
|
||||||
color: 'var(--color-ink)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="shrink-0 grid place-items-center w-9 h-9 rounded-[2px]"
|
className={`shrink-0 grid place-items-center w-8 h-8 rounded-full ${meta.bg} ${meta.text}`}
|
||||||
style={{ background: meta.color, border: '2px solid var(--color-ink)' }}
|
|
||||||
>
|
>
|
||||||
<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} />
|
<path d={meta.d} />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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 && (
|
{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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -102,18 +99,19 @@ export default function Toaster() {
|
|||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.toast-enter {
|
.toast-enter {
|
||||||
animation: toast-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: toast-in 200ms ease-out both;
|
||||||
}
|
}
|
||||||
.toast-leave {
|
.toast-leave {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px) scale(0.98);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
@keyframes toast-in {
|
@keyframes toast-in {
|
||||||
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.toast-enter { animation: none; }
|
.toast-enter { animation: none; }
|
||||||
|
.toast-leave { opacity: 0; }
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { toast } from '@/lib/toast';
|
|||||||
|
|
||||||
function setBadge(n: number) {
|
function setBadge(n: number) {
|
||||||
const v = Math.max(0, n | 0);
|
const v = Math.max(0, n | 0);
|
||||||
|
// Badge pill visual (nav): oculto si 0.
|
||||||
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
|
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
|
||||||
el.textContent = String(v);
|
el.textContent = String(v);
|
||||||
if (v > 0) el.removeAttribute('hidden');
|
if (v > 0) el.removeAttribute('hidden');
|
||||||
else el.setAttribute('hidden', '');
|
else el.setAttribute('hidden', '');
|
||||||
el.setAttribute('aria-label', `${v} solicitudes pendientes`);
|
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;
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BadgeSolicitudes() {
|
export default function BadgeSolicitudes({ count, className }: { count?: number; className?: string } = {}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-badge="pendientes"]').forEach((el) => {
|
||||||
|
el.classList.add('bg-verde-institucional', 'text-bone-white', 'rounded-full');
|
||||||
|
});
|
||||||
|
|
||||||
const supabase = browserClient();
|
const supabase = browserClient();
|
||||||
|
|
||||||
const refetch = async () => {
|
const refetch = async () => {
|
||||||
const { count } = await supabase
|
const { count: c } = await supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
.eq('estado', 'pendiente');
|
.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
|
const channel = supabase
|
||||||
.channel('solicitudes-pendientes')
|
.channel('solicitudes-pendientes')
|
||||||
.on(
|
.on(
|
||||||
@@ -43,11 +57,26 @@ export default function BadgeSolicitudes() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.on(
|
.on(
|
||||||
// ponytail: refetch en UPDATE porque payload.old no trae estado en replica identity default;
|
// Delta preciso: REPLICA IDENTITY FULL (migración 0008) hace que
|
||||||
// upgrade a delta cuando la tabla tenga replica identity full.
|
// payload.old traiga el estado anterior — evitamos el refetch en cada UPDATE.
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{ event: 'UPDATE', schema: 'prestamos', table: 'solicitudes' },
|
{ 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();
|
.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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number };
|
||||||
type Props = Barras | Linea | Donut;
|
type Props = Barras | Linea | Donut;
|
||||||
|
|
||||||
const INK = '#383838';
|
// PALETA UABC (TRACK E):
|
||||||
const PENCIL = '#a1a1a1';
|
// Primario: #00723F (verde-institucional)
|
||||||
const PRIMARY = '#00723F';
|
// Secundarios: #DD971A (dorado-uabc), #50B848 (verde-claro), #20419A (azul-institucional), #024731 (verde-jardin)
|
||||||
const SECONDARY = '#DD971A';
|
// Alerta: #000000 (negro-institucional)
|
||||||
const DANGER = '#a83224';
|
// Superficie: #fffefb (bone-white)
|
||||||
const POSITIVE = '#38c1b0';
|
// Texto: #333833 (graphite)
|
||||||
const ATTENTION = '#ffde00';
|
// Bordes: #e5e7eb (hairline-gray)
|
||||||
|
|
||||||
const MONO: React.CSSProperties = {
|
const VERDE_INSTITUCIONAL = '#00723F';
|
||||||
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
|
const DORADO_UABC = '#DD971A';
|
||||||
fontSize: 11,
|
const VERDE_CLARO = '#50B848';
|
||||||
color: INK,
|
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.
|
// Paleta rotativa para el donut. Coloreado por label cuando aplique.
|
||||||
const DONUT_BY_LABEL: Record<string, string> = {
|
const DONUT_BY_LABEL: Record<string, string> = {
|
||||||
pendiente: ATTENTION,
|
pendiente: DORADO_UABC,
|
||||||
aprobado: PRIMARY,
|
aprobado: VERDE_INSTITUCIONAL,
|
||||||
activo: PRIMARY,
|
activo: VERDE_CLARO,
|
||||||
devuelto: POSITIVE,
|
devuelto: VERDE_JARDIN,
|
||||||
rechazado: PENCIL,
|
rechazado: GRAPHITE,
|
||||||
vencido: DANGER,
|
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 {
|
function donutColor(label: string, i: number): string {
|
||||||
return DONUT_BY_LABEL[label.toLowerCase()] ?? DONUT_FALLBACK[i % DONUT_FALLBACK.length];
|
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) {
|
function TooltipBox({ active, payload, label }: any) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={TOOLTIP_CONTENT_STYLE}>
|
||||||
style={{
|
{label !== undefined && <div style={{ fontWeight: 500, color: VERDE_INSTITUCIONAL, marginBottom: 4 }}>{label}</div>}
|
||||||
background: 'white',
|
|
||||||
border: `2px solid ${INK}`,
|
|
||||||
borderRadius: 2,
|
|
||||||
padding: '6px 10px',
|
|
||||||
...MONO,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label !== undefined && <div style={{ fontWeight: 600 }}>{label}</div>}
|
|
||||||
{payload.map((p: any, i: number) => (
|
{payload.map((p: any, i: number) => (
|
||||||
<div key={i}>
|
<div key={i} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
{p.name}: <strong>{p.value}</strong>
|
{p.name}: <strong>{p.value}</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -86,8 +103,11 @@ function usePrefersReducedMotion(): boolean {
|
|||||||
|
|
||||||
function fmtFechaCorta(iso: string): string {
|
function fmtFechaCorta(iso: string): string {
|
||||||
// 'YYYY-MM-DD' → 'DD/MM'
|
// 'YYYY-MM-DD' → 'DD/MM'
|
||||||
const [, m, d] = iso.split('-');
|
const parts = iso.split('-');
|
||||||
return `${d}/${m}`;
|
if (parts.length >= 3) {
|
||||||
|
return `${parts[2]}/${parts[1]}`;
|
||||||
|
}
|
||||||
|
return iso;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Chart(props: Props) {
|
export default function Chart(props: Props) {
|
||||||
@@ -103,21 +123,40 @@ export default function Chart(props: Props) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={alto}>
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
|
<BarChart data={datos} layout="vertical" margin={{ top: 8, right: 28, left: 8, bottom: 8 }}>
|
||||||
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" horizontal={false} />
|
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" horizontal={false} />
|
||||||
<XAxis type="number" allowDecimals={false} stroke={INK} tick={MONO as any} />
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
allowDecimals={false}
|
||||||
|
stroke="#e5e7eb"
|
||||||
|
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace", fontVariantNumeric: 'tabular-nums' } as any}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
width={100}
|
width={100}
|
||||||
stroke={INK}
|
stroke="#e5e7eb"
|
||||||
tick={{ ...MONO, fontSize: 10 } as any}
|
tick={{ fill: '#333833', fontSize: 11, fontFamily: "'Azeret Mono', monospace" } as any}
|
||||||
interval={0}
|
interval={0}
|
||||||
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
|
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
|
<Tooltip
|
||||||
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
|
content={<TooltipBox />}
|
||||||
<LabelList dataKey="valor" position="right" style={MONO as any} />
|
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>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -129,17 +168,30 @@ export default function Chart(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={alto}>
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
<LineChart data={datos} margin={{ top: 8, right: 16, left: 0, bottom: 8 }}>
|
<LineChart data={datos} margin={{ top: 8, right: 16, left: 0, bottom: 8 }}>
|
||||||
<CartesianGrid stroke={PENCIL} strokeDasharray="2 3" />
|
<CartesianGrid stroke="#e5e7eb" strokeDasharray="2 3" />
|
||||||
<XAxis dataKey="fechaCorta" stroke={INK} tick={MONO as any} interval="preserveStartEnd" />
|
<XAxis
|
||||||
<YAxis allowDecimals={false} stroke={INK} tick={MONO as any} />
|
dataKey="fechaCorta"
|
||||||
<Tooltip content={<TooltipBox />} cursor={{ stroke: INK, strokeWidth: 1, strokeDasharray: '2 3' }} />
|
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
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="valor"
|
dataKey="valor"
|
||||||
stroke={PRIMARY}
|
stroke={PRIMARY}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: PRIMARY, stroke: INK, strokeWidth: 1.5, r: 3 }}
|
dot={{ fill: PRIMARY, stroke: BONE_WHITE, strokeWidth: 1, r: 3 }}
|
||||||
activeDot={{ fill: SECONDARY, stroke: INK, strokeWidth: 1.5, r: 5 }}
|
activeDot={{ fill: DORADO_UABC, stroke: PRIMARY, strokeWidth: 1, r: 5 }}
|
||||||
animationDuration={anim}
|
animationDuration={anim}
|
||||||
name="Solicitudes"
|
name="Solicitudes"
|
||||||
/>
|
/>
|
||||||
@@ -153,15 +205,15 @@ export default function Chart(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={alto}>
|
<ResponsiveContainer width="100%" height={alto}>
|
||||||
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
|
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
|
||||||
<Tooltip content={<TooltipBox />} />
|
<Tooltip content={<TooltipBox />} contentStyle={TOOLTIP_CONTENT_STYLE} />
|
||||||
<Pie
|
<Pie
|
||||||
data={datos}
|
data={datos}
|
||||||
dataKey="valor"
|
dataKey="valor"
|
||||||
nameKey="label"
|
nameKey="label"
|
||||||
innerRadius="55%"
|
innerRadius="55%"
|
||||||
outerRadius="85%"
|
outerRadius="85%"
|
||||||
stroke={INK}
|
stroke={BONE_WHITE}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1}
|
||||||
isAnimationActive={!reduced}
|
isAnimationActive={!reduced}
|
||||||
animationDuration={anim}
|
animationDuration={anim}
|
||||||
label={false}
|
label={false}
|
||||||
@@ -175,11 +227,16 @@ export default function Chart(props: Props) {
|
|||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
verticalAlign="bottom"
|
verticalAlign="bottom"
|
||||||
align="center"
|
align="center"
|
||||||
iconType="square"
|
iconType="circle"
|
||||||
wrapperStyle={{ ...MONO, marginTop: 8 }}
|
wrapperStyle={{
|
||||||
|
fontFamily: "'Azeret Mono', monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#333833',
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
formatter={(value: string, entry: any) => {
|
formatter={(value: string, entry: any) => {
|
||||||
const v = entry?.payload?.valor;
|
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>
|
</PieChart>
|
||||||
|
|||||||
@@ -81,20 +81,23 @@ export default function CategoriaForm({ mode, categoria }: Props) {
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<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}`}
|
{mode === 'create' ? 'Nueva categoría' : `Editar: ${categoria?.nombre}`}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
aria-label="Cerrar"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,8 +125,7 @@ export default function CategoriaForm({ mode, categoria }: Props) {
|
|||||||
id={`${titleId}-err`}
|
id={`${titleId}-err`}
|
||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="text-sm"
|
className="text-sm text-negro-institucional"
|
||||||
style={{ color: 'var(--color-danger-text)' }}
|
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -54,17 +54,9 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost transition-colors duration-150"
|
className="btn btn-ghost"
|
||||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
onClick={open}
|
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
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
@@ -72,37 +64,37 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<div className="p-6 flex flex-col gap-4">
|
||||||
<h2 id={titleId} className="text-lg font-semibold">
|
<h2 id={titleId} className="text-xl text-graphite font-normal">
|
||||||
Eliminar categoría
|
Eliminar categoría
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{bloqueado ? (
|
{bloqueado ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm">
|
<p className="text-sm text-graphite">
|
||||||
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
|
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{materialesCount}</span>{' '}
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{materialesCount}</span>{' '}
|
||||||
material{materialesCount === 1 ? '' : 'es'} asociado
|
material{materialesCount === 1 ? '' : 'es'} asociado
|
||||||
{materialesCount === 1 ? '' : 's'}. Reasígnalos primero.
|
{materialesCount === 1 ? '' : 's'}. Reasígnalos primero.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`/admin/inventario?cat=${id}`}
|
href={`/admin/inventario?cat=${id}`}
|
||||||
className="text-sm underline"
|
className="text-sm text-verde-institucional hover:text-verde-claro underline"
|
||||||
style={{ color: 'var(--color-primary)' }}
|
|
||||||
>
|
>
|
||||||
Ver materiales de esta categoría →
|
Ver materiales de esta categoría →
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm">
|
<p className="text-sm text-graphite">
|
||||||
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
|
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
<p role="alert" className="text-sm text-negro-institucional">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -114,8 +106,7 @@ export default function EliminarCategoria({ id, nombre, materialesCount }: Props
|
|||||||
{!bloqueado && (
|
{!bloqueado && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn btn-danger"
|
||||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={
|
className={
|
||||||
'btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]' +
|
'btn btn-ghost' +
|
||||||
(compact ? ' w-full text-sm px-2' : '')
|
(compact ? ' w-full text-xs px-2' : '')
|
||||||
}
|
}
|
||||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
@@ -67,22 +67,23 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<div className="p-6 flex flex-col gap-4">
|
||||||
<h2 id={titleId} className="text-lg font-semibold">
|
<h2 id={titleId} className="text-xl text-graphite font-normal">
|
||||||
Eliminar material
|
Eliminar material
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm">
|
<p className="text-sm text-graphite">
|
||||||
¿Seguro que quieres eliminar <strong>{nombre}</strong>? Esta acción no se puede deshacer.
|
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>? Esta acción no se puede deshacer.
|
||||||
</p>
|
</p>
|
||||||
{tienePrestamos && (
|
{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.
|
Este material tiene préstamos asociados y no podrá eliminarse.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
<p role="alert" className="text-sm text-negro-institucional">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -92,8 +93,7 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn btn-danger"
|
||||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
|||||||
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
||||||
const triggerClass =
|
const triggerClass =
|
||||||
(mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost') +
|
(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
|
const triggerStyle = compact
|
||||||
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
|
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -202,18 +202,19 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<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}`}
|
{mode === 'create' ? 'Nuevo material' : `Editar: ${material?.nombre}`}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
aria-label="Cerrar"
|
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">
|
<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" />
|
<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>
|
<label className="label" htmlFor={`${titleId}-file`}>Foto</label>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
className="w-24 h-24 shrink-0 grid place-items-center bg-[color:var(--color-chalk)]"
|
className="w-24 h-24 shrink-0 grid place-items-center bg-linen rounded-[12px] border border-hairline-gray overflow-hidden"
|
||||||
style={{ border: '2px solid var(--color-ink)' }}
|
|
||||||
>
|
>
|
||||||
{currentImg ? (
|
{currentImg ? (
|
||||||
<img src={currentImg} alt="" className="w-full h-full object-cover" />
|
<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" />
|
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||||
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
||||||
<circle cx="8" cy="9" r="1.5" />
|
<circle cx="8" cy="9" r="1.5" />
|
||||||
@@ -251,7 +251,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
|||||||
{(file || imagenPath) && (
|
{(file || imagenPath) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost self-start"
|
className="btn btn-ghost self-start text-xs"
|
||||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
@@ -326,16 +326,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={trackeado}
|
checked={trackeado}
|
||||||
onChange={(e) => setTrackeado(e.target.checked)}
|
onChange={(e) => setTrackeado(e.target.checked)}
|
||||||
className="mt-1"
|
className="mt-1 accent-verde-institucional"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium">Rastrear cada unidad por separado</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.
|
Ej. Laptop-01, Laptop-02… El stock se calcula del número de unidades registradas abajo.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -359,10 +359,10 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
|||||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||||
/>
|
/>
|
||||||
{trackeado && (
|
{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 && (
|
{!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}
|
Prestados: {material.cantidad_total - material.cantidad_disponible}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -389,8 +389,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
|||||||
id={`${titleId}-err`}
|
id={`${titleId}-err`}
|
||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="text-sm"
|
className="text-sm text-negro-institucional"
|
||||||
style={{ color: 'var(--color-danger-text)' }}
|
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
@@ -408,10 +407,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
|
|||||||
|
|
||||||
{/* Gestor de unidades — solo edit + trackeado */}
|
{/* Gestor de unidades — solo edit + trackeado */}
|
||||||
{mode === 'edit' && material && material.trackeado_por_unidad && (
|
{mode === 'edit' && material && material.trackeado_por_unidad && (
|
||||||
<div
|
<div className="border-t border-hairline-gray p-6 bg-linen rounded-b-[12px]">
|
||||||
className="border-t-2 p-5 sm:p-6"
|
|
||||||
style={{ borderColor: 'var(--color-ink)', background: 'var(--color-chalk)' }}
|
|
||||||
>
|
|
||||||
<UnidadesManager materialId={material.id} />
|
<UnidadesManager materialId={material.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<form onSubmit={crear} className="flex gap-2 mb-4">
|
||||||
<input
|
<input
|
||||||
@@ -110,9 +110,9 @@ export default function UnidadesManager({ materialId }: { materialId: number })
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm opacity-70">Cargando…</p>
|
<p className="text-sm text-graphite/70">Cargando…</p>
|
||||||
) : unidades.length === 0 ? (
|
) : 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">
|
<div className="flex flex-col gap-2">
|
||||||
{unidades.map((u) => {
|
{unidades.map((u) => {
|
||||||
@@ -120,7 +120,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={u.id}
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -151,7 +151,7 @@ export default function UnidadesManager({ materialId }: { materialId: number })
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-danger text-xs px-3"
|
||||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
disabled={bloqueada}
|
disabled={bloqueada}
|
||||||
onClick={() => eliminar(u)}
|
onClick={() => eliminar(u)}
|
||||||
|
|||||||
@@ -64,27 +64,28 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<div className="p-6 flex flex-col gap-4">
|
||||||
<h2 id={titleId} className="text-lg font-semibold">
|
<h2 id={titleId} className="text-xl text-graphite font-normal">
|
||||||
Eliminar maestro
|
Eliminar maestro
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{bloqueado ? (
|
{bloqueado ? (
|
||||||
<p className="text-sm">
|
<p className="text-sm text-graphite">
|
||||||
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
|
No se puede eliminar <strong className="font-medium text-graphite">{nombre}</strong>: tiene{' '}
|
||||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
|
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
|
||||||
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
|
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm">
|
<p className="text-sm text-graphite">
|
||||||
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
|
¿Seguro que quieres eliminar <strong className="font-medium text-graphite">{nombre}</strong>?
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
<p role="alert" className="text-sm text-negro-institucional">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -96,8 +97,7 @@ export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
|
|||||||
{!bloqueado && (
|
{!bloqueado && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn btn-danger"
|
||||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -87,20 +87,23 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<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}`}
|
{mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
aria-label="Cerrar"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,23 +125,24 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={activo}
|
checked={activo}
|
||||||
onChange={(e) => setActivo(e.target.checked)}
|
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>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-0.5"
|
className="mt-0.5 accent-verde-institucional"
|
||||||
checked={esTutor}
|
checked={esTutor}
|
||||||
onChange={(e) => setEsTutor(e.target.checked)}
|
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>
|
</label>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -147,8 +151,7 @@ export default function MaestroForm({ mode, maestro }: Props) {
|
|||||||
id={`${titleId}-err`}
|
id={`${titleId}-err`}
|
||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="text-sm"
|
className="text-sm text-negro-institucional"
|
||||||
style={{ color: 'var(--color-danger-text)' }}
|
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
type="text"
|
type="text"
|
||||||
className="input"
|
className="input rounded-[8px]"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
@@ -119,13 +119,18 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
|
|||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
if (value) setValue(''); // el usuario editó → invalidar la selección previa
|
if (value) setValue(''); // el usuario editó → invalidar la selección previa
|
||||||
}}
|
}}
|
||||||
onFocus={() => items.length && setOpen(true)}
|
onFocus={() => items.length > 0 && setOpen(true)}
|
||||||
onKeyDown={onKey}
|
onKeyDown={onKey}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
{(text || value) && (
|
{(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">
|
<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" />
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -136,32 +141,37 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
|
|||||||
<ul
|
<ul
|
||||||
id={listId}
|
id={listId}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="absolute z-10 mt-1 w-full bg-white rounded-lg max-h-64 overflow-auto"
|
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"
|
||||||
style={{ border: '1px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
|
|
||||||
>
|
>
|
||||||
{loading && items.length === 0 && (
|
{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 && (
|
{!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) => (
|
{items.map((it, i) => {
|
||||||
<li
|
const isSelected = String(it.id) === value;
|
||||||
key={it.id}
|
const isFocused = i === active;
|
||||||
id={`${listId}-opt-${i}`}
|
return (
|
||||||
role="option"
|
<li
|
||||||
aria-selected={i === active}
|
key={it.id}
|
||||||
className="px-3 py-2 text-sm cursor-pointer transition-colors"
|
id={`${listId}-opt-${i}`}
|
||||||
style={i === active ? { background: 'color-mix(in oklab, var(--color-primary) 10%, transparent)' } : undefined}
|
role="option"
|
||||||
onMouseEnter={() => setActive(i)}
|
aria-selected={isFocused || isSelected}
|
||||||
onMouseDown={(e) => {
|
className={`px-3 py-2 text-sm cursor-pointer transition-colors rounded-[8px] ${
|
||||||
e.preventDefault();
|
isFocused || isSelected
|
||||||
pick(it);
|
? 'bg-linen text-verde-institucional font-medium'
|
||||||
}}
|
: 'text-graphite hover:bg-linen'
|
||||||
>
|
}`}
|
||||||
{it.label}
|
onMouseDown={(e) => {
|
||||||
</li>
|
e.preventDefault();
|
||||||
))}
|
pick(it);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,24 +82,24 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-primary" onClick={dlg.open}>Aprobar</button>
|
<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">
|
<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-6">
|
<form onSubmit={submit} className="p-0">
|
||||||
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
|
<h2 className="text-xl md:text-2xl font-light text-graphite">Aprobar solicitud</h2>
|
||||||
<p className="text-sm opacity-70 mt-1">
|
<p className="text-sm text-graphite/70 mt-1">
|
||||||
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:
|
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:
|
||||||
</p>
|
</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) => (
|
{prestamo.items.map((i, idx) => (
|
||||||
<li key={idx}>
|
<li key={idx}>
|
||||||
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
|
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
|
||||||
{(i.material?.cantidad_disponible ?? 0) < i.cantidad && (
|
{(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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{stockInsuficiente && (
|
{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.
|
Uno o más materiales no tienen stock suficiente.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -128,7 +128,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -184,18 +184,18 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost hover:!text-[color:var(--color-danger)]"
|
className="btn btn-danger"
|
||||||
onClick={dlg.open}
|
onClick={dlg.open}
|
||||||
>
|
>
|
||||||
Rechazar
|
Rechazar
|
||||||
</button>
|
</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">
|
<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-6">
|
<form onSubmit={submit} className="p-0">
|
||||||
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
|
<h2 className="text-xl md:text-2xl font-light text-graphite">Rechazar solicitud</h2>
|
||||||
<p className="text-sm opacity-70 mt-1">
|
<p className="text-sm text-graphite/70 mt-1">
|
||||||
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
|
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
|
||||||
</p>
|
</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.
|
Explica al alumno por qué se rechaza. Este texto queda registrado.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -213,7 +213,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</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="button" className="btn btn-ghost" onClick={dlg.close} disabled={loading}>Cancelar</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn"
|
className="btn btn-danger"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
|
||||||
>
|
>
|
||||||
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
|
{loading ? 'Rechazando…' : 'Rechazar solicitud'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -40,23 +40,23 @@ export default function MarcarDevuelto({ prestamoId, resumenMateriales }: { pres
|
|||||||
|
|
||||||
return (
|
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
|
Marcar devuelto
|
||||||
</button>
|
</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">
|
<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-6">
|
<form onSubmit={submit} className="p-0">
|
||||||
<h2 className="text-lg font-semibold">Confirmar devolución</h2>
|
<h2 className="text-xl md:text-2xl font-light text-graphite">Confirmar devolución</h2>
|
||||||
<p className="text-sm opacity-80 mt-2">
|
<p className="text-sm text-graphite/80 mt-2">
|
||||||
¿Confirmas la devolución de <strong>{resumenMateriales}</strong>? El material regresará al inventario disponible.
|
¿Confirmas la devolución de <strong className="text-graphite">{resumenMateriales}</strong>? El material regresará al inventario disponible.
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<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="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'}
|
{loading ? 'Registrando…' : 'Confirmar devolución'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,8 +94,7 @@ function ReasignarUnidad({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[28px]"
|
||||||
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
|
|
||||||
onClick={abrir}
|
onClick={abrir}
|
||||||
>
|
>
|
||||||
Cambiar unidad
|
Cambiar unidad
|
||||||
@@ -103,10 +102,9 @@ function ReasignarUnidad({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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
|
<select
|
||||||
className="input"
|
className="input text-xs py-1 px-2 min-h-[32px] w-auto"
|
||||||
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(e) => setSelected(e.target.value)}
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
@@ -118,8 +116,7 @@ function ReasignarUnidad({
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary text-xs py-1 px-2.5 min-h-[32px]"
|
||||||
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
|
||||||
onClick={confirmar}
|
onClick={confirmar}
|
||||||
disabled={!selected || saving}
|
disabled={!selected || saving}
|
||||||
>
|
>
|
||||||
@@ -127,8 +124,7 @@ function ReasignarUnidad({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[32px]"
|
||||||
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
|
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
@@ -170,84 +166,118 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
|||||||
|
|
||||||
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
|
<button type="button" className="btn btn-ghost" onClick={open}>Ver 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">
|
<dialog ref={ref} className="card rounded-[12px] max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
|
||||||
<div className="p-6">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<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>
|
||||||
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
|
<button
|
||||||
<button type="button" className="rounded-lg p-1.5 hover:bg-black/5 transition-colors" onClick={() => ref.current?.close()} aria-label="Cerrar">
|
type="button"
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
className="rounded-full p-2 text-graphite/70 hover:text-graphite hover:bg-linen transition-colors"
|
||||||
<path d="M6 18L18 6M6 6l12 12" />
|
onClick={() => ref.current?.close()}
|
||||||
</svg>
|
aria-label="Cerrar"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && <p className="mt-4 text-sm opacity-70">Cargando…</p>}
|
{data && (
|
||||||
{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)' }}>
|
<dl className="mt-4 grid grid-cols-3 gap-y-3 text-sm">
|
||||||
{error}
|
<dt className="text-graphite/70">Alumno</dt>
|
||||||
</div>
|
<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>
|
||||||
{data && (
|
<dt className="text-graphite/70">Materiales</dt>
|
||||||
<>
|
<dd className="col-span-2">
|
||||||
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
|
<ul className="border-y border-hairline-gray my-2 divide-y divide-hairline-gray">
|
||||||
<dt className="opacity-70">Alumno</dt>
|
{data.prestamo.items.map((i) => (
|
||||||
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
|
<li key={i.id} className="py-2.5 first:pt-2 last:pb-2 border-b border-hairline-gray last:border-b-0">
|
||||||
<dt className="opacity-70">Maestro</dt>
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd>
|
<div>
|
||||||
<dt className="opacity-70">Materiales</dt>
|
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}×</strong>{' '}
|
||||||
<dd className="col-span-2">
|
<span className="font-medium text-graphite">{i.material?.nombre ?? '—'}</span>
|
||||||
<ul className="list-disc pl-4 space-y-2">
|
<span className="text-xs text-graphite/60 ml-2">Inv. {i.material?.numero_inventario ?? '—'}</span>
|
||||||
{data.prestamo.items.map((i) => (
|
</div>
|
||||||
<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>}
|
|
||||||
{canReasignar && i.material_unidad && (
|
{canReasignar && i.material_unidad && (
|
||||||
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
|
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
|
||||||
)}
|
)}
|
||||||
</li>
|
</div>
|
||||||
))}
|
{i.material_unidad?.etiqueta && (
|
||||||
</ul>
|
<div className="text-xs text-graphite/70 mt-1">
|
||||||
</dd>
|
Unidad: <span className="font-medium text-graphite">{i.material_unidad.etiqueta}</span>
|
||||||
<dt className="opacity-70">Solicitado</dt>
|
</div>
|
||||||
<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></>)}
|
{i.descripcion && <div className="text-xs text-graphite/60 mt-0.5">{i.descripcion}</div>}
|
||||||
{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></>)}
|
</li>
|
||||||
{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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
</dd>
|
||||||
</>
|
<dt className="text-graphite/70">Solicitado</dt>
|
||||||
)}
|
<dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
|
||||||
</div>
|
{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>
|
</dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
|
|||||||
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
|
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||||
const [razon, setRazon] = useState('');
|
const [razon, setRazon] = useState('');
|
||||||
|
const [expiresAt, setExpiresAt] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
|
|||||||
if (adminSelf) return;
|
if (adminSelf) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setRazon('');
|
setRazon('');
|
||||||
|
setExpiresAt('');
|
||||||
dialogRef.current?.showModal();
|
dialogRef.current?.showModal();
|
||||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||||
};
|
};
|
||||||
@@ -54,10 +56,18 @@ export default function BanearForm({ profile, adminSelf }: Props) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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', {
|
const res = await fetch('/api/admin/baneos', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ profile_id: profile.id, razon: trimmed }),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const json = await res.json().catch(() => ({}));
|
const json = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear');
|
if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear');
|
||||||
@@ -79,11 +89,10 @@ export default function BanearForm({ profile, adminSelf }: Props) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost"
|
className="btn btn-danger text-xs px-3"
|
||||||
style={{
|
style={{
|
||||||
minHeight: '36px',
|
minHeight: '36px',
|
||||||
paddingBlock: '0.25rem',
|
paddingBlock: '0.25rem',
|
||||||
color: adminSelf ? undefined : 'var(--color-danger-text)',
|
|
||||||
opacity: adminSelf ? 0.5 : 1,
|
opacity: adminSelf ? 0.5 : 1,
|
||||||
cursor: adminSelf ? 'not-allowed' : undefined,
|
cursor: adminSelf ? 'not-allowed' : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -98,24 +107,27 @@ export default function BanearForm({ profile, adminSelf }: Props) {
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<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}
|
Banear a {label}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
aria-label="Cerrar"
|
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>
|
</button>
|
||||||
</div>
|
</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.
|
El usuario no podrá acceder al sistema hasta ser desbaneado.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -137,19 +149,35 @@ export default function BanearForm({ profile, adminSelf }: Props) {
|
|||||||
aria-describedby={error ? `${titleId}-err` : undefined}
|
aria-describedby={error ? `${titleId}-err` : undefined}
|
||||||
placeholder="Explica el motivo — el usuario lo verá en su pantalla de bloqueo."
|
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
|
{razon.length}/500
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<p
|
<p
|
||||||
ref={errorRef}
|
ref={errorRef}
|
||||||
id={`${titleId}-err`}
|
id={`${titleId}-err`}
|
||||||
role="alert"
|
role="alert"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="text-sm"
|
className="text-sm text-negro-institucional"
|
||||||
style={{ color: 'var(--color-danger-text)' }}
|
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
@@ -161,8 +189,7 @@ export default function BanearForm({ profile, adminSelf }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn"
|
className="btn btn-danger"
|
||||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Baneando…' : 'Banear'}
|
{loading ? 'Baneando…' : 'Banear'}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-secondary text-xs px-3"
|
||||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -48,17 +48,18 @@ export default function DesbanearButton({ baneoId, nombre }: Props) {
|
|||||||
<dialog
|
<dialog
|
||||||
ref={errorDialogRef}
|
ref={errorDialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<div className="p-6 flex flex-col gap-4">
|
||||||
<h2 id={titleId} className="text-lg font-semibold">
|
<h2 id={titleId} className="text-xl text-graphite font-normal">
|
||||||
No se pudo desbanear
|
No se pudo desbanear
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
<p className="text-sm text-negro-institucional">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end pt-2">
|
<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
|
Entendido
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,32 +6,65 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AgregarMaterial({ material }: 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 sinStock = material.cantidad_disponible === 0;
|
||||||
const enCarrito = has(material.id);
|
const enCarrito = has(material.id);
|
||||||
|
|
||||||
if (enCarrito) {
|
if (enCarrito) {
|
||||||
|
const item = items.find((i) => i.material_id === material.id);
|
||||||
|
const cantidad = item?.cantidad ?? 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" className="btn btn-secondary w-full" disabled>
|
<div className="flex items-center justify-between gap-2 w-full">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<div className="flex items-center gap-1">
|
||||||
<path d="M4.5 12.75l6 6 9-13.5" />
|
<button
|
||||||
</svg>
|
type="button"
|
||||||
En tu solicitud
|
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
|
||||||
</button>
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary w-full"
|
className="btn btn-secondary w-full"
|
||||||
disabled={sinStock}
|
disabled={sinStock}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
addItem(material);
|
addItem(material);
|
||||||
toast({ kind: 'success', title: 'Agregado a tu solicitud', description: material.nombre });
|
toast({ kind: 'success', title: 'Agregado a tu solicitud', description: material.nombre });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sinStock ? 'Sin stock' : 'Agregar'}
|
{sinStock ? 'Sin stock' : 'Agregar al carrito'}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,25 +82,30 @@ export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
|
|||||||
<label className="label sr-only" htmlFor="buscador-catalogo">
|
<label className="label sr-only" htmlFor="buscador-catalogo">
|
||||||
Buscar material
|
Buscar material
|
||||||
</label>
|
</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
|
<input
|
||||||
id="buscador-catalogo"
|
id="buscador-catalogo"
|
||||||
className="input"
|
className="input pl-11 pr-10 rounded-[8px]"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Buscar por nombre o número de inventario…"
|
placeholder="Buscar por nombre o número de inventario…"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
style={{ paddingRight: q ? '2.5rem' : undefined }}
|
|
||||||
/>
|
/>
|
||||||
{q && (
|
{q && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
aria-label="Limpiar búsqueda"
|
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" />
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -50,13 +50,8 @@ export default function FiltroCategorias({ categorias }: Props) {
|
|||||||
|
|
||||||
const chipClass = (v: string) =>
|
const chipClass = (v: string) =>
|
||||||
v === active
|
v === active
|
||||||
? 'btn'
|
? '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'
|
||||||
: 'btn btn-ghost';
|
: 'chip cursor-pointer hover:bg-linen hover:border-dorado-uabc transition-colors';
|
||||||
|
|
||||||
const chipStyle = (v: string): React.CSSProperties =>
|
|
||||||
v === active
|
|
||||||
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
|
|
||||||
: { minHeight: '40px', paddingBlock: '0.375rem' };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -70,7 +65,6 @@ export default function FiltroCategorias({ categorias }: Props) {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active === ALL}
|
aria-selected={active === ALL}
|
||||||
className={chipClass(ALL)}
|
className={chipClass(ALL)}
|
||||||
style={chipStyle(ALL)}
|
|
||||||
data-cat-value={ALL}
|
data-cat-value={ALL}
|
||||||
data-cat-active={active === ALL ? 'true' : undefined}
|
data-cat-active={active === ALL ? 'true' : undefined}
|
||||||
onClick={() => pick(ALL)}
|
onClick={() => pick(ALL)}
|
||||||
@@ -86,7 +80,6 @@ export default function FiltroCategorias({ categorias }: Props) {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active === v}
|
aria-selected={active === v}
|
||||||
className={chipClass(v)}
|
className={chipClass(v)}
|
||||||
style={chipStyle(v)}
|
|
||||||
data-cat-value={v}
|
data-cat-value={v}
|
||||||
data-cat-active={active === v ? 'true' : undefined}
|
data-cat-active={active === v ? 'true' : undefined}
|
||||||
onClick={() => pick(v)}
|
onClick={() => pick(v)}
|
||||||
|
|||||||
@@ -14,14 +14,6 @@ type Material = {
|
|||||||
categoria: { id: number; nombre: string } | null;
|
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 = {
|
type CartItem = {
|
||||||
material_id: number;
|
material_id: number;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
@@ -238,7 +230,7 @@ export default function CartProvider({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CartContext.Provider value={value}>
|
<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) => {
|
{materiales.map((m) => {
|
||||||
const src = imgUrl(m.imagen_path);
|
const src = imgUrl(m.imagen_path);
|
||||||
return (
|
return (
|
||||||
@@ -249,34 +241,32 @@ export default function CartProvider({
|
|||||||
data-nombre={m.nombre}
|
data-nombre={m.nombre}
|
||||||
data-numero-inventario={m.numero_inventario ?? ''}
|
data-numero-inventario={m.numero_inventario ?? ''}
|
||||||
>
|
>
|
||||||
<div
|
<div className="relative aspect-square w-full rounded-t-[12px] overflow-hidden bg-linen flex items-center justify-center border-b border-hairline-gray">
|
||||||
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
|
||||||
style={{ borderBottom: '2px solid var(--color-ink)' }}
|
|
||||||
>
|
|
||||||
{src ? (
|
{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">
|
<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="1" />
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
||||||
<circle cx="8" cy="9" r="1.5" />
|
<circle cx="8" cy="9" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<span
|
<span className="absolute bottom-2 right-2 badge">
|
||||||
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}
|
|
||||||
>
|
|
||||||
Disponible
|
Disponible
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3 flex-1">
|
<div className="p-6 flex flex-col gap-2 flex-1">
|
||||||
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
<span className="eyebrow text-dorado-uabc">{m.categoria?.nombre ?? 'Sin categoría'}</span>
|
||||||
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
|
<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>
|
<dt className="opacity-60">Inventario</dt>
|
||||||
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
|
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
|
||||||
<dt className="opacity-60">Disponibles</dt>
|
<dt className="opacity-60">Disponibles</dt>
|
||||||
@@ -285,7 +275,7 @@ export default function CartProvider({
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="mt-auto">
|
<div className="mt-auto pt-2">
|
||||||
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,8 +287,7 @@ export default function CartProvider({
|
|||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40"
|
className="btn btn-primary fixed bottom-24 right-4 md:bottom-6 md:right-6 z-40 px-6 py-3"
|
||||||
style={{ boxShadow: 'var(--shadow-hard)' }}
|
|
||||||
onClick={openCheckout}
|
onClick={openCheckout}
|
||||||
>
|
>
|
||||||
Ver solicitud ({items.length})
|
Ver solicitud ({items.length})
|
||||||
@@ -308,44 +297,43 @@ export default function CartProvider({
|
|||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
aria-labelledby={titleId}
|
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">
|
<form onSubmit={submit} className="flex flex-col gap-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center justify-between gap-3 pb-3 border-b border-hairline-gray">
|
||||||
<h2 id={titleId} className="text-lg font-semibold">
|
<h2 id={titleId} className="text-xl font-normal text-graphite">
|
||||||
Tu solicitud
|
Tu solicitud
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeCheckout}
|
onClick={closeCheckout}
|
||||||
aria-label="Cerrar"
|
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" />
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{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">
|
<ul className="flex flex-col gap-3 max-h-64 overflow-y-auto -mx-1 px-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li
|
<li
|
||||||
key={item.material_id}
|
key={item.material_id}
|
||||||
className="flex items-start gap-2 pb-3"
|
className="flex items-start gap-3 pb-3 border-b border-hairline-gray"
|
||||||
style={{ borderBottom: '1.5px solid color-mix(in oklab, var(--color-ink) 15%, transparent)' }}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<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">
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
<label className="sr-only" htmlFor={`cant-cart-${item.material_id}`}>
|
<label className="sr-only" htmlFor={`cant-cart-${item.material_id}`}>
|
||||||
Cantidad de {item.nombre}
|
Cantidad de {item.nombre}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={`cant-cart-${item.material_id}`}
|
id={`cant-cart-${item.material_id}`}
|
||||||
className="input"
|
className="input rounded-[8px]"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={item.cantidad_disponible}
|
max={item.cantidad_disponible}
|
||||||
@@ -354,14 +342,14 @@ export default function CartProvider({
|
|||||||
onChange={(e) => updateCantidad(item.material_id, Number(e.target.value) || 1)}
|
onChange={(e) => updateCantidad(item.material_id, Number(e.target.value) || 1)}
|
||||||
style={{ width: '4.5rem', minHeight: '36px', paddingBlock: '0.25rem', fontVariantNumeric: 'tabular-nums' }}
|
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>
|
</div>
|
||||||
<label className="sr-only" htmlFor={`desc-cart-${item.material_id}`}>
|
<label className="sr-only" htmlFor={`desc-cart-${item.material_id}`}>
|
||||||
Nota para {item.nombre}
|
Nota para {item.nombre}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id={`desc-cart-${item.material_id}`}
|
id={`desc-cart-${item.material_id}`}
|
||||||
className="input mt-2"
|
className="input rounded-[8px] mt-2"
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
value={item.descripcion}
|
value={item.descripcion}
|
||||||
@@ -374,9 +362,9 @@ export default function CartProvider({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeItem(item.material_id)}
|
onClick={() => removeItem(item.material_id)}
|
||||||
aria-label={`Quitar ${item.nombre}`}
|
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" />
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -391,7 +379,7 @@ export default function CartProvider({
|
|||||||
<select
|
<select
|
||||||
ref={firstFieldRef}
|
ref={firstFieldRef}
|
||||||
id="maestro-select"
|
id="maestro-select"
|
||||||
className="input"
|
className="input rounded-[8px]"
|
||||||
value={maestroId}
|
value={maestroId}
|
||||||
onChange={(e) => setMaestroId(e.target.value)}
|
onChange={(e) => setMaestroId(e.target.value)}
|
||||||
aria-describedby="maestro-hint"
|
aria-describedby="maestro-hint"
|
||||||
@@ -403,7 +391,7 @@ export default function CartProvider({
|
|||||||
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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
|
{tutorNombre
|
||||||
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
|
? <>Si no eliges nadie se usará tu tutor: <strong>{tutorNombre}</strong>.</>
|
||||||
: 'Debes elegir un maestro; no tienes tutor guardado.'}
|
: '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>
|
<label className="label" htmlFor="notas-cart">Motivo del préstamo</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="notas-cart"
|
id="notas-cart"
|
||||||
className="input"
|
className="input rounded-[8px]"
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
value={notas}
|
value={notas}
|
||||||
@@ -424,25 +412,25 @@ export default function CartProvider({
|
|||||||
style={{ minHeight: '96px' }}
|
style={{ minHeight: '96px' }}
|
||||||
aria-describedby="notas-cart-hint"
|
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)
|
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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.
|
{error} · Ajusta la solicitud e intenta de nuevo.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{ok && (
|
{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…
|
Solicitud enviada. Actualizando…
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!perfilCompleto ? (
|
{!perfilCompleto ? (
|
||||||
<div className="card p-3">
|
<div className="card p-4 rounded-[12px] bg-linen border border-hairline-gray">
|
||||||
<p className="text-sm mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
|
<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>
|
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,31 +6,26 @@ type Props = {
|
|||||||
className?: string;
|
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) {
|
export default function Avatar({ info, size = 40, className = '' }: Props) {
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
border: '2px solid var(--color-ink)',
|
fontSize: Math.max(12, Math.floor(size * 0.38)),
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 ? (
|
{info.src ? (
|
||||||
<img
|
<img
|
||||||
src={info.src}
|
src={info.src}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
className="w-full h-full object-cover rounded-full"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -123,35 +123,46 @@ export default function PerfilForm({
|
|||||||
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
|
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
|
||||||
<div>
|
<div>
|
||||||
<label className="label" htmlFor="p-foto">Foto</label>
|
<label className="label" htmlFor="p-foto">Foto</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<Avatar info={previewInfo} size={72} />
|
<Avatar info={previewInfo} size={96} className="w-24 h-24 rounded-full" />
|
||||||
<div className="flex-1 flex flex-col gap-2">
|
<div className="flex-1 flex flex-col items-start gap-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
id="p-foto"
|
id="p-foto"
|
||||||
className="input"
|
className="hidden"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
{(file || fotoPath) && (
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost self-start"
|
className="btn btn-ghost"
|
||||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
onClick={() => {
|
|
||||||
setFile(null);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
setFotoPath(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Quitar foto
|
Subir foto
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{!file && !fotoPath && googleAvatarUrl && (
|
{!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>
|
</div>
|
||||||
|
|
||||||
@@ -159,7 +170,7 @@ export default function PerfilForm({
|
|||||||
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
|
<label className="label" htmlFor="p-mat">{matriculaLabel}</label>
|
||||||
<input
|
<input
|
||||||
id="p-mat"
|
id="p-mat"
|
||||||
className="input"
|
className="input rounded-[8px]"
|
||||||
type="text"
|
type="text"
|
||||||
value={matricula}
|
value={matricula}
|
||||||
onChange={(e) => setMatricula(e.target.value)}
|
onChange={(e) => setMatricula(e.target.value)}
|
||||||
@@ -174,7 +185,7 @@ export default function PerfilForm({
|
|||||||
<label className="label" htmlFor="p-sem">Semestre</label>
|
<label className="label" htmlFor="p-sem">Semestre</label>
|
||||||
<select
|
<select
|
||||||
id="p-sem"
|
id="p-sem"
|
||||||
className="input"
|
className="input rounded-[8px]"
|
||||||
value={semestre}
|
value={semestre}
|
||||||
onChange={(e) => setSemestre(e.target.value)}
|
onChange={(e) => setSemestre(e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -191,7 +202,7 @@ export default function PerfilForm({
|
|||||||
<label className="label" htmlFor="p-tutor">Tutor</label>
|
<label className="label" htmlFor="p-tutor">Tutor</label>
|
||||||
<select
|
<select
|
||||||
id="p-tutor"
|
id="p-tutor"
|
||||||
className="input"
|
className="input rounded-[8px]"
|
||||||
value={tutorId}
|
value={tutorId}
|
||||||
onChange={(e) => setTutorId(e.target.value)}
|
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>
|
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
<p role="alert" className="text-sm text-negro-institucional">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+38
-47
@@ -32,14 +32,10 @@ const avatar = profile
|
|||||||
|
|
||||||
const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id);
|
const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id);
|
||||||
|
|
||||||
let pendientesCount = 0;
|
// El count de pendientes lo hidrata BadgeSolicitudes (client-side) al montarse.
|
||||||
if (isAdmin) {
|
// Renderizar 0 en SSR evita 1 hit a DB por cada pageview de admin — el badge
|
||||||
const { count } = await Astro.locals.supabase
|
// aparece ~200ms después de la carga, trade-off aceptable.
|
||||||
.from('solicitudes')
|
const pendientesCount = 0;
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.eq('estado', 'pendiente');
|
|
||||||
pendientesCount = count ?? 0;
|
|
||||||
}
|
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
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">
|
<div class="min-h-screen flex flex-col md:block">
|
||||||
|
|
||||||
<!-- Sidebar desktop -->
|
<!-- 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);">
|
<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 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);">
|
<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-[2px] bg-[color:var(--color-primary)] grid place-items-center p-2" style="border: 2px solid var(--color-ink);" aria-hidden="true">
|
<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 text-white" />
|
<BrandMark class="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold leading-tight uppercase tracking-wide text-sm">LabPréstamos</div>
|
<div class="font-medium leading-tight text-graphite text-sm">LabPréstamos</div>
|
||||||
<div class="text-xs" style="color: var(--color-pencil);">UABC · Lab. Sistemas</div>
|
<div class="text-xs text-graphite/60">UABC · Lab. Sistemas</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<nav class="flex-1 p-3 flex flex-col gap-1.5" aria-label="Navegación principal">
|
<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
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class:list={[
|
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',
|
'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-[color:var(--color-primary)] text-white' : 'hover:bg-[color:var(--color-chalk)]'
|
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}
|
aria-current={activeCheck(item) ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
<span class="relative inline-grid place-items-center">
|
<span class="relative inline-grid place-items-center">
|
||||||
@@ -124,8 +121,7 @@ const activeCheck = (item: NavItem) => {
|
|||||||
data-badge="pendientes"
|
data-badge="pendientes"
|
||||||
hidden={pendientesCount === 0}
|
hidden={pendientesCount === 0}
|
||||||
aria-label={`${pendientesCount} solicitudes pendientes`}
|
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"
|
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"
|
||||||
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
|
|
||||||
>{pendientesCount}</span>
|
>{pendientesCount}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -133,26 +129,25 @@ const activeCheck = (item: NavItem) => {
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="p-3" style="border-top: 2px solid var(--color-ink);">
|
<div class="p-3 border-t border-hairline-gray">
|
||||||
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[2px] hover:bg-[color:var(--color-chalk)] transition-colors">
|
<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} />}
|
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="text-sm font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
<div class="text-sm font-medium text-graphite 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-xs text-graphite/60 flex items-center gap-1.5">
|
||||||
<span>Ver perfil</span>
|
<span>Ver perfil</span>
|
||||||
{perfilIncompleto && (
|
{perfilIncompleto && (
|
||||||
<span
|
<span
|
||||||
aria-label="Perfil incompleto"
|
aria-label="Perfil incompleto"
|
||||||
title="Perfil incompleto"
|
title="Perfil incompleto"
|
||||||
class="inline-block w-2 h-2 rounded-full motion-safe:animate-pulse"
|
class="inline-block w-2 h-2 rounded-full bg-negro-institucional motion-safe:animate-pulse"
|
||||||
style="background: var(--color-danger);"
|
|
||||||
></span>
|
></span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="/api/auth/signout" class="mt-1">
|
<form method="POST" action="/api/auth/signout" class="mt-2">
|
||||||
<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">
|
<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">
|
<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} />
|
<path d={ICONS.logout} />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -163,20 +158,19 @@ const activeCheck = (item: NavItem) => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Header mobile mínimo -->
|
<!-- 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);">
|
<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-[color:var(--color-ink)] rounded-[2px]" aria-label="LabPréstamos — Inicio">
|
<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-[2px] bg-[color:var(--color-primary)] grid place-items-center p-1.5" style="border: 2px solid var(--color-ink);" aria-hidden="true">
|
<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" />
|
<BrandMark class="w-full h-full" />
|
||||||
</div>
|
</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>
|
||||||
<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} />}
|
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||||
{perfilIncompleto && (
|
{perfilIncompleto && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full motion-safe:animate-pulse"
|
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-negro-institucional motion-safe:animate-pulse"
|
||||||
style="background: var(--color-danger); border: 1.5px solid var(--color-ink);"
|
|
||||||
></span>
|
></span>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
@@ -189,8 +183,7 @@ const activeCheck = (item: NavItem) => {
|
|||||||
id="sidebar-toggle"
|
id="sidebar-toggle"
|
||||||
aria-label="Alternar menú lateral"
|
aria-label="Alternar menú lateral"
|
||||||
aria-controls="app-sidebar"
|
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)]"
|
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"
|
||||||
style="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">
|
<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} />
|
<path d={ICONS.menu} />
|
||||||
@@ -199,10 +192,10 @@ const activeCheck = (item: NavItem) => {
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</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
|
<nav
|
||||||
class="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-white flex items-stretch justify-around"
|
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="border-top: 2px solid var(--color-ink); padding-bottom: env(safe-area-inset-bottom);"
|
style="padding-bottom: env(safe-area-inset-bottom);"
|
||||||
aria-label="Navegación principal"
|
aria-label="Navegación principal"
|
||||||
>
|
>
|
||||||
{nav.map((item) => {
|
{nav.map((item) => {
|
||||||
@@ -212,14 +205,13 @@ const activeCheck = (item: NavItem) => {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
aria-current={active ? 'page' : undefined}
|
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
|
<span
|
||||||
class:list={[
|
class:list={[
|
||||||
'relative grid place-items-center rounded-[2px] w-11 h-11 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
|
'relative grid place-items-center rounded-[8px] w-10 h-10 transition-[background-color,transform] duration-100 motion-safe:active:scale-95',
|
||||||
active ? 'bg-[color:var(--color-primary)] text-white' : ''
|
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">
|
<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]} />
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
||||||
@@ -229,8 +221,7 @@ const activeCheck = (item: NavItem) => {
|
|||||||
data-badge="pendientes"
|
data-badge="pendientes"
|
||||||
hidden={pendientesCount === 0}
|
hidden={pendientesCount === 0}
|
||||||
aria-label={`${pendientesCount} solicitudes pendientes`}
|
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"
|
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"
|
||||||
style="background: var(--color-danger); color: var(--color-ink); border: 2px solid var(--color-ink);"
|
|
||||||
>{pendientesCount}</span>
|
>{pendientesCount}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -241,9 +232,9 @@ const activeCheck = (item: NavItem) => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Cerrar sesión"
|
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">
|
<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} />
|
<path d={ICONS.logout} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
+15
-17
@@ -11,28 +11,26 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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" />
|
<meta name="color-scheme" content="light" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<!--
|
||||||
THESIS: Trade the institutional-green bitácora look for MotherDuck's crayon-terminal-on-cream
|
THESIS: Identidad editorial institucional UABC en clave monoespaciada — verde institucional
|
||||||
neobrutalism, per a fully pinned user brief — hard edges and offset shadow instead of soft,
|
#00723F como único interruptor de acción, tipografía Azeret Mono unificada (300/500), dos
|
||||||
rounded institutional polish.
|
lienzos alternos (Bone White / Negro Institucional), sin sombras, hairlines 1px.
|
||||||
OWN-WORLD: Cream Paper canvas, Frost White cards, Charcoal Ink borders/text everywhere, Sky
|
OWN-WORLD: Bone White (#fffefb) canvas, Verde Institucional (#00723F) para CTAs y estados,
|
||||||
Crayon as the sole filled-action color, Coral Sketch for danger, Mint Sketch for positive
|
Dorado UABC (#DD971A) como acento secundario, radios 8/12/30/9999 solamente, ninguna otra
|
||||||
status, Duck Bill Orange as sparse accent, JetBrains Mono throughout, 2px radius everywhere,
|
familia tipográfica.
|
||||||
-6px/6px hard offset shadow with press-in hover on buttons and modals.
|
STORY: mismo sistema de préstamos — la interfaz se lee como una hoja de especificación
|
||||||
STORY: alumno/admin still request, approve, and return lab equipment; the interface now reads
|
técnica en vez de un panel de marketing.
|
||||||
like a terminal readout instead of a soft green form.
|
FORM: user-pinned brief (UABC DESIGN.md) — concept roll skipped, brief gana.
|
||||||
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.
|
|
||||||
-->
|
-->
|
||||||
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 btn btn-primary z-50">
|
<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
|
Saltar al contenido
|
||||||
@@ -48,14 +46,14 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
|
|||||||
<style is:global>
|
<style is:global>
|
||||||
#page-loader {
|
#page-loader {
|
||||||
position: fixed; inset: 0; z-index: 9999;
|
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);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
}
|
}
|
||||||
#page-loader[hidden] { display: none; }
|
#page-loader[hidden] { display: none; }
|
||||||
.page-loader-spinner {
|
.page-loader-spinner {
|
||||||
color: var(--color-ink);
|
color: var(--color-graphite);
|
||||||
animation: labre-spin 0.9s linear infinite;
|
animation: labre-spin 0.9s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes labre-spin { to { transform: rotate(360deg); } }
|
@keyframes labre-spin { to { transform: rotate(360deg); } }
|
||||||
|
|||||||
@@ -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
@@ -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)
|
// Rutas siempre accesibles para un user baneado (sino, loop de rewrite)
|
||||||
const BANNED_ALLOWED = ['/banned', '/api/auth/signout'];
|
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) => {
|
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);
|
const supabase = serverClient(context.cookies);
|
||||||
context.locals.supabase = supabase;
|
context.locals.supabase = supabase;
|
||||||
|
|
||||||
@@ -21,27 +47,36 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
context.locals.profile = null;
|
context.locals.profile = null;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
|
const now = Date.now();
|
||||||
let { data: profile } = await supabase
|
const cached = profileCache.get(user.id);
|
||||||
.from('profiles')
|
let profile: any = cached && cached.exp > now ? cached.profile : null;
|
||||||
.select(select)
|
|
||||||
.eq('id', user.id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
// Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro
|
if (!profile) {
|
||||||
// trigger del proyecto vecino en auth.users y no siempre dispara. Si el
|
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
|
||||||
// user existe pero no hay profile, lo creamos aquí con los datos del OAuth.
|
const { data } = await supabase
|
||||||
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')
|
.from('profiles')
|
||||||
.upsert({ id: user.id, email: user.email, nombre }, { onConflict: 'id' })
|
|
||||||
.select(select)
|
.select(select)
|
||||||
|
.eq('id', user.id)
|
||||||
.maybeSingle();
|
.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;
|
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
|
// 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.
|
// 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') {
|
if (user && context.locals.profile && context.locals.profile.rol !== 'admin') {
|
||||||
const { data: banned } = await supabase.rpc('is_banned', { p_uid: user.id });
|
const now = Date.now();
|
||||||
if (banned === true && !BANNED_ALLOWED.includes(pathname)) {
|
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');
|
return context.rewrite('/banned');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -6,13 +6,17 @@ Astro.response.status = 403;
|
|||||||
---
|
---
|
||||||
<Layout title="Acceso denegado — LabPréstamos">
|
<Layout title="Acceso denegado — LabPréstamos">
|
||||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||||
<div class="w-full max-w-md text-center">
|
<div class="w-full max-w-md">
|
||||||
<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);">
|
<div class="card text-center">
|
||||||
<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 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>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
+10
-6
@@ -6,13 +6,17 @@ Astro.response.status = 404;
|
|||||||
---
|
---
|
||||||
<Layout title="Página no encontrada — LabPréstamos">
|
<Layout title="Página no encontrada — LabPréstamos">
|
||||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||||
<div class="w-full max-w-md text-center">
|
<div class="w-full max-w-md">
|
||||||
<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);">
|
<div class="card text-center">
|
||||||
<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 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>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -2,161 +2,88 @@
|
|||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import Chart from '@/components/admin/estadisticas/Chart.tsx';
|
import Chart from '@/components/admin/estadisticas/Chart.tsx';
|
||||||
import { todayMX } from '@/lib/date';
|
import { todayMX } from '@/lib/date';
|
||||||
|
import { getEstadisticas } from '@/lib/stats-cache';
|
||||||
|
|
||||||
// El middleware ya garantiza rol admin en /admin/*.
|
// El middleware ya garantiza rol admin en /admin/*.
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
const dia = todayMX();
|
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.
|
// Cache 60s en RAM (getEstadisticas absorbe >90% de visitas repetidas).
|
||||||
const [
|
const { kpis: k, topMateriales, solicitudesPorDia, distribEstados } = await getEstadisticas(supabase, dia);
|
||||||
{ 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),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const kpis = [
|
// Debe coincidir con la ventana en stats-cache.ts (rango de las 3 gráficas).
|
||||||
{ 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).
|
|
||||||
const DIAS = 30;
|
const DIAS = 30;
|
||||||
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
|
|
||||||
|
|
||||||
// (1) Top 10 materiales solicitados últimos 30 días.
|
const vencidosCount = k.nVencidos;
|
||||||
const { data: topMaterialesRaw } = await supabase
|
const kpis = [
|
||||||
.from('solicitud_items')
|
{ label: 'Solicitadas', value: k.nSolicitadas, total: false, isVencidos: false },
|
||||||
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
|
{ label: 'Aprobadas', value: k.nAprobadas, total: false, isVencidos: false },
|
||||||
.gte('solicitud.fecha_solicitud', desde);
|
{ label: 'Rechazadas', value: k.nRechazadas, total: false, isVencidos: false },
|
||||||
|
{ label: 'Devoluciones', value: k.nDevueltas, total: false, isVencidos: false },
|
||||||
const topMapa = new Map<string, number>();
|
{ label: 'En préstamo', value: k.nEnPrestamo, total: true, isVencidos: false },
|
||||||
for (const r of (topMaterialesRaw ?? []) as any[]) {
|
{ label: 'Vencidos', value: vencidosCount, total: true, isVencidos: true },
|
||||||
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,
|
|
||||||
}));
|
|
||||||
---
|
---
|
||||||
<AppLayout title="Estadísticas — LabPréstamos">
|
<AppLayout title="Estadísticas — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-6xl">
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
<h1 class="text-3xl md:text-4xl">Estadísticas</h1>
|
||||||
<p class="text-sm mt-1 first-letter:uppercase" style="color: var(--color-pencil);">
|
<p class="text-sm mt-1 opacity-70">
|
||||||
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<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) => (
|
{kpis.map((k) => {
|
||||||
<div class="card p-4">
|
const isVencidosAlert = k.isVencidos && k.value > 0;
|
||||||
<div class="text-xs" style="color: var(--color-pencil);">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
return (
|
||||||
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
<div
|
||||||
{k.value}
|
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>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<div class="card">
|
<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) ? (
|
{solicitudesPorDia.some((d) => d.valor > 0) ? (
|
||||||
<Chart tipo="linea" datos={solicitudesPorDia} client:idle />
|
<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>
|
||||||
|
|
||||||
<div class="card">
|
<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 ? (
|
{distribEstados.length > 0 ? (
|
||||||
<Chart tipo="donut" datos={distribEstados} client:idle />
|
<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>
|
||||||
|
|
||||||
<div class="card lg:col-span-2">
|
<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 ? (
|
{topMateriales.length > 0 ? (
|
||||||
<Chart tipo="barras" datos={topMateriales} alto={320} client:idle />
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+57
-58
@@ -23,8 +23,13 @@ type Sol = {
|
|||||||
items: Item[];
|
items: Item[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
|
// 2 queries en paralelo (antes eran 4):
|
||||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
// - 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
|
supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
.select('*', { count: 'exact', head: true })
|
.select('*', { count: 'exact', head: true })
|
||||||
@@ -33,18 +38,13 @@ const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
|
|||||||
supabase
|
supabase
|
||||||
.from('solicitudes')
|
.from('solicitudes')
|
||||||
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
.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 })
|
.order('fecha_solicitud', { ascending: false })
|
||||||
.limit(2),
|
.limit(8),
|
||||||
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),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pendientesData = (pendientes2.data ?? []) as unknown as Sol[];
|
const recientesData = (recientes.data ?? []) as unknown as Sol[];
|
||||||
const ultimasData = (ultimas.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> = {
|
const estadoLabel: Record<Estado, string> = {
|
||||||
pendiente: 'Pendiente',
|
pendiente: 'Pendiente',
|
||||||
@@ -54,16 +54,14 @@ const estadoLabel: Record<Estado, string> = {
|
|||||||
rechazado: 'Rechazado',
|
rechazado: 'Rechazado',
|
||||||
vencido: 'Vencido',
|
vencido: 'Vencido',
|
||||||
};
|
};
|
||||||
const estadoColor: Record<Estado, string> = {
|
const estadoChipClass: Record<Estado, string> = {
|
||||||
pendiente: 'var(--color-ink)',
|
pendiente: 'chip-pendiente',
|
||||||
aprobado: 'var(--color-positive)',
|
aprobado: 'chip-aprobado',
|
||||||
activo: 'var(--color-positive)',
|
activo: 'chip-activo',
|
||||||
devuelto: 'var(--color-ink)',
|
devuelto: 'chip-devuelto',
|
||||||
rechazado: 'var(--color-danger)',
|
rechazado: 'chip-rechazado',
|
||||||
vencido: 'var(--color-danger)',
|
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 resumenItems = (items: Item[]) => {
|
||||||
const preview = items.slice(0, 3).map((i) => `${i.cantidad}× ${i.material?.nombre ?? '—'}`).join(', ');
|
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;
|
return extra > 0 ? `${preview} (+${extra})` : preview;
|
||||||
};
|
};
|
||||||
|
|
||||||
const kpiPend = pend.count ?? 0;
|
|
||||||
const kpiVenc = vencidos.count ?? 0;
|
const kpiVenc = vencidos.count ?? 0;
|
||||||
---
|
---
|
||||||
<AppLayout title="Panel — LabPréstamos">
|
<AppLayout title="Panel — LabPréstamos">
|
||||||
<div class="max-w-5xl mx-auto">
|
<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">
|
<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}
|
{saludo}, {nombreCorto}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex gap-3 shrink-0">
|
<div class="flex gap-3 shrink-0">
|
||||||
<a href="/admin/solicitudes" class="card-flat px-4 py-3 text-center">
|
<a href="/admin/solicitudes" class="card text-center block min-w-[120px] transition-colors hover:border-dorado-uabc">
|
||||||
<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 data-count="pendientes" class="text-3xl md:text-4xl font-light" style="font-variant-numeric: tabular-nums;">—</div>
|
||||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Pendientes</div>
|
<div class="mt-1 text-xs eyebrow text-dorado-uabc">Pendientes</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/solicitudes/activos" class="card-flat px-4 py-3 text-center">
|
<a
|
||||||
<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>
|
href="/admin/solicitudes/activos"
|
||||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Vencidos</div>
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-labelledby="atencion-h" class="mb-10">
|
<section aria-labelledby="atencion-h" class="card mb-10">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<div class="flex items-baseline justify-between mb-4">
|
||||||
<h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
|
<h2 id="atencion-h" class="text-xl md:text-2xl font-light">Requieren tu atención</h2>
|
||||||
{pendientesData.length > 0 && (
|
{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>
|
</div>
|
||||||
{pendientesData.length === 0 ? (
|
{pendientesData.length === 0 ? (
|
||||||
<div class="card text-center">
|
<p class="text-sm text-graphite/70 text-center py-6">Todo al día. No hay solicitudes pendientes.</p>
|
||||||
<p class="text-sm opacity-70">Todo al día. No hay solicitudes pendientes.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
{pendientesData.map((s) => (
|
{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">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
<h3 class="font-semibold leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
|
<h3 class="text-base font-medium leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
|
||||||
<span class="badge shrink-0" style={badgeStyle(s.estado)}>{estadoLabel[s.estado]}</span>
|
<span class:list={['chip shrink-0', estadoChipClass[s.estado]]}>{estadoLabel[s.estado]}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm leading-snug mb-3" style="color: var(--color-pencil);">{resumenItems(s.items ?? [])}</p>
|
<p class="text-sm leading-snug mb-4 text-graphite/70">{resumenItems(s.items ?? [])}</p>
|
||||||
<div class="flex items-center justify-between text-xs">
|
<div class="flex items-center justify-between text-xs pt-3 border-t border-hairline-gray">
|
||||||
<span style="color: var(--color-pencil);">{fmtFechaRelativa(s.fecha_solicitud)}</span>
|
<span class="text-graphite/60">{fmtFechaRelativa(s.fecha_solicitud)}</span>
|
||||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Revisar</span>
|
<span class="btn btn-primary text-xs py-1 px-3 min-h-[32px]">Revisar</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -122,21 +123,19 @@ const kpiVenc = vencidos.count ?? 0;
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-labelledby="ultimas-h">
|
<section aria-labelledby="ultimas-h" class="card">
|
||||||
<div class="flex items-baseline justify-between mb-3">
|
<div class="flex items-baseline justify-between mb-4">
|
||||||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
|
<h2 id="ultimas-h" class="text-xl md:text-2xl font-light">Actividad reciente</h2>
|
||||||
<a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
|
<a href="/admin/solicitudes/historial" class="btn btn-ghost text-xs py-1 px-3 min-h-[36px]">Historial</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ultimasData.length === 0 ? (
|
{ultimasData.length === 0 ? (
|
||||||
<div class="card text-center">
|
<p class="text-sm text-graphite/70 text-center py-6">Aún no hay solicitudes registradas.</p>
|
||||||
<p class="text-sm opacity-70">Aún no hay solicitudes registradas.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<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">
|
<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>
|
<tr>
|
||||||
<th class="p-3 font-medium">Alumno</th>
|
<th class="p-3 font-medium">Alumno</th>
|
||||||
<th class="p-3 font-medium">Material</th>
|
<th class="p-3 font-medium">Material</th>
|
||||||
@@ -146,8 +145,8 @@ const kpiVenc = vencidos.count ?? 0;
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ultimasData.map((r) => (
|
{ultimasData.map((r) => (
|
||||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
<tr class="border-b border-hairline-gray last:border-b-0">
|
||||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
<td class="p-3 font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
{(r.items ?? []).map((i) => (
|
{(r.items ?? []).map((i) => (
|
||||||
@@ -156,9 +155,9 @@ const kpiVenc = vencidos.count ?? 0;
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<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>
|
||||||
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
|
<td class="p-3 text-graphite/80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -170,10 +169,10 @@ const kpiVenc = vencidos.count ?? 0;
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="font-semibold leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
|
<h3 class="text-base font-medium 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>
|
<p class="text-xs text-graphite/60 mt-1">{fmtFechaRelativa(r.fecha_solicitud)}</p>
|
||||||
</div>
|
</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>
|
||||||
<div class="text-sm leading-snug">
|
<div class="text-sm leading-snug">
|
||||||
{(r.items ?? []).map((i) => (
|
{(r.items ?? []).map((i) => (
|
||||||
|
|||||||
@@ -24,27 +24,37 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
|
|||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Categorías</h1>
|
<h1 class="text-3xl">Categorías</h1>
|
||||||
<p class="text-sm opacity-70 mt-1">Organiza los materiales por tipo.</p>
|
<p class="text-sm text-graphite/70 mt-1">Organiza los materiales por tipo.</p>
|
||||||
</div>
|
</div>
|
||||||
<CategoriaForm mode="create" client:load />
|
<CategoriaForm mode="create" client:load />
|
||||||
</header>
|
</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">
|
<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 border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
<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
|
Materiales
|
||||||
</a>
|
</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
|
Categorías
|
||||||
</a>
|
</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
|
Maestros
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
<div class="card mb-6" role="alert">
|
||||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
<p class="text-sm text-negro-institucional">
|
||||||
No se pudieron cargar las categorías. Recarga la página.
|
No se pudieron cargar las categorías. Recarga la página.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,8 +62,8 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
|
|||||||
|
|
||||||
{!error && categorias.length === 0 && (
|
{!error && categorias.length === 0 && (
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<h2 class="text-lg font-semibold mb-1">Sin categorías aún</h2>
|
<h2 class="text-lg mb-1">Sin categorías aún</h2>
|
||||||
<p class="text-sm opacity-70">Crea la primera para agrupar tus materiales.</p>
|
<p class="text-sm text-graphite/70">Crea la primera para agrupar tus materiales.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -61,24 +71,24 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
|
|||||||
<>
|
<>
|
||||||
<div class="hidden md:block card p-0 overflow-hidden">
|
<div class="hidden md:block card p-0 overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
|
||||||
<th class="px-4 py-3 font-medium text-right"># Materiales</th>
|
<th class="px-4 py-3 font-medium text-graphite 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 text-right">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{categorias.map((c) => (
|
{categorias.map((c) => (
|
||||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
<tr class="border-b border-hairline-gray last:border-b-0">
|
||||||
<td class="px-4 py-3 font-medium">{c.nombre}</td>
|
<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;">
|
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
||||||
{c.count > 0 ? (
|
{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}
|
{c.count}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span class="opacity-60">0</span>
|
<span class="text-graphite/50">0</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<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">
|
<div class="md:hidden flex flex-col gap-3">
|
||||||
{categorias.map((c) => (
|
{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>
|
<div>
|
||||||
<h3 class="font-semibold">{c.nombre}</h3>
|
<h3 class="font-medium text-graphite">{c.nombre}</h3>
|
||||||
<p class="text-xs opacity-70 mt-1">
|
<p class="text-xs text-graphite/70 mt-1">
|
||||||
{c.count > 0 ? (
|
{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'}
|
{c.count} material{c.count === 1 ? '' : 'es'}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -23,39 +23,38 @@ const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
|||||||
const estadoRaw = Astro.url.searchParams.get('estado') ?? '';
|
const estadoRaw = Astro.url.searchParams.get('estado') ?? '';
|
||||||
const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? estadoRaw : '';
|
const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? estadoRaw : '';
|
||||||
|
|
||||||
let query = Astro.locals.supabase
|
let matQuery = Astro.locals.supabase
|
||||||
.from('materiales')
|
.from('materiales')
|
||||||
.select(
|
.select(
|
||||||
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, imagen_path, trackeado_por_unidad, categoria:categorias(id, nombre)',
|
'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 (q) matQuery = matQuery.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
|
||||||
if (catId != null) query = query.eq('categoria_id', catId);
|
if (catId != null) matQuery = matQuery.eq('categoria_id', catId);
|
||||||
if (estado) query = query.eq('estado', estado);
|
if (estado) matQuery = matQuery.eq('estado', estado);
|
||||||
|
|
||||||
const { data: matData, error: matError } = await query.order('nombre');
|
const [matRes, catRes] = await Promise.all([
|
||||||
const materiales = (matData ?? []) as unknown as Material[];
|
matQuery.order('nombre'),
|
||||||
|
Astro.locals.supabase.from('categorias').select('id, nombre').order('nombre'),
|
||||||
const { data: catData } = await Astro.locals.supabase
|
]);
|
||||||
.from('categorias')
|
const materiales = (matRes.data ?? []) as unknown as Material[];
|
||||||
.select('id, nombre')
|
const matError = matRes.error;
|
||||||
.order('nombre');
|
const categorias = (catRes.data ?? []) as { id: number; nombre: string }[];
|
||||||
const categorias = (catData ?? []) as { id: number; nombre: string }[];
|
|
||||||
|
|
||||||
const estadoBadge = (e: Material['estado']) => {
|
const estadoBadge = (e: Material['estado']) => {
|
||||||
if (e === 'disponible')
|
if (e === 'disponible')
|
||||||
return {
|
return {
|
||||||
label: 'Disponible',
|
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')
|
if (e === 'mantenimiento')
|
||||||
return {
|
return {
|
||||||
label: 'Mantenimiento',
|
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 {
|
return {
|
||||||
label: 'Baja',
|
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">
|
<div class="max-w-7xl">
|
||||||
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
|
<h1 class="text-3xl">Inventario</h1>
|
||||||
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
|
<p class="text-sm text-graphite/70 mt-1">Materiales del laboratorio.</p>
|
||||||
</div>
|
</div>
|
||||||
<MaterialForm mode="create" categorias={categorias} client:load />
|
<MaterialForm mode="create" categorias={categorias} client:load />
|
||||||
</header>
|
</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">
|
<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 border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
<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
|
Materiales
|
||||||
</a>
|
</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
|
Categorías
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<form id="inv-filter-form" method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
|
<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>
|
</form>
|
||||||
|
|
||||||
{matError && (
|
{matError && (
|
||||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
<div class="card mb-6" role="alert">
|
||||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
<p class="text-sm text-negro-institucional">
|
||||||
No se pudo cargar el inventario. Recarga la página.
|
No se pudo cargar el inventario. Recarga la página.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,8 +121,8 @@ const estadoBadge = (e: Material['estado']) => {
|
|||||||
|
|
||||||
{!matError && materiales.length === 0 && (
|
{!matError && materiales.length === 0 && (
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<h2 class="text-lg font-semibold mb-1">Sin resultados</h2>
|
<h2 class="text-lg mb-1">Sin resultados</h2>
|
||||||
<p class="text-sm opacity-70">
|
<p class="text-sm text-graphite/70">
|
||||||
{q || catId != null || estado
|
{q || catId != null || estado
|
||||||
? 'Ningún material coincide con los filtros. Ajusta la búsqueda.'
|
? 'Ningún material coincide con los filtros. Ajusta la búsqueda.'
|
||||||
: 'Aún no hay material registrado. Agrega uno para comenzar.'}
|
: '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);
|
const src = imgUrl(m.imagen_path);
|
||||||
return (
|
return (
|
||||||
<article class="card p-0 flex flex-col overflow-hidden">
|
<article class="card p-0 flex flex-col overflow-hidden">
|
||||||
<div
|
<div class="relative aspect-square w-full bg-linen rounded-t-[12px] overflow-hidden grid place-items-center border-b border-hairline-gray">
|
||||||
class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
|
|
||||||
style="border-bottom: 2px solid var(--color-ink);"
|
|
||||||
>
|
|
||||||
{src ? (
|
{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" />
|
<rect x="3" y="4" width="18" height="16" rx="1" />
|
||||||
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
<path d="M3 16l5-5 4 4 3-3 6 6" />
|
||||||
<circle cx="8" cy="9" r="1.5" />
|
<circle cx="8" cy="9" r="1.5" />
|
||||||
</svg>
|
</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}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 flex flex-col gap-2 flex-1">
|
<div class="p-4 flex flex-col gap-2 flex-1">
|
||||||
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
|
<h3 class="font-medium text-sm leading-snug line-clamp-2 text-graphite">{m.nombre}</h3>
|
||||||
{m.categoria && (
|
{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;">
|
<p class="text-xs text-graphite" style="font-variant-numeric: tabular-nums;">
|
||||||
<span class="font-semibold">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
|
<span class="font-medium text-graphite">{m.cantidad_disponible}</span> / {m.cantidad_total} disponibles
|
||||||
{m.trackeado_por_unidad && <span class="opacity-70"> · por unidad</span>}
|
{m.trackeado_por_unidad && <span class="text-graphite/60"> · por unidad</span>}
|
||||||
</p>
|
</p>
|
||||||
{m.numero_inventario && (
|
{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">
|
<div class="grid grid-cols-2 gap-2 mt-auto pt-2">
|
||||||
<MaterialForm
|
<MaterialForm
|
||||||
|
|||||||
@@ -28,27 +28,37 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
|||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Maestros</h1>
|
<h1 class="text-3xl">Maestros</h1>
|
||||||
<p class="text-sm opacity-70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
|
<p class="text-sm text-graphite/70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
|
||||||
</div>
|
</div>
|
||||||
<MaestroForm mode="create" client:load />
|
<MaestroForm mode="create" client:load />
|
||||||
</header>
|
</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">
|
<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 border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
<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
|
Materiales
|
||||||
</a>
|
</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
|
Categorías
|
||||||
</a>
|
</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
|
Maestros
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
<div class="card mb-6" role="alert">
|
||||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
<p class="text-sm text-negro-institucional">
|
||||||
No se pudieron cargar los maestros. Recarga la página.
|
No se pudieron cargar los maestros. Recarga la página.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,8 +66,8 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
|||||||
|
|
||||||
{!error && maestros.length === 0 && (
|
{!error && maestros.length === 0 && (
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<h2 class="text-lg font-semibold mb-1">Sin maestros aún</h2>
|
<h2 class="text-lg 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>
|
<p class="text-sm text-graphite/70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -65,37 +75,35 @@ const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
|||||||
<>
|
<>
|
||||||
<div class="hidden md:block card p-0 overflow-hidden">
|
<div class="hidden md:block card p-0 overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
|
||||||
<th class="px-4 py-3 font-medium">Estado</th>
|
<th class="px-4 py-3 font-medium text-graphite">Estado</th>
|
||||||
<th class="px-4 py-3 font-medium text-center">Tutor</th>
|
<th class="px-4 py-3 font-medium text-graphite 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-graphite 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 text-right">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{maestros.map((m) => (
|
{maestros.map((m) => (
|
||||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
<tr class="border-b border-hairline-gray last:border-b-0">
|
||||||
<td class="px-4 py-3 font-medium">{m.nombre}</td>
|
<td class="px-4 py-3 font-medium text-graphite">{m.nombre}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{m.activo ? (
|
<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'}`}>
|
||||||
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
|
{m.activo ? 'Activo' : 'Inactivo'}
|
||||||
) : (
|
</span>
|
||||||
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
{m.es_tutor ? (
|
{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" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<span class="opacity-40" aria-label="No">—</span>
|
<span class="text-graphite/40" aria-label="No">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
<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>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex gap-2 justify-end">
|
<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">
|
<div class="md:hidden flex flex-col gap-3">
|
||||||
{maestros.map((m) => (
|
{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>
|
<div>
|
||||||
<h3 class="font-semibold">{m.nombre}</h3>
|
<h3 class="font-medium text-graphite">{m.nombre}</h3>
|
||||||
<p class="text-xs opacity-70 mt-1">
|
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
{m.activo ? 'Activo' : 'Inactivo'} · {m.es_tutor ? 'Tutor' : 'No tutor'} · {m.count} alumno{m.count === 1 ? '' : 's'}
|
<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'}`}>
|
||||||
</p>
|
{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>
|
||||||
<div class="flex flex-col gap-2 shrink-0">
|
<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 />
|
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo, es_tutor: m.es_tutor }} client:load />
|
||||||
|
|||||||
@@ -128,24 +128,34 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
<AppLayout title="Reportes — LabPréstamos">
|
<AppLayout title="Reportes — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-6xl">
|
||||||
<header class="mb-6">
|
<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>
|
<p class="text-sm opacity-70 mt-1">Historial filtrable de préstamos y lista de vencidos.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 12%, transparent);" aria-label="Vistas de reportes">
|
<nav class="mb-6 border-b border-hairline-gray" aria-label="Vistas de reportes">
|
||||||
<ul class="flex gap-1">
|
<ul class="flex gap-2 -mb-px">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="?vista=historial"
|
href="?vista=historial"
|
||||||
aria-current={vista === 'historial' ? 'page' : undefined}
|
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>
|
>Historial</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="?vista=vencidos"
|
href="?vista=vencidos"
|
||||||
aria-current={vista === 'vencidos' ? 'page' : undefined}
|
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>
|
>Vencidos</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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="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">
|
<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>
|
<a href="/admin/reportes?vista=historial" class="btn btn-ghost">Limpiar</a>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
class="btn btn-secondary w-full md:w-auto"
|
class="btn btn-primary w-full md:w-auto"
|
||||||
href={exportHref}
|
href={exportHref}
|
||||||
download={exportFilename}
|
download={exportFilename}
|
||||||
>
|
>
|
||||||
@@ -210,15 +220,14 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
{hitLimit && (
|
{hitLimit && (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
class="mb-4 rounded-lg p-3 text-sm"
|
class="mb-4 rounded-[8px] p-4 text-sm bg-linen border border-hairline-gray text-graphite"
|
||||||
style="background: color-mix(in oklab, var(--color-secondary) 12%, transparent); border: 1px solid color-mix(in oklab, var(--color-secondary) 30%, transparent);"
|
|
||||||
>
|
>
|
||||||
Se muestran los {HARD_LIMIT} más recientes — refina los filtros o exporta a CSV.
|
Se muestran los {HARD_LIMIT} más recientes — refina los filtros o exporta a CSV.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filas.length === 0 ? (
|
{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="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>
|
<p class="text-sm opacity-60 mt-1">Ajusta los filtros o exporta todo el historial.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,37 +237,39 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
<div class="card p-0 overflow-hidden hidden md:block">
|
<div class="card p-0 overflow-hidden hidden md:block">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="p-3 font-medium">Fecha solicitud</th>
|
<th class="p-4 font-medium">Fecha solicitud</th>
|
||||||
<th class="p-3 font-medium">Alumno</th>
|
<th class="p-4 font-medium">Alumno</th>
|
||||||
<th class="p-3 font-medium">Maestro responsable</th>
|
<th class="p-4 font-medium">Maestro responsable</th>
|
||||||
<th class="p-3 font-medium">Material</th>
|
<th class="p-4 font-medium">Material</th>
|
||||||
<th class="p-3 font-medium">Estado</th>
|
<th class="p-4 font-medium">Estado</th>
|
||||||
<th class="p-3 font-medium">Fecha devolución</th>
|
<th class="p-4 font-medium">Fecha devolución</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filas.map((r: any) => (
|
{filas.map((r: any) => (
|
||||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
<tr class="border-b border-hairline-gray hover:bg-linen/50 transition-colors">
|
||||||
<td class="p-3 opacity-90 whitespace-nowrap">{fmt(r.fecha_solicitud)}</td>
|
<td class="p-4 opacity-90 whitespace-nowrap tabular-nums">{fmt(r.fecha_solicitud)}</td>
|
||||||
<td class="p-3">
|
<td class="p-4">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
|
<div class="font-medium text-graphite">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</div>
|
||||||
{r.alumno?.matricula && <div class="text-xs opacity-70">{r.alumno.matricula}</div>}
|
{r.alumno?.matricula && <div class="text-xs opacity-70 tabular-nums">{r.alumno.matricula}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 opacity-90">{r.maestro_responsable ?? '—'}</td>
|
<td class="p-4 opacity-90">{r.maestro_responsable ?? '—'}</td>
|
||||||
<td class="p-3">
|
<td class="p-4">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div>
|
<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 && (
|
{i.material?.numero_inventario && (
|
||||||
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
|
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
<td class="p-4">
|
||||||
<td class="p-3 opacity-90 whitespace-nowrap">
|
<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)}
|
{fmt(r.fecha_devolucion_real ?? r.fecha_devolucion_estimada)}
|
||||||
{!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 class="text-xs opacity-60"> (est.)</span>
|
||||||
@@ -268,9 +279,9 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<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);">
|
<tr class="bg-linen text-graphite border-t border-hairline-gray">
|
||||||
<td class="p-3 text-sm font-medium" colspan="6">
|
<td class="p-4 text-sm font-medium" colspan="6">
|
||||||
Total: <span style="font-variant-numeric: tabular-nums;">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
|
Total: <span class="tabular-nums font-medium">{total}</span> {total === 1 ? 'préstamo' : 'préstamos'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -283,29 +294,37 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
{filas.map((r: any) => (
|
{filas.map((r: any) => (
|
||||||
<li class="card">
|
<li class="card">
|
||||||
<div class="flex items-baseline justify-between gap-3">
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
<span class="text-xs opacity-70">{fmt(r.fecha_solicitud)}</span>
|
<span class="text-xs opacity-70 tabular-nums">{fmt(r.fecha_solicitud)}</span>
|
||||||
<span class="text-xs font-medium">{estadoLabel[r.estado] ?? r.estado}</span>
|
<span class={`chip chip-${r.estado}`}>{estadoLabel[r.estado] ?? r.estado}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 space-y-0.5">
|
<div class="mt-2 space-y-0.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="font-medium">
|
<div class="font-medium text-graphite">
|
||||||
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm opacity-80">
|
<div class="text-sm opacity-80 mt-1">
|
||||||
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
|
{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>
|
||||||
<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">
|
<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)}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -315,7 +334,7 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
{vista === 'vencidos' && (
|
{vista === 'vencidos' && (
|
||||||
<>
|
<>
|
||||||
{vencidos.length === 0 ? (
|
{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>
|
<p class="opacity-75">No hay préstamos vencidos. Todo al día.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -323,39 +342,38 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
<div class="card p-0 overflow-hidden hidden md:block">
|
<div class="card p-0 overflow-hidden hidden md:block">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="p-3 font-medium">Alumno</th>
|
<th class="p-4 font-medium">Alumno</th>
|
||||||
<th class="p-3 font-medium">Material</th>
|
<th class="p-4 font-medium">Material</th>
|
||||||
<th class="p-3 font-medium">Debía devolver el</th>
|
<th class="p-4 font-medium">Debía devolver el</th>
|
||||||
<th class="p-3 font-medium">Días de atraso</th>
|
<th class="p-4 font-medium">Días de atraso</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{vencidos.map((r: any) => {
|
{vencidos.map((r: any) => {
|
||||||
const dias = diasAtraso(r.fecha_devolucion_estimada);
|
const dias = diasAtraso(r.fecha_devolucion_estimada);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr class="border-b border-hairline-gray hover:bg-linen/50 transition-colors">
|
||||||
class="border-t"
|
<td class="p-4">
|
||||||
style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); background: color-mix(in oklab, var(--color-danger) 8%, transparent);"
|
<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 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>}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<td class="p-4">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div>
|
<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 && (
|
{i.material?.numero_inventario && (
|
||||||
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
|
<span class="text-xs opacity-70"> ({i.material.numero_inventario})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 whitespace-nowrap">{fmt(r.fecha_devolucion_estimada)}</td>
|
<td class="p-4 whitespace-nowrap tabular-nums">{fmt(r.fecha_devolucion_estimada)}</td>
|
||||||
<td class="p-3 font-medium" style={`font-variant-numeric: tabular-nums; color: var(--color-danger-text);`}>
|
<td class="p-4">
|
||||||
{dias} {dias === 1 ? 'día' : 'días'}
|
<span class="chip chip-vencido tabular-nums">
|
||||||
|
{dias} {dias === 1 ? 'día' : 'días'}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -369,23 +387,30 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
{vencidos.map((r: any) => {
|
{vencidos.map((r: any) => {
|
||||||
const dias = diasAtraso(r.fecha_devolucion_estimada);
|
const dias = diasAtraso(r.fecha_devolucion_estimada);
|
||||||
return (
|
return (
|
||||||
<li class="card" style="background: color-mix(in oklab, var(--color-danger) 8%, transparent);">
|
<li class="card">
|
||||||
<div class="space-y-0.5">
|
<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) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="font-medium">
|
<div class="font-medium text-graphite">
|
||||||
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm opacity-80">
|
<div class="text-sm opacity-80 mt-1">
|
||||||
{r.alumno?.nombre ?? r.alumno?.email ?? '—'}
|
{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>
|
||||||
<div class="mt-2 flex items-center justify-between text-sm">
|
<div class="mt-2 flex items-center justify-between text-sm">
|
||||||
<span>Debía devolver el {fmt(r.fecha_devolucion_estimada)}</span>
|
<span class="opacity-75">Debía devolver el</span>
|
||||||
<span class="font-medium" style="font-variant-numeric: tabular-nums; color: var(--color-danger-text);">
|
<span class="tabular-nums font-medium">{fmt(r.fecha_devolucion_estimada)}</span>
|
||||||
{dias} {dias === 1 ? 'día' : 'días'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -397,23 +422,3 @@ const exportFilename = `reporte-${today}.csv`;
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</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>
|
|
||||||
|
|||||||
@@ -32,12 +32,11 @@ const tabs = [
|
|||||||
---
|
---
|
||||||
<AppLayout title="Préstamos activos — LabPréstamos">
|
<AppLayout title="Préstamos activos — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-6xl">
|
||||||
<header class="mb-4">
|
<header class="mb-6">
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<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
|
<span
|
||||||
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
|
class="badge"
|
||||||
style="background: var(--color-positive); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
|
|
||||||
aria-label={`${filas.length} préstamos activos`}
|
aria-label={`${filas.length} préstamos activos`}
|
||||||
>
|
>
|
||||||
{filas.length}
|
{filas.length}
|
||||||
@@ -45,14 +44,16 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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) => {
|
{tabs.map((t) => {
|
||||||
const active = path === t.href;
|
const active = path === t.href;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={t.href}
|
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']}
|
class:list={[
|
||||||
style={active ? 'border-color: var(--color-primary);' : ''}
|
'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}
|
aria-current={active ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -61,24 +62,25 @@ const tabs = [
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{error && (
|
{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);">
|
<div role="alert" class="card text-center py-10">
|
||||||
Error cargando préstamos: {error.message}
|
<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>
|
</div>
|
||||||
)}
|
) : filas.length === 0 ? (
|
||||||
|
|
||||||
{filas.length === 0 ? (
|
|
||||||
<div class="card text-center py-12">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div class="card p-0 overflow-hidden hidden md:block">
|
<div class="card p-0 overflow-hidden hidden md:block">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="p-3 font-medium">Alumno</th>
|
<th class="p-3 font-medium">Alumno</th>
|
||||||
<th class="p-3 font-medium">Material</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">Aprobado</th>
|
||||||
<th class="p-3 font-medium">Devolver antes de</th>
|
<th class="p-3 font-medium">Devolver antes de</th>
|
||||||
<th class="p-3 font-medium text-right">Acciones</th>
|
<th class="p-3 font-medium text-right">Acciones</th>
|
||||||
@@ -88,46 +90,40 @@ const tabs = [
|
|||||||
{filas.map((r: any) => {
|
{filas.map((r: any) => {
|
||||||
const vencido = isVencido(r.fecha_devolucion_estimada);
|
const vencido = isVencido(r.fecha_devolucion_estimada);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr class="border-b border-hairline-gray last:border-b-0 align-top">
|
||||||
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);' : ''}`}
|
|
||||||
>
|
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
<div class="text-xs text-graphite/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="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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>
|
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 opacity-80">{r.fecha_aprobacion ? fmtFecha.format(new Date(r.fecha_aprobacion)) : '—'}</td>
|
|
||||||
<td class="p-3">
|
<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;">
|
<span style="font-variant-numeric: tabular-nums;">
|
||||||
{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}
|
{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}
|
||||||
</span>
|
</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>
|
||||||
<td class="p-3">
|
<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} />
|
<VerDetalles client:load prestamoId={r.id} />
|
||||||
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
|
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -143,30 +139,30 @@ const tabs = [
|
|||||||
{filas.map((r: any) => {
|
{filas.map((r: any) => {
|
||||||
const vencido = isVencido(r.fecha_devolucion_estimada);
|
const vencido = isVencido(r.fecha_devolucion_estimada);
|
||||||
return (
|
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="flex items-baseline justify-between gap-2">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
|
||||||
{vencido && (
|
<span class:list={['chip shrink-0', vencido ? 'chip-vencido' : 'chip-aprobado']}>
|
||||||
<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>
|
{vencido ? 'Vencido' : 'En préstamo'}
|
||||||
)}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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 opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</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">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
|
<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>
|
</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 && (
|
{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>
|
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||||
</p>
|
</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} />
|
<VerDetalles client:load prestamoId={r.id} />
|
||||||
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
|
<MarcarDevuelto client:load prestamoId={r.id} resumenMateriales={resumenMateriales(r.items)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,15 +34,20 @@ const estadoLabel: Record<string, string> = {
|
|||||||
rechazado: 'Rechazado',
|
rechazado: 'Rechazado',
|
||||||
vencido: 'Vencido',
|
vencido: 'Vencido',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const chipPorEstado: Record<string, string> = {
|
||||||
|
devuelto: 'chip-devuelto',
|
||||||
|
rechazado: 'chip-rechazado',
|
||||||
|
vencido: 'chip-vencido',
|
||||||
|
};
|
||||||
---
|
---
|
||||||
<AppLayout title="Historial de solicitudes — LabPréstamos">
|
<AppLayout title="Historial de solicitudes — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-6xl">
|
||||||
<header class="mb-4">
|
<header class="mb-6">
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<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
|
<span
|
||||||
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
|
class="badge"
|
||||||
style="background: var(--color-chalk); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
|
|
||||||
aria-label={`${filas.length} registros`}
|
aria-label={`${filas.length} registros`}
|
||||||
>
|
>
|
||||||
{filas.length}
|
{filas.length}
|
||||||
@@ -50,14 +55,16 @@ const estadoLabel: Record<string, string> = {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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) => {
|
{tabs.map((t) => {
|
||||||
const active = path === t.href;
|
const active = path === t.href;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={t.href}
|
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']}
|
class:list={[
|
||||||
style={active ? 'border-color: var(--color-primary);' : ''}
|
'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}
|
aria-current={active ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -66,7 +73,7 @@ const estadoLabel: Record<string, string> = {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</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">
|
<div class="flex-1 min-w-[200px] max-w-xs">
|
||||||
<label class="label" for="estado">Filtrar por estado</label>
|
<label class="label" for="estado">Filtrar por estado</label>
|
||||||
<select
|
<select
|
||||||
@@ -86,21 +93,20 @@ const estadoLabel: Record<string, string> = {
|
|||||||
</noscript>
|
</noscript>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && (
|
{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);">
|
<div role="alert" class="card text-center py-10">
|
||||||
Error cargando historial: {error.message}
|
<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>
|
</div>
|
||||||
)}
|
) : filas.length === 0 ? (
|
||||||
|
|
||||||
{filas.length === 0 ? (
|
|
||||||
<div class="card text-center py-12">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div class="card p-0 overflow-hidden hidden md:block">
|
<div class="card p-0 overflow-hidden hidden md:block">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="p-3 font-medium">Alumno</th>
|
<th class="p-3 font-medium">Alumno</th>
|
||||||
<th class="p-3 font-medium">Material</th>
|
<th class="p-3 font-medium">Material</th>
|
||||||
@@ -111,25 +117,29 @@ const estadoLabel: Record<string, string> = {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filas.map((r: any) => (
|
{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">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
<div class="text-xs text-graphite/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="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
<td class="p-3">
|
||||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
<span class:list={['chip', chipPorEstado[r.estado] ?? 'chip-devuelto']}>
|
||||||
<td class="p-3 opacity-80">{r.fecha_devolucion_real ? fmtFecha.format(new Date(r.fecha_devolucion_real)) : '—'}</td>
|
{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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -140,18 +150,22 @@ const estadoLabel: Record<string, string> = {
|
|||||||
{filas.map((r: any) => (
|
{filas.map((r: any) => (
|
||||||
<li class="card">
|
<li class="card">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<div class="font-medium text-graphite">{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>
|
<span class:list={['chip shrink-0', chipPorEstado[r.estado] ?? 'chip-devuelto']}>
|
||||||
|
{estadoLabel[r.estado] ?? r.estado}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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 opacity-70 break-words">Maestro: {r.maestro_responsable}</div>
|
<div class="text-xs text-graphite/70 mb-2 break-words">Maestro: {r.maestro_responsable}</div>
|
||||||
<div class="flex flex-col gap-1 mt-2">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(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>
|
||||||
<div class="text-xs opacity-70 mt-2">Solicitado {fmtFecha.format(new Date(r.fecha_solicitud))}</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 opacity-70">Devuelto {fmtFecha.format(new Date(r.fecha_devolucion_real))}</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import AppLayout from '@/layouts/AppLayout.astro';
|
import AppLayout from '@/layouts/AppLayout.astro';
|
||||||
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
|
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
|
||||||
|
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
|
||||||
import { truncate } from '@/lib/text';
|
import { truncate } from '@/lib/text';
|
||||||
|
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
@@ -23,12 +24,11 @@ const tabs = [
|
|||||||
---
|
---
|
||||||
<AppLayout title="Solicitudes pendientes — LabPréstamos">
|
<AppLayout title="Solicitudes pendientes — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-6xl">
|
||||||
<header class="mb-4">
|
<header class="mb-6">
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<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
|
<span
|
||||||
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
|
class="badge"
|
||||||
style="background: var(--color-primary); color: white; border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
|
|
||||||
aria-label={`${filas.length} pendientes`}
|
aria-label={`${filas.length} pendientes`}
|
||||||
>
|
>
|
||||||
{filas.length}
|
{filas.length}
|
||||||
@@ -36,14 +36,16 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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) => {
|
{tabs.map((t) => {
|
||||||
const active = path === t.href || (t.href === '/admin/solicitudes' && path === '/admin/solicitudes/');
|
const active = path === t.href || (t.href === '/admin/solicitudes' && path === '/admin/solicitudes/');
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={t.href}
|
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']}
|
class:list={[
|
||||||
style={active ? 'border-color: var(--color-primary);' : ''}
|
'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}
|
aria-current={active ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -52,61 +54,65 @@ const tabs = [
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{error && (
|
{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);">
|
<div role="alert" class="card text-center py-10">
|
||||||
Error cargando solicitudes: {error.message}
|
<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>
|
</div>
|
||||||
)}
|
) : filas.length === 0 ? (
|
||||||
|
|
||||||
{filas.length === 0 ? (
|
|
||||||
<div class="card text-center py-12">
|
<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" />
|
<path d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="font-medium">No hay solicitudes por revisar.</p>
|
<p class="font-medium text-graphite">No hay solicitudes por revisar.</p>
|
||||||
<p class="text-sm opacity-70 mt-1">Todo al día.</p>
|
<p class="text-sm text-graphite/70 mt-1">Todo al día.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Desktop: tabla */}
|
{/* Desktop: tabla */}
|
||||||
<div class="card p-0 overflow-hidden hidden md:block">
|
<div class="card p-0 overflow-hidden hidden md:block">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="p-3 font-medium">Alumno</th>
|
<th class="p-3 font-medium">Alumno</th>
|
||||||
<th class="p-3 font-medium">Material</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">Solicitado</th>
|
||||||
<th class="p-3 font-medium text-right">Acciones</th>
|
<th class="p-3 font-medium text-right">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filas.map((r: any) => (
|
{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">
|
<td class="p-3">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
|
<div class="text-xs text-graphite/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="text-xs text-graphite/70 mt-1 break-words">Maestro: {r.maestro_responsable}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<td class="p-3">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</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>
|
Inv. {i.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{i.material?.cantidad_disponible ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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>
|
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
|
||||||
<td class="p-3">
|
<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} />
|
<AccionesSolicitud client:load prestamo={r} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -121,25 +127,29 @@ const tabs = [
|
|||||||
{filas.map((r: any) => (
|
{filas.map((r: any) => (
|
||||||
<li class="card">
|
<li class="card">
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
|
<div class="font-medium text-graphite">{r.alumno?.nombre ?? '—'}</div>
|
||||||
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
|
<span class="chip chip-pendiente">Pendiente</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</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 opacity-70 mb-2 break-words">Maestro: {r.maestro_responsable}</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">
|
<div class="flex flex-col gap-1.5">
|
||||||
{(r.items ?? []).map((i: any) => (
|
{(r.items ?? []).map((i: any) => (
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{r.notas && (
|
{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>
|
<span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
|
||||||
</p>
|
</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} />
|
<AccionesSolicitud client:load prestamo={r} />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
|
|||||||
<AppLayout title="Usuarios — Admin">
|
<AppLayout title="Usuarios — Admin">
|
||||||
<div class="max-w-5xl">
|
<div class="max-w-5xl">
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Usuarios</h1>
|
<h1 class="text-3xl">Usuarios</h1>
|
||||||
<p class="text-sm opacity-70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
|
<p class="text-sm text-graphite/70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{perr && (
|
{perr && (
|
||||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
<div class="card mb-6" role="alert">
|
||||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
<p class="text-sm text-negro-institucional">
|
||||||
No se pudieron cargar los usuarios. Recarga la página.
|
No se pudieron cargar los usuarios. Recarga la página.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,8 +67,8 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
|
|||||||
|
|
||||||
{!perr && usuarios.length === 0 && (
|
{!perr && usuarios.length === 0 && (
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<h2 class="text-lg font-semibold mb-1">Sin usuarios aún</h2>
|
<h2 class="text-lg mb-1">Sin usuarios aún</h2>
|
||||||
<p class="text-sm opacity-70">Se listarán aquí en cuanto alguien inicie sesión.</p>
|
<p class="text-sm text-graphite/70">Se listarán aquí en cuanto alguien inicie sesión.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -77,43 +77,41 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
|
|||||||
{/* Desktop */}
|
{/* Desktop */}
|
||||||
<div class="hidden md:block card p-0 overflow-hidden">
|
<div class="hidden md:block card p-0 overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<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>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
<th class="px-4 py-3 font-medium text-graphite">Nombre</th>
|
||||||
<th class="px-4 py-3 font-medium">Email</th>
|
<th class="px-4 py-3 font-medium text-graphite">Email</th>
|
||||||
<th class="px-4 py-3 font-medium">Rol</th>
|
<th class="px-4 py-3 font-medium text-graphite">Rol</th>
|
||||||
<th class="px-4 py-3 font-medium">Estado</th>
|
<th class="px-4 py-3 font-medium text-graphite">Estado</th>
|
||||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
<th class="px-4 py-3 font-medium text-graphite text-right">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{usuarios.map((u) => {
|
{usuarios.map((u) => {
|
||||||
const isSelf = u.id === meId;
|
const isSelf = u.id === meId;
|
||||||
return (
|
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">
|
<td class="px-4 py-3">
|
||||||
<div class="font-medium break-words">{u.nombre ?? '—'}</div>
|
<div class="font-medium text-graphite 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>}
|
{u.matricula && <div class="text-xs text-graphite/60 mt-0.5 font-mono">{u.matricula}</div>}
|
||||||
</td>
|
</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">
|
<td class="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px]"
|
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'}`}
|
||||||
style={`border: 1.5px solid var(--color-ink); ${u.rol === 'admin' ? 'background: var(--color-secondary);' : ''}`}
|
|
||||||
>{rolLabel(u.rol)}</span>
|
>{rolLabel(u.rol)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{u.baneo ? (
|
{u.baneo ? (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1 items-start">
|
||||||
<span
|
<span
|
||||||
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] w-fit"
|
class="chip bg-negro-institucional text-bone-white rounded-[8px] text-xs px-2.5 py-0.5 font-medium border-0"
|
||||||
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
|
|
||||||
>Baneado</span>
|
>Baneado</span>
|
||||||
<span class="text-xs opacity-70 break-words">{trunc(u.baneo.razon)}</span>
|
<span class="text-xs text-graphite/70 break-words">{trunc(u.baneo.razon)}</span>
|
||||||
<span class="text-[11px] opacity-50">desde {fmtDate(u.baneo.banned_at)}</span>
|
<span class="text-[11px] text-graphite/50">desde {fmtDate(u.baneo.banned_at)}</span>
|
||||||
</div>
|
</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>
|
||||||
<td class="px-4 py-3">
|
<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) => {
|
{usuarios.map((u) => {
|
||||||
const isSelf = u.id === meId;
|
const isSelf = u.id === meId;
|
||||||
return (
|
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="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="font-semibold break-words">{u.nombre ?? '—'}</h3>
|
<h3 class="font-medium text-graphite break-words">{u.nombre ?? '—'}</h3>
|
||||||
<p class="text-xs opacity-70 break-all">{u.email}</p>
|
<p class="text-xs text-graphite/70 break-all">{u.email}</p>
|
||||||
<p class="text-xs opacity-60 mt-1">
|
<p class="text-xs text-graphite/60 mt-1">
|
||||||
{rolLabel(u.rol)}{u.matricula ? ` · ${u.matricula}` : ''}
|
{rolLabel(u.rol)}{u.matricula ? ` · ${u.matricula}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{u.baneo ? (
|
{u.baneo ? (
|
||||||
<span
|
<span
|
||||||
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] shrink-0"
|
class="chip bg-negro-institucional text-bone-white rounded-[8px] text-xs px-2.5 py-0.5 font-medium shrink-0 border-0"
|
||||||
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
|
|
||||||
>Baneado</span>
|
>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>
|
</div>
|
||||||
|
|
||||||
{u.baneo && (
|
{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)}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -7,33 +7,15 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
|
|||||||
const qInitial = Astro.url.searchParams.get('q') ?? '';
|
const qInitial = Astro.url.searchParams.get('q') ?? '';
|
||||||
|
|
||||||
const supabase = Astro.locals.supabase;
|
const supabase = Astro.locals.supabase;
|
||||||
const userId = Astro.locals.user?.id;
|
const profile = Astro.locals.profile;
|
||||||
const rol = Astro.locals.profile?.rol ?? 'alumno';
|
const rol = profile?.rol ?? 'alumno';
|
||||||
const esDocente = rol === 'docente';
|
const esDocente = rol === 'docente';
|
||||||
|
|
||||||
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
|
// El middleware ya trae matricula y tutor_id en Astro.locals.profile — no requery.
|
||||||
let matricula: string | null = null;
|
const matricula = profile?.matricula ?? null;
|
||||||
let tutorId: number | null = null;
|
const tutorId = profile?.tutor_id ?? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tutorNombre: string | null = null;
|
// Una sola query para maestros activos; el nombre del tutor sale del mismo array.
|
||||||
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).
|
|
||||||
type MaestroOpt = { id: number; nombre: string };
|
type MaestroOpt = { id: number; nombre: string };
|
||||||
let maestros: MaestroOpt[] = [];
|
let maestros: MaestroOpt[] = [];
|
||||||
if (!esDocente) {
|
if (!esDocente) {
|
||||||
@@ -44,6 +26,10 @@ if (!esDocente) {
|
|||||||
.order('nombre');
|
.order('nombre');
|
||||||
maestros = (ms ?? []) as MaestroOpt[];
|
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 = {
|
type Material = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -79,13 +65,14 @@ const categoriasFiltro = grupoList
|
|||||||
<AppLayout title="Catálogo — LabPréstamos">
|
<AppLayout title="Catálogo — LabPréstamos">
|
||||||
<div class="max-w-6xl">
|
<div class="max-w-6xl">
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Catálogo</h1>
|
<span class="eyebrow mb-1">Inventario disponible</span>
|
||||||
<p class="text-sm opacity-70 mt-1">Material disponible en el Laboratorio de Sistemas.</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
<div class="card mb-6 border border-hairline-gray" role="alert">
|
||||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
<p class="text-sm text-negro-institucional">
|
||||||
No se pudo cargar el catálogo. Recarga la página o intenta más tarde.
|
No se pudo cargar el catálogo. Recarga la página o intenta más tarde.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,8 +80,8 @@ const categoriasFiltro = grupoList
|
|||||||
|
|
||||||
{materiales.length === 0 && !error && (
|
{materiales.length === 0 && !error && (
|
||||||
<div class="card text-center">
|
<div class="card text-center">
|
||||||
<h2 class="text-lg font-semibold mb-1">Aún no hay material publicado</h2>
|
<h2 class="text-xl mb-1">Aún no hay material publicado</h2>
|
||||||
<p class="text-sm opacity-70">Vuelve pronto o contacta al encargado del laboratorio.</p>
|
<p class="text-sm text-graphite/70">Vuelve pronto o contacta al encargado del laboratorio.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -107,7 +94,7 @@ const categoriasFiltro = grupoList
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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.
|
Ningún material en esta categoría. Prueba con otra.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -48,37 +48,35 @@ const estadoLabel: Record<Estado, string> = {
|
|||||||
vencido: 'Vencido',
|
vencido: 'Vencido',
|
||||||
};
|
};
|
||||||
|
|
||||||
const estadoColor: Record<Estado, string> = {
|
const chipClass: Record<Estado, string> = {
|
||||||
pendiente: 'var(--color-ink)',
|
pendiente: 'chip chip-pendiente',
|
||||||
aprobado: 'var(--color-positive)',
|
aprobado: 'chip chip-aprobado',
|
||||||
activo: 'var(--color-positive)',
|
activo: 'chip chip-activo',
|
||||||
devuelto: 'var(--color-ink)',
|
devuelto: 'chip chip-devuelto',
|
||||||
rechazado: 'var(--color-danger)',
|
rechazado: 'chip chip-rechazado',
|
||||||
vencido: 'var(--color-danger)',
|
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">
|
<AppLayout title="Mis préstamos — LabPréstamos">
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<header class="mb-6">
|
<header class="mb-6">
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold">Mis préstamos</h1>
|
<span class="eyebrow mb-1">Mis solicitudes</span>
|
||||||
<p class="text-sm opacity-70 mt-1">Estado de tus solicitudes actuales e historial.</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
<div class="card mb-6 border border-hairline-gray p-6" role="alert">
|
||||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
<p class="text-sm text-negro-institucional">
|
||||||
No se pudieron cargar tus préstamos. Recarga la página o intenta más tarde.
|
No se pudieron cargar tus préstamos. Recarga la página o intenta más tarde.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && prestamos.length === 0 && (
|
{!error && prestamos.length === 0 && (
|
||||||
<div class="card text-center">
|
<div class="card text-center p-8">
|
||||||
<h2 class="text-lg font-semibold mb-1">Aún no has solicitado material</h2>
|
<h2 class="text-xl 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>
|
<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>
|
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -86,106 +84,117 @@ const badgeStyle = (e: Estado) =>
|
|||||||
{prestamos.length > 0 && (
|
{prestamos.length > 0 && (
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<section>
|
<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>
|
<details open class="group">
|
||||||
{activos.length === 0 ? (
|
<summary class="text-2xl cursor-pointer list-none flex items-center gap-2 select-none font-light">
|
||||||
<p class="text-sm opacity-70">Nada activo por ahora.</p>
|
<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">
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
<path d="M9 6l6 6-6 6" />
|
<path d="M9 6l6 6-6 6" />
|
||||||
</svg>
|
</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>
|
</summary>
|
||||||
{historial.length === 0 ? (
|
{activos.length === 0 ? (
|
||||||
<p class="text-sm opacity-70 mt-3">Sin historial todavía.</p>
|
<div class="card p-6 text-sm text-graphite/70 mt-4">Nada activo por ahora.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul class="space-y-3 mt-3">
|
<ul class="space-y-4 mt-4">
|
||||||
{historial.map((p) => (
|
{activos.map((p) => (
|
||||||
<li class="card" style="opacity: 0.92;">
|
<li class="card p-6">
|
||||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||||
<ul class="min-w-0 space-y-1">
|
<ul class="min-w-0 space-y-1">
|
||||||
{p.items.map((it) => (
|
{p.items.map((it) => (
|
||||||
<li class="leading-snug">
|
<li class="leading-snug">
|
||||||
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
|
<span class="font-medium text-graphite tabular-nums">{it.cantidad}×</span>{' '}
|
||||||
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
|
<span class="font-medium text-graphite">{it.material?.nombre ?? 'Material eliminado'}</span>
|
||||||
{it.material?.numero_inventario && (
|
{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 && (
|
{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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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]}
|
{estadoLabel[p.estado]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
|
<dl class="mt-4 pt-3 border-t border-hairline-gray grid grid-cols-2 gap-y-1.5 text-sm">
|
||||||
<dt class="opacity-60">Maestro responsable</dt>
|
<dt class="text-graphite/60">Maestro responsable</dt>
|
||||||
<dd class="text-right truncate">{p.maestro_responsable}</dd>
|
<dd class="text-right truncate font-medium text-graphite">{p.maestro_responsable}</dd>
|
||||||
<dt class="opacity-60">Solicitado</dt>
|
<dt class="text-graphite/60">Solicitado</dt>
|
||||||
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
|
<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 && (
|
{p.fecha_devolucion_real && (
|
||||||
<>
|
<>
|
||||||
<dt class="opacity-60">Devuelto</dt>
|
<dt class="text-graphite/60">Devuelto</dt>
|
||||||
<dd class="text-right">{fmt(p.fecha_devolucion_real)}</dd>
|
<dd class="text-right text-graphite">{fmt(p.fecha_devolucion_real)}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -202,8 +211,4 @@ const badgeStyle = (e: Estado) =>
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
details > summary::-webkit-details-marker { display: none; }
|
details > summary::-webkit-details-marker { display: none; }
|
||||||
details[open] > summary > svg { transform: rotate(90deg); }
|
|
||||||
details > summary > svg {
|
|
||||||
transition: transform 150ms ease-out;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { invalidateUserCaches } from '@/middleware';
|
||||||
|
|
||||||
export const prerender = false;
|
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 })
|
.update({ unbanned_at: new Date().toISOString(), unbanned_by: locals.user!.id })
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.is('unbanned_at', null)
|
.is('unbanned_at', null)
|
||||||
.select('id');
|
.select('id, profile_id');
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return Response.json({ error: 'No se pudo desbanear' }, { status: 500 });
|
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 });
|
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 });
|
return Response.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { invalidateUserCaches } from '@/middleware';
|
||||||
|
|
||||||
export const prerender = false;
|
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 });
|
return Response.json({ error: 'No se pudo crear el baneo' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateUserCaches(profile_id);
|
||||||
return Response.json({ id: data.id }, { status: 201 });
|
return Response.json({ id: data.id }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { serverClient } from '@/lib/supabase';
|
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();
|
await serverClient(cookies).auth.signOut();
|
||||||
|
if (uid) invalidateUserCaches(uid);
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { invalidateUserCaches } from '@/middleware';
|
||||||
|
|
||||||
export const prerender = false;
|
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 });
|
return Response.json({ error: 'no se pudo actualizar' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateUserCaches(user.id);
|
||||||
return Response.json({ ok: true, profile: data });
|
return Response.json({ ok: true, profile: data });
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-14
@@ -39,40 +39,40 @@ Astro.response.status = 403;
|
|||||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||||
<div class="w-full max-w-lg">
|
<div class="w-full max-w-lg">
|
||||||
<div class="text-center mb-6">
|
<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);">
|
<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" style="color: var(--color-ink);" />
|
<BrandMark class="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Cuenta bloqueada</h1>
|
<h1>Cuenta bloqueada</h1>
|
||||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">Tu acceso al sistema de préstamos fue suspendido.</p>
|
<p class="text-sm mt-2 text-graphite/70">Tu acceso al sistema de préstamos fue suspendido.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-raised">
|
<div class="card">
|
||||||
<dl class="flex flex-col gap-4 text-sm">
|
<dl class="flex flex-col gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Motivo</dt>
|
<dt class="eyebrow mb-1">Motivo</dt>
|
||||||
<dd class="whitespace-pre-wrap break-words">{ban.razon}</dd>
|
<dd class="whitespace-pre-wrap break-words text-graphite font-medium">{ban.razon}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid sm:grid-cols-2 gap-4">
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Fecha del bloqueo</dt>
|
<dt class="eyebrow mb-1">Fecha del bloqueo</dt>
|
||||||
<dd>{bannedAtStr}</dd>
|
<dd class="text-graphite">{bannedAtStr}</dd>
|
||||||
</div>
|
</div>
|
||||||
{expiresStr && (
|
{expiresStr && (
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Expira</dt>
|
<dt class="eyebrow mb-1">Expira</dt>
|
||||||
<dd>{expiresStr}</dd>
|
<dd class="text-graphite">{expiresStr}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{byName && (
|
{byName && (
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Bloqueado por</dt>
|
<dt class="eyebrow mb-1">Bloqueado por</dt>
|
||||||
<dd>{byName}</dd>
|
<dd class="text-graphite">{byName}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</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.
|
Si crees que esto es un error, contacta al Laboratorio de Sistemas Computacionales.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
+63
-38
@@ -22,16 +22,40 @@ type Item = { cantidad: number; material: { nombre: string } | null };
|
|||||||
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null;
|
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null;
|
||||||
|
|
||||||
let actual: Actual = null;
|
let actual: Actual = null;
|
||||||
|
let activosCount = 0;
|
||||||
|
let pendientesCount = 0;
|
||||||
|
let devueltosCount = 0;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const { data } = await supabase
|
const [actualRes, activosRes, pendRes, devRes] = await Promise.all([
|
||||||
.from('solicitudes')
|
supabase
|
||||||
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
|
.from('solicitudes')
|
||||||
.eq('alumno_id', user.id)
|
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||||
.in('estado', ['pendiente', 'aprobado', 'activo'])
|
.eq('alumno_id', user.id)
|
||||||
.order('fecha_solicitud', { ascending: false })
|
.in('estado', ['pendiente', 'aprobado', 'activo'])
|
||||||
.limit(1)
|
.order('fecha_solicitud', { ascending: false })
|
||||||
.maybeSingle();
|
.limit(1)
|
||||||
actual = (data as unknown as Actual) ?? null;
|
.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> = {
|
const estadoLabel: Record<Estado, string> = {
|
||||||
@@ -42,16 +66,6 @@ const estadoLabel: Record<Estado, string> = {
|
|||||||
devuelto: 'Devuelto',
|
devuelto: 'Devuelto',
|
||||||
vencido: 'Vencido',
|
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 items = actual?.items ?? [];
|
||||||
const preview = items.slice(0, 3);
|
const preview = items.slice(0, 3);
|
||||||
@@ -60,46 +74,57 @@ const extra = items.length - preview.length;
|
|||||||
<AppLayout title="Inicio — LabPréstamos">
|
<AppLayout title="Inicio — LabPréstamos">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<header class="mb-8">
|
<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}
|
{saludo}, {nombreCorto}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card-raised mb-8">
|
<div class="grid grid-cols-3 gap-4 mb-8">
|
||||||
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Nuevo préstamo</p>
|
<div class="card p-5">
|
||||||
<h2 class="text-xl md:text-2xl font-semibold mb-5">¿Qué vas a pedir hoy?</h2>
|
<span class="eyebrow mb-1">Activos</span>
|
||||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
<div class="text-2xl md:text-3xl text-graphite font-light" style="font-variant-numeric: tabular-nums;">{activosCount}</div>
|
||||||
</section>
|
</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 ? (
|
{actual ? (
|
||||||
<section aria-labelledby="actual-h">
|
<section aria-labelledby="actual-h">
|
||||||
<div class="flex items-center justify-between gap-3 mb-3">
|
<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>
|
<h2 id="actual-h" class="text-xl">Tu préstamo actual</h2>
|
||||||
<span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
|
<span class:list={['chip', `chip-${actual.estado}`]}>{estadoLabel[actual.estado]}</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="/alumno/mis-prestamos" class="card-link">
|
<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) => (
|
{preview.map((it) => (
|
||||||
<li class="leading-snug">
|
<li class="leading-snug text-graphite">
|
||||||
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
|
<span class="font-medium" 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">{it.material?.nombre ?? 'Material eliminado'}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{extra > 0 && (
|
{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>
|
</ul>
|
||||||
<div class="flex items-center justify-between gap-3 text-sm">
|
<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="text-graphite/60">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
|
||||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Ver detalles</span>
|
<span class="text-verde-institucional font-medium">Ver detalles</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/alumno/catalogo" class="btn btn-primary">Nuevo préstamo</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section class="card text-center">
|
<section class="text-center py-8">
|
||||||
<h2 class="text-lg font-semibold mb-1">Aún no tienes préstamos activos</h2>
|
<p class="text-graphite/70 mb-5">Sin préstamos activos.</p>
|
||||||
<p class="text-sm opacity-70 mb-5">¿Vamos por algo del laboratorio?</p>
|
<a href="/alumno/catalogo" class="btn btn-primary inline-flex">Nuevo préstamo</a>
|
||||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-11
@@ -16,33 +16,33 @@ const googleAuthUrl = '/api/auth/signin';
|
|||||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<div class="text-center mb-8">
|
<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);">
|
<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 text-white" />
|
<BrandMark class="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Sistema de Préstamos</h1>
|
<h1>Sistema de Préstamos</h1>
|
||||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">Laboratorio de Sistemas Computacionales · UABC</p>
|
<p class="text-sm mt-2 text-graphite/70">Laboratorio de Sistemas Computacionales · UABC</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-raised motion-safe:animate-[fadein_.5s_ease-out]">
|
<div class="card motion-safe:animate-[fadein_.5s_ease-out]">
|
||||||
<h2 class="text-lg font-semibold mb-1 uppercase tracking-wide">Inicia sesión</h2>
|
<h2 class="text-xl mb-1">Inicia sesión</h2>
|
||||||
<p class="text-sm mb-6" style="color: var(--color-pencil);">Accede con tu cuenta institucional.</p>
|
<p class="text-sm mb-6 text-graphite/70">Accede con tu cuenta institucional.</p>
|
||||||
|
|
||||||
<a href={googleAuthUrl} class="btn btn-primary w-full">
|
<a href={googleAuthUrl} class="btn btn-primary w-full">
|
||||||
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
<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>
|
</svg>
|
||||||
Continuar con Google
|
Continuar con Google
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{errorMsg && (
|
{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}
|
{errorMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-center text-xs mt-6" style="color: var(--color-pencil);">
|
<p class="text-center text-xs mt-6 text-graphite/70">
|
||||||
Acceso exclusivo con correo institucional <strong style="color: var(--color-ink);">@uabc.edu.mx</strong>
|
Acceso exclusivo con correo institucional <strong class="text-graphite font-medium">@uabc.edu.mx</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -29,18 +29,18 @@ const copyIntro =
|
|||||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||||
<div class="w-full max-w-lg">
|
<div class="w-full max-w-lg">
|
||||||
<div class="text-center mb-6">
|
<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);">
|
<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 text-white" />
|
<BrandMark class="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">
|
<h1>
|
||||||
Bienvenido, {primerNombre}
|
Bienvenido, {primerNombre}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">
|
<p class="text-sm mt-2 text-graphite/70">
|
||||||
{copyIntro}
|
{copyIntro}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-raised">
|
<div class="card">
|
||||||
<PerfilForm
|
<PerfilForm
|
||||||
client:load
|
client:load
|
||||||
mode="onboarding"
|
mode="onboarding"
|
||||||
|
|||||||
+10
-9
@@ -31,22 +31,23 @@ const info = avatarInfo({
|
|||||||
});
|
});
|
||||||
---
|
---
|
||||||
<AppLayout title="Mi perfil — LabPréstamos">
|
<AppLayout title="Mi perfil — LabPréstamos">
|
||||||
<div class="max-w-md mx-auto">
|
<div class="max-w-xl mx-auto space-y-6">
|
||||||
<header class="flex items-center gap-4 mb-6">
|
<header class="card p-6 flex items-center gap-5">
|
||||||
<Avatar client:load info={info} size={80} />
|
<Avatar client:load info={info} size={80} className="w-20 h-20 rounded-full" />
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<h1 class="text-xl md:text-2xl font-semibold uppercase tracking-wide leading-tight truncate">
|
<span class="eyebrow mb-1">Mi perfil</span>
|
||||||
|
<h1 class="text-2xl md:text-3xl leading-tight truncate">
|
||||||
{profile.nombre ?? profile.email.split('@')[0]}
|
{profile.nombre ?? profile.email.split('@')[0]}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm truncate" style="color: var(--color-pencil);">{profile.email}</p>
|
<p class="text-sm text-graphite/70 truncate">{profile.email}</p>
|
||||||
<span class="badge mt-2" style={`background: ${profile.rol === 'admin' ? 'var(--color-secondary)' : 'var(--color-chalk)'};`}>
|
<span class="badge mt-2">
|
||||||
{rolLabel}
|
{rolLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card-raised">
|
<section class="card p-6">
|
||||||
<h2 class="text-sm uppercase tracking-wide font-semibold mb-4">Datos del perfil</h2>
|
<h2 class="text-xl md:text-2xl mb-4">Datos del perfil</h2>
|
||||||
<PerfilForm
|
<PerfilForm
|
||||||
client:load
|
client:load
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
|
|||||||
+248
-95
@@ -1,44 +1,49 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@fontsource-variable/jetbrains-mono";
|
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* MotherDuck-derived system (shadow/radius/type mechanics); identity colors are UABC's own */
|
/* UABC — Identidad editorial institucional (DESIGN.md) */
|
||||||
--color-cream: #f4efea;
|
--color-verde-institucional: #00723F;
|
||||||
--color-frost: #ffffff;
|
--color-verde-claro: #50B848;
|
||||||
--color-chalk: #f8f8f7;
|
--color-dorado-uabc: #DD971A;
|
||||||
--color-ice: #ebf9ff;
|
--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;
|
--font-sans: 'Mulish', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--color-pencil: #a1a1a1;
|
--font-mono: 'Mulish', ui-sans-serif, system-ui, sans-serif;
|
||||||
--color-graphite: #818181;
|
--font-serif: 'Mulish', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
/* UABC institutional green/gold — dark enough that primary needs white text, not charcoal */
|
--text-micro: 10px;
|
||||||
--color-primary: #00723F;
|
--text-caption: 12px;
|
||||||
--color-primary-hover: #005f34;
|
--text-body-sm: 14px;
|
||||||
--color-secondary: #DD971A;
|
--text-body: 16px;
|
||||||
--color-secondary-hover: #c78614;
|
--text-ui: 16px;
|
||||||
--color-danger: #f38e84;
|
--text-heading: 24px;
|
||||||
--color-danger-hover: #ec7469;
|
--text-heading-lg: 32px;
|
||||||
/* AA-contrast text variant of Coral Sketch — the fill color reads ~2:1 on white, too light for body text */
|
--text-display: 72px;
|
||||||
--color-danger-text: #a83224;
|
|
||||||
--color-positive: #38c1b0;
|
|
||||||
--color-attention: #ffde00;
|
|
||||||
|
|
||||||
--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;
|
/* Sin sombras — elevación por color de superficie + hairline border */
|
||||||
|
--shadow-hard: none;
|
||||||
--radius-card: 2px;
|
--shadow-hard-sm: none;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -46,116 +51,264 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
background: var(--color-cream);
|
background-color: var(--color-bone-white);
|
||||||
color: var(--color-ink);
|
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);
|
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;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-ink);
|
outline: 2px solid var(--color-verde-institucional);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-primary);
|
background: var(--color-verde-institucional);
|
||||||
color: white;
|
color: var(--color-bone-white);
|
||||||
}
|
}
|
||||||
/* Tabular data keeps real square corners even where a nested control forgets to ask */
|
|
||||||
table {
|
table {
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
@layer components {
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-2 rounded-[2px] px-4 py-2.5 font-medium
|
@apply inline-flex items-center justify-center gap-2 px-6 py-2.5
|
||||||
motion-safe:transition-transform motion-safe:duration-100 motion-safe:ease-out
|
motion-safe:transition-colors motion-safe:duration-200 motion-safe:ease-out
|
||||||
disabled:opacity-50 disabled:pointer-events-none disabled:translate-x-0 disabled:translate-y-0;
|
disabled:opacity-50 disabled:pointer-events-none;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
border: 2px solid var(--color-ink);
|
border: 1px solid transparent;
|
||||||
color: var(--color-ink);
|
color: var(--color-graphite);
|
||||||
box-shadow: var(--shadow-hard);
|
background: transparent;
|
||||||
}
|
font-family: var(--font-sans);
|
||||||
.btn:hover,
|
font-size: 16px;
|
||||||
.btn:focus-visible {
|
font-weight: 700;
|
||||||
transform: translate(-6px, 6px);
|
letter-spacing: 0;
|
||||||
box-shadow: 0 0 0 0 transparent;
|
line-height: 1.5;
|
||||||
}
|
box-shadow: none;
|
||||||
.btn:active {
|
text-decoration: none;
|
||||||
transform: translate(-3px, 3px);
|
|
||||||
box-shadow: var(--shadow-hard-sm);
|
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-primary);
|
background: var(--color-verde-institucional);
|
||||||
color: white;
|
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 {
|
.btn-secondary {
|
||||||
background: var(--color-secondary);
|
background: transparent;
|
||||||
color: var(--color-ink);
|
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 {
|
.btn-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-ink);
|
color: var(--color-graphite);
|
||||||
border-color: color-mix(in oklab, var(--color-ink) 35%, transparent);
|
border-color: var(--color-hairline-gray);
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
.btn-ghost:hover,
|
.btn-ghost:hover,
|
||||||
.btn-ghost:focus-visible {
|
.btn-ghost:focus-visible {
|
||||||
transform: none;
|
background: var(--color-linen);
|
||||||
background: var(--color-chalk);
|
border-color: var(--color-hairline-gray);
|
||||||
border-color: var(--color-ink);
|
}
|
||||||
|
.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 {
|
.card {
|
||||||
@apply rounded-[2px] bg-white p-6;
|
background: color-mix(in oklab, var(--color-linen) 72%, transparent);
|
||||||
border: 2px solid var(--color-ink);
|
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 18%, transparent);
|
||||||
}
|
border-radius: var(--radius-cards);
|
||||||
.card-flat {
|
padding: 24px;
|
||||||
@apply rounded-[2px] bg-white p-6;
|
backdrop-filter: blur(6px) saturate(1.1);
|
||||||
border: 1.5px solid var(--color-ink);
|
-webkit-backdrop-filter: blur(6px) saturate(1.1);
|
||||||
}
|
}
|
||||||
|
.card-flat,
|
||||||
.card-raised {
|
.card-raised {
|
||||||
@apply rounded-[2px] bg-white p-6;
|
background: color-mix(in oklab, var(--color-linen) 82%, transparent);
|
||||||
border: 2px solid var(--color-ink);
|
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 22%, transparent);
|
||||||
box-shadow: var(--shadow-hard);
|
border-radius: var(--radius-cards);
|
||||||
|
padding: 24px;
|
||||||
|
backdrop-filter: blur(8px) saturate(1.1);
|
||||||
|
-webkit-backdrop-filter: blur(8px) saturate(1.1);
|
||||||
}
|
}
|
||||||
.card-link {
|
.card-link {
|
||||||
@apply rounded-[2px] bg-white p-6 block
|
@apply block motion-safe:transition-colors motion-safe:duration-200 motion-safe:ease-out;
|
||||||
motion-safe:transition-transform motion-safe:duration-100 motion-safe:ease-out;
|
background: color-mix(in oklab, var(--color-linen) 72%, transparent);
|
||||||
border: 2px solid var(--color-ink);
|
border: 1px solid color-mix(in oklab, var(--color-verde-institucional) 18%, transparent);
|
||||||
box-shadow: var(--shadow-hard);
|
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:hover,
|
||||||
.card-link:focus-visible {
|
.card-link:focus-visible {
|
||||||
transform: translate(-6px, 6px);
|
background: color-mix(in oklab, var(--color-linen) 88%, transparent);
|
||||||
box-shadow: 0 0 0 0 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 {
|
.input {
|
||||||
@apply w-full rounded-[2px] bg-white px-3 py-2.5
|
@apply w-full px-4 py-3
|
||||||
transition-[border-color] duration-100
|
transition-[border-color,box-shadow] duration-200
|
||||||
focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2
|
focus:outline-none;
|
||||||
focus-visible:outline-[color:var(--color-ink)];
|
background: var(--color-bone-white);
|
||||||
border: 2px solid var(--color-ink);
|
border: 1px solid var(--color-hairline-gray);
|
||||||
|
border-radius: var(--radius-tags);
|
||||||
|
color: var(--color-graphite);
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.1px;
|
||||||
}
|
}
|
||||||
.input:focus {
|
.input:focus,
|
||||||
border-color: var(--color-primary);
|
.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 {
|
.label {
|
||||||
@apply block text-sm font-medium mb-1 uppercase;
|
@apply block mb-2;
|
||||||
letter-spacing: 0.05em;
|
font-family: var(--font-sans);
|
||||||
font-size: 0.75rem;
|
font-size: 12px;
|
||||||
color: var(--color-ink);
|
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 {
|
.badge {
|
||||||
@apply inline-flex items-center rounded-[2px] px-2 py-0.5 text-xs font-semibold uppercase whitespace-nowrap;
|
@apply inline-flex items-center px-3 py-1 whitespace-nowrap;
|
||||||
letter-spacing: 0.03em;
|
border-radius: var(--radius-tags);
|
||||||
border: 1.5px solid var(--color-ink);
|
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; }
|
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;
|
||||||
Reference in New Issue
Block a user