Se agrega marca de agua GRH y se corrige interacción de perfil en la interfaz
This commit is contained in:
201
src/components/SettingsModals.tsx
Normal file
201
src/components/SettingsModals.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type Theme = "system" | "light" | "dark";
|
||||
|
||||
export interface AppSettings {
|
||||
theme: Theme;
|
||||
compactMode: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "water_project_settings_v1";
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
compactMode: false,
|
||||
};
|
||||
|
||||
export const loadSettings = (): AppSettings => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultSettings;
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>;
|
||||
return {
|
||||
theme: parsed.theme ?? defaultSettings.theme,
|
||||
compactMode: parsed.compactMode ?? defaultSettings.compactMode,
|
||||
};
|
||||
} catch {
|
||||
return defaultSettings;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSettings = (s: AppSettings) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
};
|
||||
|
||||
const applyTheme = (theme: Theme) => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("dark");
|
||||
|
||||
if (theme === "dark") root.classList.add("dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)")?.matches;
|
||||
if (prefersDark) root.classList.add("dark");
|
||||
}
|
||||
};
|
||||
|
||||
export default function SettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
settings,
|
||||
setSettings,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
settings: AppSettings;
|
||||
setSettings: (s: AppSettings) => void;
|
||||
}) {
|
||||
const [local, setLocal] = useState<AppSettings>(settings);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setLocal(settings);
|
||||
}, [open, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
if (open) window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const onSave = () => {
|
||||
setSettings(local);
|
||||
saveSettings(local);
|
||||
applyTheme(local.theme);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onReset = () => setLocal(defaultSettings);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Configuración"
|
||||
>
|
||||
{/* Overlay */}
|
||||
<button
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative w-[92vw] max-w-lg rounded-xl bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h2 className="text-base font-semibold text-gray-800">Configuración</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cerrar"
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{/* Tema */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-gray-800">Tema</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([
|
||||
{ key: "system", label: "Sistema" },
|
||||
{ key: "light", label: "Claro" },
|
||||
{ key: "dark", label: "Oscuro" },
|
||||
] as const).map((t) => {
|
||||
const active = local.theme === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setLocal((p) => ({ ...p, theme: t.key }))}
|
||||
className={[
|
||||
"px-3 py-1 rounded-full text-sm border transition",
|
||||
active
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50",
|
||||
].join(" ")}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact mode */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-800">Modo compacto</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Reduce paddings/espaciado en tablas y tarjetas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setLocal((p) => ({ ...p, compactMode: !p.compactMode }))
|
||||
}
|
||||
className={[
|
||||
"w-12 h-7 rounded-full transition relative",
|
||||
local.compactMode ? "bg-blue-600" : "bg-gray-300",
|
||||
].join(" ")}
|
||||
aria-label="Alternar modo compacto"
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
"absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition",
|
||||
local.compactMode ? "left-5" : "left-0.5",
|
||||
].join(" ")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-sm font-semibold text-gray-600 hover:text-gray-800 transition"
|
||||
>
|
||||
Restablecer
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold border border-gray-300 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 transition"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,65 @@
|
||||
import React from "react";
|
||||
import { Bell, User, Settings } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, User, LogOut } from "lucide-react";
|
||||
|
||||
interface TopMenuProps {
|
||||
page: string;
|
||||
subPage: string;
|
||||
setSubPage: (subPage: string) => void;
|
||||
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
avatarUrl?: string | null;
|
||||
|
||||
onLogout?: () => void;
|
||||
onOpenProfile?: () => void;
|
||||
}
|
||||
|
||||
const TopMenu: React.FC<TopMenuProps> = ({ page, subPage, setSubPage }) => {
|
||||
const TopMenu: React.FC<TopMenuProps> = ({
|
||||
page,
|
||||
subPage,
|
||||
setSubPage,
|
||||
|
||||
userName = "Usuario",
|
||||
userEmail,
|
||||
avatarUrl = null,
|
||||
|
||||
onLogout,
|
||||
onOpenProfile,
|
||||
}) => {
|
||||
const [openUserMenu, setOpenUserMenu] = useState(false);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const parts = (userName || "").trim().split(/\s+/).filter(Boolean);
|
||||
const a = parts[0]?.[0] ?? "U";
|
||||
const b = parts[1]?.[0] ?? "";
|
||||
return (a + b).toUpperCase();
|
||||
}, [userName]);
|
||||
|
||||
// Cerrar al click afuera
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (!openUserMenu) return;
|
||||
const el = menuRef.current;
|
||||
if (el && !el.contains(e.target as Node)) setOpenUserMenu(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [openUserMenu]);
|
||||
|
||||
// Cerrar con ESC
|
||||
useEffect(() => {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpenUserMenu(false);
|
||||
}
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
||||
className="relative z-40 h-14 shrink-0 flex items-center justify-between px-4 text-white"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #4c5f9e, #2a355d, #566bb8, #3d4e87)",
|
||||
@@ -36,26 +85,144 @@ const TopMenu: React.FC<TopMenuProps> = ({ page, subPage, setSubPage }) => {
|
||||
<button
|
||||
aria-label="Notificaciones"
|
||||
className="p-2 rounded-full hover:bg-white/10 transition"
|
||||
type="button"
|
||||
>
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Configuración"
|
||||
className="p-2 rounded-full hover:bg-white/10 transition"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
{/* USER MENU */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Perfil"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openUserMenu}
|
||||
onClick={() => setOpenUserMenu((v) => !v)}
|
||||
className="w-9 h-9 rounded-full bg-white/15 flex items-center justify-center cursor-pointer hover:bg-white/25 transition overflow-hidden"
|
||||
title="Perfil"
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-bold opacity-90">{initials}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="w-9 h-9 rounded-full bg-white/15 flex items-center justify-center cursor-pointer hover:bg-white/25 transition"
|
||||
title="Perfil"
|
||||
>
|
||||
<User size={20} />
|
||||
{openUserMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="
|
||||
absolute right-0 mt-2 w-80
|
||||
rounded-2xl
|
||||
bg-white
|
||||
border border-slate-200
|
||||
shadow-xl
|
||||
overflow-hidden
|
||||
z-50
|
||||
"
|
||||
>
|
||||
{/* Header usuario */}
|
||||
<div className="px-5 py-4 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-full bg-slate-100 overflow-hidden flex items-center justify-center">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-semibold text-slate-700">
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900 truncate">
|
||||
{userName}
|
||||
</div>
|
||||
{userEmail ? (
|
||||
<div className="text-xs text-slate-500 truncate">
|
||||
{userEmail}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400 truncate">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items (solo 2) */}
|
||||
<MenuItem
|
||||
label="Ver / editar perfil"
|
||||
onClick={() => {
|
||||
setOpenUserMenu(false);
|
||||
onOpenProfile?.();
|
||||
}}
|
||||
left={<User size={16} />}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-slate-200 my-1" />
|
||||
|
||||
<MenuItem
|
||||
label="Cerrar sesión"
|
||||
tone="danger"
|
||||
onClick={() => {
|
||||
setOpenUserMenu(false);
|
||||
if (onLogout) onLogout();
|
||||
else {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}}
|
||||
left={<LogOut size={16} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
function MenuItem({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
tone = "default",
|
||||
left,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
left?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"w-full flex items-center gap-3 px-5 py-3 text-sm text-left",
|
||||
"transition-colors",
|
||||
disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-slate-100",
|
||||
tone === "danger"
|
||||
? "text-red-600 hover:text-red-700"
|
||||
: "text-slate-700",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="text-slate-400">{left}</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopMenu;
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title = "Confirmar",
|
||||
message = "¿Estás seguro?",
|
||||
confirmText = "Confirmar",
|
||||
cancelText = "Cancelar",
|
||||
danger = false,
|
||||
loading = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// enfoque inicial para accesibilidad
|
||||
const t = setTimeout(() => panelRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cerrar"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/40"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative mx-auto mt-28 w-[min(520px,calc(100vw-32px))]">
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="rounded-2xl bg-white border border-slate-200 shadow-xl overflow-hidden outline-none"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<div className="text-base font-semibold text-slate-900">{title}</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
<p className="text-sm text-slate-700">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition disabled:opacity-60"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={[
|
||||
"rounded-xl px-4 py-2 text-sm font-semibold text-white transition",
|
||||
danger ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700",
|
||||
"focus:outline-none focus:ring-2",
|
||||
danger ? "focus:ring-red-500" : "focus:ring-blue-500",
|
||||
"focus:ring-offset-2",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
].join(" ")}
|
||||
>
|
||||
{loading ? "Procesando..." : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
284
src/components/layout/common/ProfileModal.tsx
Normal file
284
src/components/layout/common/ProfileModal.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type ProfileForm = {
|
||||
name: string;
|
||||
email: string;
|
||||
organismName?: string; // "Empresa" / "Organismo" (CESPT, etc.)
|
||||
};
|
||||
|
||||
export default function ProfileModal({
|
||||
open,
|
||||
loading = false,
|
||||
initial,
|
||||
avatarUrl = null,
|
||||
onClose,
|
||||
onSave,
|
||||
onUploadAvatar,
|
||||
}: {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
initial: ProfileForm;
|
||||
avatarUrl?: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (next: ProfileForm) => void | Promise<void>;
|
||||
onUploadAvatar?: (file: File) => void | Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [email, setEmail] = useState(initial?.email ?? "");
|
||||
const [organismName, setOrganismName] = useState(initial?.organismName ?? "");
|
||||
|
||||
// Avatar preview local (si el usuario selecciona imagen)
|
||||
const [localAvatar, setLocalAvatar] = useState<string | null>(null);
|
||||
const lastPreviewUrlRef = useRef<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Mantener el form sincronizado cuando se abre o cambia initial
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? "");
|
||||
setEmail(initial?.email ?? "");
|
||||
setOrganismName(initial?.organismName ?? "");
|
||||
setLocalAvatar(null);
|
||||
}, [open, initial?.name, initial?.email, initial?.organismName]);
|
||||
|
||||
// Cerrar con ESC
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Limpieza de object URLs
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const parts = (name || "").trim().split(/\s+/).filter(Boolean);
|
||||
const a = parts[0]?.[0] ?? "U";
|
||||
const b = parts[1]?.[0] ?? "";
|
||||
return (a + b).toUpperCase();
|
||||
}, [name]);
|
||||
|
||||
const computedAvatarSrc = useMemo(() => {
|
||||
const src = localAvatar ?? avatarUrl;
|
||||
if (!src) return null;
|
||||
if (src.startsWith("blob:")) return src;
|
||||
// cache-bust por si el backend mantiene la misma URL
|
||||
const sep = src.includes("?") ? "&" : "?";
|
||||
return `${src}${sep}t=${Date.now()}`;
|
||||
}, [localAvatar, avatarUrl]);
|
||||
|
||||
const triggerFilePicker = () => {
|
||||
if (!onUploadAvatar) return;
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const maxMb = 5;
|
||||
const sizeOk = file.size <= maxMb * 1024 * 1024;
|
||||
|
||||
if (!isImage) {
|
||||
alert("Selecciona un archivo de imagen.");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (!sizeOk) {
|
||||
alert(`La imagen debe pesar máximo ${maxMb}MB.`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview inmediato
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
if (lastPreviewUrlRef.current) URL.revokeObjectURL(lastPreviewUrlRef.current);
|
||||
lastPreviewUrlRef.current = previewUrl;
|
||||
setLocalAvatar(previewUrl);
|
||||
|
||||
try {
|
||||
await onUploadAvatar?.(file);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("No se pudo subir la imagen. Intenta de nuevo.");
|
||||
} finally {
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
alert("El nombre es obligatorio.");
|
||||
return;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
alert("El correo es obligatorio.");
|
||||
return;
|
||||
}
|
||||
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
organismName: organismName.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cerrar"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/40"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative mx-auto mt-16 w-[min(860px,calc(100vw-32px))]">
|
||||
<div className="rounded-2xl bg-white shadow-xl border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<div className="text-base font-semibold text-slate-900">Editar perfil</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[260px_1fr] gap-6">
|
||||
{/* LEFT: Avatar */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-28 h-28 rounded-2xl bg-white border border-slate-200 overflow-hidden flex items-center justify-center">
|
||||
{computedAvatarSrc ? (
|
||||
<img
|
||||
src={computedAvatarSrc}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-700 font-semibold text-2xl">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-semibold text-slate-900 truncate max-w-[220px]">
|
||||
{name || "Usuario"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 truncate max-w-[220px]">
|
||||
{email || "correo@ejemplo.gob.mx"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={triggerFilePicker}
|
||||
disabled={!onUploadAvatar}
|
||||
className={[
|
||||
"mt-4 w-full rounded-xl px-4 py-2 text-sm font-medium",
|
||||
"border border-slate-200 bg-white text-slate-700",
|
||||
"hover:bg-slate-100 transition",
|
||||
!onUploadAvatar ? "opacity-50 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
Cambiar imagen
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Form */}
|
||||
<div className="rounded-2xl border border-slate-200 p-5">
|
||||
{/* “correo electronico” como en tu dibujo */}
|
||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
correo electrónico
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<Field label="Nombre:">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
|
||||
placeholder="Nombre del usuario"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Correo:">
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
|
||||
placeholder="correo@organismo.gob.mx"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Empresa:">
|
||||
<input
|
||||
value={organismName}
|
||||
onChange={(e) => setOrganismName(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 outline-none focus:ring-2 focus:ring-slate-200"
|
||||
placeholder="Organismo operador"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl px-4 py-2 text-sm font-medium border border-slate-200 bg-white text-slate-700 hover:bg-slate-100 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className={[
|
||||
"rounded-xl px-4 py-2 text-sm font-semibold",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
loading ? "opacity-60 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{loading ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[90px_1fr] items-center gap-3">
|
||||
<div className="text-sm font-medium text-slate-700">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user