Compare commits
2 Commits
1ce1343882
...
e159e8d297
| Author | SHA1 | Date | |
|---|---|---|---|
| e159e8d297 | |||
| ef40241ade |
@@ -6,6 +6,12 @@ RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG PUBLIC_SUPABASE_URL
|
||||
ARG PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG PUBLIC_APP_URL
|
||||
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL \
|
||||
PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY \
|
||||
PUBLIC_APP_URL=$PUBLIC_APP_URL
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
+6
-1
@@ -1,6 +1,11 @@
|
||||
services:
|
||||
labre-web:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL}
|
||||
PUBLIC_SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY}
|
||||
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
|
||||
container_name: labre-web
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LabelList,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
type Barras = { tipo: 'barras'; datos: { label: string; valor: number }[]; alto?: number };
|
||||
@@ -108,10 +109,11 @@ export default function Chart(props: Props) {
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={140}
|
||||
width={100}
|
||||
stroke={INK}
|
||||
tick={{ ...MONO, fontSize: 10 } as any}
|
||||
interval={0}
|
||||
tickFormatter={(v: string) => (v.length > 14 ? v.slice(0, 13) + '…' : v)}
|
||||
/>
|
||||
<Tooltip content={<TooltipBox />} cursor={{ fill: 'rgba(56,56,56,0.06)' }} />
|
||||
<Bar dataKey="valor" fill={PRIMARY} stroke={INK} strokeWidth={1.5} animationDuration={anim} name="Solicitado">
|
||||
@@ -148,7 +150,6 @@ export default function Chart(props: Props) {
|
||||
|
||||
// donut
|
||||
const datos = props.datos;
|
||||
const total = datos.reduce((s, d) => s + d.valor, 0);
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={alto}>
|
||||
<PieChart margin={{ top: 8, right: 8, left: 8, bottom: 8 }}>
|
||||
@@ -163,13 +164,24 @@ export default function Chart(props: Props) {
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={!reduced}
|
||||
animationDuration={anim}
|
||||
label={({ label, valor }: any) => (total ? `${label} (${valor})` : label)}
|
||||
labelLine={{ stroke: PENCIL }}
|
||||
label={false}
|
||||
labelLine={false}
|
||||
>
|
||||
{datos.map((d, i) => (
|
||||
<Cell key={d.label} fill={donutColor(d.label, i)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
iconType="square"
|
||||
wrapperStyle={{ ...MONO, marginTop: 8 }}
|
||||
formatter={(value: string, entry: any) => {
|
||||
const v = entry?.payload?.valor;
|
||||
return v != null ? `${value} (${v})` : value;
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,10 @@ type Props = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
tienePrestamos?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props) {
|
||||
export default function EliminarMaterial({ id, nombre, tienePrestamos, compact }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -53,7 +54,10 @@ export default function EliminarMaterial({ id, nombre, tienePrestamos }: Props)
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]"
|
||||
className={
|
||||
'btn btn-ghost hover:!bg-[color:var(--color-danger)] hover:!text-[color:var(--color-ink)]' +
|
||||
(compact ? ' w-full text-sm px-2' : '')
|
||||
}
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
onClick={open}
|
||||
>
|
||||
|
||||
@@ -22,11 +22,12 @@ type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
material?: Material;
|
||||
categorias: Categoria[];
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const BUCKET = 'materiales-fotos';
|
||||
|
||||
export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
export default function MaterialForm({ mode, material, categorias, compact }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||
@@ -200,13 +201,18 @@ export default function MaterialForm({ mode, material, categorias }: Props) {
|
||||
};
|
||||
|
||||
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
|
||||
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
|
||||
const triggerClass =
|
||||
(mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost') +
|
||||
(compact ? ' w-full text-sm px-2' : '');
|
||||
const triggerStyle = compact
|
||||
? { minHeight: '36px', paddingBlock: '0.25rem' as const }
|
||||
: undefined;
|
||||
|
||||
const currentImg = previewUrl ?? imgUrl(imagenPath);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={triggerClass} onClick={open}>
|
||||
<button type="button" className={triggerClass} style={triggerStyle} onClick={open}>
|
||||
{triggerLabel}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -114,86 +114,65 @@ export default function UnidadesManager({ materialId }: { materialId: number })
|
||||
) : unidades.length === 0 ? (
|
||||
<p className="text-sm opacity-70">Sin unidades registradas.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead
|
||||
className="text-left"
|
||||
style={{ background: 'color-mix(in oklab, var(--color-ink) 4%, transparent)' }}
|
||||
>
|
||||
<tr>
|
||||
<th className="px-2 py-2 font-medium">Etiqueta</th>
|
||||
<th className="px-2 py-2 font-medium">Estado</th>
|
||||
<th className="px-2 py-2 font-medium">Notas</th>
|
||||
<th className="px-2 py-2 font-medium text-right">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unidades.map((u) => {
|
||||
const bloqueada = u.estado === 'prestado';
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="border-t"
|
||||
style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 12%, transparent)' }}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.etiqueta}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
|
||||
else e.target.value = u.etiqueta;
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
value={bloqueada ? 'prestado' : u.estado}
|
||||
disabled={bloqueada}
|
||||
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
|
||||
>
|
||||
{bloqueada && <option value="prestado">Prestado</option>}
|
||||
{ESTADOS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.notas ?? ''}
|
||||
placeholder="Opcional…"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
disabled={bloqueada}
|
||||
onClick={() => eliminar(u)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-col gap-2">
|
||||
{unidades.map((u) => {
|
||||
const bloqueada = u.estado === 'prestado';
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className="card p-3 grid grid-cols-1 sm:grid-cols-[1fr_auto_auto] gap-2 items-center"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
aria-label="Etiqueta"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.etiqueta}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
|
||||
else e.target.value = u.etiqueta;
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
aria-label="Estado"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem', minWidth: '130px' }}
|
||||
value={bloqueada ? 'prestado' : u.estado}
|
||||
disabled={bloqueada}
|
||||
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
|
||||
>
|
||||
{bloqueada && <option value="prestado">Prestado</option>}
|
||||
{ESTADOS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
disabled={bloqueada}
|
||||
onClick={() => eliminar(u)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="input sm:col-span-3"
|
||||
aria-label="Notas"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.notas ?? ''}
|
||||
placeholder="Notas (opcional)…"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
alumnosCount: number;
|
||||
};
|
||||
|
||||
export default function EliminarMaestro({ id, nombre, alumnosCount }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const titleId = `del-mae-${id}`;
|
||||
const bloqueado = alumnosCount > 0;
|
||||
|
||||
const open = () => {
|
||||
setError(null);
|
||||
dialogRef.current?.showModal();
|
||||
};
|
||||
const close = () => {
|
||||
if (loading) return;
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
if (!dlg) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.target === dlg) close();
|
||||
};
|
||||
dlg.addEventListener('click', onClick);
|
||||
return () => dlg.removeEventListener('click', onClick);
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/maestros/${id}`, { method: 'DELETE' });
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo eliminar');
|
||||
dialogRef.current?.close();
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
onClick={open}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-labelledby={titleId}
|
||||
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
|
||||
>
|
||||
<div className="p-5 sm:p-6 flex flex-col gap-4">
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
Eliminar maestro
|
||||
</h2>
|
||||
|
||||
{bloqueado ? (
|
||||
<p className="text-sm">
|
||||
No se puede eliminar <strong>{nombre}</strong>: tiene{' '}
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{alumnosCount}</span>{' '}
|
||||
alumno{alumnosCount === 1 ? '' : 's'} como tutor. Reasígnalos primero desde su perfil.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
¿Seguro que quieres eliminar <strong>{nombre}</strong>?
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
|
||||
{bloqueado ? 'Cerrar' : 'Cancelar'}
|
||||
</button>
|
||||
{!bloqueado && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Eliminando…' : 'Eliminar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
maestro?: { id: number; nombre: string; activo: boolean };
|
||||
};
|
||||
|
||||
export default function MaestroForm({ mode, maestro }: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
const errorRef = useRef<HTMLParagraphElement | null>(null);
|
||||
const [nombre, setNombre] = useState(maestro?.nombre ?? '');
|
||||
const [activo, setActivo] = useState(maestro?.activo ?? true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const titleId = `mae-form-${mode}-${maestro?.id ?? 'new'}`;
|
||||
|
||||
const open = () => {
|
||||
setError(null);
|
||||
if (mode === 'create') {
|
||||
setNombre('');
|
||||
setActivo(true);
|
||||
}
|
||||
dialogRef.current?.showModal();
|
||||
queueMicrotask(() => firstFieldRef.current?.focus());
|
||||
};
|
||||
const close = () => {
|
||||
if (loading) return;
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
if (!dlg) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (e.target === dlg) close();
|
||||
};
|
||||
dlg.addEventListener('click', onClick);
|
||||
return () => dlg.removeEventListener('click', onClick);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) errorRef.current?.focus();
|
||||
}, [error]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
const trimmed = nombre.trim();
|
||||
if (!trimmed) {
|
||||
setError('El nombre es obligatorio');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const url = mode === 'create' ? '/api/admin/maestros' : `/api/admin/maestros/${maestro!.id}`;
|
||||
const method = mode === 'create' ? 'POST' : 'PATCH';
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nombre: trimmed, activo }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
|
||||
dialogRef.current?.close();
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLabel = mode === 'create' ? 'Nuevo maestro' : 'Editar';
|
||||
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={triggerClass} onClick={open}>
|
||||
{triggerLabel}
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-labelledby={titleId}
|
||||
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,24rem)]"
|
||||
>
|
||||
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 id={titleId} className="text-lg font-semibold">
|
||||
{mode === 'create' ? 'Nuevo maestro' : `Editar: ${maestro?.nombre}`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Cerrar"
|
||||
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor={`${titleId}-nombre`}>
|
||||
Nombre
|
||||
</label>
|
||||
<input
|
||||
ref={firstFieldRef}
|
||||
id={`${titleId}-nombre`}
|
||||
className="input"
|
||||
type="text"
|
||||
value={nombre}
|
||||
onChange={(e) => setNombre(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${titleId}-err` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activo}
|
||||
onChange={(e) => setActivo(e.target.checked)}
|
||||
/>
|
||||
Activo (visible en la lista de tutores)
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
ref={errorRef}
|
||||
id={`${titleId}-err`}
|
||||
role="alert"
|
||||
tabIndex={-1}
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--color-danger-text)' }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Guardando…' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -39,10 +39,18 @@ export function useCart() {
|
||||
|
||||
const clamp = (n: number, max: number) => Math.max(1, Math.min(max, n));
|
||||
|
||||
export default function CartProvider({ materiales }: { materiales: Material[] }) {
|
||||
export default function CartProvider({
|
||||
materiales,
|
||||
tutorNombre = null,
|
||||
perfilCompleto = true,
|
||||
}: {
|
||||
materiales: Material[];
|
||||
tutorNombre?: string | null;
|
||||
perfilCompleto?: boolean;
|
||||
}) {
|
||||
const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [maestroResponsable, setMaestroResponsable] = useState('');
|
||||
const [notas, setNotas] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -129,7 +137,7 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading || items.length === 0) return;
|
||||
if (loading || items.length === 0 || !perfilCompleto) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -151,10 +159,13 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
|
||||
}
|
||||
setOk(true);
|
||||
const usedFallback = maestroResponsable.trim() === '' && !!tutorNombre;
|
||||
toastAfterReload({
|
||||
kind: 'success',
|
||||
title: 'Solicitud enviada',
|
||||
description: `${items.length} material${items.length === 1 ? '' : 'es'} · pendiente de aprobación`,
|
||||
description: usedFallback
|
||||
? `${items.length} material${items.length === 1 ? '' : 'es'} · maestro: ${tutorNombre} (tu tutor)`
|
||||
: `${items.length} material${items.length === 1 ? '' : 'es'} · pendiente de aprobación`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
dialogRef.current?.close();
|
||||
@@ -308,16 +319,27 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="maestro-responsable">Maestro responsable</label>
|
||||
<input
|
||||
<textarea
|
||||
ref={firstFieldRef}
|
||||
id="maestro-responsable"
|
||||
className="input"
|
||||
type="text"
|
||||
required
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
value={maestroResponsable}
|
||||
onChange={(e) => setMaestroResponsable(e.target.value)}
|
||||
placeholder={tutorNombre ?? 'Escribe el nombre del maestro responsable'}
|
||||
aria-describedby="maestro-hint"
|
||||
style={{ minHeight: '64px' }}
|
||||
/>
|
||||
{tutorNombre ? (
|
||||
<p id="maestro-hint" className="text-xs opacity-70 mt-1">
|
||||
Si dejas esto vacío, se usará tu tutor: <strong>{tutorNombre}</strong>.
|
||||
</p>
|
||||
) : (
|
||||
<p id="maestro-hint" className="text-xs opacity-60 mt-1">
|
||||
Debes escribir un maestro; no tienes tutor guardado.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -349,14 +371,21 @@ export default function CartProvider({ materiales }: { materiales: Material[] })
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
|
||||
{loading ? 'Enviando…' : 'Enviar solicitud'}
|
||||
</button>
|
||||
</div>
|
||||
{!perfilCompleto ? (
|
||||
<div className="card p-3">
|
||||
<p className="text-sm mb-3">Necesitas completar tu perfil (matrícula + tutor) para crear vales.</p>
|
||||
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
|
||||
{loading ? 'Enviando…' : 'Enviar solicitud'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</dialog>
|
||||
</CartContext.Provider>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AvatarInfo } from '@/lib/avatar';
|
||||
|
||||
type Props = {
|
||||
info: AvatarInfo;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Contraste rápido: fondo HSL con L=55% → texto blanco pasa AA con casi todos los hues.
|
||||
// Mantenemos blanco fijo para consistencia visual.
|
||||
export default function Avatar({ info, size = 40, className = '' }: Props) {
|
||||
const style: React.CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
border: '2px solid var(--color-ink)',
|
||||
borderRadius: 2,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
overflow: 'hidden',
|
||||
background: info.src ? 'white' : info.hueColor,
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
fontSize: Math.max(11, Math.floor(size * 0.4)),
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
};
|
||||
return (
|
||||
<div className={className} style={style} aria-hidden="true">
|
||||
{info.src ? (
|
||||
<img
|
||||
src={info.src}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<span>{info.initials}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
import { toast, toastAfterReload } from '@/lib/toast';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
type Maestro = { id: number; nombre: string };
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
email: string;
|
||||
nombre: string | null;
|
||||
initialProfile: {
|
||||
matricula: string | null;
|
||||
semestre: string | null;
|
||||
tutor_id: number | null;
|
||||
foto_path: string | null;
|
||||
};
|
||||
maestros: Maestro[];
|
||||
googleAvatarUrl?: string | null;
|
||||
mode?: 'edit' | 'onboarding';
|
||||
};
|
||||
|
||||
const BUCKET = 'avatares';
|
||||
const SEM_OPTS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
||||
|
||||
export default function PerfilForm({
|
||||
userId,
|
||||
email,
|
||||
nombre,
|
||||
initialProfile,
|
||||
maestros,
|
||||
googleAvatarUrl,
|
||||
mode = 'edit',
|
||||
}: Props) {
|
||||
const [matricula, setMatricula] = useState(initialProfile.matricula ?? '');
|
||||
const [semestre, setSemestre] = useState(initialProfile.semestre ?? '');
|
||||
const [tutorId, setTutorId] = useState<string>(
|
||||
initialProfile.tutor_id != null ? String(initialProfile.tutor_id) : '',
|
||||
);
|
||||
const [fotoPath, setFotoPath] = useState<string | null>(initialProfile.foto_path);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
setPreviewUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
const info = avatarInfo({
|
||||
email,
|
||||
nombre,
|
||||
fotoPath,
|
||||
googleAvatarUrl,
|
||||
});
|
||||
const previewInfo = previewUrl
|
||||
? { ...info, src: previewUrl }
|
||||
: info;
|
||||
|
||||
async function uploadAvatar(): Promise<string | null> {
|
||||
if (!file) return null;
|
||||
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg';
|
||||
const path = `${userId}/${Date.now()}.${ext}`;
|
||||
const { error: upErr } = await browserClient()
|
||||
.storage.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type || undefined });
|
||||
if (upErr) {
|
||||
toast({ kind: 'error', title: 'La foto no se subió', description: upErr.message });
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let nuevoPath: string | null | undefined = undefined;
|
||||
if (file) {
|
||||
nuevoPath = await uploadAvatar();
|
||||
}
|
||||
const body: Record<string, string | number | null> = {
|
||||
matricula: matricula.trim() || null,
|
||||
semestre: semestre || null,
|
||||
tutor_id: tutorId ? Number(tutorId) : null,
|
||||
};
|
||||
if (nuevoPath !== undefined) body.foto_path = nuevoPath;
|
||||
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error ?? 'no se pudo guardar');
|
||||
|
||||
if (mode === 'onboarding') {
|
||||
toastAfterReload({ kind: 'success', title: 'Perfil guardado', description: 'Ya puedes crear vales.' });
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
toastAfterReload({ kind: 'success', title: 'Perfil actualizado' });
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error inesperado');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-5" autoComplete="off">
|
||||
<div>
|
||||
<label className="label" htmlFor="p-foto">Foto</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar info={previewInfo} size={72} />
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="p-foto"
|
||||
className="input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{(file || fotoPath) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost self-start"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setFotoPath(null);
|
||||
}}
|
||||
>
|
||||
Quitar foto
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!file && !fotoPath && googleAvatarUrl && (
|
||||
<p className="text-xs opacity-70 mt-2">Mostrando tu foto de Google. Sube una para reemplazarla.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-mat">Matrícula</label>
|
||||
<input
|
||||
id="p-mat"
|
||||
className="input"
|
||||
type="text"
|
||||
value={matricula}
|
||||
onChange={(e) => setMatricula(e.target.value)}
|
||||
placeholder="123456"
|
||||
autoComplete="off"
|
||||
maxLength={15}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-sem">Semestre</label>
|
||||
<select
|
||||
id="p-sem"
|
||||
className="input"
|
||||
value={semestre}
|
||||
onChange={(e) => setSemestre(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{SEM_OPTS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-tutor">Tutor</label>
|
||||
<select
|
||||
id="p-tutor"
|
||||
className="input"
|
||||
value={tutorId}
|
||||
onChange={(e) => setTutorId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{maestros.map((m) => (
|
||||
<option key={m.id} value={String(m.id)}>{m.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs opacity-70 mt-1">Se rellenará por defecto al crear vales.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2 flex-wrap">
|
||||
{mode === 'onboarding' && (
|
||||
<a href="/" className="btn btn-ghost">Omitir por ahora</a>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Guardando…' : mode === 'onboarding' ? 'Guardar y continuar' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Vendored
+3
@@ -8,6 +8,9 @@ export type Profile = {
|
||||
nombre: string | null;
|
||||
matricula: string | null;
|
||||
rol: 'alumno' | 'admin';
|
||||
semestre: string | null;
|
||||
tutor_id: number | null;
|
||||
foto_path: string | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -3,15 +3,35 @@ import Layout from './Layout.astro';
|
||||
import Toaster from '@/components/Toaster.tsx';
|
||||
import BadgeSolicitudes from '@/components/admin/BadgeSolicitudes.tsx';
|
||||
import BrandMark from '@/components/BrandMark.astro';
|
||||
import Avatar from '@/components/profile/Avatar.tsx';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
}
|
||||
const { title } = Astro.props;
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const profile = Astro.locals.profile;
|
||||
const isAdmin = profile?.rol === 'admin';
|
||||
|
||||
const googleAvatarUrl = user
|
||||
? ((user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined) ||
|
||||
((user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined) ||
|
||||
null
|
||||
: null;
|
||||
|
||||
const avatar = profile
|
||||
? avatarInfo({
|
||||
email: profile.email,
|
||||
nombre: profile.nombre,
|
||||
fotoPath: profile.foto_path,
|
||||
googleAvatarUrl,
|
||||
})
|
||||
: null;
|
||||
|
||||
const perfilIncompleto = profile?.rol === 'alumno' && (!profile.matricula || !profile.tutor_id);
|
||||
|
||||
let pendientesCount = 0;
|
||||
if (isAdmin) {
|
||||
const { count } = await Astro.locals.supabase
|
||||
@@ -112,11 +132,24 @@ const activeCheck = (item: NavItem) => {
|
||||
))}
|
||||
</nav>
|
||||
<div class="p-3" style="border-top: 2px solid var(--color-ink);">
|
||||
<div class="px-3 py-2 text-sm">
|
||||
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||
<div class="text-xs uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div>
|
||||
</div>
|
||||
<form method="POST" action="/api/auth/signout">
|
||||
<a href="/perfil" class="flex items-center gap-3 px-2 py-2 rounded-[2px] hover:bg-[color:var(--color-chalk)] transition-colors">
|
||||
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||
<div class="text-xs uppercase tracking-wide flex items-center gap-1.5" style="color: var(--color-pencil);">
|
||||
<span>Ver perfil</span>
|
||||
{perfilIncompleto && (
|
||||
<span
|
||||
aria-label="Perfil incompleto"
|
||||
title="Perfil incompleto"
|
||||
class="inline-block w-2 h-2 rounded-full motion-safe:animate-pulse"
|
||||
style="background: var(--color-danger);"
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<form method="POST" action="/api/auth/signout" class="mt-1">
|
||||
<button type="submit" class="w-full text-left px-3 py-2 rounded-[2px] text-sm uppercase tracking-wide hover:bg-[color:var(--color-chalk)] transition-colors flex items-center gap-2">
|
||||
<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} />
|
||||
@@ -135,10 +168,16 @@ const activeCheck = (item: NavItem) => {
|
||||
</div>
|
||||
<span class="font-semibold text-sm uppercase tracking-wide">LabPréstamos</span>
|
||||
</a>
|
||||
<div class="text-xs truncate max-w-[45%] text-right">
|
||||
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
||||
<div class="uppercase tracking-wide" style="color: var(--color-pencil);">{profile?.rol}</div>
|
||||
</div>
|
||||
<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">
|
||||
{avatar && <Avatar client:load info={avatar} size={36} />}
|
||||
{perfilIncompleto && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full motion-safe:animate-pulse"
|
||||
style="background: var(--color-danger); border: 1.5px solid var(--color-ink);"
|
||||
></span>
|
||||
)}
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main id="main" class="flex-1 p-4 md:p-8 pb-24 md:pb-8 md:ml-64 sidebar-aware transition-[margin] duration-200 ease-out">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Helper puro para resolver avatar del usuario: URL, iniciales y color de fondo.
|
||||
export type AvatarInfo = {
|
||||
src?: string | null;
|
||||
initials: string;
|
||||
hueColor: string;
|
||||
};
|
||||
|
||||
function supabaseBase(): string | undefined {
|
||||
return (
|
||||
(import.meta.env.PUBLIC_SUPABASE_URL as string | undefined) ??
|
||||
(process.env.PUBLIC_SUPABASE_URL as string | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function fotoUrl(path: string): string | null {
|
||||
const base = supabaseBase();
|
||||
if (!base) return null;
|
||||
return `${base}/storage/v1/object/public/avatares/${path}`;
|
||||
}
|
||||
|
||||
function initialsFrom(nombre: string | null | undefined, email: string): string {
|
||||
const src = (nombre ?? '').trim();
|
||||
if (src) {
|
||||
const parts = src.split(/\s+/).filter(Boolean);
|
||||
const letters = parts.slice(0, 2).map((p) => p[0]!.toUpperCase());
|
||||
if (letters.length) return letters.join('');
|
||||
}
|
||||
const local = (email.split('@')[0] ?? '').trim();
|
||||
return (local[0] ?? '?').toUpperCase();
|
||||
}
|
||||
|
||||
function hashHue(seed: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
export function avatarInfo(args: {
|
||||
email: string;
|
||||
nombre?: string | null;
|
||||
fotoPath?: string | null;
|
||||
googleAvatarUrl?: string | null;
|
||||
}): AvatarInfo {
|
||||
const src = args.fotoPath
|
||||
? fotoUrl(args.fotoPath)
|
||||
: (args.googleAvatarUrl ?? undefined);
|
||||
return {
|
||||
src: src ?? undefined,
|
||||
initials: initialsFrom(args.nombre, args.email),
|
||||
hueColor: `hsl(${hashHue(args.email.toLowerCase())}, 60%, 55%)`,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,43 @@ function tzOffset(date: Date, tz: string): string {
|
||||
return m?.[1] ?? '+00:00';
|
||||
}
|
||||
|
||||
function hourMX(now: Date = new Date()): number {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: MX_TZ,
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(now);
|
||||
const h = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10);
|
||||
return h % 24; // "24" en algunos runtimes al filo de medianoche.
|
||||
}
|
||||
|
||||
export function saludoHora(
|
||||
now: Date = new Date()
|
||||
): 'Buenos días' | 'Buenas tardes' | 'Buenas noches' {
|
||||
const h = hourMX(now);
|
||||
if (h >= 5 && h < 12) return 'Buenos días';
|
||||
if (h >= 12 && h < 19) return 'Buenas tardes';
|
||||
return 'Buenas noches';
|
||||
}
|
||||
|
||||
// "Hoy a las 15:32" / "Ayer a las 10:00" / "Hace 3 días" / fecha absoluta.
|
||||
export function fmtFechaRelativa(input: string | Date, now: Date = new Date()): string {
|
||||
const d = typeof input === 'string' ? new Date(input) : input;
|
||||
const hoy = todayMX(now);
|
||||
const suDia = todayMX(d);
|
||||
const diffDays = Math.round((hoy.startUTC.getTime() - suDia.startUTC.getTime()) / 86400000);
|
||||
const hora = new Intl.DateTimeFormat('es-MX', {
|
||||
timeZone: MX_TZ,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
if (diffDays === 0) return `Hoy a las ${hora}`;
|
||||
if (diffDays === 1) return `Ayer a las ${hora}`;
|
||||
if (diffDays > 1 && diffDays < 7) return `Hace ${diffDays} días`;
|
||||
return new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeZone: MX_TZ }).format(d);
|
||||
}
|
||||
|
||||
export function todayMX(now: Date = new Date()): {
|
||||
label: string;
|
||||
startUTC: Date;
|
||||
|
||||
+2
-2
@@ -43,8 +43,8 @@ export function serverClient(cookies: AstroCookies) {
|
||||
|
||||
export function browserClient() {
|
||||
return createBrowserClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
|
||||
SUPABASE_URL,
|
||||
SUPABASE_ANON_KEY,
|
||||
{ db: { schema: SCHEMA }, cookieOptions },
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
if (user) {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email, nombre, matricula, rol')
|
||||
.select('id, email, nombre, matricula, rol, semestre, tutor_id, foto_path')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
context.locals.profile = profile ?? null;
|
||||
|
||||
+156
-64
@@ -1,32 +1,52 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import { todayMX } from '@/lib/date';
|
||||
import { todayMX, saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const profile = Astro.locals.profile;
|
||||
const dia = todayMX();
|
||||
const saludo = saludoHora();
|
||||
|
||||
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
||||
const nombreCorto =
|
||||
profile?.nombre?.trim().split(/\s+/)[0] ??
|
||||
profile?.email?.split('@')[0] ??
|
||||
'';
|
||||
|
||||
type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido';
|
||||
type Item = { cantidad: number; material: { nombre: string } | null };
|
||||
type Alumno = { nombre: string | null; email: string } | null;
|
||||
type Sol = {
|
||||
id: number;
|
||||
estado: Estado;
|
||||
fecha_solicitud: string;
|
||||
alumno: Alumno;
|
||||
items: Item[];
|
||||
};
|
||||
|
||||
const [pend, vencidos, pendientes2, ultimas] = await Promise.all([
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('estado', ['aprobado', 'activo'])
|
||||
.lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.eq('estado', 'pendiente')
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(2),
|
||||
supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const pendientesData = (pendientes2.data ?? []) as unknown as Sol[];
|
||||
const ultimasData = (ultimas.data ?? []) as unknown as Sol[];
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Pendientes', value: pend.count ?? 0, href: '/admin/solicitudes', tone: 'primary' },
|
||||
{ label: 'En préstamo', value: activos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'secondary' },
|
||||
{ label: 'Vencidos', value: vencidos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'danger' },
|
||||
{ label: 'Agotados', value: agotados.count ?? 0, href: '/admin/inventario', tone: 'ink' },
|
||||
];
|
||||
|
||||
const estadoLabel: Record<string, string> = {
|
||||
const estadoLabel: Record<Estado, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'En préstamo',
|
||||
activo: 'En préstamo',
|
||||
@@ -34,65 +54,137 @@ const estadoLabel: Record<string, string> = {
|
||||
rechazado: 'Rechazado',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoColor: Record<Estado, string> = {
|
||||
pendiente: 'var(--color-ink)',
|
||||
aprobado: 'var(--color-positive)',
|
||||
activo: 'var(--color-positive)',
|
||||
devuelto: 'var(--color-ink)',
|
||||
rechazado: 'var(--color-danger)',
|
||||
vencido: 'var(--color-danger)',
|
||||
};
|
||||
const badgeStyle = (e: Estado) =>
|
||||
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
|
||||
|
||||
const resumenItems = (items: Item[]) => {
|
||||
const preview = items.slice(0, 3).map((i) => `${i.cantidad}× ${i.material?.nombre ?? '—'}`).join(', ');
|
||||
const extra = items.length - 3;
|
||||
return extra > 0 ? `${preview} (+${extra})` : preview;
|
||||
};
|
||||
|
||||
const kpiPend = pend.count ?? 0;
|
||||
const kpiVenc = vencidos.count ?? 0;
|
||||
---
|
||||
<AppLayout title="Panel — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-pencil);">Resumen del laboratorio.</p>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<header class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||
{saludo}, {nombreCorto}
|
||||
</h1>
|
||||
<div class="flex gap-3 shrink-0">
|
||||
<a href="/admin/solicitudes" class="card-flat px-4 py-3 text-center">
|
||||
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiPend > 0 ? 'var(--color-primary)' : 'var(--color-ink)'};`}>{kpiPend}</div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Pendientes</div>
|
||||
</a>
|
||||
<a href="/admin/solicitudes/activos" class="card-flat px-4 py-3 text-center">
|
||||
<div class="text-2xl font-semibold leading-none" style={`font-variant-numeric: tabular-nums; color: ${kpiVenc > 0 ? 'var(--color-danger)' : 'var(--color-ink)'};`}>{kpiVenc}</div>
|
||||
<div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-pencil);">Vencidos</div>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
{kpis.map((k) => (
|
||||
<a href={k.href} class="card-link">
|
||||
<div
|
||||
class="text-4xl font-semibold leading-none"
|
||||
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`}
|
||||
>
|
||||
{k.value}
|
||||
</div>
|
||||
<div class="mt-2 text-sm uppercase tracking-wide" style="color: var(--color-pencil);">{k.label}</div>
|
||||
</a>
|
||||
))}
|
||||
<section aria-labelledby="atencion-h" class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 id="atencion-h" class="text-lg font-semibold uppercase tracking-wide">Requieren tu atención</h2>
|
||||
{pendientesData.length > 0 && (
|
||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||||
)}
|
||||
</div>
|
||||
{pendientesData.length === 0 ? (
|
||||
<div class="card text-center">
|
||||
<p class="text-sm opacity-70">Todo al día. No hay solicitudes pendientes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{pendientesData.map((s) => (
|
||||
<a href="/admin/solicitudes" class="card-link">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h3 class="font-semibold leading-tight">{s.alumno?.nombre ?? s.alumno?.email ?? '—'}</h3>
|
||||
<span class="badge shrink-0" style={badgeStyle(s.estado)}>{estadoLabel[s.estado]}</span>
|
||||
</div>
|
||||
<p class="text-sm leading-snug mb-3" style="color: var(--color-pencil);">{resumenItems(s.items ?? [])}</p>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span style="color: var(--color-pencil);">{fmtFechaRelativa(s.fecha_solicitud)}</span>
|
||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Revisar</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ultimas-h">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Últimas 5 solicitudes</h2>
|
||||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Actividad reciente</h2>
|
||||
<a href="/admin/solicitudes/historial" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Historial</a>
|
||||
</div>
|
||||
<div class="card-flat p-0 overflow-hidden">
|
||||
{ultimas.data && ultimas.data.length > 0 ? (
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="p-3 font-medium">Alumno</th>
|
||||
<th class="p-3 font-medium">Material</th>
|
||||
<th class="p-3 font-medium">Estado</th>
|
||||
<th class="p-3 font-medium">Solicitado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ultimas.data.map((r: any) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{(r.items ?? []).map((i: any) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
||||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
||||
|
||||
{ultimasData.length === 0 ? (
|
||||
<div class="card text-center">
|
||||
<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">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="p-3 font-medium">Alumno</th>
|
||||
<th class="p-3 font-medium">Material</th>
|
||||
<th class="p-3 font-medium">Estado</th>
|
||||
<th class="p-3 font-medium">Solicitado</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
|
||||
)}
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ultimasData.map((r) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{(r.items ?? []).map((i) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<span class="badge" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
|
||||
</td>
|
||||
<td class="p-3 opacity-80">{fmtFechaRelativa(r.fecha_solicitud)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
{ultimasData.map((r) => (
|
||||
<article class="card">
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-semibold leading-tight truncate">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</h3>
|
||||
<p class="text-xs mt-1" style="color: var(--color-pencil);">{fmtFechaRelativa(r.fecha_solicitud)}</p>
|
||||
</div>
|
||||
<span class="badge shrink-0" style={badgeStyle(r.estado)}>{estadoLabel[r.estado]}</span>
|
||||
</div>
|
||||
<div class="text-sm leading-snug">
|
||||
{(r.items ?? []).map((i) => (
|
||||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -37,6 +37,9 @@ const categorias = ((data ?? []) as unknown as CategoriaRow[]).map((c) => ({
|
||||
<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);">
|
||||
Categorías
|
||||
</a>
|
||||
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Maestros
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -181,9 +181,10 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
{m.numero_inventario && (
|
||||
<p class="text-xs opacity-60 font-mono truncate">{m.numero_inventario}</p>
|
||||
)}
|
||||
<div class="flex gap-2 mt-auto pt-2">
|
||||
<div class="grid grid-cols-2 gap-2 mt-auto pt-2">
|
||||
<MaterialForm
|
||||
mode="edit"
|
||||
compact
|
||||
material={{
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
@@ -199,7 +200,7 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
categorias={categorias}
|
||||
client:load
|
||||
/>
|
||||
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
|
||||
<EliminarMaterial id={m.id} nombre={m.nombre} compact client:load />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import MaestroForm from '@/components/admin/maestros/MaestroForm.tsx';
|
||||
import EliminarMaestro from '@/components/admin/maestros/EliminarMaestro.tsx';
|
||||
|
||||
type MaestroRow = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
activo: boolean;
|
||||
alumnos: { count: number }[];
|
||||
};
|
||||
|
||||
const { data, error } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre, activo, alumnos:profiles!tutor_id(count)')
|
||||
.order('nombre');
|
||||
|
||||
const maestros = ((data ?? []) as unknown as MaestroRow[]).map((m) => ({
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
activo: m.activo,
|
||||
count: m.alumnos?.[0]?.count ?? 0,
|
||||
}));
|
||||
---
|
||||
<AppLayout title="Maestros — Admin">
|
||||
<div class="max-w-4xl">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Maestros</h1>
|
||||
<p class="text-sm opacity-70 mt-1">Catálogo de tutores que los alumnos eligen en su perfil.</p>
|
||||
</div>
|
||||
<MaestroForm mode="create" client:load />
|
||||
</header>
|
||||
|
||||
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
|
||||
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Materiales
|
||||
</a>
|
||||
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
|
||||
Categorías
|
||||
</a>
|
||||
<a href="/admin/maestros" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||
Maestros
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
|
||||
<p class="text-sm" style="color: var(--color-danger-text);">
|
||||
No se pudieron cargar los maestros. Recarga la página.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && maestros.length === 0 && (
|
||||
<div class="card text-center">
|
||||
<h2 class="text-lg font-semibold mb-1">Sin maestros aún</h2>
|
||||
<p class="text-sm opacity-70">Agrega el primero para que los alumnos puedan elegirlo como tutor.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && maestros.length > 0 && (
|
||||
<>
|
||||
<div class="hidden md:block card p-0 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Nombre</th>
|
||||
<th class="px-4 py-3 font-medium">Estado</th>
|
||||
<th class="px-4 py-3 font-medium text-right"># Alumnos</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{maestros.map((m) => (
|
||||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
|
||||
<td class="px-4 py-3 font-medium">{m.nombre}</td>
|
||||
<td class="px-4 py-3">
|
||||
{m.activo ? (
|
||||
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
|
||||
) : (
|
||||
<span class="text-xs uppercase tracking-wide opacity-60">Inactivo</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
|
||||
{m.count > 0 ? m.count : <span class="opacity-60">0</span>}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="md:hidden flex flex-col gap-3">
|
||||
{maestros.map((m) => (
|
||||
<article class="card flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold">{m.nombre}</h3>
|
||||
<p class="text-xs opacity-70 mt-1">
|
||||
{m.activo ? 'Activo' : 'Inactivo'} · {m.count} alumno{m.count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
<MaestroForm mode="edit" maestro={{ id: m.id, nombre: m.nombre, activo: m.activo }} client:load />
|
||||
<EliminarMaestro id={m.id} nombre={m.nombre} alumnosCount={m.count} client:load />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -6,6 +6,29 @@ import BuscadorCatalogo from '@/components/alumno/BuscadorCatalogo.tsx';
|
||||
|
||||
const qInitial = Astro.url.searchParams.get('q') ?? '';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const userId = Astro.locals.user?.id;
|
||||
|
||||
// Trae matricula + tutor_id directo (el middleware sólo hidrata columnas antiguas).
|
||||
let matricula: string | null = null;
|
||||
let tutorId: number | null = null;
|
||||
if (userId) {
|
||||
const { data: p } = await supabase
|
||||
.from('profiles')
|
||||
.select('matricula, tutor_id')
|
||||
.eq('id', userId)
|
||||
.maybeSingle();
|
||||
matricula = p?.matricula ?? null;
|
||||
tutorId = (p as { tutor_id?: number | null } | null)?.tutor_id ?? null;
|
||||
}
|
||||
|
||||
let tutorNombre: string | null = null;
|
||||
if (tutorId) {
|
||||
const { data } = await supabase.from('maestros').select('nombre').eq('id', tutorId).maybeSingle();
|
||||
tutorNombre = data?.nombre ?? null;
|
||||
}
|
||||
const perfilCompleto = !!(matricula && tutorId);
|
||||
|
||||
type Material = {
|
||||
id: number;
|
||||
nombre: string;
|
||||
@@ -72,7 +95,7 @@ const categoriasFiltro = grupoList
|
||||
Ningún material en esta categoría. Prueba con otra.
|
||||
</p>
|
||||
|
||||
<CartProvider materiales={materiales} client:load />
|
||||
<CartProvider materiales={materiales} tutorNombre={tutorNombre} perfilCompleto={perfilCompleto} client:load />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const parseId = (raw: string | undefined) => {
|
||||
const id = Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
};
|
||||
|
||||
export const PATCH: APIRoute = async ({ request, locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof body.nombre === 'string') {
|
||||
const nombre = body.nombre.trim();
|
||||
if (!nombre) return Response.json({ error: 'El nombre no puede estar vacío' }, { status: 400 });
|
||||
patch.nombre = nombre;
|
||||
}
|
||||
if (typeof body.activo === 'boolean') patch.activo = body.activo;
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return Response.json({ error: 'Nada para actualizar' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await locals.supabase.from('maestros').update(patch).eq('id', id);
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 });
|
||||
}
|
||||
return Response.json({ error: 'No se pudo actualizar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ locals, params }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseId(params.id);
|
||||
if (!id) return Response.json({ error: 'ID inválido' }, { status: 400 });
|
||||
|
||||
const { count } = await locals.supabase
|
||||
.from('profiles')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('tutor_id', id);
|
||||
|
||||
const { error } = await locals.supabase.from('maestros').delete().eq('id', id);
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return Response.json(
|
||||
{ error: `Tiene ${count ?? 'algún'} alumno${(count ?? 0) === 1 ? '' : 's'} como tutor — reasígnalos primero` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return Response.json({ error: 'No se pudo eliminar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (locals.profile?.rol !== 'admin') {
|
||||
return Response.json({ error: 'No autorizado' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { nombre?: unknown; activo?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const nombre = typeof body.nombre === 'string' ? body.nombre.trim() : '';
|
||||
if (!nombre) {
|
||||
return Response.json({ error: 'El nombre es obligatorio' }, { status: 400 });
|
||||
}
|
||||
const activo = typeof body.activo === 'boolean' ? body.activo : true;
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('maestros')
|
||||
.insert({ nombre, activo })
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23505') {
|
||||
return Response.json({ error: 'Ya existe un maestro con ese nombre' }, { status: 409 });
|
||||
}
|
||||
return Response.json({ error: 'No se pudo crear el maestro' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ id: data.id }, { status: 201 });
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const MAT_RE = /^[A-Za-z0-9]{5,15}$/;
|
||||
const SEM_VALID = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']);
|
||||
|
||||
type PatchBody = {
|
||||
matricula?: unknown;
|
||||
semestre?: unknown;
|
||||
tutor_id?: unknown;
|
||||
foto_path?: unknown;
|
||||
};
|
||||
|
||||
function cleanStr(v: unknown): string | null | undefined {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null) return null;
|
||||
if (typeof v !== 'string') return undefined;
|
||||
const t = v.trim();
|
||||
return t === '' ? null : t;
|
||||
}
|
||||
|
||||
export const PATCH: APIRoute = async ({ request, locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) return Response.json({ error: 'no autorizado' }, { status: 401 });
|
||||
|
||||
let body: PatchBody;
|
||||
try {
|
||||
body = (await request.json()) as PatchBody;
|
||||
} catch {
|
||||
return Response.json({ error: 'JSON inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload: Record<string, string | number | null> = {};
|
||||
|
||||
const matricula = cleanStr(body.matricula);
|
||||
if (matricula !== undefined) {
|
||||
if (matricula !== null && !MAT_RE.test(matricula)) {
|
||||
return Response.json({ error: 'matrícula: 5–15 caracteres alfanuméricos' }, { status: 400 });
|
||||
}
|
||||
payload.matricula = matricula;
|
||||
}
|
||||
|
||||
const semestre = cleanStr(body.semestre);
|
||||
if (semestre !== undefined) {
|
||||
if (semestre !== null && !SEM_VALID.has(semestre)) {
|
||||
return Response.json({ error: 'semestre: debe ser entre 1 y 12' }, { status: 400 });
|
||||
}
|
||||
payload.semestre = semestre;
|
||||
}
|
||||
|
||||
if (body.tutor_id !== undefined) {
|
||||
if (body.tutor_id === null) {
|
||||
payload.tutor_id = null;
|
||||
} else if (typeof body.tutor_id === 'number' && Number.isInteger(body.tutor_id)) {
|
||||
payload.tutor_id = body.tutor_id;
|
||||
} else {
|
||||
return Response.json({ error: 'tutor_id inválido' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const fotoPath = cleanStr(body.foto_path);
|
||||
if (fotoPath !== undefined) payload.foto_path = fotoPath;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return Response.json({ error: 'sin cambios' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data, error } = await locals.supabase
|
||||
.from('profiles')
|
||||
.update(payload)
|
||||
.eq('id', user.id)
|
||||
.select('id, email, nombre, matricula, rol, semestre, tutor_id, foto_path')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if ((error as { code?: string }).code === '23503') {
|
||||
return Response.json({ error: 'tutor no válido' }, { status: 400 });
|
||||
}
|
||||
return Response.json({ error: 'no se pudo actualizar' }, { status: 500 });
|
||||
}
|
||||
|
||||
return Response.json({ ok: true, profile: data });
|
||||
};
|
||||
+95
-34
@@ -1,45 +1,106 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import { saludoHora, fmtFechaRelativa } from '@/lib/date';
|
||||
|
||||
const profile = Astro.locals.profile;
|
||||
const isAdmin = profile?.rol === 'admin';
|
||||
const nombre = profile?.nombre ?? profile?.email?.split('@')[0] ?? '';
|
||||
|
||||
if (profile?.rol === 'admin') {
|
||||
return Astro.redirect('/admin', 302);
|
||||
}
|
||||
|
||||
const user = Astro.locals.user;
|
||||
const supabase = Astro.locals.supabase;
|
||||
|
||||
const nombreCorto =
|
||||
profile?.nombre?.trim().split(/\s+/)[0] ??
|
||||
profile?.email?.split('@')[0] ??
|
||||
'';
|
||||
const saludo = saludoHora();
|
||||
|
||||
type Estado = 'pendiente' | 'aprobado' | 'activo' | 'rechazado' | 'devuelto' | 'vencido';
|
||||
type Item = { cantidad: number; material: { nombre: string } | null };
|
||||
type Actual = { id: number; estado: Estado; fecha_solicitud: string; items: Item[] } | null;
|
||||
|
||||
let actual: Actual = null;
|
||||
if (user) {
|
||||
const { data } = await supabase
|
||||
.from('solicitudes')
|
||||
.select('id, estado, fecha_solicitud, items:solicitud_items(cantidad, material:materiales(nombre))')
|
||||
.eq('alumno_id', user.id)
|
||||
.in('estado', ['pendiente', 'aprobado', 'activo'])
|
||||
.order('fecha_solicitud', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
actual = (data as unknown as Actual) ?? null;
|
||||
}
|
||||
|
||||
const estadoLabel: Record<Estado, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'Aprobado',
|
||||
activo: 'Activo',
|
||||
rechazado: 'Rechazado',
|
||||
devuelto: 'Devuelto',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoColor: Record<Estado, string> = {
|
||||
pendiente: 'var(--color-ink)',
|
||||
aprobado: 'var(--color-positive)',
|
||||
activo: 'var(--color-positive)',
|
||||
devuelto: 'var(--color-ink)',
|
||||
rechazado: 'var(--color-danger)',
|
||||
vencido: 'var(--color-danger)',
|
||||
};
|
||||
const badgeStyle = (e: Estado) =>
|
||||
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
|
||||
|
||||
const items = actual?.items ?? [];
|
||||
const preview = items.slice(0, 3);
|
||||
const extra = items.length - preview.length;
|
||||
---
|
||||
<AppLayout title="Inicio — LabPréstamos">
|
||||
<div class="max-w-3xl">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<header class="mb-8">
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Bienvenido{nombre ? ',' : ''}</p>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">{nombre || 'Laboratorio de Sistemas'}</h1>
|
||||
<h1 class="font-mono uppercase text-3xl md:text-5xl font-semibold leading-tight" style="letter-spacing: -0.01em;">
|
||||
{saludo}, {nombreCorto}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<a href="/admin/solicitudes" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Solicitudes</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Aprobar o rechazar peticiones de material.</p>
|
||||
</a>
|
||||
<a href="/admin/inventario" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Inventario</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Materiales y categorías del laboratorio.</p>
|
||||
</a>
|
||||
<a href="/admin/reportes" class="card-link md:col-span-2">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Reportes</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Historial y exportación de préstamos.</p>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a href="/alumno/catalogo" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Catálogo</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Ver material disponible y solicitar un préstamo.</p>
|
||||
</a>
|
||||
<a href="/alumno/mis-prestamos" class="card-link">
|
||||
<h2 class="font-semibold text-lg mb-1 uppercase tracking-wide">Mis préstamos</h2>
|
||||
<p class="text-sm" style="color: var(--color-pencil);">Estado de tus solicitudes actuales e historial.</p>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<section class="card-raised mb-8">
|
||||
<p class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Nuevo préstamo</p>
|
||||
<h2 class="text-xl md:text-2xl font-semibold mb-5">¿Qué vas a pedir hoy?</h2>
|
||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||
</section>
|
||||
|
||||
{actual ? (
|
||||
<section aria-labelledby="actual-h">
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 id="actual-h" class="text-lg font-semibold uppercase tracking-wide">Tu préstamo actual</h2>
|
||||
<span class="badge" style={badgeStyle(actual.estado)}>{estadoLabel[actual.estado]}</span>
|
||||
</div>
|
||||
<a href="/alumno/mis-prestamos" class="card-link">
|
||||
<ul class="space-y-1 mb-4">
|
||||
{preview.map((it) => (
|
||||
<li class="leading-snug">
|
||||
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
|
||||
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
|
||||
</li>
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<li class="text-sm opacity-70">(y {extra} más)</li>
|
||||
)}
|
||||
</ul>
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span style="color: var(--color-pencil);">Solicitado {fmtFechaRelativa(actual.fecha_solicitud)}</span>
|
||||
<span class="underline decoration-[color:var(--color-ink)] underline-offset-4">Ver detalles</span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
) : (
|
||||
<section class="card text-center">
|
||||
<h2 class="text-lg font-semibold mb-1">Aún no tienes préstamos activos</h2>
|
||||
<p class="text-sm opacity-70 mb-5">¿Vamos por algo del laboratorio?</p>
|
||||
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import BrandMark from '@/components/BrandMark.astro';
|
||||
import PerfilForm from '@/components/profile/PerfilForm.tsx';
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const profile = Astro.locals.profile!;
|
||||
|
||||
const googleAvatarUrl =
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined ||
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined ||
|
||||
null;
|
||||
|
||||
const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const primerNombre = (profile.nombre ?? profile.email.split('@')[0]).split(/\s+/)[0];
|
||||
---
|
||||
<Layout title="Bienvenido — LabPréstamos">
|
||||
<main id="main" class="min-h-screen grid place-items-center p-4">
|
||||
<div class="w-full max-w-lg">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] bg-[color:var(--color-primary)] p-4 mb-4" style="border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
|
||||
<BrandMark class="w-full h-full text-white" />
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">
|
||||
Bienvenido, {primerNombre}
|
||||
</h1>
|
||||
<p class="text-sm mt-2" style="color: var(--color-pencil);">
|
||||
Completa tu perfil para crear vales de préstamo. Puedes omitirlo, pero será requerido antes de solicitar material.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card-raised">
|
||||
<PerfilForm
|
||||
client:load
|
||||
mode="onboarding"
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
tutor_id: profile.tutor_id,
|
||||
foto_path: profile.foto_path,
|
||||
}}
|
||||
maestros={maestros}
|
||||
googleAvatarUrl={googleAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import PerfilForm from '@/components/profile/PerfilForm.tsx';
|
||||
import Avatar from '@/components/profile/Avatar.tsx';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
|
||||
const user = Astro.locals.user!;
|
||||
const profile = Astro.locals.profile!;
|
||||
|
||||
const googleAvatarUrl =
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.avatar_url as string | undefined ||
|
||||
(user.user_metadata as Record<string, unknown> | undefined)?.picture as string | undefined ||
|
||||
null;
|
||||
|
||||
const { data: maestrosData } = await Astro.locals.supabase
|
||||
.from('maestros')
|
||||
.select('id, nombre')
|
||||
.eq('activo', true)
|
||||
.order('nombre');
|
||||
const maestros = (maestrosData ?? []) as { id: number; nombre: string }[];
|
||||
|
||||
const info = avatarInfo({
|
||||
email: profile.email,
|
||||
nombre: profile.nombre,
|
||||
fotoPath: profile.foto_path,
|
||||
googleAvatarUrl,
|
||||
});
|
||||
---
|
||||
<AppLayout title="Mi perfil — LabPréstamos">
|
||||
<div class="max-w-md mx-auto">
|
||||
<header class="flex items-center gap-4 mb-6">
|
||||
<Avatar client:load info={info} size={80} />
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl md:text-2xl font-semibold uppercase tracking-wide leading-tight truncate">
|
||||
{profile.nombre ?? profile.email.split('@')[0]}
|
||||
</h1>
|
||||
<p class="text-sm truncate" style="color: var(--color-pencil);">{profile.email}</p>
|
||||
<span class="badge mt-2" style={`background: ${profile.rol === 'admin' ? 'var(--color-secondary)' : 'var(--color-chalk)'};`}>
|
||||
{profile.rol}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card-raised">
|
||||
<h2 class="text-sm uppercase tracking-wide font-semibold mb-4">Datos del perfil</h2>
|
||||
<PerfilForm
|
||||
client:load
|
||||
userId={user.id}
|
||||
email={profile.email}
|
||||
nombre={profile.nombre}
|
||||
initialProfile={{
|
||||
matricula: profile.matricula,
|
||||
semestre: profile.semestre,
|
||||
tutor_id: profile.tutor_id,
|
||||
foto_path: profile.foto_path,
|
||||
}}
|
||||
maestros={maestros}
|
||||
googleAvatarUrl={googleAvatarUrl}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -0,0 +1,203 @@
|
||||
-- 0004_perfil_maestros_avatares.sql
|
||||
-- 3 bloques:
|
||||
-- A) tabla prestamos.maestros (catalogo de tutores) + RLS
|
||||
-- B) profiles.tutor_id + profiles.foto_path
|
||||
-- C) bucket 'avatares' publico con RLS por carpeta = uid
|
||||
-- + update de crear_solicitud: fallback a nombre del tutor si el
|
||||
-- alumno deja p_maestro_responsable vacio.
|
||||
|
||||
begin;
|
||||
|
||||
-- ============================================================
|
||||
-- A) Tabla de maestros (catalogo admin-managed)
|
||||
-- ============================================================
|
||||
create table if not exists prestamos.maestros (
|
||||
id serial primary key,
|
||||
nombre text not null unique,
|
||||
activo boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table prestamos.maestros enable row level security;
|
||||
|
||||
drop policy if exists maestros_read_all on prestamos.maestros;
|
||||
drop policy if exists maestros_admin_write on prestamos.maestros;
|
||||
|
||||
create policy maestros_read_all on prestamos.maestros
|
||||
for select to authenticated using (true);
|
||||
|
||||
create policy maestros_admin_write on prestamos.maestros
|
||||
for all to authenticated
|
||||
using (prestamos.is_admin())
|
||||
with check (prestamos.is_admin());
|
||||
|
||||
grant select, insert, update, delete on prestamos.maestros to authenticated, service_role;
|
||||
grant usage, select on prestamos.maestros_id_seq to authenticated, service_role;
|
||||
|
||||
insert into prestamos.maestros (nombre) values
|
||||
('Sin especificar')
|
||||
on conflict (nombre) do nothing;
|
||||
|
||||
-- ============================================================
|
||||
-- B) profiles: tutor_id + foto_path
|
||||
-- ============================================================
|
||||
alter table prestamos.profiles
|
||||
add column if not exists tutor_id int references prestamos.maestros(id) on delete set null,
|
||||
add column if not exists foto_path text;
|
||||
|
||||
-- policy profiles_update_self (definida en 0001) usa:
|
||||
-- with check (id = auth.uid() and rol = (select rol from ...))
|
||||
-- solo bloquea cambios al rol, permite editar matricula/semestre/tutor_id/foto_path.
|
||||
-- No requiere cambios.
|
||||
|
||||
-- ============================================================
|
||||
-- C) Bucket 'avatares' publico + policies por carpeta = uid
|
||||
-- Path pattern esperado: '<uid>/<timestamp>.<ext>'
|
||||
-- ============================================================
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('avatares', 'avatares', true)
|
||||
on conflict (id) do update set public = true;
|
||||
|
||||
drop policy if exists "avatares_read_all" on storage.objects;
|
||||
drop policy if exists "avatares_owner_write" on storage.objects;
|
||||
|
||||
create policy "avatares_read_all" on storage.objects
|
||||
for select to anon, authenticated
|
||||
using (bucket_id = 'avatares');
|
||||
|
||||
create policy "avatares_owner_write" on storage.objects
|
||||
for all to authenticated
|
||||
using (
|
||||
bucket_id = 'avatares'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'avatares'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- D) crear_solicitud: si p_maestro_responsable llega vacio,
|
||||
-- autocompletar con el nombre del tutor guardado en profiles.
|
||||
-- Si el usuario tampoco tiene tutor, sigue el raise
|
||||
-- 'maestro_responsable_requerido' original -> el checkout ya
|
||||
-- guarda contra ese caso (perfil incompleto).
|
||||
-- ============================================================
|
||||
create or replace function prestamos.crear_solicitud(
|
||||
p_maestro_responsable text,
|
||||
p_notas text,
|
||||
p_items jsonb
|
||||
) returns bigint
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = prestamos, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_alumno uuid := auth.uid();
|
||||
v_solicitud_id bigint;
|
||||
v_item jsonb;
|
||||
v_material_id int;
|
||||
v_cantidad int;
|
||||
v_descripcion text;
|
||||
v_disponible int;
|
||||
v_estado_mat text;
|
||||
v_trackeado boolean;
|
||||
v_unidad_id bigint;
|
||||
v_asignadas int;
|
||||
v_maestro text;
|
||||
begin
|
||||
if v_alumno is null then
|
||||
raise exception 'no_autenticado';
|
||||
end if;
|
||||
|
||||
v_maestro := nullif(trim(coalesce(p_maestro_responsable, '')), '');
|
||||
|
||||
-- fallback al tutor del perfil si el alumno no escribio nada
|
||||
if v_maestro is null then
|
||||
select m.nombre into v_maestro
|
||||
from prestamos.profiles p
|
||||
join prestamos.maestros m on m.id = p.tutor_id
|
||||
where p.id = v_alumno;
|
||||
end if;
|
||||
|
||||
if v_maestro is null then
|
||||
raise exception 'maestro_responsable_requerido';
|
||||
end if;
|
||||
|
||||
if p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 then
|
||||
raise exception 'items_requeridos';
|
||||
end if;
|
||||
|
||||
for v_material_id in
|
||||
select distinct (elem->>'material_id')::int
|
||||
from jsonb_array_elements(p_items) elem
|
||||
order by 1
|
||||
loop
|
||||
perform 1 from prestamos.materiales where id = v_material_id for update;
|
||||
end loop;
|
||||
|
||||
insert into prestamos.solicitudes (alumno_id, estado, maestro_responsable, notas)
|
||||
values (v_alumno, 'pendiente', v_maestro, nullif(trim(coalesce(p_notas, '')), ''))
|
||||
returning id into v_solicitud_id;
|
||||
|
||||
for v_item in select * from jsonb_array_elements(p_items)
|
||||
loop
|
||||
v_material_id := (v_item->>'material_id')::int;
|
||||
v_cantidad := (v_item->>'cantidad')::int;
|
||||
v_descripcion := nullif(trim(coalesce(v_item->>'descripcion', '')), '');
|
||||
|
||||
if v_material_id is null or v_cantidad is null or v_cantidad <= 0 then
|
||||
raise exception 'item_invalido';
|
||||
end if;
|
||||
|
||||
select cantidad_disponible, estado, trackeado_por_unidad
|
||||
into v_disponible, v_estado_mat, v_trackeado
|
||||
from prestamos.materiales where id = v_material_id;
|
||||
|
||||
if v_estado_mat is null then
|
||||
raise exception 'material_no_existe: %', v_material_id;
|
||||
end if;
|
||||
if v_estado_mat <> 'disponible' then
|
||||
raise exception 'material_no_disponible: %', v_material_id;
|
||||
end if;
|
||||
if v_disponible < v_cantidad then
|
||||
raise exception 'stock_insuficiente: %', v_material_id;
|
||||
end if;
|
||||
|
||||
if v_trackeado then
|
||||
v_asignadas := 0;
|
||||
while v_asignadas < v_cantidad loop
|
||||
select id into v_unidad_id
|
||||
from prestamos.material_unidades
|
||||
where material_id = v_material_id and estado = 'disponible'
|
||||
order by id
|
||||
for update skip locked
|
||||
limit 1;
|
||||
|
||||
if v_unidad_id is null then
|
||||
raise exception 'sin_unidad_disponible: %', v_material_id;
|
||||
end if;
|
||||
|
||||
insert into prestamos.solicitud_items
|
||||
(solicitud_id, material_id, cantidad, descripcion, material_unidad_id)
|
||||
values (v_solicitud_id, v_material_id, 1, v_descripcion, v_unidad_id);
|
||||
|
||||
update prestamos.material_unidades
|
||||
set estado = 'prestado'
|
||||
where id = v_unidad_id;
|
||||
|
||||
v_asignadas := v_asignadas + 1;
|
||||
end loop;
|
||||
else
|
||||
insert into prestamos.solicitud_items (solicitud_id, material_id, cantidad, descripcion)
|
||||
values (v_solicitud_id, v_material_id, v_cantidad, v_descripcion);
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_solicitud_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function prestamos.crear_solicitud(text, text, jsonb) to authenticated;
|
||||
|
||||
commit;
|
||||
Reference in New Issue
Block a user