diff --git a/src/components/admin/inventario/MaterialForm.tsx b/src/components/admin/inventario/MaterialForm.tsx index c54e097..5ec34f6 100644 --- a/src/components/admin/inventario/MaterialForm.tsx +++ b/src/components/admin/inventario/MaterialForm.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from 'react'; -import { browserClient } from '@/lib/supabase'; import { imgUrl } from '@/lib/materialImg'; import { toast } from '@/lib/toast'; import UnidadesManager from './UnidadesManager'; @@ -25,8 +24,6 @@ type Props = { compact?: boolean; }; -const BUCKET = 'materiales-fotos'; - export default function MaterialForm({ mode, material, categorias, compact }: Props) { const dialogRef = useRef(null); const firstFieldRef = useRef(null); @@ -99,18 +96,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr if (error) errorRef.current?.focus(); }, [error]); - async function uploadFotoFor(id: number): Promise { - if (!file) return null; - const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, ''); - const path = `${id}/${Date.now()}.${ext || 'jpg'}`; - 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: 'El material se guardó sin foto.' }); + async function uploadFotoFor(materialId: number, f: File): Promise { + const fd = new FormData(); + fd.append('file', f); + const res = await fetch(`/api/upload/material-foto/${materialId}`, { method: 'POST', body: fd }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + toast({ kind: 'error', title: 'La foto no se subió', description: json?.error ?? 'El material se guardó sin foto.' }); return null; } - return path; + return json.path as string; } const submit = async (e: React.FormEvent) => { @@ -151,26 +146,14 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json?.error ?? 'No se pudo crear'); - // 2) subir foto (si hay) y PATCH imagen_path + // 2) subir foto (si hay) — el endpoint ya persiste imagen_path if (file && json.id) { - const path = await uploadFotoFor(json.id); - if (path) { - const p = await fetch(`/api/admin/materiales/${json.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ imagen_path: path }), - }); - if (!p.ok) { - toast({ kind: 'error', title: 'Foto subida pero no vinculada', description: 'Recarga y vuelve a intentar.' }); - } - } + await uploadFotoFor(json.id, file); } } else { - // edit — sube nueva foto primero (si hay) y luego PATCH con el path incluido - let newImagenPath: string | null | undefined = undefined; + // edit — sube nueva foto primero (si hay); el endpoint persiste imagen_path if (file && material) { - const path = await uploadFotoFor(material.id); - if (path) newImagenPath = path; + await uploadFotoFor(material.id, file); } const patch: Record = { nombre: nombreT, @@ -181,7 +164,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr trackeado_por_unidad: trackeado, }; if (!trackeado) patch.cantidad_total = cantidad; - if (newImagenPath !== undefined) patch.imagen_path = newImagenPath; + // ponytail: si el user quitó la foto (imagenPath=null y sin file), no lo PATCHeamos aquí — ese flow ya está fuera de scope de este fix const res = await fetch(`/api/admin/materiales/${material!.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, diff --git a/src/components/admin/usuarios/BanearForm.tsx b/src/components/admin/usuarios/BanearForm.tsx new file mode 100644 index 0000000..a42d6f6 --- /dev/null +++ b/src/components/admin/usuarios/BanearForm.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from 'react'; +import { toastAfterReload } from '@/lib/toast'; + +type Props = { + profile: { id: string; nombre?: string | null; email: string }; + adminSelf: boolean; +}; + +export default function BanearForm({ profile, adminSelf }: Props) { + const dialogRef = useRef(null); + const firstFieldRef = useRef(null); + const errorRef = useRef(null); + const [razon, setRazon] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const titleId = `banear-${profile.id}`; + const label = profile.nombre?.trim() || profile.email; + + const open = () => { + if (adminSelf) return; + setError(null); + setRazon(''); + 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 = razon.trim(); + if (trimmed.length < 5) { + setError('La razón debe tener al menos 5 caracteres'); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetch('/api/admin/baneos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_id: profile.id, razon: trimmed }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear'); + toastAfterReload({ + title: 'Usuario baneado', + description: label, + kind: 'success', + }); + dialogRef.current?.close(); + location.reload(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error inesperado'); + } finally { + setLoading(false); + } + }; + + return ( + <> + + + +
+
+

+ Banear a {label} +

+ +
+ +

+ El usuario no podrá acceder al sistema hasta ser desbaneado. +

+ +
+ +