feat: subir versión final Tablero CDC
This commit is contained in:
@@ -13,7 +13,7 @@ export function ProjectCard({ project, onClick }: { project: Project; onClick: (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group text-left w-full rounded-2xl bg-card overflow-hidden border border-border",
|
||||
"group block text-left w-full rounded-2xl bg-card overflow-hidden p-0 ring-1 ring-border",
|
||||
"transition-all duration-200 hover:-translate-y-0.5",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
closed && "opacity-65 hover:opacity-90",
|
||||
@@ -32,7 +32,7 @@ export function ProjectCard({ project, onClick }: { project: Project; onClick: (
|
||||
>
|
||||
{/* Pestaña de color con título */}
|
||||
<div
|
||||
className="px-4 py-3"
|
||||
className="min-h-14 rounded-t-2xl px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: closed ? "#9AA0A6" : colorHex(project.color),
|
||||
}}
|
||||
|
||||
@@ -20,14 +20,22 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Lock, Trash2, DollarSign, Calendar } from "lucide-react";
|
||||
import { AlertCircle, Loader2, Lock, DollarSign, Calendar } from "lucide-react";
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
import { LinkList } from "./LinkList";
|
||||
import { CLIENTES, BUS, BU_CM, MARCAS, STATUS, COLORS, MESES, type Status } from "@/data/lists";
|
||||
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;
|
||||
@@ -57,16 +65,31 @@ const empty = (): Omit<Project, "id" | "createdAt"> => {
|
||||
};
|
||||
|
||||
export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
const { add, update, remove } = useProjects();
|
||||
const { add, update } = useProjects();
|
||||
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
|
||||
const { isGerardo } = useAuth();
|
||||
const isEdit = !!project;
|
||||
|
||||
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
|
||||
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;
|
||||
setForm(rest);
|
||||
setForm({
|
||||
...rest,
|
||||
bu: canonicalOptionLabel(rest.bu),
|
||||
});
|
||||
} else if (open) {
|
||||
setForm(empty());
|
||||
}
|
||||
@@ -75,26 +98,82 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
const set = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
// BU → CM automático
|
||||
const onBU = (bu: string) => setForm((f) => ({ ...f, bu, cm: BU_CM[bu] ?? "" }));
|
||||
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 app_lists, con fallback local.
|
||||
const onBU = (bu: string) => {
|
||||
const cleanBu = canonicalOptionLabel(bu);
|
||||
setForm((f) => ({ ...f, bu: cleanBu, cm: getCountryManager(cleanBu) }));
|
||||
};
|
||||
|
||||
const requiredOk =
|
||||
form.nombre.trim() && form.cliente && form.bu && form.marca && form.solicitante.trim();
|
||||
|
||||
const submit = () => {
|
||||
if (!requiredOk) return;
|
||||
if (isEdit && project) {
|
||||
update(project.id, form);
|
||||
} else {
|
||||
add({ ...form, id: crypto.randomUUID(), createdAt: Date.now() });
|
||||
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;
|
||||
}
|
||||
onOpenChange(false);
|
||||
|
||||
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 onDelete = () => {
|
||||
if (project && confirm("¿Eliminar este proyecto?")) {
|
||||
remove(project.id);
|
||||
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…");
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,6 +230,41 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
|
||||
<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>
|
||||
@@ -161,7 +275,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
<SelectValue placeholder="Seleccionar…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLIENTES.map((c) => (
|
||||
{clienteOptions.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
@@ -180,7 +294,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
<SelectValue placeholder="Seleccionar…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MARCAS.map((m) => (
|
||||
{marcaOptions.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
@@ -199,7 +313,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
<SelectValue placeholder="Seleccionar…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BUS.map((b) => (
|
||||
{buOptions.map((b) => (
|
||||
<SelectItem key={b} value={b}>
|
||||
{b}
|
||||
</SelectItem>
|
||||
@@ -270,7 +384,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
label="Links de Propuestas"
|
||||
links={form.propuestaLinks}
|
||||
onChange={(v) => set("propuestaLinks", v)}
|
||||
emptyText="Aún no hay propuestas. Pegá tantos links como necesites."
|
||||
emptyText="Aún no hay propuestas. Pega tantos links como necesites."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -287,38 +401,45 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
|
||||
<Separator className="md:col-span-2 my-1" />
|
||||
|
||||
{/* Status (solo Marrero edita) */}
|
||||
{/* 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>
|
||||
<Select
|
||||
value={form.status || "_none"}
|
||||
onValueChange={(v) => set("status", (v === "_none" ? "" : v) as Status | "")}
|
||||
disabled={!isGerardo}
|
||||
>
|
||||
<SelectTrigger className={cn("h-10", !isGerardo && "opacity-70")}>
|
||||
<SelectValue placeholder="Sin estatus" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">Sin estatus</SelectItem>
|
||||
{STATUS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{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 Marrero puede cambiar el estatus.
|
||||
Solo Gerardo Marrero puede cambiar el estatus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monto: SOLO visible para Marrero */}
|
||||
{isGerardo ? (
|
||||
{/* 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
|
||||
@@ -332,40 +453,38 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
||||
placeholder="0.00"
|
||||
className="h-10"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Privado · solo lo ves vos.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground flex items-center gap-1.5">
|
||||
<Lock className="w-3.5 h-3.5" /> Interno Cargado
|
||||
</Label>
|
||||
<div className="h-10 px-3 flex items-center rounded-md border border-dashed border-border bg-muted/30 text-xs text-muted-foreground italic">
|
||||
Solo visible para Marrero
|
||||
</div>
|
||||
<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 flex-row justify-between sm:justify-between">
|
||||
<div>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> Eliminar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg flex-row justify-end sm:justify-end">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!requiredOk}>
|
||||
{isEdit ? "Guardar cambios" : "Crear proyecto"}
|
||||
<Button onClick={submit} disabled={!requiredOk || saving}>
|
||||
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||
{saving ? "Guardando…" : isEdit ? "Guardar cambios" : "Crear proyecto"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user