Compare commits

..

3 Commits

Author SHA1 Message Date
LakG 7bab90b408 UI: navegación SPA, tabs sin bordes, patrón de fondo fijo
- ClientRouter (view transitions) para navegación sin recarga entera;
  Toaster y BadgeSolicitudes marcados con transition:persist para no
  rearmar la suscripción realtime en cada nav.
- Overlay de carga ahora activa a los 250ms del swap (evita parpadeo).
- Tabs de subnav (inventario/categorías/maestros, solicitudes, reportes)
  reescritos: sin border-b-2 ni underline verde; activo usa bg-linen
  como el sidebar. Coherente y sin patrón AI-slop.
- Marco horizontal debajo de todos los tab strips removido.
- Patrón de puntos verde del canvas ahora con background-attachment:
  fixed — estático en scroll.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
2026-09-21 14:52:56 -07:00
LakG ac3e7061ac 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
2026-09-21 09:42:12 -07:00
LakG a41d50ca62 DESIGN.md added 2026-09-15 13:04:08 -07:00
53 changed files with 2249 additions and 1519 deletions
+325 -180
View File
@@ -1,210 +1,355 @@
--- # UABC — Style Reference
name: LabPréstamos > Identidad editorial institucional en clave monoespaciada
description: Sistema institucional UABC para préstamo de material de laboratorio — un ticket de equipo, cruzado con un terminal de desarrollador.
colors:
cream: "#f4efea"
frost: "#ffffff"
chalk: "#f8f8f7"
ice: "#ebf9ff"
ink: "#383838"
pencil: "#a1a1a1"
graphite: "#818181"
primary: "#00723F"
primary-hover: "#005f34"
secondary: "#DD971A"
secondary-hover: "#c78614"
danger: "#f38e84"
danger-hover: "#ec7469"
danger-text: "#a83224"
positive: "#38c1b0"
attention: "#ffde00"
typography:
body:
fontFamily: "JetBrains Mono Variable, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
fontSize: "0.875rem"
fontWeight: 400
lineHeight: 1.5
letterSpacing: "0.02em"
label:
fontFamily: "JetBrains Mono Variable, ui-monospace, monospace"
fontSize: "0.75rem"
fontWeight: 500
letterSpacing: "0.05em"
textTransform: uppercase
rounded:
control: "2px"
spacing:
tight: "0.5rem"
base: "1rem"
loose: "1.5rem"
section: "2rem"
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "#ffffff"
rounded: "{rounded.control}"
padding: "10px 16px"
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.ink}"
rounded: "{rounded.control}"
padding: "10px 16px"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.ink}"
rounded: "{rounded.control}"
padding: "10px 16px"
card:
backgroundColor: "{colors.frost}"
textColor: "{colors.ink}"
rounded: "{rounded.control}"
padding: "24px"
input:
backgroundColor: "{colors.frost}"
textColor: "{colors.ink}"
rounded: "{rounded.control}"
padding: "10px 12px"
---
# Design System: LabPréstamos **Theme:** mixed
## Overview 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.
**Creative North Star: "The Equipment Tag on a Terminal Readout"** ## Tokens — Colors
LabPréstamos runs the *mechanics* of a fully pinned external brief (MotherDuck's crayon-terminal-on-cream neobrutalism) — a warm cream canvas, monospace type standing in for a terminal readout, hard 2px corners, and a flat charcoal-offset shadow that never blurs — while carrying UABC's own institutional green and gold as its identity colors instead of the source brief's blue and orange. The first pass at this system used the source's literal palette wholesale; that was corrected once the user flagged it — adopting a design *language* is not the same as adopting someone else's brand colors, and UABC's green/gold predate this redesign and outrank it. What replaces MotherDuck's duck mascot is the product's own mark: a loan tag with a punch-hole, echoing the paper ticket this system replaced. | Name | Value | Token | Role |
|------|-------|-------|------|
| Verde Institucional | `#00723F` | `--color-verde-institucional` | Color institucional principal (Pantone 356) — CTAs primarios, nav activo, íconos, acentos de enlace; el único color con función de "interruptor" en la UI |
| Verde Claro | `#50B848` | `--color-verde-claro` | Estados hover, contornos de acento suaves, eco desaturado del Verde Institucional (Pantone 369) |
| Dorado UABC | `#DD971A` | `--color-dorado-uabc` | Acento cálido secundario — bordes de tags, highlights, contraste cálido frente al verde (Pantone 131) |
| Amarillo Dorado | `#FEBE10` | `--color-amarillo-dorado` | Badges y estados destacados sobre fondo oscuro (Pantone 124) |
| Verde Jardín | `#024731` | `--color-verde-jardin` | Superficie oscura alterna, bordes fuertes sobre fondo oscuro (Pantone 3435) |
| Dorado Oscuro | `#B08208` | `--color-dorado-oscuro` | Fondo translúcido de nav, bordes de fuerza media, superficies UI mudas (Pantone 132) |
| Azul Institucional | `#20419A` | `--color-azul-institucional` | Acento informativo/enlaces alternos — distingue contenido secundario de las acciones primarias en verde (Pantone Reflex Blue) |
| Bone White | `#fffefb` | `--color-bone-white` | Lienzo de página en secciones claras, color de texto en secciones oscuras |
| Linen | `#f3f2ee` | `--color-linen` | Superficie secundaria cálida, fondo de tarjetas elevadas |
| Hairline Gray | `#e5e7eb` | `--color-hairline-gray` | Bordes finos en tarjetas, imágenes, divisores y campos de formulario |
| Graphite | `#333833` | `--color-graphite` | Texto principal y encabezados sobre superficies claras |
| Negro Institucional | `#000000` | `--color-negro-institucional` | Lienzos de sección oscura (Pantone Process Black), texto de máximo contraste, scrims sobre imagen |
Depth here is binary, not a hierarchy: an element either sits flat on the page (bordered, no shadow — tables, list rows, inputs, the nav) or it's a discrete raised object that "presses" into its own shadow on hover (buttons, standalone cards, modals, the login card). There is no soft, no blur, no ambient glow anywhere in the system — every visual weight decision reads as a hard yes/no. ## Tokens — Typography
Color plays a stricter role here than in the source brief. Verde Institucional is reserved for actionable, filled controls — never for status. A teal (Mint Sketch, from the source brief's decorative rainbow set) is fixed as the system's "positive/available" status color, kept distinct from the primary action color because an operational tool needs that distinction and a marketing site never did. Coral Sketch is fixed as the sole danger color, with a separately darkened text variant (`--color-danger-text`, `#a83224`) for body copy, because the coral fill fails AA contrast on white at normal text weight — the fill stays light and loud, the text goes dark and readable. Because Verde Institucional is a dark, saturated green (unlike the source brief's light Sky Crayon), it is the one fill in the system that takes white text rather than charcoal — see the Text-on-Fill Rule below. ### Azeret Mono — Fuente única del sistema: encabezados, cuerpo y controles de UI. Al ser monoespaciada en todos los roles, la jerarquía se construye solo con peso y tamaño, reforzando la lectura de "hoja de especificación técnica" en toda la interfaz. · `--font-azeret-mono`
- **Fuente:** Fontsource (`@fontsource/azeret-mono` / fontsource.org/fonts/azeret-mono)
- **Substitute:** JetBrains Mono, IBM Plex Mono, o monoespacio del sistema
- **Weights:** 300 (encabezados y cuerpo), 500 (nav, botones, controles de UI)
- **Sizes:** 10px, 12px, 14px, 16px, 24px, 32px, 72px
- **Line height:** 1.10–1.83 según rol
- **Letter spacing:** -1.44px a 72px, -0.64px a 32px, -0.48px a 24px, -0.32px a 16px (cuerpo), +0.5px a 10px, +0.6px a 12px, +0.7px a 14px (micro/caption)
- **Role — Display/Heading:** peso 300, tamaños 24/32/72px. El peso liviano en tamaños grandes es no negociable — mantiene la autoridad editorial por contención en vez de volumen.
- **Role — Body:** peso 300, tamaños 14–16px, line-height 1.57–1.83. Trata el párrafo como salida de terminal, no como folleto de marketing.
- **Role — UI/Nav/Buttons:** peso 500, tamaños 12–16px. El salto de peso (300 → 500) es la única señal que distingue controles interactivos del texto editorial, ya que comparten la misma fuente.
**Key Characteristics:** ### Type Scale
- Cream page canvas, white card surfaces, charcoal (`#383838`, never pure black) as the universal line and border color
- Every corner is 2px — buttons, cards, inputs, dialogs, badges, the brand mark's own chip. No pills, no circles, no `rounded-full` anywhere in the system
- One shadow, one behavior: `-6px 6px 0 0 var(--color-ink)`, hard-edged, zero blur, on every button, standalone card, toast, and modal — and it flattens into the element on hover/focus (`translate(-6px, 6px)`)
- JetBrains Mono Variable everywhere, 0.02em tracking, uppercase treatment on nav labels, button labels, section labels, and headlines
- UABC Verde Institucional carries every filled action (white text); UABC Dorado carries the secondary action and a handful of accents (charcoal text)
- Status pills and count badges are tinted or solid fills with a matching-color border — text stays whichever of charcoal/white actually clears contrast against that fill, never picked by habit
- Dense, repeated surfaces (tables, list-item cards, form inputs) stay flat and bordered; only discrete, single-instance elements carry the shadow
## Colors | Role | Size | Line Height | Letter Spacing | Weight | Token |
|------|------|-------------|----------------|--------|-------|
| micro | 10px | 1.6 | 0.5px | 300 | `--text-micro` |
| caption | 12px | 1.63 | 0.6px | 300 | `--text-caption` |
| body-sm | 14px | 1.63 | 0.7px | 300 | `--text-body-sm` |
| body | 16px | 1.57 | -0.32px | 300 | `--text-body` |
| ui | 16px | 1.83 | -0.017em | 500 | `--text-ui` |
| heading | 24px | 1.3 | -0.48px | 300 | `--text-heading` |
| heading-lg | 32px | 1.3 | -0.64px | 300 | `--text-heading-lg` |
| display | 72px | 1.1 | -1.44px | 300 | `--text-display` |
The shape/shadow/type system is inherited from the pinned MotherDuck brief; the color *values* are UABC's own institutional palette, carried into that system's roles. ## Tokens — Spacing & Shapes
### Primary **Base unit:** 8px
- **Verde Institucional** (`#00723F`): the system's primary filled-action color — unchanged from the project's pre-redesign identity. Primary buttons, active nav state, focused/selected chips, "Solicitar"/"Aprobar"/"Filtrar"/"Continuar con Google," the brand mark's chip fill. Dark enough that it always pairs with **white** text/icons, not charcoal — the one fill in the system where that's true.
### Secondary **Density:** comfortable
- **Dorado UABC** (`#DD971A`): the institutional gold, carried over unchanged. Secondary buttons ("Exportar CSV," "Marcar devuelto"), "Mantenimiento" status tint, the "En préstamo" KPI tone. Light enough to pair with charcoal text (passes AA; white on gold does not).
### Neutral ### Spacing Scale
- **Cream Paper** (`#f4efea`): the page canvas — every screen's base layer.
- **Frost White** (`#ffffff`): card, dialog, sidebar, and nav surfaces — the raised layer on cream.
- **Chalk Gray** (`#f8f8f7`): hover wash on flat (non-shadowed) interactive rows, and the neutral badge fill for "Baja"/inactive states.
- **Charcoal Ink** (`#383838`): the universal line — every border, most default text, the shadow color itself. Never pure black.
- **Pencil Gray** (`#a1a1a1`): secondary/muted text (labels under headings, metadata, roles) — a real token, not an opacity trick.
### Status colors (extension beyond the source brief) | Name | Value | Token |
- **Mint Sketch → Positive** (`#38c1b0`): "disponible," "aprobado," "devuelto" status. Pulled out of the source brief's decorative rainbow-button palette because the operational tool needs a status color distinct from the primary action color; the source brief, built for a marketing site, never had this problem. |------|-------|-------|
- **Coral Sketch → Danger** (`#f38e84` fill / `#a83224` text): "rechazado," "vencido," destructive actions. Split into two values because the fill color (used for badge backgrounds, borders, button fills) is deliberately light and loud, while body text needs the darker `-text` variant to clear 4.5:1 contrast on white. | 8 | 8px | `--spacing-8` |
| 16 | 16px | `--spacing-16` |
| 24 | 24px | `--spacing-24` |
| 32 | 32px | `--spacing-32` |
| 64 | 64px | `--spacing-64` |
| 120 | 120px | `--spacing-120` |
| 128 | 128px | `--spacing-128` |
| 152 | 152px | `--spacing-152` |
### Named Rules ### Border Radius
**The Text-on-Fill Rule.** Text and icon color on any filled surface is whichever of Charcoal Ink or white actually clears AA contrast against that specific fill — never a blanket choice. In practice: white on Verde Institucional (dark), Charcoal Ink on everything else (Dorado, the status tints, the danger fill), because that's what the numbers say for each color. Never pick by habit; check the fill.
**The One Shadow Rule.** `-6px 6px 0 0 var(--color-ink)` is the only shadow value in the system. It never blurs, never softens, never gets a second variant for "subtle" elevation — an element either has this exact shadow or it has none. | Element | Value |
|---------|-------|
| nav | 30px |
| tags | 8px |
| cards | 12px |
| buttons | 9999px |
## Typography ### Layout
**Body & Display Font:** JetBrains Mono Variable (weights 100–800 available; the system uses 400/500/600), with `ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace` fallback. Chosen as the pinned brief's own named substitute for the licensed Aeonik Mono. - **Page max-width:** 1200px
- **Section gap:** 64px
**Character:** One monospace face for the entire system, 0.02em letter-spacing throughout to open up the mechanical density of mono at small sizes. Uppercase tracking is reserved for structural labels (nav items, button text, form labels, section headers, the login headline) — body copy, names, and descriptions stay in normal case so real data (student names, material names) stays legible rather than shouting. - **Card padding:** 24px
- **Element gap:** 8px
### Hierarchy
- **Headline** (600, 1.5rem → 1.875rem at `md:`, uppercase): page `<h1>`s, the login screen's title.
- **Title** (600, 1.125rem): section and card headers, dialog titles — not uppercased by default, uppercased where it doubles as a nav/label element.
- **Body** (400, 0.875rem, 1.5 line-height): the default text size — table cells, descriptions, list content.
- **Label** (500, 0.75rem, 0.05em tracking, uppercase): every form field label (`.label`), nav items, badge text.
- **Caption** (400, 0.75rem, Pencil Gray): metadata — matrícula, inventory numbers, timestamps.
### Named Rules
**The Tabular Numbers Rule.** Any figure representing a count — cantidad, stock, KPI values, dates — carries `font-variant-numeric: tabular-nums`, inherited unchanged from the prior system. JetBrains Mono's own distinctive dotted-zero glyph reinforces the "terminal readout" reading at large KPI sizes.
## Layout
Unchanged from the incumbent structural system — the redesign is a full re-skin, not a re-architecture. `md:` (768px) remains the single hard breakpoint between mobile and desktop. Desktop keeps the fixed 256px collapsible sidebar; mobile keeps a bottom navigation bar, now a flush, flat, full-width strip (not a floating pill) with a 2px charcoal top border and no shadow — matching the source brief's own flat, unshadowed nav-button treatment. Content still ships both a `md:block` table and a `md:hidden` card-list rendering for every data-dense admin view.
## Elevation & Depth
Binary, not graduated. An element is either **flat** (a 1.5–2px charcoal border, no shadow — the default for anything dense or repeated: tables, list-item cards, inputs, the nav) or **raised** (2px charcoal border plus the one hard shadow — reserved for discrete, single-instance, actionable surfaces: buttons, the login card, modals/dialogs, toasts, KPI tiles, and clickable dashboard tiles). There is no third state, no blur, no soft ambient shadow anywhere in the system.
### Shadow Vocabulary
- **Hard** (`box-shadow: -6px 6px 0 0 var(--color-ink)`, token `--shadow-hard`): every raised element at rest.
- **Hard, pressed** (`transform: translate(-6px, 6px)`, shadow reads as `0 0 0 0`): the same element on hover/focus — it visually "presses" into its own shadow. Active state presses halfway (`--shadow-hard-sm`, `-3px 3px`) for a two-stage press.
### Named Rules
**The Press Rule.** Every raised element's hover state is the same transform: translate exactly the shadow's own offset, so the shadow disappears under the element. No brightness filter, no color darken — motion is the only hover signal on raised elements.
## Shapes
One radius for the entire system: **2px** (`--radius-card`, and Tailwind's `rounded-lg`/`rounded-2xl`/`rounded-full` are globally overridden to resolve to it). Buttons, cards, inputs, dialogs, badges, the brand mark's chip — everything is a near-square corner. There is no pill, no circle, no soft/emphasis radius tier; the prior system's `rounded-full` nav dock and status pills were converted wholesale. Borders are always 1.5–2px solid Charcoal Ink (never a tinted or literal-gray border) — the system has no hairline-border convention.
## Components ## Components
### Buttons ### Verde Pill CTA
- **Shape:** 2px radius, 2px Charcoal Ink border, 44px minimum height, `touch-action: manipulation`. **Role:** Botón de llamada a la acción primaria
- **Primary:** Verde Institucional fill, **white** text, hard shadow. The default call to action.
- **Secondary:** Dorado UABC fill, Charcoal Ink text, hard shadow. "Exportar CSV," "Marcar devuelto."
- **Ghost:** transparent fill, Charcoal Ink text, a lighter border (35% ink-mix), no shadow. Used for "Cancelar"/"Limpiar."
- **Hover / Focus / Active:** see Elevation → The Press Rule.
### Badges / Status Pills 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.
- **Style:** 2px radius, 1.5px border in the semantic color, uppercase text at 0.03em tracking.
- **The Tinted-Fill-Bordered Rule.** Background = the semantic color mixed 18–25% into white; border = the semantic color at full strength; text = Charcoal Ink (the tinted backgrounds are always light enough for it). Count badges (the "N pendientes" chip beside a heading) use a **solid** semantic fill instead of a tint, bordered in Charcoal Ink — text on those follows the Text-on-Fill Rule (white on a solid Verde Institucional count badge, Charcoal Ink on a solid Positive/mint one).
- **Semantic mapping:** disponible / aprobado / devuelto → Positive (mint). mantenimiento / en préstamo (KPI) → Secondary (Dorado). rechazado / vencido → Danger (coral). baja / neutral counts → Chalk Gray with a graphite border.
### Cards / Containers ### Nav Flotante Translúcido
- **Corner:** 2px. **Role:** Navegación superior de página
- **Background:** Frost White, always.
- **Flat variant** (`.card-flat`, `.card`): 1.5–2px Charcoal Ink border, no shadow. Used for tables, list rows, dense repeated content.
- **Raised variant** (`.card-raised`, `.card-link`): adds the hard shadow. Used for the login card, clickable dashboard tiles, KPI tiles — anything discrete and singular rather than part of a repeated list.
- **Internal Padding:** `p-6` standard, `p-4` for dense KPI tiles, `p-0` when the card wraps a table (the table supplies its own cell padding).
### Inputs / Fields 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.
- **Style:** Frost White background, 2px Charcoal Ink border, 2px radius, 44px minimum height. No shadow — inputs are always flat, matching the source brief's own Text Input spec.
- **Focus:** border color shifts to Verde Institucional; no glow, no ring.
- **Label:** `.label` — uppercase, 0.75rem, 0.05em tracking, Charcoal Ink.
- **Error:** inline `role="alert"` text in Danger-text below the field.
### Navigation ### Mono Display Heading
- **Desktop sidebar:** Frost White panel (a deliberate departure from the pre-redesign solid-green sidebar, matching the source brief's flat white nav-bar structure — the *color* identity moved to the active-state chip instead of the whole panel), 2px Charcoal Ink right border. Active item = solid Verde Institucional chip with white text and a 2px Charcoal border; inactive = transparent with a Chalk Gray hover wash. Uppercase labels throughout. Collapsible, same mechanism as before (localStorage + blocking inline script, no flash). **Role:** Encabezados de hero y de sección
- **Mobile nav:** a flush, full-width bottom bar (not a floating pill), Frost White, 2px Charcoal top border, no shadow — flat, matching the source brief's "nav buttons are flat" rule. Active icon = a 2px-radius Verde Institucional square with white icon and Charcoal border.
- **Brand mark chip:** a Verde Institucional fill with a white glyph and a 2px Charcoal border, used identically on the sidebar header, mobile header, login screen, and 404 page. The 403 page uses the same chip shape in the Danger color instead.
### Toasts 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.
- Frost White, 2px Charcoal Ink border, hard shadow, 2px radius (down from the prior system's blurred glass `rounded-2xl` treatment — MotherDuck bans blur and ambient shadow outright).
- A 2px-bordered color chip (36px) leads each toast: Positive for success, Danger for error, Secondary (Dorado) for info — Charcoal Ink stroke icon (all three chip colors clear contrast with charcoal).
- Entrance: 260ms translate+fade, same timing as before; still respects `prefers-reduced-motion`; still survives `location.reload()` via the sessionStorage queue.
### Modals ### Mono Body Block
- Native `<dialog>`, 2px radius, 2px Charcoal Ink border, hard shadow, `backdrop:bg-black/40`. The prior system's borderless, shadowless dialog surface gained both a border and the system's one shadow — modals are a "raised" surface by the binary elevation rule. **Role:** Copy de párrafo en secciones de contenido
- Close controls are always a drawn SVG X (never a `×` character), matching the rest of the icon system.
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".
### Two-Column Editorial Section
**Role:** Layout de contenido: encabezado izquierda, cuerpo derecha
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.
### Full-Bleed Documentary Image
**Role:** Banda de imagen atmosférica entre secciones de contenido
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.
### Vertical Sidebar Label
**Role:** Indicador de sección en el margen de la página
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.
### Ghost Text Link
**Role:** Enlaces en línea dentro del cuerpo de texto
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).
### Hairline Divider
**Role:** Separador de sección
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.
### Image Frame Card
**Role:** Tarjeta de contenido con imagen
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.
### Compact Pill Tag
**Role:** Badge de categoría o estado
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
- **Do** use the exact shadow `-6px 6px 0 0 var(--color-ink)` (`--shadow-hard`) for every raised element, and the exact press transform `translate(-6px, 6px)` on its hover/focus state. - Configura todos los CTAs primarios a 9999px de border-radius con relleno #00723F y texto Bone White en Azeret Mono peso 500, 16px.
- **Do** keep every radius at 2px — buttons, cards, inputs, dialogs, badges, chips. No exceptions, no emphasis tier. - Usa Azeret Mono peso 300 para todo encabezado a 24px, 32px y 72px — nunca lo pongas en negrita.
- **Do** check contrast per fill before choosing text color (Text-on-Fill Rule) — don't assume charcoal always works. - Escribe el copy de cuerpo en Azeret Mono peso 300 a 14–16px con line-height 1.63–1.83.
- **Do** keep Verde Institucional for actionable, filled controls; use Positive (mint), Danger (coral), or Secondary (Dorado) for status and non-primary accents. - Separa secciones con 64px de padding vertical, no con divisores ni solo cambios de color de fondo.
- **Do** keep dense/repeated surfaces (tables, list cards, inputs, nav) flat and bordered; reserve the hard shadow for discrete, singular, actionable surfaces. - Aplica bordes 1px sólidos #e5e7eb a todas las tarjetas e imágenes — nunca uses drop shadows para elevación.
- **Do** draw every icon as an SVG in the shared heroicons-outline language (or the `BrandMark` glyph for identity) — never a unicode glyph or emoji standing in for one. - Usa #fffefb para lienzos claros y #000000 para bandas oscuras; deja que la fotografía llene las bandas oscuras a sangre completa.
- **Do** apply `font-variant-numeric: tabular-nums` to every numeric quantity, stock figure, or date. - 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.
- **Do** ship both a `md:block` table and a `md:hidden` card-list rendering for every data-dense admin view.
### Don't: ### Don't
- **Don't** use blur, glass, or a soft ambient shadow anywhere — every shadow is the one hard-edged offset value, or there is no shadow. - No uses pesos negrita (600–700) en encabezados Azeret Mono — el peso 300 es la firma del sistema.
- **Don't** introduce a radius above 2px, or a pill/circle shape, anywhere in the UI. - No introduzcas drop shadows para elevar tarjetas; usa el color de superficie Linen (#f3f2ee) y bordes hairline en su lugar.
- **Don't** adopt a borrowed style system's literal brand colors — take its shape/shadow/type mechanics, keep the project's own identity colors in them. This system exists because that line was crossed once and had to be corrected. - 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** reuse Verde Institucional for status pills — status has its own colors (Positive/Danger/Secondary); Verde Institucional means "you can act on this." - No apliques el Verde Institucional a fondos, bandas de sección o elementos decorativos — es solo de acción.
- **Don't** introduce a second typeface. JetBrains Mono Variable carries every role through weight, size, and case. - No uses valores de radio fuera de los cuatro tokens: 8px (tags), 12px (cards), 30px (nav), 9999px (buttons).
- **Don't** stand a unicode glyph in for an icon. - No rompas el ritmo de lienzo de dos tonos — no insertes un tercer color de fondo de sección (además de Bone White y Negro Institucional).
- No uses fotografía de stock, ilustraciones o renders 3D — la imaginería debe ser documental y candid.
## Surfaces
| Level | Name | Value | Purpose |
|-------|------|-------|---------|
| 1 | Bone Canvas | `#fffefb` | Secciones claras de lectura, fondo de página por defecto |
| 2 | Linen Card | `#f3f2ee` | Bloques de contenido elevados, paneles secundarios |
| 3 | Dorado Nav | `#B08208` | Superficie de navegación flotante translúcida |
| 4 | Negro Institucional | `#000000` | Secciones documentales oscuras, lienzos de banda de imagen |
## Elevation
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
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
La página alterna entre dos estados de lienzo: bandas documentales oscuras a sangre completa (Negro Institucional #000000 con foto) y secciones claras de lectura (Bone White #fffefb). El contenido está centrado a 1200px de max-width. El hero es una imagen oscura a pantalla completa con encabezado Azeret Mono centrado y CTA verde. Las secciones siguientes usan un split editorial de dos columnas — encabezado izquierda, cuerpo mono derecha — con gaps generosos de 64px. Las etiquetas verticales de sidebar se ubican en el margen izquierdo de la página como marcadores de sección. La navegación es una cápsula pill flotante, no una barra de ancho completo. La densidad general es espaciosa; la página está dominada por texto y fotografía, no por grillas de tarjetas.
## Agent Prompt Guide
Quick Color Reference:\n- text: #333833 (light) / #fffefb (dark)\n- background: #fffefb (light) / #000000 (dark)\n- border: #e5e7eb (hairline) / #B08208 (medium)\n- accent primario: #00723F (Verde Institucional — CTAs, enlaces, estados activos)\n- accent secundario: #DD971A (Dorado UABC — tags, highlights)\n- primary action: #00723F (acción rellena)
## Quick Start
### CSS Custom Properties
```css
:root {
/* Colors */
--color-verde-institucional: #00723F;
--color-verde-claro: #50B848;
--color-dorado-uabc: #DD971A;
--color-amarillo-dorado: #FEBE10;
--color-verde-jardin: #024731;
--color-dorado-oscuro: #B08208;
--color-azul-institucional: #20419A;
--color-bone-white: #fffefb;
--color-linen: #f3f2ee;
--color-hairline-gray: #e5e7eb;
--color-graphite: #333833;
--color-negro-institucional: #000000;
/* Typography — Font Family (única) */
--font-azeret-mono: 'Azeret Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Typography — Scale */
--text-micro: 10px;
--leading-micro: 1.6;
--tracking-micro: 0.5px;
--text-caption: 12px;
--leading-caption: 1.63;
--tracking-caption: 0.6px;
--text-body-sm: 14px;
--leading-body-sm: 1.63;
--tracking-body-sm: 0.7px;
--text-body: 16px;
--leading-body: 1.57;
--tracking-body: -0.32px;
--text-ui: 16px;
--leading-ui: 1.83;
--tracking-ui: -0.017em;
--text-heading: 24px;
--leading-heading: 1.3;
--tracking-heading: -0.48px;
--text-heading-lg: 32px;
--leading-heading-lg: 1.3;
--tracking-heading-lg: -0.64px;
--text-display: 72px;
--leading-display: 1.1;
--tracking-display: -1.44px;
/* Typography — Weights */
--font-weight-light: 300;
--font-weight-ui: 500;
/* Spacing */
--spacing-unit: 8px;
--spacing-8: 8px;
--spacing-16: 16px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-64: 64px;
--spacing-120: 120px;
--spacing-128: 128px;
--spacing-152: 152px;
/* Layout */
--page-max-width: 1200px;
--section-gap: 64px;
--card-padding: 24px;
--element-gap: 8px;
/* Border Radius */
--radius-lg: 8px;
--radius-xl: 12px;
--radius-3xl: 30px;
--radius-full: 9999px;
/* Named Radii */
--radius-nav: 30px;
--radius-tags: 8px;
--radius-cards: 12px;
--radius-buttons: 9999px;
/* Surfaces */
--surface-bone-canvas: #fffefb;
--surface-linen-card: #f3f2ee;
--surface-dorado-nav: #B08208;
--surface-negro-institucional: #000000;
}
```
### Fontsource — instalación de Azeret Mono
```bash
npm install @fontsource/azeret-mono
```
```javascript
// Importa los pesos usados por el sistema (300 y 500)
import '@fontsource/azeret-mono/300.css';
import '@fontsource/azeret-mono/500.css';
```
Si el proyecto no usa un bundler con soporte npm, se puede enlazar directamente el CSS servido por Fontsource (`https://cdn.jsdelivr.net/fontsource/fonts/azeret-mono@latest/latin-300-normal.css` y su equivalente `-500-`) en el `<head>` del documento.
### Tailwind v4
```css
@theme {
/* Colors */
--color-verde-institucional: #00723F;
--color-verde-claro: #50B848;
--color-dorado-uabc: #DD971A;
--color-amarillo-dorado: #FEBE10;
--color-verde-jardin: #024731;
--color-dorado-oscuro: #B08208;
--color-azul-institucional: #20419A;
--color-bone-white: #fffefb;
--color-linen: #f3f2ee;
--color-hairline-gray: #e5e7eb;
--color-graphite: #333833;
--color-negro-institucional: #000000;
/* Typography */
--font-azeret-mono: 'Azeret Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Typography — Scale */
--text-micro: 10px;
--leading-micro: 1.6;
--tracking-micro: 0.5px;
--text-caption: 12px;
--leading-caption: 1.63;
--tracking-caption: 0.6px;
--text-body-sm: 14px;
--leading-body-sm: 1.63;
--tracking-body-sm: 0.7px;
--text-body: 16px;
--leading-body: 1.57;
--tracking-body: -0.32px;
--text-ui: 16px;
--leading-ui: 1.83;
--tracking-ui: -0.017em;
--text-heading: 24px;
--leading-heading: 1.3;
--tracking-heading: -0.48px;
--text-heading-lg: 32px;
--leading-heading-lg: 1.3;
--tracking-heading-lg: -0.64px;
--text-display: 72px;
--leading-display: 1.1;
--tracking-display: -1.44px;
/* Spacing */
--spacing-8: 8px;
--spacing-16: 16px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-64: 64px;
--spacing-120: 120px;
--spacing-128: 128px;
--spacing-152: 152px;
/* Border Radius */
--radius-lg: 8px;
--radius-xl: 12px;
--radius-3xl: 30px;
--radius-full: 9999px;
}
```
+18 -20
View File
@@ -3,17 +3,20 @@ import type { ToastPayload, ToastKind } from '@/lib/toast';
type Item = ToastPayload & { id: number; leaving?: boolean }; 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>
+59 -7
View File
@@ -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>
);
} }
+109 -52
View File
@@ -21,50 +21,67 @@ type Linea = { tipo: 'linea'; datos: { fecha: string; valor: number }[]; alto?:
type Donut = { tipo: 'donut'; datos: { label: string; valor: number }[]; alto?: number }; type 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}
> >
+15 -12
View File
@@ -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>
+23 -13
View File
@@ -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,24 +141,28 @@ 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) => {
const isSelected = String(it.id) === value;
const isFocused = i === active;
return (
<li <li
key={it.id} key={it.id}
id={`${listId}-opt-${i}`} id={`${listId}-opt-${i}`}
role="option" role="option"
aria-selected={i === active} aria-selected={isFocused || isSelected}
className="px-3 py-2 text-sm cursor-pointer transition-colors" className={`px-3 py-2 text-sm cursor-pointer transition-colors rounded-[8px] ${
style={i === active ? { background: 'color-mix(in oklab, var(--color-primary) 10%, transparent)' } : undefined} isFocused || isSelected
onMouseEnter={() => setActive(i)} ? 'bg-linen text-verde-institucional font-medium'
: 'text-graphite hover:bg-linen'
}`}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
pick(it); pick(it);
@@ -161,7 +170,8 @@ export function Autocomplete({ name, endpoint, label, placeholder, initialValue,
> >
{it.label} {it.label}
</li> </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-lg font-semibold">Detalle del préstamo</h2> <h2 className="text-xl md:text-2xl font-light text-graphite">Detalle del préstamo</h2>
<button type="button" className="rounded-lg p-1.5 hover:bg-black/5 transition-colors" onClick={() => ref.current?.close()} aria-label="Cerrar"> <button
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"> type="button"
className="rounded-full p-2 text-graphite/70 hover:text-graphite hover:bg-linen transition-colors"
onClick={() => ref.current?.close()}
aria-label="Cerrar"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" /> <path d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
{loading && <p className="mt-4 text-sm opacity-70">Cargando…</p>} {loading && <p className="mt-4 text-sm text-graphite/70">Cargando…</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>
)} )}
{data && ( {data && (
<> <>
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm"> <dl className="mt-4 grid grid-cols-3 gap-y-3 text-sm">
<dt className="opacity-70">Alumno</dt> <dt className="text-graphite/70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd> <dd className="col-span-2 text-graphite font-medium">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Maestro</dt> <dt className="text-graphite/70">Maestro</dt>
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd> <dd className="col-span-2 break-words text-graphite">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt> <dt className="text-graphite/70">Materiales</dt>
<dd className="col-span-2"> <dd className="col-span-2">
<ul className="list-disc pl-4 space-y-2"> <ul className="border-y border-hairline-gray my-2 divide-y divide-hairline-gray">
{data.prestamo.items.map((i) => ( {data.prestamo.items.map((i) => (
<li key={i.id}> <li key={i.id} className="py-2.5 first:pt-2 last:pb-2 border-b border-hairline-gray last:border-b-0">
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '} <div className="flex items-baseline justify-between gap-2">
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span> <div>
{i.material_unidad?.etiqueta && ( <strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}×</strong>{' '}
<div className="text-xs"> <span className="font-medium text-graphite">{i.material?.nombre ?? '—'}</span>
<span className="opacity-70">Unidad: </span> <span className="text-xs text-graphite/60 ml-2">Inv. {i.material?.numero_inventario ?? '—'}</span>
<span className="font-semibold">{i.material_unidad.etiqueta}</span>
</div> </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} />
)} )}
</div>
{i.material_unidad?.etiqueta && (
<div className="text-xs text-graphite/70 mt-1">
Unidad: <span className="font-medium text-graphite">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs text-graphite/60 mt-0.5">{i.descripcion}</div>}
</li> </li>
))} ))}
</ul> </ul>
</dd> </dd>
<dt className="opacity-70">Solicitado</dt> <dt className="text-graphite/70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd> <dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)} {data.prestamo.fecha_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="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)} {data.prestamo.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="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</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> </dl>
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3> <h3 className="mt-6 mb-3 text-base font-medium text-graphite">Historial de cambios</h3>
{data.audit_log.length === 0 ? ( {data.audit_log.length === 0 ? (
<p className="text-sm opacity-70">Sin cambios registrados.</p> <p className="text-sm text-graphite/70">Sin cambios registrados.</p>
) : ( ) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}> <div className="relative pl-4 border-l border-hairline-gray space-y-3 my-2">
{data.audit_log.map((e, i) => { {data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp; const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
const estadoNuevo = e.estado_nuevo;
const estadoAnt = e.estado_anterior;
return ( return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap"> <div key={i} className="relative">
<span className="font-medium">{e.accion ?? 'cambio'}</span> <div className="absolute -left-[21px] top-1.5 w-2 h-2 rounded-full bg-verde-institucional" />
<span className="opacity-80"> <div className="flex items-center justify-between gap-2 flex-wrap text-sm">
{(e.estado_anterior ?? '—')} → {(e.estado_nuevo ?? '—')} <span className="font-medium text-graphite capitalize">{e.accion ?? 'Cambio'}</span>
</span> {fecha && <span className="text-xs text-graphite/60">{fmt.format(new Date(fecha))}</span>}
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>} </div>
</li> {(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>
); );
})} })}
</ul> </div>
)} )}
</> </>
)} )}
</div>
</dialog> </dialog>
</> </>
); );
+41 -14
View File
@@ -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>
+40 -7
View File
@@ -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
type="button"
className="btn btn-ghost rounded-full w-8 h-8 p-0 flex items-center justify-center shrink-0"
aria-label={`Disminuir cantidad de ${material.nombre}`}
onClick={() => {
if (cantidad > 1) {
updateCantidad(material.id, cantidad - 1);
} else {
removeItem(material.id);
toast({ kind: 'info', title: 'Eliminado de tu solicitud', description: material.nombre });
}
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg> </svg>
En tu solicitud
</button> </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>
); );
} }
+10 -5
View File
@@ -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>
+2 -9
View File
@@ -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)}
+39 -51
View File
@@ -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>
) : ( ) : (
+10 -15
View File
@@ -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"
/> />
) : ( ) : (
+23 -12
View File
@@ -123,22 +123,29 @@ 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)}
/> />
<div className="flex gap-2 flex-wrap items-center">
<button
type="button"
className="btn btn-ghost"
onClick={() => fileInputRef.current?.click()}
>
Subir foto
</button>
{(file || fotoPath) && ( {(file || fotoPath) && (
<button <button
type="button" type="button"
className="btn btn-ghost self-start" className="btn btn-ghost text-negro-institucional"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={() => { onClick={() => {
setFile(null); setFile(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
@@ -149,9 +156,13 @@ export default function PerfilForm({
</button> </button>
)} )}
</div> </div>
{file && (
<span className="text-xs text-graphite/70 truncate max-w-xs">{file.name}</span>
)}
</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>
)} )}
+40 -49
View File
@@ -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>
@@ -252,8 +243,8 @@ const activeCheck = (item: NavItem) => {
</form> </form>
</nav> </nav>
<Toaster client:idle /> <Toaster client:idle transition:persist />
{isAdmin && <BadgeSolicitudes client:idle />} {isAdmin && <BadgeSolicitudes client:idle transition:persist />}
</div> </div>
</Layout> </Layout>
+37 -36
View File
@@ -1,5 +1,6 @@
--- ---
import '@/styles/global.css'; import '@/styles/global.css';
import { ClientRouter } from 'astro:transitions';
interface Props { interface Props {
title?: string; title?: string;
@@ -11,28 +12,27 @@ 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} />
<ClientRouter />
<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 +48,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); } }
@@ -65,27 +65,28 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
</style> </style>
<script is:inline> <script is:inline>
(() => { (() => {
const show = () => {
const overlay = document.getElementById('page-loader'); const overlay = document.getElementById('page-loader');
if (!overlay) return; if (overlay) overlay.hidden = false;
const show = () => { overlay.hidden = false; }; };
const hide = () => {
const overlay = document.getElementById('page-loader');
if (overlay) overlay.hidden = true;
};
document.addEventListener('click', (e) => { // Con ClientRouter, la navegación entre páginas dispara eventos propios.
const a = (e.target instanceof Element) ? e.target.closest('a[href]') : null; // La navegación es rápida (fetch + swap sin recargar) — mostramos el
if (!a) return; // overlay solo si el swap tarda >250ms para no parpadear en cambios rápidos.
if (e.defaultPrevented) return; let pendingShow = null;
if (e.button !== 0) return; document.addEventListener('astro:before-preparation', () => {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; pendingShow = setTimeout(show, 250);
if (a.target && a.target !== '_self') return; });
const href = a.getAttribute('href') || ''; document.addEventListener('astro:after-swap', () => {
if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) return; if (pendingShow) { clearTimeout(pendingShow); pendingShow = null; }
try { hide();
const url = new URL(a.href, location.href); });
if (url.origin !== location.origin) return;
if (url.pathname === location.pathname && url.search === location.search) return;
} catch { return; }
show();
}, true);
// Submits que hagan navegación real (no fetch client-side) sí ameritan overlay inmediato.
document.addEventListener('submit', (e) => { document.addEventListener('submit', (e) => {
if (e.defaultPrevented) return; if (e.defaultPrevented) return;
const form = e.target; const form = e.target;
@@ -100,7 +101,7 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
show(); show();
}, true); }, true);
window.addEventListener('pageshow', () => { overlay.hidden = true; }); window.addEventListener('pageshow', hide);
})(); })();
</script> </script>
</body> </body>
+144
View File
@@ -0,0 +1,144 @@
// Cache in-memory de las estadísticas admin — 60s TTL.
// Motivo: /admin/estadisticas dispara 9 queries por visita; a 20-50 usuarios
// concurrentes con un admin abriendo la pestaña varias veces al día, absorbemos
// >90% de los hits con este cache.
// El cache es por día (isoDate) — cuando cambia el día, la key cambia y se re-computa.
import type { SupabaseClient } from '@supabase/supabase-js';
const TTL_MS = 60_000;
export type EstadisticasBundle = {
kpis: {
nSolicitadas: number;
nAprobadas: number;
nRechazadas: number;
nDevueltas: number;
nEnPrestamo: number;
nVencidos: number;
};
topMateriales: { label: string; valor: number }[];
solicitudesPorDia: { fecha: string; valor: number }[];
distribEstados: { label: string; valor: number }[];
};
type Entry = { at: number; data: EstadisticasBundle };
const cache = new Map<string, Entry>();
export function invalidateEstadisticas() {
cache.clear();
}
export async function getEstadisticas(
supabase: SupabaseClient,
dia: { isoDate: string; startUTC: Date; endUTC: Date },
): Promise<EstadisticasBundle> {
const key = dia.isoDate;
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.at < TTL_MS) return hit.data;
const countHead = { count: 'exact' as const, head: true };
const startISO = dia.startUTC.toISOString();
const endISO = dia.endUTC.toISOString();
const DIAS = 30;
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
// 9 queries en paralelo — mismo shape que la implementación inline anterior.
const [
{ count: nSolicitadas },
{ count: nAprobadas },
{ count: nRechazadas },
{ count: nDevueltas },
{ count: nEnPrestamo },
{ count: nVencidos },
{ data: topMaterialesRaw },
{ data: solicitudesUlt30 },
{ data: estadosRaw },
] = await Promise.all([
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
supabase
.from('solicitud_items')
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
.gte('solicitud.fecha_solicitud', desde),
supabase
.from('solicitudes')
.select('fecha_solicitud')
.gte('fecha_solicitud', desde)
.order('fecha_solicitud', { ascending: true }),
supabase.from('solicitudes').select('estado'),
]);
// Top 10 materiales (agrupado en memoria).
const topMapa = new Map<string, number>();
for (const r of (topMaterialesRaw ?? []) as any[]) {
const nom = r.material?.nombre ?? '—';
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
}
const topMateriales = [...topMapa.entries()]
.map(([label, valor]) => ({ label, valor }))
.sort((a, b) => b.valor - a.valor)
.slice(0, 10);
// Solicitudes por día (30 días, rellena huecos con 0).
function toIsoDateMX(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Tijuana',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
}
const conteoPorDia = new Map<string, number>();
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
const k = toIsoDateMX(new Date(r.fecha_solicitud));
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
}
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
for (let i = 0; i < DIAS; i++) {
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
const iso = toIsoDateMX(d);
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
}
// Distribución de estados.
const estadoLabelMap: Record<string, string> = {
pendiente: 'Pendiente',
aprobado: 'Aprobado',
activo: 'Activo',
devuelto: 'Devuelto',
rechazado: 'Rechazado',
vencido: 'Vencido',
};
const estadoMapa = new Map<string, number>();
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
if (!r.estado) continue;
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
}
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
label: estadoLabelMap[k] ?? k,
valor: v,
}));
const data: EstadisticasBundle = {
kpis: {
nSolicitadas: nSolicitadas ?? 0,
nAprobadas: nAprobadas ?? 0,
nRechazadas: nRechazadas ?? 0,
nDevueltas: nDevueltas ?? 0,
nEnPrestamo: nEnPrestamo ?? 0,
nVencidos: nVencidos ?? 0,
},
topMateriales,
solicitudesPorDia,
distribEstados,
};
cache.set(key, { at: now, data });
return data;
}
+48 -3
View File
@@ -6,7 +6,33 @@ const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api
// Rutas siempre accesibles para un user baneado (sino, loop de rewrite) // 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,12 +47,18 @@ export const onRequest = defineMiddleware(async (context, next) => {
context.locals.profile = null; context.locals.profile = null;
if (user) { if (user) {
const now = Date.now();
const cached = profileCache.get(user.id);
let profile: any = cached && cached.exp > now ? cached.profile : null;
if (!profile) {
const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path'; const select = 'id, email, nombre, matricula, rol, semestre, tutor_id, foto_path';
let { data: profile } = await supabase const { data } = await supabase
.from('profiles') .from('profiles')
.select(select) .select(select)
.eq('id', user.id) .eq('id', user.id)
.maybeSingle(); .maybeSingle();
profile = data ?? null;
// Self-heal: el trigger prestamos_on_auth_user_created coexiste con otro // 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 // trigger del proyecto vecino en auth.users y no siempre dispara. Si el
@@ -44,6 +76,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
profile = creado ?? null; 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
View File
@@ -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
View File
@@ -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>
+41 -114
View File
@@ -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
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} {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
View File
@@ -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) => (
+32 -22
View File
@@ -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" 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 text-graphite hover:bg-linen 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 bg-linen 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 text-graphite hover:bg-linen 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>
) : ( ) : (
+37 -38
View File
@@ -23,39 +23,38 @@ const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
const estadoRaw = Astro.url.searchParams.get('estado') ?? ''; const 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" 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 bg-linen 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 text-graphite hover:bg-linen 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 text-graphite hover:bg-linen 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
+47 -31
View File
@@ -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" 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 text-graphite hover:bg-linen 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 text-graphite hover:bg-linen 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 bg-linen 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 />
+92 -87
View File
@@ -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" aria-label="Vistas de reportes">
<ul class="flex gap-1"> <ul class="flex gap-2">
<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'
? 'bg-linen text-verde-institucional'
: 'text-graphite hover:bg-linen hover:text-verde-institucional',
]}
>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'
? 'bg-linen text-verde-institucional'
: 'text-graphite hover:bg-linen hover:text-verde-institucional',
]}
>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">
<span class="chip chip-vencido tabular-nums">
{dias} {dias === 1 ? 'día' : 'días'} {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>
+41 -45
View File
@@ -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 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 rounded-[8px]',
active ? 'bg-linen text-verde-institucional' : 'text-graphite/70 hover:bg-linen 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>
+46 -32
View File
@@ -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 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 rounded-[8px]',
active ? 'bg-linen text-verde-institucional' : 'text-graphite/70 hover:bg-linen 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>
+42 -32
View File
@@ -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 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 rounded-[8px]',
active ? 'bg-linen text-verde-institucional' : 'text-graphite/70 hover:bg-linen 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>
+30 -33
View File
@@ -53,13 +53,13 @@ const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' :
<AppLayout title="Usuarios — Admin"> <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>
)} )}
+18 -31
View File
@@ -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>
+65 -60
View File
@@ -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,55 +84,63 @@ 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">
<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>
Activos <span class="text-base text-graphite/60 font-sans tabular-nums font-normal">({activos.length})</span>
</span>
</summary>
{activos.length === 0 ? ( {activos.length === 0 ? (
<p class="text-sm opacity-70">Nada activo por ahora.</p> <div class="card p-6 text-sm text-graphite/70 mt-4">Nada activo por ahora.</div>
) : ( ) : (
<ul class="space-y-3"> <ul class="space-y-4 mt-4">
{activos.map((p) => ( {activos.map((p) => (
<li class="card"> <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 && ( {it.material_unidad?.etiqueta && (
<div class="text-xs opacity-70">Unidad: {it.material_unidad.etiqueta}</div> <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 && ( {p.fecha_aprobacion && (
<> <>
<dt class="opacity-60">Aprobado</dt> <dt class="text-graphite/60">Aprobado</dt>
<dd class="text-right">{fmt(p.fecha_aprobacion)}</dd> <dd class="text-right text-graphite">{fmt(p.fecha_aprobacion)}</dd>
</> </>
)} )}
{p.fecha_devolucion_estimada && ( {p.fecha_devolucion_estimada && (
<> <>
<dt class="opacity-60">Devolver antes de</dt> <dt class="text-graphite/60">Devolver antes de</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_estimada)}</dd> <dd class="text-right text-graphite">{fmt(p.fecha_devolucion_estimada)}</dd>
</> </>
)} )}
</dl> </dl>
{p.notas && ( {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 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.notas}
</p> </p>
)} )}
@@ -142,50 +148,53 @@ const badgeStyle = (e: Estado) =>
))} ))}
</ul> </ul>
)} )}
</details>
</section> </section>
<section> <section>
<details open={historial.length <= 5}> <details open={historial.length <= 5} class="group">
<summary class="text-lg font-semibold cursor-pointer list-none flex items-center gap-2"> <summary class="text-2xl cursor-pointer list-none flex items-center gap-2 select-none font-light">
<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"> <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" /> <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>
Historial <span class="text-base text-graphite/60 font-sans tabular-nums font-normal">({historial.length})</span>
</span>
</summary> </summary>
{historial.length === 0 ? ( {historial.length === 0 ? (
<p class="text-sm opacity-70 mt-3">Sin historial todavía.</p> <p class="text-sm text-graphite/70 mt-3">Sin historial todavía.</p>
) : ( ) : (
<ul class="space-y-3 mt-3"> <ul class="space-y-4 mt-4">
{historial.map((p) => ( {historial.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.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_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>
+3 -1
View File
@@ -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 });
}; };
+2
View File
@@ -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 });
}; };
+4 -1
View File
@@ -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');
}; };
+2
View File
@@ -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
View File
@@ -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>
+57 -32
View File
@@ -22,16 +22,40 @@ type Item = { cantidad: number; material: { nombre: string } | null };
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null; 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([
supabase
.from('solicitudes') .from('solicitudes')
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))') .select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
.eq('alumno_id', user.id) .eq('alumno_id', user.id)
.in('estado', ['pendiente', 'aprobado', 'activo']) .in('estado', ['pendiente', 'aprobado', 'activo'])
.order('fecha_solicitud', { ascending: false }) .order('fecha_solicitud', { ascending: false })
.limit(1) .limit(1)
.maybeSingle(); .maybeSingle(),
actual = (data as unknown as Actual) ?? null; 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
View File
@@ -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>
+5 -5
View File
@@ -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
View File
@@ -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}
+249 -95
View File
@@ -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,265 @@
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;
background-attachment: fixed;
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;