feat: agregar eliminacion y Walmart Connect multipais
This commit is contained in:
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-341
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+348
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<meta name="description" content="Herramienta interna del Departamento de Diseño Creativo." />
|
<meta name="description" content="Herramienta interna del Departamento de Diseño Creativo." />
|
||||||
<meta name="author" content="CDC" />
|
<meta name="author" content="CDC" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
|
||||||
<script type="module" crossorigin src="/tablero-cdc/assets/index-BEinrcZb.js"></script>
|
<script type="module" crossorigin src="/tablero-cdc/assets/index-ZKG15NE5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-DjXLQf1G.css">
|
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-7ZtFhhZY.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -5,15 +5,30 @@ import { colorHex } from "@/lib/colors";
|
|||||||
import type { Project } from "@/lib/store";
|
import type { Project } from "@/lib/store";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
export function ProjectCard({ project, onClick }: { project: Project; onClick: () => void }) {
|
interface ProjectCardProps {
|
||||||
|
project: Project;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectCard({ project, onClick }: ProjectCardProps) {
|
||||||
const closed = !!project.status;
|
const closed = !!project.status;
|
||||||
const { isGerardo } = useAuth();
|
const { isGerardo } = useAuth();
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group block text-left w-full rounded-2xl bg-card overflow-hidden p-0 ring-1 ring-border",
|
"group block text-left w-full rounded-2xl bg-card overflow-hidden p-0 ring-1 ring-border cursor-pointer",
|
||||||
"transition-all duration-200 hover:-translate-y-0.5",
|
"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",
|
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
closed && "opacity-65 hover:opacity-90",
|
closed && "opacity-65 hover:opacity-90",
|
||||||
@@ -86,7 +101,7 @@ export function ProjectCard({ project, onClick }: { project: Project; onClick: (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { AlertCircle, Loader2, Lock, DollarSign, Calendar } from "lucide-react";
|
import { AlertCircle, Loader2, Lock, DollarSign, Calendar, Trash2 } from "lucide-react";
|
||||||
import { ColorPicker } from "./ColorPicker";
|
import { ColorPicker } from "./ColorPicker";
|
||||||
import { LinkList } from "./LinkList";
|
import { LinkList } from "./LinkList";
|
||||||
import { MESES, type Status } from "@/data/lists";
|
import { MESES, type Status } from "@/data/lists";
|
||||||
@@ -41,6 +42,14 @@ interface Props {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
project?: Project | null; // editar; vacío = crear
|
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 empty = (): Omit<Project, "id" | "createdAt"> => {
|
||||||
@@ -64,13 +73,14 @@ const empty = (): Omit<Project, "id" | "createdAt"> => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting = false }: Props) {
|
||||||
const { add, update } = useProjects();
|
const { add, update } = useProjects();
|
||||||
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
|
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
|
||||||
const { isGerardo } = useAuth();
|
const { isGerardo, canDeleteProjects } = useAuth();
|
||||||
const isEdit = !!project;
|
const isEdit = !!project;
|
||||||
|
|
||||||
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
|
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
|
||||||
|
const [selectedWmcCountries, setSelectedWmcCountries] = useState<string[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [savingStep, setSavingStep] = useState<string | null>(null);
|
const [savingStep, setSavingStep] = useState<string | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -86,12 +96,15 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
|
|
||||||
if (project) {
|
if (project) {
|
||||||
const { id: _i, createdAt: _c, ...rest } = project;
|
const { id: _i, createdAt: _c, ...rest } = project;
|
||||||
|
const cleanBu = canonicalOptionLabel(rest.bu);
|
||||||
setForm({
|
setForm({
|
||||||
...rest,
|
...rest,
|
||||||
bu: canonicalOptionLabel(rest.bu),
|
bu: cleanBu,
|
||||||
});
|
});
|
||||||
|
setSelectedWmcCountries(cleanBu ? [cleanBu] : []);
|
||||||
} else if (open) {
|
} else if (open) {
|
||||||
setForm(empty());
|
setForm(empty());
|
||||||
|
setSelectedWmcCountries([]);
|
||||||
}
|
}
|
||||||
}, [project, open]);
|
}, [project, open]);
|
||||||
|
|
||||||
@@ -114,8 +127,51 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
setForm((f) => ({ ...f, bu: cleanBu, cm: getCountryManager(cleanBu) }));
|
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 =
|
const requiredOk =
|
||||||
form.nombre.trim() && form.cliente && form.bu && form.marca && form.solicitante.trim();
|
form.nombre.trim() &&
|
||||||
|
form.cliente &&
|
||||||
|
form.marca &&
|
||||||
|
form.solicitante.trim() &&
|
||||||
|
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : form.bu);
|
||||||
|
|
||||||
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
|
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
|
||||||
if (result.sheetSyncStatus === "synced") {
|
if (result.sheetSyncStatus === "synced") {
|
||||||
@@ -153,12 +209,46 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
setFormError(null);
|
setFormError(null);
|
||||||
setSavingStep("Guardando y sincronizando con Google Sheet…");
|
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 =
|
const result =
|
||||||
isEdit && project
|
isEdit && project
|
||||||
? await update(project.id, safeForm)
|
? await update(project.id, safeForm)
|
||||||
: await add({ ...safeForm, id: crypto.randomUUID(), createdAt: Date.now() });
|
: await add({ ...safeForm, id: crypto.randomUUID(), createdAt: Date.now() });
|
||||||
|
|
||||||
showSaveToast(result);
|
showSaveToast(result);
|
||||||
|
}
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error guardando proyecto:", error);
|
console.error("Error guardando proyecto:", error);
|
||||||
@@ -270,7 +360,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
<Label>
|
<Label>
|
||||||
Cliente <span className="text-destructive">*</span>
|
Cliente <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={form.cliente} onValueChange={(v) => set("cliente", v)}>
|
<Select value={form.cliente} onValueChange={onCliente}>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue placeholder="Seleccionar…" />
|
<SelectValue placeholder="Seleccionar…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -304,6 +394,54 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BU */}
|
{/* 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">
|
<div className="space-y-1.5">
|
||||||
<Label>
|
<Label>
|
||||||
BU Solicita (País) <span className="text-destructive">*</span>
|
BU Solicita (País) <span className="text-destructive">*</span>
|
||||||
@@ -321,12 +459,28 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* CM autocompletado */}
|
{/* CM autocompletado */}
|
||||||
<div className="space-y-1.5">
|
<div className={cn("space-y-1.5", isWalmartConnectCreate && "md:col-span-2")}>
|
||||||
<Label className="flex items-center gap-1.5">
|
<Label className="flex items-center gap-1.5">
|
||||||
Country Manager <span className="text-xs text-muted-foreground">(automático)</span>
|
Country Manager <span className="text-xs text-muted-foreground">(automático)</span>
|
||||||
</Label>
|
</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
|
<Input
|
||||||
value={form.cm}
|
value={form.cm}
|
||||||
readOnly
|
readOnly
|
||||||
@@ -334,6 +488,7 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
className="h-10 bg-muted/50"
|
className="h-10 bg-muted/50"
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Solicitante */}
|
{/* Solicitante */}
|
||||||
@@ -477,16 +632,43 @@ export function ProjectDialog({ open, onOpenChange, project }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg flex-row justify-end sm:justify-end">
|
<DialogFooter className="px-7 py-4 border-t bg-muted/30 rounded-b-lg sm:justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={!requiredOk || saving}>
|
<Button onClick={submit} disabled={!requiredOk || saving || deleting}>
|
||||||
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||||
{saving ? "Guardando…" : isEdit ? "Guardar cambios" : "Crear proyecto"}
|
{saving
|
||||||
|
? "Guardando…"
|
||||||
|
: isEdit
|
||||||
|
? "Guardar cambios"
|
||||||
|
: isWalmartConnectCreate
|
||||||
|
? "Crear proyectos"
|
||||||
|
: "Crear proyecto"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { supabase } from "@/lib/supabase";
|
|||||||
|
|
||||||
const ALLOWED_DOMAIN = "gomezleemarketing.com";
|
const ALLOWED_DOMAIN = "gomezleemarketing.com";
|
||||||
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
||||||
|
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "areyes@gomezleemarketing.com"];
|
||||||
|
|
||||||
function isAllowedEmail(email: string | null | undefined): boolean {
|
function isAllowedEmail(email: string | null | undefined): boolean {
|
||||||
if (!email) return false;
|
if (!email) return false;
|
||||||
@@ -59,6 +60,7 @@ interface AuthContextValue {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isGerardo: boolean;
|
isGerardo: boolean;
|
||||||
|
canDeleteProjects: boolean;
|
||||||
loginWithGoogle: () => Promise<void>;
|
loginWithGoogle: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -158,10 +160,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isGerardo = useMemo(() => !!user && user.email?.toLowerCase() === GERARDO_EMAIL, [user]);
|
const normalizedEmail = user?.email?.toLowerCase() || "";
|
||||||
|
const isGerardo = useMemo(() => normalizedEmail === GERARDO_EMAIL, [normalizedEmail]);
|
||||||
|
const canDeleteProjects = useMemo(
|
||||||
|
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
|
||||||
|
[normalizedEmail],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, error, isGerardo, loginWithGoogle, logout }}>
|
<AuthContext.Provider
|
||||||
|
value={{ user, loading, error, isGerardo, canDeleteProjects, loginWithGoogle, logout }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
+69
-5
@@ -57,6 +57,7 @@ type DbProject = {
|
|||||||
const DEFAULT_COLOR: ColorId = "blue";
|
const DEFAULT_COLOR: ColorId = "blue";
|
||||||
const OPEN_STATUS = "Activo";
|
const OPEN_STATUS = "Activo";
|
||||||
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
||||||
|
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "areyes@gomezleemarketing.com"];
|
||||||
|
|
||||||
let listeners: Array<() => void> = [];
|
let listeners: Array<() => void> = [];
|
||||||
let cache: Project[] = [];
|
let cache: Project[] = [];
|
||||||
@@ -146,6 +147,11 @@ async function currentUserIsGerardo(): Promise<boolean> {
|
|||||||
return user.email?.toLowerCase() === GERARDO_EMAIL;
|
return user.email?.toLowerCase() === GERARDO_EMAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function currentUserCanDeleteProjects(): Promise<boolean> {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
return DELETE_ALLOWED_EMAILS.includes(user.email?.toLowerCase() || "");
|
||||||
|
}
|
||||||
|
|
||||||
function toProjectRow(project: Omit<Project, "id" | "createdAt"> | Partial<Project>) {
|
function toProjectRow(project: Omit<Project, "id" | "createdAt"> | Partial<Project>) {
|
||||||
return {
|
return {
|
||||||
title: project.nombre,
|
title: project.nombre,
|
||||||
@@ -207,7 +213,7 @@ function getSyncWebhookUrl() {
|
|||||||
|
|
||||||
async function syncProjectToSheet(
|
async function syncProjectToSheet(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
action: "create" | "update",
|
action: "create" | "update" | "delete",
|
||||||
): Promise<Omit<SaveProjectResult, "projectId">> {
|
): Promise<Omit<SaveProjectResult, "projectId">> {
|
||||||
const webhookUrl = getSyncWebhookUrl();
|
const webhookUrl = getSyncWebhookUrl();
|
||||||
|
|
||||||
@@ -215,7 +221,10 @@ async function syncProjectToSheet(
|
|||||||
console.info("No se configuró VITE_WEBHOOK_URL; se omite sincronización con Google Sheet.");
|
console.info("No se configuró VITE_WEBHOOK_URL; se omite sincronización con Google Sheet.");
|
||||||
return {
|
return {
|
||||||
sheetSyncStatus: "skipped",
|
sheetSyncStatus: "skipped",
|
||||||
sheetSyncMessage: "Proyecto guardado. No hay webhook configurado para sincronizar el Sheet.",
|
sheetSyncMessage:
|
||||||
|
action === "delete"
|
||||||
|
? "No hay webhook configurado para eliminar el proyecto del Google Sheet."
|
||||||
|
: "Proyecto guardado. No hay webhook configurado para sincronizar el Sheet.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +258,19 @@ async function syncProjectToSheet(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sheetSyncStatus: "failed",
|
sheetSyncStatus: "failed",
|
||||||
sheetSyncMessage: `Proyecto guardado, pero el Sheet no se pudo sincronizar: ${message}`,
|
sheetSyncMessage:
|
||||||
|
action === "delete"
|
||||||
|
? `No se pudo eliminar el proyecto del Google Sheet: ${message}`
|
||||||
|
: `Proyecto guardado, pero el Sheet no se pudo sincronizar: ${message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sheetSyncStatus: "synced",
|
sheetSyncStatus: "synced",
|
||||||
sheetSyncMessage: "Proyecto guardado y sincronizado con el Google Sheet.",
|
sheetSyncMessage:
|
||||||
|
action === "delete"
|
||||||
|
? "Proyecto eliminado del Google Sheet."
|
||||||
|
: "Proyecto guardado y sincronizado con el Google Sheet.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const isTimeout = error instanceof DOMException && error.name === "AbortError";
|
const isTimeout = error instanceof DOMException && error.name === "AbortError";
|
||||||
@@ -265,7 +280,11 @@ async function syncProjectToSheet(
|
|||||||
return {
|
return {
|
||||||
sheetSyncStatus: isTimeout ? "timeout" : "failed",
|
sheetSyncStatus: isTimeout ? "timeout" : "failed",
|
||||||
sheetSyncMessage: isTimeout
|
sheetSyncMessage: isTimeout
|
||||||
? "Proyecto guardado, pero n8n tardó demasiado en responder. Revisa el workflow si el Sheet no se actualizó."
|
? action === "delete"
|
||||||
|
? "n8n tardó demasiado en responder. No se eliminó el proyecto para evitar inconsistencias."
|
||||||
|
: "Proyecto guardado, pero n8n tardó demasiado en responder. Revisa el workflow si el Sheet no se actualizó."
|
||||||
|
: action === "delete"
|
||||||
|
? "No se pudo eliminar el proyecto del Google Sheet. No se eliminó el proyecto para evitar inconsistencias."
|
||||||
: "Proyecto guardado, pero no se pudo sincronizar con Google Sheet.",
|
: "Proyecto guardado, pero no se pudo sincronizar con Google Sheet.",
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -465,6 +484,50 @@ async function updateProject(id: string, patch: Partial<Project>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function deleteProject(id: string) {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const canDelete = await currentUserCanDeleteProjects();
|
||||||
|
|
||||||
|
if (!canDelete) {
|
||||||
|
const message = "No tienes permiso para eliminar proyectos.";
|
||||||
|
setError(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheetSync = await syncProjectToSheet(id, "delete");
|
||||||
|
|
||||||
|
if (sheetSync.sheetSyncStatus !== "synced") {
|
||||||
|
const message = sheetSync.sheetSyncMessage || "No se pudo eliminar el proyecto del Google Sheet.";
|
||||||
|
setError(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: linksError } = await supabase
|
||||||
|
.from("tablero_cdc_project_links")
|
||||||
|
.delete()
|
||||||
|
.eq("project_id", id);
|
||||||
|
|
||||||
|
if (linksError) {
|
||||||
|
setError(linksError.message);
|
||||||
|
throw linksError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: projectError } = await supabase
|
||||||
|
.from("tablero_cdc_projects")
|
||||||
|
.delete()
|
||||||
|
.eq("id", id);
|
||||||
|
|
||||||
|
if (projectError) {
|
||||||
|
setError(projectError.message);
|
||||||
|
throw projectError;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCache(cache.filter((project) => project.id !== id));
|
||||||
|
await loadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const [, force] = useState(0);
|
const [, force] = useState(0);
|
||||||
|
|
||||||
@@ -496,6 +559,7 @@ export function useProjects() {
|
|||||||
refresh: loadProjects,
|
refresh: loadProjects,
|
||||||
add: addProject,
|
add: addProject,
|
||||||
update: updateProject,
|
update: updateProject,
|
||||||
|
remove: deleteProject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-3
@@ -39,7 +39,7 @@ function initials(name: string | null | undefined, email: string | null | undefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BoardPage() {
|
export default function BoardPage() {
|
||||||
const { projects, loading, error, refresh } = useProjects();
|
const { projects, loading, error, refresh, remove } = useProjects();
|
||||||
const { user, isGerardo, logout } = useAuth();
|
const { user, isGerardo, logout } = useAuth();
|
||||||
|
|
||||||
const projectsTopRef = useRef<HTMLDivElement | null>(null);
|
const projectsTopRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -53,6 +53,7 @@ export default function BoardPage() {
|
|||||||
abiertos: 1,
|
abiertos: 1,
|
||||||
cerrados: 1,
|
cerrados: 1,
|
||||||
});
|
});
|
||||||
|
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -105,6 +106,34 @@ export default function BoardPage() {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteProject = async (project: Project) => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`¿Seguro que deseas eliminar el proyecto "${project.nombre || "Sin título"}"?\n\nEsta acción eliminará el proyecto del tablero y del Google Sheet.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setDeletingProjectId(project.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await remove(project.id);
|
||||||
|
|
||||||
|
if (editing?.id === project.id) {
|
||||||
|
setOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error eliminando proyecto:", err);
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "No se pudo eliminar el proyecto. Revisa n8n, Supabase o permisos.";
|
||||||
|
window.alert(message);
|
||||||
|
} finally {
|
||||||
|
setDeletingProjectId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const displayName = user?.displayName ?? user?.email ?? "Usuario";
|
const displayName = user?.displayName ?? user?.email ?? "Usuario";
|
||||||
const userRole = isGerardo ? "Jefe CDC" : "Usuario CDC";
|
const userRole = isGerardo ? "Jefe CDC" : "Usuario CDC";
|
||||||
const userInitials = initials(user?.displayName, user?.email);
|
const userInitials = initials(user?.displayName, user?.email);
|
||||||
@@ -256,7 +285,11 @@ export default function BoardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{paginatedProjects.map((p) => (
|
{paginatedProjects.map((p) => (
|
||||||
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
|
<ProjectCard
|
||||||
|
key={p.id}
|
||||||
|
project={p}
|
||||||
|
onClick={() => openEdit(p)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -273,7 +306,13 @@ export default function BoardPage() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ProjectDialog open={open} onOpenChange={setOpen} project={editing} />
|
<ProjectDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
project={editing}
|
||||||
|
onDelete={deleteProject}
|
||||||
|
deleting={!!editing && deletingProjectId === editing.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user