Files
tablero-cdc/src/components/board/ProjectDialog.tsx
T

677 lines
24 KiB
TypeScript

import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { AlertCircle, Loader2, Lock, DollarSign, Calendar, Trash2 } from "lucide-react";
import { ColorPicker } from "./ColorPicker";
import { LinkList } from "./LinkList";
import { MESES, type Status } from "@/data/lists";
import { useAppLists } from "@/lib/appLists";
import { useProjects, type Project } from "@/lib/store";
import { useAuth } from "@/context/AuthContext";
import { colorHex } from "@/lib/colors";
import { cn } from "@/lib/utils";
import {
canonicalOptionLabel,
dedupeOptions,
ensureOption,
normalizeOptionKey,
} from "@/lib/optionUtils";
import { toast } from "sonner";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
project?: Project | null; // editar; vacío = crear
onDelete?: (project: Project) => void;
deleting?: boolean;
}
const WALMART_CONNECT_CLIENT = "Walmart Connect WMC";
function isWalmartConnectClient(client: string | null | undefined) {
return normalizeOptionKey(client) === normalizeOptionKey(WALMART_CONNECT_CLIENT);
}
const empty = (): Omit<Project, "id" | "createdAt"> => {
const now = new Date();
return {
nombre: "",
color: "blue",
cliente: "",
bu: "",
cm: "",
marca: "",
solicitante: "",
comentarios: "",
briefLink: "",
propuestaLinks: [],
afLinks: [],
status: "",
monto: null,
mes: MESES[now.getMonth()],
anio: now.getFullYear(),
};
};
export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting = false }: Props) {
const { add, update } = useProjects();
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
const { isGerardo, canDeleteProjects } = useAuth();
const isEdit = !!project;
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
const [selectedWmcCountries, setSelectedWmcCountries] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [savingStep, setSavingStep] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const clienteOptions = dedupeOptions(ensureOption(lists.clientes, form.cliente));
const marcaOptions = dedupeOptions(ensureOption(lists.marcas, form.marca));
const buOptions = dedupeOptions(ensureOption(lists.bus, form.bu));
const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[];
useEffect(() => {
setFormError(null);
setSavingStep(null);
if (project) {
const { id: _i, createdAt: _c, ...rest } = project;
const cleanBu = canonicalOptionLabel(rest.bu);
setForm({
...rest,
bu: cleanBu,
});
setSelectedWmcCountries(cleanBu ? [cleanBu] : []);
} else if (open) {
setForm(empty());
setSelectedWmcCountries([]);
}
}, [project, open]);
const set = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const getCountryManager = (bu: string) => {
const direct = lists.buCm[bu];
if (direct) return direct;
const buKey = normalizeOptionKey(bu);
const match = Object.entries(lists.buCm).find(([key]) => normalizeOptionKey(key) === buKey);
return match?.[1] ?? "";
};
// BU → Country Manager automático desde Supabase tablero_cdc_app_lists, con fallback local.
const onBU = (bu: string) => {
const cleanBu = canonicalOptionLabel(bu);
setForm((f) => ({ ...f, bu: cleanBu, cm: getCountryManager(cleanBu) }));
};
const onCliente = (cliente: string) => {
const cleanCliente = canonicalOptionLabel(cliente);
setForm((f) => ({
...f,
cliente: cleanCliente,
...(isWalmartConnectClient(cleanCliente) && !isEdit
? { bu: "", cm: "" }
: f.cliente && isWalmartConnectClient(f.cliente)
? { bu: "", cm: "" }
: {}),
}));
if (!isWalmartConnectClient(cleanCliente)) {
setSelectedWmcCountries([]);
}
};
const isWalmartConnectCreate = !isEdit && isWalmartConnectClient(form.cliente);
const toggleWmcCountry = (country: string, checked: boolean) => {
const cleanCountry = canonicalOptionLabel(country);
setSelectedWmcCountries((current) => {
if (checked) {
return dedupeOptions([...current, cleanCountry]);
}
return current.filter(
(item) => normalizeOptionKey(item) !== normalizeOptionKey(cleanCountry),
);
});
};
const selectedWmcCountryManagers = selectedWmcCountries.map((country) => ({
country,
manager: getCountryManager(country),
}));
const requiredOk =
form.nombre.trim() &&
form.cliente &&
form.marca &&
form.solicitante.trim() &&
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : form.bu);
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
if (result.sheetSyncStatus === "synced") {
toast.success(isEdit ? "Proyecto actualizado" : "Proyecto creado", {
description: "Se guardó y se sincronizó con el Google Sheet.",
});
return;
}
if (result.sheetSyncStatus === "skipped") {
toast.success(isEdit ? "Proyecto actualizado" : "Proyecto creado", {
description: "Se guardó. La sincronización con Google Sheet no está configurada.",
});
return;
}
toast.warning("Proyecto guardado con aviso", {
description: result.sheetSyncMessage,
});
};
const submit = async () => {
if (!requiredOk || saving) return;
const safeForm = isGerardo
? form
: {
...form,
status: project?.status ?? "",
monto: project?.monto ?? null,
};
try {
setSaving(true);
setFormError(null);
setSavingStep("Guardando y sincronizando con Google Sheet…");
if (isWalmartConnectCreate) {
const countries = selectedWmcCountries;
const results = [];
for (const [index, country] of countries.entries()) {
setSavingStep(
`Guardando Walmart Connect WMC ${index + 1} de ${countries.length}: ${country}`,
);
const countryProject = {
...safeForm,
bu: country,
cm: getCountryManager(country),
id: crypto.randomUUID(),
createdAt: Date.now() + index,
};
results.push(await add(countryProject));
}
const failedSyncs = results.filter((result) => result.sheetSyncStatus !== "synced");
if (failedSyncs.length === 0) {
toast.success("Proyectos Walmart Connect creados", {
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet.`,
});
} else {
toast.warning("Proyectos Walmart Connect creados con aviso", {
description: `${failedSyncs.length} de ${countries.length} sincronizaciones con Google Sheet requieren revisión.`,
});
}
} else {
const result =
isEdit && project
? await update(project.id, safeForm)
: await add({ ...safeForm, id: crypto.randomUUID(), createdAt: Date.now() });
showSaveToast(result);
}
onOpenChange(false);
} catch (error) {
console.error("Error guardando proyecto:", error);
const message =
error instanceof Error
? error.message
: "No se pudo guardar el proyecto. Revisa los permisos o intenta de nuevo.";
setFormError(message);
toast.error("No se pudo guardar el proyecto", {
description: message,
});
} finally {
setSaving(false);
setSavingStep(null);
}
};
const closed = !!form.status;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[92vh] overflow-y-auto p-0 gap-0">
{/* Pestaña de color superior */}
<div
className="h-2.5 w-full rounded-t-lg"
style={{ backgroundColor: colorHex(form.color) }}
/>
<div className="px-7 pt-6 pb-7">
<DialogHeader className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<DialogTitle className="text-2xl tracking-tight">
{isEdit ? form.nombre || "Sin título" : "Nuevo proyecto"}
</DialogTitle>
<DialogDescription className="mt-1 flex items-center gap-2 text-xs">
<Calendar className="w-3.5 h-3.5" />
{form.mes} · {form.anio}
{closed && (
<Badge variant="secondary" className="ml-2">
Cerrado
</Badge>
)}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mt-6">
{/* Nombre */}
<div className="md:col-span-2 space-y-1.5">
<Label>
Nombre del proyecto <span className="text-destructive">*</span>
</Label>
<Input
value={form.nombre}
onChange={(e) => set("nombre", e.target.value)}
placeholder="Ej: Promoción Día de las Madres"
className="h-10"
/>
</div>
{/* Color */}
<div className="md:col-span-2 space-y-2">
<Label>Etiqueta de color</Label>
<ColorPicker value={form.color} onChange={(c) => set("color", c)} />
</div>
<Separator className="md:col-span-2 my-1" />
{(listsLoading || listsError) && (
<div
className={cn(
"md:col-span-2 flex items-center justify-between gap-3 rounded-xl border px-3 py-2 text-xs",
listsError
? "border-amber-300/50 bg-amber-50 text-amber-900"
: "border-border bg-muted/30 text-muted-foreground",
)}
>
<span className="flex items-center gap-2">
{listsLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Cargando listas
</>
) : (
<>
<AlertCircle className="h-3.5 w-3.5" />
No se pudieron cargar las listas. Se usaron opciones locales.
</>
)}
</span>
{listsError && (
<button
type="button"
onClick={() => void refreshLists()}
className="font-medium text-amber-950 underline-offset-4 hover:underline"
>
Reintentar
</button>
)}
</div>
)}
{/* Cliente */}
<div className="space-y-1.5">
<Label>
Cliente <span className="text-destructive">*</span>
</Label>
<Select value={form.cliente} onValueChange={onCliente}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Seleccionar…" />
</SelectTrigger>
<SelectContent>
{clienteOptions.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Marca */}
<div className="space-y-1.5">
<Label>
Marca <span className="text-destructive">*</span>
</Label>
<Select value={form.marca} onValueChange={(v) => set("marca", v)}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Seleccionar…" />
</SelectTrigger>
<SelectContent>
{marcaOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* BU */}
{isWalmartConnectCreate ? (
<div className="md:col-span-2 space-y-2 rounded-xl border border-primary/20 bg-primary/5 p-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<Label>
BU Solicita (Países) <span className="text-destructive">*</span>
</Label>
<span className="text-xs font-medium text-primary">
{selectedWmcCountries.length} seleccionado(s)
</span>
</div>
<p className="text-xs text-muted-foreground">
Para Walmart Connect WMC puedes seleccionar varios países. La app creará un
proyecto y una fila del Sheet por cada país.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{buOptions.map((b) => {
const checked = selectedWmcCountries.some(
(country) => normalizeOptionKey(country) === normalizeOptionKey(b),
);
const manager = getCountryManager(b);
return (
<label
key={b}
className={cn(
"flex cursor-pointer items-start gap-2 rounded-lg border bg-card px-3 py-2 text-sm transition-colors",
checked
? "border-primary/50 bg-primary/10"
: "border-border hover:bg-muted/40",
)}
>
<Checkbox
checked={checked}
onCheckedChange={(value) => toggleWmcCountry(b, value === true)}
className="mt-0.5"
/>
<span className="leading-tight">
<span className="block font-medium">{b}</span>
<span className="text-xs text-muted-foreground">
CM: {manager || "—"}
</span>
</span>
</label>
);
})}
</div>
</div>
) : (
<div className="space-y-1.5">
<Label>
BU Solicita (País) <span className="text-destructive">*</span>
</Label>
<Select value={form.bu} onValueChange={onBU}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Seleccionar…" />
</SelectTrigger>
<SelectContent>
{buOptions.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* CM autocompletado */}
<div className={cn("space-y-1.5", isWalmartConnectCreate && "md:col-span-2")}>
<Label className="flex items-center gap-1.5">
Country Manager <span className="text-xs text-muted-foreground">(automático)</span>
</Label>
{isWalmartConnectCreate ? (
<div className="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
{selectedWmcCountryManagers.length === 0 ? (
<span className="text-muted-foreground">Selecciona uno o más países.</span>
) : (
<div className="flex flex-wrap gap-1.5">
{selectedWmcCountryManagers.map(({ country, manager }) => (
<Badge key={country} variant="secondary" className="font-normal">
{country}: {manager || "—"}
</Badge>
))}
</div>
)}
</div>
) : (
<Input
value={form.cm}
readOnly
disabled
className="h-10 bg-muted/50"
placeholder="—"
/>
)}
</div>
{/* Solicitante */}
<div className="space-y-1.5">
<Label>
Solicitado por <span className="text-destructive">*</span>
</Label>
<Input
value={form.solicitante}
onChange={(e) => set("solicitante", e.target.value)}
placeholder="Nombre de quien solicita"
className="h-10"
/>
</div>
{/* Comentarios */}
<div className="md:col-span-2 space-y-1.5">
<Label>Comentarios</Label>
<Textarea
value={form.comentarios}
onChange={(e) => set("comentarios", e.target.value)}
placeholder="Notas adicionales del proyecto…"
rows={2}
/>
</div>
<Separator className="md:col-span-2 my-1" />
{/* Brief link único */}
<div className="md:col-span-2 space-y-1.5">
<Label>
Link del Brief{" "}
<span className="text-xs text-muted-foreground font-normal">
(carpeta de Fulgencio)
</span>
</Label>
<Input
value={form.briefLink}
onChange={(e) => set("briefLink", e.target.value)}
placeholder="https://drive.google.com/…"
className="h-10"
/>
</div>
{/* Propuestas */}
<div className="md:col-span-2">
<LinkList
label="Links de Propuestas"
links={form.propuestaLinks}
onChange={(v) => set("propuestaLinks", v)}
emptyText="Aún no hay propuestas. Pega tantos links como necesites."
/>
</div>
{/* Artes Finales */}
<div className="md:col-span-2">
<LinkList
label="Links de Artes Finales"
prefix="AF"
links={form.afLinks}
onChange={(v) => set("afLinks", v)}
emptyText="Aún no hay artes finales."
/>
</div>
<Separator className="md:col-span-2 my-1" />
{/* Status: Gerardo edita; el equipo solo visualiza */}
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
Estatus
{!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />}
</Label>
{isGerardo ? (
<Select
value={form.status || "_none"}
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="Sin estatus" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none">Sin estatus</SelectItem>
{statusOptions.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="h-10 px-3 flex items-center rounded-md border border-border bg-muted/30 text-sm text-foreground">
{form.status || "Sin estatus"}
</div>
)}
{!isGerardo && (
<p className="text-[11px] text-muted-foreground">
Solo Gerardo Marrero puede cambiar el estatus.
</p>
)}
</div>
{/* Monto: SOLO visible para Gerardo Marrero */}
{isGerardo && (
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<DollarSign className="w-3.5 h-3.5" /> Interno Cargado
</Label>
<Input
type="number"
value={form.monto ?? ""}
onChange={(e) =>
set("monto", e.target.value === "" ? null : Number(e.target.value))
}
placeholder="0.00"
className="h-10"
/>
<p className="text-[11px] text-muted-foreground">Privado · solo Gerardo Marrero.</p>
</div>
)}
</div>
{(formError || savingStep) && (
<div
className={cn(
"mt-5 flex items-start gap-2 rounded-xl border px-3 py-2 text-sm",
formError
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-primary/20 bg-primary/5 text-primary",
)}
>
{formError ? (
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
) : (
<Loader2 className="mt-0.5 h-4 w-4 flex-shrink-0 animate-spin" />
)}
<span>{formError || savingStep}</span>
</div>
)}
</div>
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg sm:justify-between">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
{isEdit && project && canDeleteProjects && onDelete && (
<Button
type="button"
variant="destructive"
onClick={() => onDelete(project)}
disabled={saving || deleting}
className="gap-1.5 rounded-full"
>
{deleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
{deleting ? "Eliminando…" : "Eliminar proyecto"}
</Button>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
Cancelar
</Button>
<Button onClick={submit} disabled={!requiredOk || saving || deleting}>
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{saving
? "Guardando…"
: isEdit
? "Guardar cambios"
: isWalmartConnectCreate
? "Crear proyectos"
: "Crear proyecto"}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}