feat: finalizar Tablero CDC con multipais, Fulgencio y tarifario

This commit is contained in:
2026-06-16 14:23:30 -04:00
parent 8c3d18814d
commit 1fe2ffba80
17 changed files with 3903 additions and 638 deletions
File diff suppressed because one or more lines are too long
+388
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-348
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<meta name="description" content="Herramienta interna del Departamento de Diseño Creativo." />
<meta name="author" content="CDC" />
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
<script type="module" crossorigin src="/tablero-cdc/assets/index-ZKG15NE5.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-7ZtFhhZY.css">
<script type="module" crossorigin src="/tablero-cdc/assets/index-DkJPw2yo.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-O1Ai8omu.css">
</head>
<body>
<div id="root"></div>
+672 -97
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useCallback, useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -21,12 +21,34 @@ import {
} 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 {
AlertCircle,
Loader2,
Lock,
DollarSign,
Calendar,
Trash2,
Search,
History,
MessageSquarePlus,
Clock3,
} from "lucide-react";
import { ColorPicker } from "./ColorPicker";
import { LinkList } from "./LinkList";
import { SearchableSelect } from "./SearchableSelect";
import { SearchableMultiSelect } from "./SearchableMultiSelect";
import { ProjectPricingPanel } from "./ProjectPricingPanel";
import { MESES, type Status } from "@/data/lists";
import { useAppLists } from "@/lib/appLists";
import { useProjects, type Project } from "@/lib/store";
import {
addProjectActivityNote,
loadProjectActivities,
loadProjectPricingItems,
useProjects,
type Project,
type ProjectActivity,
type ProjectPricingItemInput,
} from "@/lib/store";
import { useAuth } from "@/context/AuthContext";
import { colorHex } from "@/lib/colors";
import { cn } from "@/lib/utils";
@@ -47,6 +69,149 @@ interface Props {
}
const WALMART_CONNECT_CLIENT = "Walmart Connect WMC";
const ACTIVITY_PAGE_SIZE = 15;
function formatActivityDate(timestamp: number) {
return new Intl.DateTimeFormat("es-DO", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(timestamp));
}
function formatActivityDescription(description: string) {
return description
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function getPricingTotal(items: ProjectPricingItemInput[]) {
return items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
}
function toPricingInputItems(
items: Awaited<ReturnType<typeof loadProjectPricingItems>>,
): ProjectPricingItemInput[] {
return items.map((item) => ({
id: item.id,
source: item.source,
category: item.category,
serviceName: item.serviceName,
workType: item.workType,
complexityLevel: item.complexityLevel,
referenceLabel: item.referenceLabel,
referenceMin: item.referenceMin,
referenceMax: item.referenceMax,
amount: item.amount,
description: item.description,
}));
}
function hasManualInternalAmountOverride(
savedAmount: number | null | undefined,
items: ProjectPricingItemInput[],
) {
if (!items.length || savedAmount == null) return false;
const cleanSavedAmount = Number(savedAmount);
if (!Number.isFinite(cleanSavedAmount) || cleanSavedAmount <= 0) return false;
const calculatedTotal = getPricingTotal(items);
return Math.abs(cleanSavedAmount - calculatedTotal) > 0.01;
}
function splitBrandValues(value: string | null | undefined, knownBrands: string[] = []) {
const cleanValue = canonicalOptionLabel(value);
if (!cleanValue) return [];
const knownByKey = new Map(
dedupeOptions(knownBrands).map((brand) => [normalizeOptionKey(brand), brand] as const),
);
const exactKnownBrand = knownByKey.get(normalizeOptionKey(cleanValue));
if (exactKnownBrand) {
return [exactKnownBrand];
}
const parts = cleanValue
.split(/[,;]/)
.map((brand) => canonicalOptionLabel(brand))
.filter(Boolean);
if (parts.length <= 1) {
return dedupeOptions([exactKnownBrand || cleanValue]);
}
const parsedBrands: string[] = [];
let index = 0;
while (index < parts.length) {
let matchedBrand = "";
let nextIndex = index + 1;
for (let end = parts.length; end > index; end -= 1) {
const candidate = parts.slice(index, end).join(", ");
const knownBrand = knownByKey.get(normalizeOptionKey(candidate));
if (knownBrand) {
matchedBrand = knownBrand;
nextIndex = end;
break;
}
}
parsedBrands.push(matchedBrand || parts[index]);
index = nextIndex;
}
return dedupeOptions(parsedBrands);
}
function joinBrandValues(values: string[]) {
return dedupeOptions(values.map((brand) => canonicalOptionLabel(brand)).filter(Boolean)).join(
", ",
);
}
function splitCountryValues(value: string | null | undefined, knownCountries: string[] = []) {
const cleanValue = canonicalOptionLabel(value);
if (!cleanValue) return [];
const knownByKey = new Map(
dedupeOptions(knownCountries).map((country) => [normalizeOptionKey(country), country] as const),
);
const exactKnownCountry = knownByKey.get(normalizeOptionKey(cleanValue));
if (exactKnownCountry) {
return [exactKnownCountry];
}
const parts = cleanValue
.split(/[,;]/)
.map((country) => canonicalOptionLabel(country))
.filter(Boolean);
if (parts.length <= 1) {
return dedupeOptions([exactKnownCountry || cleanValue]);
}
return dedupeOptions(
parts.map((country) => knownByKey.get(normalizeOptionKey(country)) || country),
);
}
function joinCountryValues(values: string[]) {
return dedupeOptions(values.map((country) => canonicalOptionLabel(country)).filter(Boolean)).join(
", ",
);
}
function isWalmartConnectClient(client: string | null | undefined) {
return normalizeOptionKey(client) === normalizeOptionKey(WALMART_CONNECT_CLIENT);
@@ -76,19 +241,37 @@ const empty = (): Omit<Project, "id" | "createdAt"> => {
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 { isGerardo, canDeleteProjects, canManageInternalPricing } = useAuth();
const isEdit = !!project;
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
const [selectedWmcCountries, setSelectedWmcCountries] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [wmcCountrySearch, setWmcCountrySearch] = useState("");
const [savingStep, setSavingStep] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [activityItems, setActivityItems] = useState<ProjectActivity[]>([]);
const [activityLoading, setActivityLoading] = useState(false);
const [activityError, setActivityError] = useState<string | null>(null);
const [activityNote, setActivityNote] = useState("");
const [activityHasMore, setActivityHasMore] = useState(false);
const [activityLoadingMore, setActivityLoadingMore] = useState(false);
const [addingActivityNote, setAddingActivityNote] = useState(false);
const [pricingItems, setPricingItems] = useState<ProjectPricingItemInput[]>([]);
const [pricingLoading, setPricingLoading] = useState(false);
const [pricingError, setPricingError] = useState<string | null>(null);
const [internalAmountManuallyEdited, setInternalAmountManuallyEdited] = useState(false);
const selectedBrands = splitBrandValues(form.marca, lists.marcas);
const selectedCountries = splitCountryValues(form.bu, lists.bus);
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 marcaOptions = dedupeOptions([...lists.marcas, ...selectedBrands]);
const buOptions = dedupeOptions([...lists.bus, ...selectedCountries]);
const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[];
const filteredWmcBuOptions = buOptions.filter((b) =>
normalizeOptionKey(b).includes(normalizeOptionKey(wmcCountrySearch)),
);
const pricingTotal = getPricingTotal(pricingItems);
useEffect(() => {
setFormError(null);
@@ -102,15 +285,129 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
bu: cleanBu,
});
setSelectedWmcCountries(cleanBu ? [cleanBu] : []);
setWmcCountrySearch("");
setActivityItems([]);
setActivityHasMore(false);
setActivityError(null);
setActivityNote("");
setPricingItems([]);
setPricingError(null);
setInternalAmountManuallyEdited(false);
} else if (open) {
setForm(empty());
setSelectedWmcCountries([]);
setWmcCountrySearch("");
setActivityItems([]);
setActivityHasMore(false);
setActivityError(null);
setActivityNote("");
setPricingItems([]);
setPricingError(null);
setInternalAmountManuallyEdited(false);
}
}, [project, open]);
const refreshActivity = useCallback(async () => {
if (!project?.id) return;
try {
setActivityLoading(true);
setActivityError(null);
const items = await loadProjectActivities(project.id, ACTIVITY_PAGE_SIZE + 1);
setActivityItems(items.slice(0, ACTIVITY_PAGE_SIZE));
setActivityHasMore(items.length > ACTIVITY_PAGE_SIZE);
} catch (error) {
console.warn("No se pudo cargar el historial del proyecto:", error);
setActivityError(
"No se pudo cargar el historial. Revisa que el SQL de actividad esté ejecutado en Supabase.",
);
setActivityItems([]);
setActivityHasMore(false);
} finally {
setActivityLoading(false);
}
}, [project?.id]);
const loadMoreActivity = useCallback(async () => {
if (!project?.id || activityLoadingMore || !activityHasMore) return;
try {
setActivityLoadingMore(true);
setActivityError(null);
const items = await loadProjectActivities(
project.id,
ACTIVITY_PAGE_SIZE + 1,
activityItems.length,
);
setActivityItems((current) => [...current, ...items.slice(0, ACTIVITY_PAGE_SIZE)]);
setActivityHasMore(items.length > ACTIVITY_PAGE_SIZE);
} catch (error) {
console.warn("No se pudo cargar más historial del proyecto:", error);
setActivityError("No se pudo cargar más actividad. Inténtalo nuevamente.");
} finally {
setActivityLoadingMore(false);
}
}, [activityHasMore, activityItems.length, activityLoadingMore, project?.id]);
useEffect(() => {
if (!open || !project?.id) return;
void refreshActivity();
}, [open, project?.id, refreshActivity]);
useEffect(() => {
if (!open || !project?.id || !canManageInternalPricing) return;
let cancelled = false;
const loadPricing = async () => {
try {
setPricingLoading(true);
setPricingError(null);
const items = await loadProjectPricingItems(project.id);
const inputItems = toPricingInputItems(items);
if (!cancelled) {
setInternalAmountManuallyEdited(
hasManualInternalAmountOverride(project.monto, inputItems),
);
setPricingItems(inputItems);
}
} catch (error) {
console.warn("No se pudo cargar el tarifario del proyecto:", error);
if (!cancelled) {
setPricingItems([]);
setPricingError(
"No se pudieron cargar los costos guardados. Revisa que el SQL del tarifario esté ejecutado en Supabase.",
);
}
} finally {
if (!cancelled) {
setPricingLoading(false);
}
}
};
void loadPricing();
return () => {
cancelled = true;
};
}, [canManageInternalPricing, open, project?.id, project?.monto]);
const set = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => ({ ...f, [k]: v }));
useEffect(() => {
if (!canManageInternalPricing || !pricingItems.length || internalAmountManuallyEdited) return;
const nextAmount = Number(pricingTotal.toFixed(2));
setForm((current) =>
current.monto === nextAmount ? current : { ...current, monto: nextAmount },
);
}, [canManageInternalPricing, internalAmountManuallyEdited, pricingItems.length, pricingTotal]);
const getCountryManager = (bu: string) => {
const direct = lists.buCm[bu];
if (direct) return direct;
@@ -122,9 +419,17 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
};
// 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 onCountries = (countries: string[]) => {
const cleanCountries = dedupeOptions(
countries.map((country) => canonicalOptionLabel(country)).filter(Boolean),
);
const managers = dedupeOptions(cleanCountries.map(getCountryManager).filter(Boolean));
setForm((f) => ({
...f,
bu: joinCountryValues(cleanCountries),
cm: managers.join(", "),
}));
};
const onCliente = (cliente: string) => {
@@ -142,10 +447,13 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
if (!isWalmartConnectClient(cleanCliente)) {
setSelectedWmcCountries([]);
setWmcCountrySearch("");
}
};
const isWalmartConnectCreate = !isEdit && isWalmartConnectClient(form.cliente);
const selectedCreateCountries = isWalmartConnectCreate ? selectedWmcCountries : selectedCountries;
const isMultiCountryCreate = !isEdit && selectedCreateCountries.length > 1;
const toggleWmcCountry = (country: string, checked: boolean) => {
const cleanCountry = canonicalOptionLabel(country);
@@ -165,13 +473,17 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
country,
manager: getCountryManager(country),
}));
const selectedCountryManagers = selectedCountries.map((country) => ({
country,
manager: getCountryManager(country),
}));
const requiredOk =
form.nombre.trim() &&
form.cliente &&
form.marca &&
form.solicitante.trim() &&
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : form.bu);
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : selectedCountries.length > 0);
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
if (result.sheetSyncStatus === "synced") {
@@ -193,30 +505,67 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
});
};
const addActivityNote = async () => {
if (!project?.id || !activityNote.trim() || addingActivityNote) return;
try {
setAddingActivityNote(true);
const result = await addProjectActivityNote(project.id, activityNote);
set("comentarios", result.comments);
setActivityNote("");
await refreshActivity();
if (result.sheetSyncStatus === "synced") {
toast.success("Comentario agregado al historial", {
description: "También se actualizó la columna Comentarios del Google Sheet.",
});
} else if (result.sheetSyncStatus === "skipped") {
toast.success("Comentario agregado al historial", {
description:
"Se guardó como comentario interno. La sincronización con Sheet no está configurada.",
});
} else {
toast.warning("Comentario agregado con aviso", {
description: result.sheetSyncMessage,
});
}
} catch (error) {
console.error("Error agregando comentario al historial:", error);
const message =
error instanceof Error ? error.message : "No se pudo agregar el comentario al historial.";
toast.error("No se pudo agregar el comentario", { description: message });
} finally {
setAddingActivityNote(false);
}
};
const submit = async () => {
if (!requiredOk || saving) return;
const safeForm = isGerardo
? form
: {
...form,
status: project?.status ?? "",
monto: project?.monto ?? null,
};
: canManageInternalPricing
? {
...form,
status: project?.status ?? "",
}
: {
...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;
if (isMultiCountryCreate) {
const countries = selectedCreateCountries;
const results = [];
for (const [index, country] of countries.entries()) {
setSavingStep(
`Guardando Walmart Connect WMC ${index + 1} de ${countries.length}: ${country}`,
);
setSavingStep(`Guardando país ${index + 1} de ${countries.length}: ${country}`);
const countryProject = {
...safeForm,
@@ -226,25 +575,38 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
createdAt: Date.now() + index,
};
results.push(await add(countryProject));
results.push(
await add({
...countryProject,
pricingItems: canManageInternalPricing ? pricingItems : undefined,
}),
);
}
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.`,
toast.success("Proyectos multipaís creados", {
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet. El Interno Cargado se aplicó por país.`,
});
} else {
toast.warning("Proyectos Walmart Connect creados con aviso", {
toast.warning("Proyectos multipaís 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() });
? await update(project.id, {
...safeForm,
pricingItems: canManageInternalPricing ? pricingItems : undefined,
})
: await add({
...safeForm,
id: crypto.randomUUID(),
createdAt: Date.now(),
pricingItems: canManageInternalPricing ? pricingItems : undefined,
});
showSaveToast(result);
}
@@ -360,18 +722,14 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
<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>
<SearchableSelect
value={form.cliente}
options={clienteOptions}
onValueChange={onCliente}
placeholder="Seleccionar…"
searchPlaceholder="Buscar cliente…"
emptyText="No se encontró ese cliente."
/>
</div>
{/* Marca */}
@@ -379,18 +737,15 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
<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>
<SearchableMultiSelect
values={selectedBrands}
options={marcaOptions}
onValuesChange={(values) => set("marca", joinBrandValues(values))}
placeholder="Seleccionar una o varias marcas…"
searchPlaceholder="Buscar marca…"
emptyText="No se encontró esa marca."
summaryLabel="marcas seleccionadas"
/>
</div>
{/* BU */}
@@ -406,10 +761,19 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
</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.
proyecto y una fila del Sheet por cada país, como hasta ahora.
</p>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={wmcCountrySearch}
onChange={(e) => setWmcCountrySearch(e.target.value)}
placeholder="Buscar país…"
className="h-9 pl-9 bg-card"
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{buOptions.map((b) => {
{filteredWmcBuOptions.map((b) => {
const checked = selectedWmcCountries.some(
(country) => normalizeOptionKey(country) === normalizeOptionKey(b),
);
@@ -442,22 +806,23 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="md:col-span-2 space-y-1.5">
<Label>
BU Solicita (País) <span className="text-destructive">*</span>
BU Solicita (Países) <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>
<SearchableMultiSelect
values={selectedCountries}
options={buOptions}
onValuesChange={onCountries}
placeholder="Seleccionar uno o varios países…"
searchPlaceholder="Buscar país…"
emptyText="No se encontró ese país."
summaryLabel="países seleccionados"
/>
<p className="text-xs text-muted-foreground">
Si seleccionas varios países al crear, la app creará un proyecto y una fila del
Sheet por cada país.
</p>
</div>
)}
@@ -481,16 +846,29 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
)}
</div>
) : (
<Input
value={form.cm}
readOnly
disabled
className="h-10 bg-muted/50"
placeholder="—"
/>
<div className="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
{selectedCountryManagers.length === 0 ? (
<span className="text-muted-foreground">Selecciona uno o más países.</span>
) : (
<div className="flex flex-wrap gap-1.5">
{selectedCountryManagers.map(({ country, manager }) => (
<Badge key={country} variant="secondary" className="font-normal">
{country}: {manager || "—"}
</Badge>
))}
</div>
)}
</div>
)}
</div>
{isMultiCountryCreate && canManageInternalPricing && (
<div className="md:col-span-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Si seleccionas varios países, el Interno Cargado se aplicará por cada país. La app
no divide el monto automáticamente.
</div>
)}
{/* Solicitante */}
<div className="space-y-1.5">
<Label>
@@ -504,16 +882,20 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
/>
</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>
<ProjectActivityPanel
isEdit={isEdit}
items={activityItems}
loading={activityLoading}
error={activityError}
note={activityNote}
hasMore={activityHasMore}
loadingMore={activityLoadingMore}
addingNote={addingActivityNote}
onNoteChange={setActivityNote}
onAddNote={addActivityNote}
onRefresh={refreshActivity}
onLoadMore={loadMoreActivity}
/>
<Separator className="md:col-span-2 my-1" />
@@ -556,9 +938,20 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
<Separator className="md:col-span-2 my-1" />
{/* Status: Gerardo edita; el equipo solo visualiza */}
{canManageInternalPricing && (
<ProjectPricingPanel
items={pricingItems}
onChange={setPricingItems}
loading={pricingLoading}
error={pricingError}
/>
)}
<Separator className="md:col-span-2 my-1" />
{/* Status: Director Creativo edita; el equipo solo visualiza */}
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<Label className="flex items-center gap-1.5 pl-1">
Estatus
{!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />}
</Label>
@@ -587,28 +980,36 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
)}
{!isGerardo && (
<p className="text-[11px] text-muted-foreground">
Solo Gerardo Marrero puede cambiar el estatus.
<p className="pl-1 text-[11px] text-muted-foreground">
Solo el Director Creativo puede cambiar el estatus.
</p>
)}
</div>
{/* Monto: SOLO visible para Gerardo Marrero */}
{isGerardo && (
{/* Monto: SOLO visible para usuarios internos autorizados */}
{canManageInternalPricing && (
<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 className="flex items-center gap-1.5 pl-1">
Interno Cargado <DollarSign className="w-3.5 h-3.5 text-muted-foreground" />
</Label>
<Input
type="number"
value={form.monto ?? ""}
onChange={(e) =>
set("monto", e.target.value === "" ? null : Number(e.target.value))
}
onChange={(e) => {
setInternalAmountManuallyEdited(true);
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>
<p className="pl-1 text-[11px] text-muted-foreground">
Privado · solo usuarios internos autorizados.
{internalAmountManuallyEdited && pricingItems.length > 0
? " Monto ajustado manualmente; se respetará al guardar."
: pricingItems.length > 0
? " El total del tarifario se refleja aquí y puedes ajustarlo."
: ""}
</p>
</div>
)}
</div>
@@ -654,7 +1055,11 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={saving || deleting}
>
Cancelar
</Button>
<Button onClick={submit} disabled={!requiredOk || saving || deleting}>
@@ -663,7 +1068,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
? "Guardando…"
: isEdit
? "Guardar cambios"
: isWalmartConnectCreate
: isMultiCountryCreate
? "Crear proyectos"
: "Crear proyecto"}
</Button>
@@ -674,3 +1079,173 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
</Dialog>
);
}
function ProjectActivityPanel({
isEdit,
items,
loading,
error,
note,
hasMore,
loadingMore,
addingNote,
onNoteChange,
onAddNote,
onRefresh,
onLoadMore,
}: {
isEdit: boolean;
items: ProjectActivity[];
loading: boolean;
error: string | null;
note: string;
hasMore: boolean;
loadingMore: boolean;
addingNote: boolean;
onNoteChange: (value: string) => void;
onAddNote: () => Promise<void>;
onRefresh: () => Promise<void>;
onLoadMore: () => Promise<void>;
}) {
return (
<div className="md:col-span-2 rounded-2xl border border-border bg-muted/20 p-4">
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<History className="h-4 w-4" />
</span>
<div>
<h3 className="text-sm font-semibold">Actividad del proyecto</h3>
<p className="text-xs text-muted-foreground">
Comentarios y movimientos recientes con fecha.
</p>
</div>
</div>
{isEdit && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void onRefresh()}
disabled={loading}
className="h-8 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground"
>
{loading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
Actualizar
</Button>
)}
</div>
{!isEdit ? (
<div className="rounded-xl border border-dashed border-border bg-card/70 px-3 py-3 text-sm text-muted-foreground">
El historial se activará cuando el proyecto sea creado.
</div>
) : (
<div className="space-y-3">
<div className="rounded-xl border border-border bg-card p-3">
<Label className="text-xs font-medium">Agregar comentario o solicitud adicional</Label>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<Textarea
value={note}
onChange={(event) => onNoteChange(event.target.value)}
placeholder="Ej: CM pidió ajustar artes, agregar una marca o cambiar una fecha…"
rows={2}
className="min-h-[64px] flex-1 resize-none"
/>
<Button
type="button"
onClick={() => void onAddNote()}
disabled={!note.trim() || addingNote}
className="gap-1.5 rounded-full sm:self-end"
>
{addingNote ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquarePlus className="h-4 w-4" />
)}
Agregar
</Button>
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{loading && items.length === 0 ? (
<div className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Cargando actividad
</div>
) : items.length === 0 ? (
<div className="rounded-xl border border-border bg-card px-3 py-3 text-sm text-muted-foreground">
Aún no hay actividad registrada. Los próximos comentarios o cambios quedarán aquí.
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 px-0.5 text-[11px] text-muted-foreground">
<span>Mostrando los movimientos más recientes.</span>
<span>{items.length} visibles</span>
</div>
<div className="max-h-[440px] space-y-2 overflow-y-auto pr-1">
{items.map((item) => {
const lines = formatActivityDescription(item.description);
return (
<div
key={item.id}
className="rounded-xl border border-border bg-card px-3 py-3 shadow-sm"
>
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">
{item.actorName}
{item.actorEmail ? ` · ${item.actorEmail}` : ""}
</p>
</div>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock3 className="h-3.5 w-3.5" />
{formatActivityDate(item.createdAt)}
</span>
</div>
{lines.length > 0 && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{lines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
)}
</div>
);
})}
</div>
{hasMore && (
<div className="flex justify-center pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void onLoadMore()}
disabled={loadingMore}
className="rounded-full"
>
{loadingMore ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
{loadingMore ? "Cargando…" : "Cargar más"}
</Button>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,484 @@
import { useMemo, useState } from "react";
import { Calculator, DollarSign, Plus, Trash2, Info } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import {
TARIFF_CATALOG,
TARIFF_SECTIONS,
defaultTariffAmount,
formatTariffRange,
type TariffCatalogItem,
type TariffLevel,
type TariffWorkType,
} from "@/data/tariff";
import type { ProjectPricingItemInput } from "@/lib/store";
import { cn } from "@/lib/utils";
interface Props {
items: ProjectPricingItemInput[];
onChange: (items: ProjectPricingItemInput[]) => void;
loading?: boolean;
error?: string | null;
}
function parseAmount(value: string) {
const parsed = Number(value.replace(/,/g, "").trim());
return Number.isFinite(parsed) ? parsed : 0;
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value || 0);
}
function findDefaultWorkType(item?: TariffCatalogItem | null) {
return item?.workTypes[0] || null;
}
function findDefaultLevel(workType?: TariffWorkType | null) {
return workType?.levels[0] || null;
}
function clean(value: string) {
return value.trim();
}
export function ProjectPricingPanel({ items, onChange, loading, error }: Props) {
const [section, setSection] = useState<string>("grafico");
const [serviceId, setServiceId] = useState<string>("");
const [workTypeId, setWorkTypeId] = useState<string>("");
const [levelId, setLevelId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [manualName, setManualName] = useState<string>("");
const [manualDescription, setManualDescription] = useState<string>("");
const [manualAmount, setManualAmount] = useState<string>("");
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
const serviceOptions = useMemo(
() =>
TARIFF_CATALOG.filter((item) => item.section === section).map(
(item) => `${item.category} · ${item.service}`,
),
[section],
);
const selectedItem = useMemo(() => {
const selectedLabel = serviceId;
return (
TARIFF_CATALOG.find(
(item) =>
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
) || null
);
}, [section, serviceId]);
const selectedWorkType = useMemo(() => {
if (!selectedItem) return null;
return selectedItem.workTypes.find((workType) => workType.id === workTypeId) || null;
}, [selectedItem, workTypeId]);
const selectedLevel = useMemo(() => {
if (!selectedWorkType) return null;
return selectedWorkType.levels.find((level) => level.id === levelId) || null;
}, [selectedWorkType, levelId]);
const total = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
const resetTariffSelection = () => {
setServiceId("");
setWorkTypeId("");
setLevelId("");
setAmount("");
setDescription("");
};
const onSectionChange = (label: string) => {
const option = TARIFF_SECTIONS.find((item) => item.label === label);
setSection(option?.id || "grafico");
resetTariffSelection();
};
const onServiceChange = (label: string) => {
setServiceId(label);
const item = TARIFF_CATALOG.find(
(catalogItem) =>
catalogItem.section === section &&
`${catalogItem.category} · ${catalogItem.service}` === label,
);
const firstWorkType = findDefaultWorkType(item);
const firstLevel = findDefaultLevel(firstWorkType);
setWorkTypeId(firstWorkType?.id || "");
setLevelId(firstLevel?.id || "");
setAmount(defaultTariffAmount(firstLevel));
setDescription("");
};
const onWorkTypeChange = (label: string) => {
if (!selectedItem) return;
const workType = selectedItem.workTypes.find((item) => item.label === label);
const firstLevel = findDefaultLevel(workType);
setWorkTypeId(workType?.id || "");
setLevelId(firstLevel?.id || "");
setAmount(defaultTariffAmount(firstLevel));
};
const onLevelChange = (label: string) => {
if (!selectedWorkType) return;
const level = selectedWorkType.levels.find((item) => item.label === label);
setLevelId(level?.id || "");
setAmount(defaultTariffAmount(level));
};
const addTariffItem = () => {
if (!selectedItem || !selectedWorkType || !selectedLevel) return;
const finalAmount = parseAmount(amount);
if (finalAmount <= 0) return;
onChange([
...items,
{
source: "tariff",
category: selectedItem.category,
serviceName: selectedItem.service,
workType: selectedWorkType.label,
complexityLevel: selectedLevel.label,
referenceLabel: formatTariffRange(selectedLevel),
referenceMin: selectedLevel.min ?? null,
referenceMax: selectedLevel.max ?? null,
amount: finalAmount,
description: clean(description),
},
]);
setDescription("");
setAmount(defaultTariffAmount(selectedLevel));
};
const addManualItem = () => {
const finalAmount = parseAmount(manualAmount);
const name = clean(manualName) || "Otros / costo manual";
if (finalAmount <= 0) return;
onChange([
...items,
{
source: "manual",
category: "Otros",
serviceName: name,
workType: "Manual",
complexityLevel: "",
referenceLabel: "Monto manual",
referenceMin: null,
referenceMax: null,
amount: finalAmount,
description: clean(manualDescription),
},
]);
setManualName("");
setManualDescription("");
setManualAmount("");
};
const removeItem = (index: number) => {
onChange(items.filter((_, itemIndex) => itemIndex !== index));
};
const selectedWorkTypeLabel = selectedWorkType?.label || "";
const selectedLevelLabel = selectedLevel?.label || "";
return (
<div className="md:col-span-2 rounded-2xl border border-border bg-muted/20 p-4">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<Calculator className="h-4 w-4" />
</span>
<div>
<h3 className="text-sm font-semibold">Tarifario / Estimación interna</h3>
<p className="text-xs text-muted-foreground">
Agrega costos del tarifario o montos manuales. El total se guarda como Interno
Cargado.
</p>
</div>
</div>
<div className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
Total: {formatCurrency(total)}
</div>
</div>
{error && (
<div className="mb-3 flex items-start gap-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{loading && (
<div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
Cargando costos guardados
</div>
)}
<div className="space-y-4">
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">Agregar desde tarifario</p>
<p className="text-xs text-muted-foreground">
Referencia flexible. El Director Creativo decide el monto final.
</p>
</div>
<Badge variant="secondary">Tarifario</Badge>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label>Sección</Label>
<SearchableSelect
value={selectedSectionLabel}
options={sectionOptions}
onValueChange={onSectionChange}
placeholder="Seleccionar sección…"
searchPlaceholder="Buscar sección…"
/>
</div>
<div className="space-y-1.5">
<Label>Ítem a cobrar</Label>
<SearchableSelect
value={serviceId}
options={serviceOptions}
onValueChange={onServiceChange}
placeholder="Seleccionar ítem…"
searchPlaceholder="Buscar ítem…"
emptyText="No se encontró ese ítem."
/>
</div>
<div className="space-y-1.5">
<Label>Tipo de trabajo</Label>
<SearchableSelect
value={selectedWorkTypeLabel}
options={selectedItem?.workTypes.map((workType) => workType.label) || []}
onValueChange={onWorkTypeChange}
placeholder="Seleccionar tipo…"
searchPlaceholder="Buscar tipo…"
disabled={!selectedItem}
/>
</div>
<div className="space-y-1.5">
<Label>Nivel / referencia</Label>
<SearchableSelect
value={selectedLevelLabel}
options={selectedWorkType?.levels.map((item) => item.label) || []}
onValueChange={onLevelChange}
placeholder="Seleccionar nivel…"
searchPlaceholder="Buscar nivel…"
disabled={!selectedWorkType}
/>
</div>
</div>
{selectedItem && selectedWorkType && selectedLevel && (
<div className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<p>
<span className="font-medium text-foreground">Referencia:</span>{" "}
{formatTariffRange(selectedLevel)} USD
{selectedWorkType.hourReference
? ` · Hora hombre ref.: ${selectedWorkType.hourReference}`
: ""}
</p>
{selectedItem.notes && <p className="mt-1">{selectedItem.notes}</p>}
{selectedLevel.hint && <p className="mt-1">{selectedLevel.hint}</p>}
</div>
)}
<div className="grid grid-cols-1 gap-3 md:grid-cols-[220px_minmax(0,1fr)]">
<div className="space-y-1.5">
<Label>Monto final</Label>
<Input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(event) => setAmount(event.target.value)}
placeholder="0.00"
disabled={!selectedLevel}
/>
</div>
<div className="space-y-1.5">
<Label>Observación</Label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Ej: más rondas de cambios, piezas adicionales, complejidad especial…"
disabled={!selectedLevel}
rows={5}
className="min-h-[132px] resize-y"
/>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={addTariffItem}
disabled={
!selectedItem || !selectedWorkType || !selectedLevel || parseAmount(amount) <= 0
}
className="gap-1.5 rounded-full"
>
<Plus className="h-4 w-4" />
Agregar tarifa
</Button>
</div>
</div>
<div className="space-y-4 rounded-xl border border-border bg-card p-4">
<div>
<p className="text-sm font-medium">Agregar costo manual</p>
<p className="text-xs text-muted-foreground">
Para otros, adicionales o casos fuera del tarifario.
</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-1.5">
<Label>Nombre</Label>
<Input
value={manualName}
onChange={(event) => setManualName(event.target.value)}
placeholder="Ej: Piezas adicionales"
/>
</div>
<div className="space-y-1.5">
<Label>Monto</Label>
<Input
type="number"
min="0"
step="0.01"
value={manualAmount}
onChange={(event) => setManualAmount(event.target.value)}
placeholder="0.00"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Descripción</Label>
<Textarea
value={manualDescription}
onChange={(event) => setManualDescription(event.target.value)}
placeholder="Ej: Cliente pidió 8 piezas adicionales fuera del paquete."
rows={4}
className="min-h-[110px] resize-y"
/>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={addManualItem}
disabled={parseAmount(manualAmount) <= 0}
className="w-full gap-1.5 rounded-full sm:w-auto sm:min-w-[240px]"
>
<Plus className="h-4 w-4" />
Agregar costo manual
</Button>
</div>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">Costos agregados</p>
<p className="text-xs text-muted-foreground">{items.length} línea(s)</p>
</div>
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-card/70 px-3 py-3 text-sm text-muted-foreground">
Aún no hay costos agregados. Puedes usar el tarifario, agregar otros manuales o ambos.
</div>
) : (
<div className="space-y-2">
{items.map((item, index) => (
<div
key={`${item.source}-${item.serviceName}-${index}`}
className="flex flex-col gap-2 rounded-xl border border-border bg-card px-3 py-3 sm:flex-row sm:items-start sm:justify-between"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={item.source === "manual" ? "outline" : "secondary"}>
{item.source === "manual" ? "Manual" : "Tarifario"}
</Badge>
<p className="text-sm font-medium">{item.serviceName}</p>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
{item.category && <span>{item.category}</span>}
{item.workType && <span>{item.workType}</span>}
{item.complexityLevel && <span>{item.complexityLevel}</span>}
{item.referenceLabel && <span>Ref.: {item.referenceLabel}</span>}
</div>
{item.description && (
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
)}
</div>
<div className="flex items-center justify-between gap-3 sm:flex-col sm:items-end">
<p className="text-sm font-semibold text-foreground">
{formatCurrency(Number(item.amount || 0))}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeItem(index)}
className={cn(
"h-8 rounded-full px-2 text-muted-foreground hover:text-destructive",
)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Quitar
</Button>
</div>
</div>
))}
<div className="flex items-center justify-end gap-2 rounded-xl bg-primary/10 px-3 py-2 text-primary">
<DollarSign className="h-4 w-4" />
<span className="text-sm font-semibold">
Total interno estimado: {formatCurrency(total)}
</span>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,150 @@
import { useMemo, useState, type WheelEvent } from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { canonicalOptionLabel, dedupeOptions, normalizeOptionKey } from "@/lib/optionUtils";
import { cn } from "@/lib/utils";
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.scrollTop += event.deltaY;
}
interface SearchableMultiSelectProps {
values: string[];
options: string[];
onValuesChange: (values: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
summaryLabel?: string;
}
function cleanValues(values: string[]) {
return dedupeOptions(values.map((value) => canonicalOptionLabel(value)).filter(Boolean));
}
export function SearchableMultiSelect({
values,
options,
onValuesChange,
placeholder = "Seleccionar…",
searchPlaceholder = "Buscar…",
emptyText = "No hay resultados.",
disabled = false,
summaryLabel = "opciones seleccionadas",
}: SearchableMultiSelectProps) {
const [open, setOpen] = useState(false);
const selectedValues = useMemo(() => cleanValues(values), [values]);
const normalizedSelected = useMemo(
() => new Set(selectedValues.map((value) => normalizeOptionKey(value))),
[selectedValues],
);
const cleanOptions = useMemo(
() =>
dedupeOptions([...options, ...selectedValues].map((option) => canonicalOptionLabel(option))),
[options, selectedValues],
);
const selectedSummary = useMemo(() => {
if (selectedValues.length === 0) return "";
if (selectedValues.length <= 2) return selectedValues.join(", ");
return `${selectedValues.length} ${summaryLabel}`;
}, [selectedValues, summaryLabel]);
const updateValues = (nextValues: string[]) => {
onValuesChange(cleanValues(nextValues));
};
const toggleOption = (option: string) => {
const cleanOption = canonicalOptionLabel(option);
const optionKey = normalizeOptionKey(cleanOption);
if (normalizedSelected.has(optionKey)) {
updateValues(selectedValues.filter((value) => normalizeOptionKey(value) !== optionKey));
return;
}
updateValues([...selectedValues, cleanOption]);
};
const removeOption = (option: string) => {
const optionKey = normalizeOptionKey(option);
updateValues(selectedValues.filter((value) => normalizeOptionKey(value) !== optionKey));
};
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"min-h-10 w-full justify-between rounded-md border-border bg-background px-3 font-normal hover:bg-background",
!selectedSummary && "text-muted-foreground",
)}
>
<span className="truncate text-left">{selectedSummary || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList className="overscroll-contain" onWheel={handleDropdownWheel}>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{cleanOptions.map((option) => {
const selected = normalizedSelected.has(normalizeOptionKey(option));
return (
<CommandItem key={option} value={option} onSelect={() => toggleOption(option)}>
<Check
className={cn("mr-2 h-4 w-4", selected ? "opacity-100" : "opacity-0")}
/>
<span className="truncate">{option}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedValues.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedValues.map((value) => (
<Badge key={value} variant="secondary" className="gap-1 rounded-full px-2 py-0.5">
<span className="max-w-[190px] truncate">{value}</span>
<button
type="button"
onClick={() => removeOption(value)}
className="rounded-full text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Quitar ${value}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useMemo, useState, type WheelEvent } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
function handleDropdownWheel(event: WheelEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.scrollTop += event.deltaY;
}
interface SearchableSelectProps {
value: string;
options: string[];
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
clearLabel?: string;
}
export function SearchableSelect({
value,
options,
onValueChange,
placeholder = "Seleccionar…",
searchPlaceholder = "Buscar…",
emptyText = "No hay resultados.",
disabled = false,
clearLabel,
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const selectedLabel = useMemo(
() => options.find((option) => option === value) ?? value,
[options, value],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"h-10 w-full justify-between rounded-md border-border bg-background px-3 font-normal hover:bg-background",
!selectedLabel && "text-muted-foreground",
)}
>
<span className="truncate">{selectedLabel || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[min(92vw,560px)] min-w-[var(--radix-popover-trigger-width)] p-0"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList className="overscroll-contain" onWheel={handleDropdownWheel}>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{clearLabel && (
<>
<CommandItem
key="__clear__"
value={`__clear_${clearLabel}`}
onSelect={() => {
onValueChange("");
setOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
<span className="whitespace-normal break-words font-medium">{clearLabel}</span>
</CommandItem>
{options.length > 0 && <CommandSeparator className="my-1" />}
</>
)}
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={() => {
onValueChange(option);
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")}
/>
<span className="whitespace-normal break-words leading-snug">{option}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+25 -2
View File
@@ -12,7 +12,16 @@ import { supabase } from "@/lib/supabase";
const ALLOWED_DOMAIN = "gomezleemarketing.com";
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "areyes@gomezleemarketing.com"];
const INTERNAL_PRICING_ALLOWED_EMAILS = [
GERARDO_EMAIL,
"areyes@gomezleemarketing.com",
"iaracena@gomezleemarketing.com",
];
const DELETE_ALLOWED_EMAILS = [
GERARDO_EMAIL,
"areyes@gomezleemarketing.com",
"iaracena@gomezleemarketing.com",
];
function isAllowedEmail(email: string | null | undefined): boolean {
if (!email) return false;
@@ -61,6 +70,7 @@ interface AuthContextValue {
error: string | null;
isGerardo: boolean;
canDeleteProjects: boolean;
canManageInternalPricing: boolean;
loginWithGoogle: () => Promise<void>;
logout: () => Promise<void>;
}
@@ -162,6 +172,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const normalizedEmail = user?.email?.toLowerCase() || "";
const isGerardo = useMemo(() => normalizedEmail === GERARDO_EMAIL, [normalizedEmail]);
const canManageInternalPricing = useMemo(
() => INTERNAL_PRICING_ALLOWED_EMAILS.includes(normalizedEmail),
[normalizedEmail],
);
const canDeleteProjects = useMemo(
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
[normalizedEmail],
@@ -169,7 +183,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return (
<AuthContext.Provider
value={{ user, loading, error, isGerardo, canDeleteProjects, loginWithGoogle, logout }}
value={{
user,
loading,
error,
isGerardo,
canDeleteProjects,
canManageInternalPricing,
loginWithGoogle,
logout,
}}
>
{children}
</AuthContext.Provider>
+526
View File
@@ -0,0 +1,526 @@
export type TariffLevel = {
id: string;
label: string;
reference: string;
min?: number;
max?: number;
hint?: string;
};
export type TariffWorkType = {
id: string;
label: string;
shortLabel: string;
hourReference?: string;
levels: TariffLevel[];
};
export type TariffCatalogItem = {
id: string;
section: "grafico" | "estrategia";
category: string;
service: string;
notes?: string;
workTypes: TariffWorkType[];
};
export const TARIFF_SECTIONS = [
{ id: "grafico", label: "Tarifario Gráfico CDC" },
{ id: "estrategia", label: "Estrategia y Creatividad" },
] as const;
export const WORK_TYPE_DESIGN = "design_final_art";
export const WORK_TYPE_ADAPTATION = "adaptation_final_art";
export const WORK_TYPE_REFERENCE = "reference";
function level(
id: string,
label: string,
reference: string,
min?: number,
max?: number,
hint?: string,
): TariffLevel {
return { id, label, reference, min, max, hint };
}
function graphicWorkTypes({
designHour = "35 - 50",
adaptationHour = "30",
design,
adaptation,
}: {
designHour?: string;
adaptationHour?: string;
design: TariffLevel[];
adaptation?: TariffLevel[];
}): TariffWorkType[] {
const workTypes: TariffWorkType[] = [
{
id: WORK_TYPE_DESIGN,
label: "Diseño + Entregable Arte Final",
shortLabel: "Diseño + AF",
hourReference: designHour,
levels: design,
},
];
if (adaptation?.length) {
workTypes.push({
id: WORK_TYPE_ADAPTATION,
label: "Adaptación Arte Final",
shortLabel: "Adaptación AF",
hourReference: adaptationHour,
levels: adaptation,
});
}
return workTypes;
}
export const TARIFF_CATALOG: TariffCatalogItem[] = [
{
id: "pdv-materiales-basicos",
section: "grafico",
category: "Materiales de PDV estándar",
service:
"Cenefas, habladores, danglers, rompetráficos, afiches, backings, rollups, uniformes, piezas WhatsApp/web",
notes:
"Incluye diseño en alta resolución, troqueles y 3 rondas de cambios. Cambios adicionales pueden agregarse como Otros.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "50 - 80", 50, 80),
level("intermediate", "Intermedio", "100 - 150", 100, 150),
level("advanced", "Avanzado", "150 - 300", 150, 300),
],
adaptation: [
level("basic", "Básico", "30 - 50", 30, 50),
level("intermediate", "Intermedio", "60 - 80", 60, 80),
level("advanced", "Avanzado", "100 - 150", 100, 150),
],
}),
},
{
id: "pdv-promocion-pack",
section: "grafico",
category: "Promoción en PDV",
service: "Quick counter + roll up + mecánica de canje + uniforme (pack 3 a 5 piezas o más)",
notes:
"Montos por paquete de 3 a 5 piezas. Piezas o solicitudes extra deben agregarse como Otros.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "320 - 500", 320, 500),
level("intermediate", "Intermedio", "500 - 700", 500, 700),
level("advanced", "Avanzado", "800 - 1000", 800, 1000),
],
adaptation: [
level("basic", "Básico", "200 - 350", 200, 350),
level("intermediate", "Intermedio", "350 - 550", 350, 550),
level(
"advanced",
"Avanzado",
"600 - 800+",
600,
800,
"Puede superar el rango si el caso lo requiere.",
),
],
}),
},
{
id: "ooh-gigantografias",
section: "grafico",
category: "OOH y gigantografías",
service: "Vallas, mupies y gigantografías",
notes: "Los niveles están asociados a tamaño aproximado: 6m², más de 10m² y más de 20m².",
workTypes: graphicWorkTypes({
design: [
level("basic", "6m² aprox", "250 - 350", 250, 350),
level("intermediate", "Más 10m²", "400 - 650", 400, 650),
level("advanced", "Más de 20m²", "700 - 1100", 700, 1100),
],
adaptation: [
level("basic", "6m² aprox", "100 - 150", 100, 150),
level("intermediate", "Más 10m²", "200 - 300", 200, 300),
level("advanced", "Más de 20m²", "400 - 600", 400, 600),
],
}),
},
{
id: "stands-muebles-exhibidores",
section: "grafico",
category: "Stands, muebles y exhibidores",
service: "One Way, cabeceras de góndola, revestimiento de islas y displays básicos",
notes: "Incluye diseño en alta resolución, troqueles y 3 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "300 - 400", 300, 400),
level("intermediate", "Intermedio", "450 - 650", 450, 650),
level("advanced", "Avanzado", "700 - 1000", 700, 1000),
],
adaptation: [
level("basic", "Básico", "200 - 300", 200, 300),
level("intermediate", "Intermedio", "300 - 400", 300, 400),
level("advanced", "Avanzado", "450 - 600", 450, 600),
],
}),
},
{
id: "proyectos-especiales-muebles",
section: "grafico",
category: "Proyectos especiales",
service:
"Diseño de muebles u otros proyectos especiales con planos estructurales y troqueles desde cero",
notes:
"Precio por proyecto. Incluye diseño gráfico/estructural, planos, troqueles, conceptualización y 4 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "1000 - 1500", 1000, 1500)],
adaptation: [level("project", "Precio único", "600 - 850", 600, 850)],
}),
},
{
id: "stands-creativos-eventos",
section: "grafico",
category: "Proyectos especiales",
service:
"Stands creativos para ferias/eventos, montaje de eventos especiales y diseños de espacios especiales",
notes:
"Precio por proyecto. Incluye stand creativo, planos estructurales, visualizaciones 3D y 4 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "1500 - 3000", 1500, 3000)],
adaptation: [level("project", "Precio único", "800 - 1200", 800, 1200)],
}),
},
{
id: "empaque-simple",
section: "grafico",
category: "Diseño de empaques",
service: "Empaque simple",
notes: "Diseño gráfico original para empaques simples sin troqueles complejos.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "700 - 1000", 700, 1000)],
adaptation: [level("project", "Adaptación / SKU", "100 - 300", 100, 300)],
}),
},
{
id: "empaque-complejo",
section: "grafico",
category: "Diseño de empaques",
service: "Empaque complejo",
notes:
"Diseño gráfico y estructural para empaques con troqueles personalizados o formas no convencionales.",
workTypes: graphicWorkTypes({
design: [level("project", "Precio único", "1000 - 1500", 1000, 1500)],
adaptation: [level("project", "Adaptación / SKU", "100 - 300", 100, 300)],
}),
},
{
id: "brochures",
section: "grafico",
category: "Catálogos, brochures y folletos",
service: "Brochures",
notes: "Incluye diseño y 3 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "200 - 400", 200, 400),
level("intermediate", "Intermedio", "400 - 600", 400, 600),
level("advanced", "Avanzado", "600 - 800", 600, 800),
],
adaptation: [
level("basic", "Básico", "50 - 100", 50, 100),
level("intermediate", "Intermedio", "100 - 150", 100, 150),
level("advanced", "Avanzado", "150 - 350", 150, 350),
],
}),
},
{
id: "catalogos-folletos-pagina",
section: "grafico",
category: "Catálogos, brochures y folletos",
service: "Catálogos y folletos (costo por página)",
notes: "Costo por página. Incluye diseño y 3 rondas de cambios.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "30 - 60", 30, 60),
level("intermediate", "Intermedio", "60 - 90", 60, 90),
level("advanced", "Avanzado", "90 - 120", 90, 120),
],
adaptation: [
level("basic", "Básico", "15 - 30", 15, 30),
level("intermediate", "Intermedio", "30 - 60", 30, 60),
level("advanced", "Avanzado", "60 - 100", 60, 100),
],
}),
},
{
id: "web-landing",
section: "grafico",
category: "Páginas web",
service: "Landing page o sitio simple hasta 5 páginas",
notes:
"Sitio web básico con diseño estático, contenido del cliente y hasta 2 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Diseño web",
shortLabel: "Web",
hourReference: "35 - 50",
levels: [level("project", "Precio único", "800 - 1500", 800, 1500)],
},
],
},
{
id: "web-intermedio",
section: "grafico",
category: "Páginas web",
service: "Sitio corporativo intermedio de 6 a 15 páginas",
notes:
"Sitio mediano con páginas adicionales y diseño más personalizado. Hasta 3 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Diseño web",
shortLabel: "Web",
hourReference: "35 - 50",
levels: [level("project", "Precio único", "2000 - 5000", 2000, 5000)],
},
],
},
{
id: "web-avanzado",
section: "grafico",
category: "Páginas web",
service: "Sitio avanzado: más de 15 páginas, interactividad o integraciones complejas",
notes: "Incluye desarrollo a medida y hasta 5 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Diseño web",
shortLabel: "Web",
hourReference: "36 - 50",
levels: [level("project", "Precio único", "4000 - 8000", 4000, 8000)],
},
],
},
{
id: "retoques-fotograficos",
section: "grafico",
category: "Otros servicios",
service: "Retoques fotográficos",
notes: "Incluye 2 rondas de ajustes.",
workTypes: graphicWorkTypes({
design: [
level("basic", "Básico", "20 - 40", 20, 40),
level("intermediate", "Intermedio", "50 - 100", 50, 100),
level("advanced", "Avanzado", "150 - 300", 150, 300),
],
adaptation: undefined,
}),
},
{
id: "videos",
section: "grafico",
category: "Otros servicios",
service: "Videos",
notes: "Referencia por complejidad del video.",
workTypes: graphicWorkTypes({
designHour: "35 - 50",
design: [
level("basic", "Básico", "100 - 250", 100, 250),
level("intermediate", "Intermedio", "250 - 500", 250, 500),
level("advanced", "Avanzado", "500 - 1200", 500, 1200),
],
}),
},
{
id: "copy-puntual",
section: "grafico",
category: "Otros servicios",
service: "Copy puntual para ADS, tarjetas o comunicaciones sueltas",
notes: "Solo copy puntual. Si requiere concepto, presupuestar según proyecto/hora hombre.",
workTypes: graphicWorkTypes({
designHour: "1 - 25",
design: [
level("basic", "Básico", "100", 100, 100),
level("intermediate", "Intermedio", "100 - 200", 100, 200),
level("advanced", "Avanzado", "200 - 500", 200, 500),
],
}),
},
{
id: "render-sencillo",
section: "grafico",
category: "Otros servicios",
service: "Render sencillo de 1 solo plano por unidad",
notes: "No aplica para eventos donde hay que conceptualizar el ambiente total.",
workTypes: graphicWorkTypes({
designHour: "1 - 50",
design: [
level("basic", "Básico", "100", 100, 100),
level("intermediate", "Intermedio", "100 - 150", 100, 150),
level("advanced", "Avanzado", "150 - 300", 150, 300),
],
}),
},
{
id: "carnets",
section: "grafico",
category: "Otros servicios",
service: "Carnets",
notes:
"Referencia especial: diseño base + réplicas. El Director Creativo calcula y escribe el monto final manualmente.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Referencia manual",
shortLabel: "Manual",
levels: [
level("base", "Diseño base", "20 base", 20, 20),
level("replica", "Réplicas", "5 por réplica", 5, 5),
],
},
],
},
{
id: "identidad-basica",
section: "estrategia",
category: "Desarrollo de Identidad Gráfica para Marca",
service: "Desarrollo básico para pequeñas marcas o emprendimientos",
notes:
"Incluye logo, paleta, tipografía, identidad visual básica, hasta 2 conceptos y 3 rondas de cambios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Básico", "2000 - 4000", 2000, 4000)],
},
],
},
{
id: "identidad-intermedia",
section: "estrategia",
category: "Desarrollo de Identidad Gráfica para Marca",
service: "Desarrollo intermedio para marcas medianas",
notes:
"Incluye nombre si aplica, logotipo, paleta, tipografía, key visuals y manual de marca detallado.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Intermedio", "4000 - 8000", 4000, 8000)],
},
],
},
{
id: "identidad-avanzada",
section: "estrategia",
category: "Desarrollo de Identidad Gráfica para Marca",
service: "Desarrollo avanzado para grandes marcas o redes",
notes:
"Identidad visual robusta con aplicaciones de marca, investigación, key visuals y manual exhaustivo.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Avanzado", "8000 - 15000 o más", 8000, 15000)],
},
],
},
{
id: "campana-key-visual-basica",
section: "estrategia",
category: "Desarrollo de Campaña Creativa",
service: "Concepto y Key Visual básico para campañas pequeñas",
notes:
"Concepto creativo básico, key visual, línea gráfica y eslogan con aplicaciones limitadas.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Básico", "2000 - 3000", 2000, 3000)],
},
],
},
{
id: "campana-creativa-intermedia",
section: "estrategia",
category: "Desarrollo de Campaña Creativa",
service: "Campaña creativa intermedia para lanzamientos medianos",
notes: "Concepto creativo completo, key visuals, eslogan y piezas para distintos medios.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Intermedio", "3000 - 5000", 3000, 5000)],
},
],
},
{
id: "campana-creativa-avanzada",
section: "estrategia",
category: "Desarrollo de Campaña Creativa",
service: "Campaña creativa avanzada para lanzamientos grandes",
notes:
"Desarrollo estratégico y creativo de campaña grande con insights, key visuals y múltiples piezas.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija",
shortLabel: "Fija",
levels: [level("project", "Avanzado", "5000 - 8000 o más", 5000, 8000)],
},
],
},
{
id: "consultoria-estrategica",
section: "estrategia",
category: "Desarrollo Estratégico y Conceptual",
service: "Consultoría estratégica y desarrollo de concepto",
notes:
"Investigación de mercado, análisis de competencia, concepto estratégico y alineación con objetivos del cliente.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Tarifa fija o por hora",
shortLabel: "Referencia",
levels: [
level("project", "Por proyecto", "1500 - 3000", 1500, 3000),
level("hour", "Por hora", "100 - 200 por hora", 100, 200),
],
},
],
},
{
id: "rondas-adicionales-estrategia",
section: "estrategia",
category: "Adicionales estratégicos",
service: "Rondas adicionales de cambios",
notes: "Referencia para trabajo adicional en estrategia o creatividad.",
workTypes: [
{
id: WORK_TYPE_REFERENCE,
label: "Por hora",
shortLabel: "Hora",
levels: [level("hour", "Por hora", "50 - 150 por hora", 50, 150)],
},
],
},
];
export function formatTariffRange(level?: TariffLevel | null) {
if (!level) return "";
return level.reference;
}
export function defaultTariffAmount(level?: TariffLevel | null) {
if (!level) return "";
if (typeof level.min === "number" && typeof level.max === "number") {
return String(level.min === level.max ? level.min : level.min);
}
return "";
}
+7 -1
View File
@@ -1,6 +1,12 @@
/* eslint-disable no-control-regex */
const INVISIBLE_CONTROL_CHARS = new RegExp(
"[\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200D\\uFEFF]",
"g",
);
export function cleanOptionLabel(value: string | null | undefined) {
return String(value || "")
.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF]/g, "")
.replace(INVISIBLE_CONTROL_CHARS, "")
.replace(/\s+/g, " ")
.trim();
}
+955 -141
View File
File diff suppressed because it is too large Load Diff
+227 -46
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useState } from "react";
import {
ChevronDown,
ChevronLeft,
@@ -9,6 +9,8 @@ import {
Plus,
RefreshCw,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -22,11 +24,28 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { useProjects, type Project } from "@/lib/store";
import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
/** Extract up-to-2-letter initials from a display name or email */
const PROJECTS_PER_PAGE = 12;
const PROJECTS_PER_PAGE = 24;
type AdvancedFilters = {
country: string;
brand: string;
client: string;
cm: string;
};
const EMPTY_ADVANCED_FILTERS: AdvancedFilters = {
country: "",
brand: "",
client: "",
cm: "",
};
function initials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
@@ -39,15 +58,11 @@ function initials(name: string | null | undefined, email: string | null | undefi
}
export default function BoardPage() {
const { projects, loading, error, refresh, remove } = useProjects();
const { user, isGerardo, logout } = useAuth();
const projectsTopRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Project | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
const [pageByFilter, setPageByFilter] = useState({
todos: 1,
abiertos: 1,
@@ -55,32 +70,59 @@ export default function BoardPage() {
});
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const orderedProjects = [...projects].sort((a, b) => b.createdAt - a.createdAt);
return orderedProjects.filter((p) => {
if (filter === "abiertos" && p.status) return false;
if (filter === "cerrados" && !p.status) return false;
if (!q) return true;
return [p.nombre, p.cliente, p.marca, p.bu, p.solicitante]
.join(" ")
.toLowerCase()
.includes(q);
});
}, [projects, query, filter]);
const currentPage = pageByFilter[filter];
const totalPages = Math.max(1, Math.ceil(filtered.length / PROJECTS_PER_PAGE));
const { lists } = useAppLists();
const { projects, totalProjects, filterOptions, loading, error, refresh, remove } = useProjects({
tab: filter,
search: query,
page: currentPage,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
});
const { user, isGerardo, logout } = useAuth();
const cmListOptions = Object.values(lists.buCm);
const countryFilterOptions = dedupeOptions([
...lists.bus,
...filterOptions.countries,
advancedFilters.country,
]);
const brandFilterOptions = dedupeOptions([
...lists.marcas,
...filterOptions.brands,
advancedFilters.brand,
]);
const clientFilterOptions = dedupeOptions([
...lists.clientes,
...filterOptions.clients,
advancedFilters.client,
]);
const cmFilterOptions = dedupeOptions([
...cmListOptions,
...filterOptions.cms,
advancedFilters.cm,
]);
const activeAdvancedFilterCount = Object.values(advancedFilters).filter(Boolean).length;
const totalPages = Math.max(1, Math.ceil(totalProjects / PROJECTS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages);
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
const paginatedProjects = filtered.slice(pageStart, pageStart + PROJECTS_PER_PAGE);
const visibleStart = filtered.length === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, filtered.length);
const visibleStart = totalProjects === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, totalProjects);
useEffect(() => {
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
}, [query, filter]);
}, [
query,
filter,
advancedFilters.country,
advancedFilters.brand,
advancedFilters.client,
advancedFilters.cm,
]);
useEffect(() => {
if (currentPage > totalPages) {
@@ -90,11 +132,19 @@ export default function BoardPage() {
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), totalPages);
setPageByFilter((prev) => ({ ...prev, [filter]: nextPage }));
window.requestAnimationFrame(() => {
projectsTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
if (nextPage === currentPage) return;
setPageByFilter((prev) => ({ ...prev, [filter]: nextPage }));
window.scrollTo({ top: 0, behavior: "smooth" });
};
const setAdvancedFilter = (key: keyof AdvancedFilters, value: string) => {
setAdvancedFilters((current) => ({ ...current, [key]: value }));
};
const clearAdvancedFilters = () => {
setAdvancedFilters(EMPTY_ADVANCED_FILTERS);
};
const openNew = () => {
@@ -150,7 +200,7 @@ export default function BoardPage() {
</div>
<div className="leading-tight">
<h1 className="text-[15px] font-semibold tracking-tight">Tablero CDC</h1>
<p className="text-[11px] text-muted-foreground">Aprobaciones de Proyectos</p>
<p className="text-[11px] text-muted-foreground">Flujo de Proyectos</p>
</div>
</div>
@@ -220,10 +270,10 @@ export default function BoardPage() {
</header>
{/* Filters */}
<div ref={projectsTopRef} className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 space-y-3">
<div className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 space-y-3">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Proyectos</h2>
<h2 className="pl-3 text-2xl font-semibold tracking-tight">Proyectos</h2>
</div>
<div className="flex items-center gap-2">
@@ -260,6 +310,17 @@ export default function BoardPage() {
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/>
</div>
<AdvancedProjectFilters
filters={advancedFilters}
activeCount={activeAdvancedFilterCount}
countryOptions={countryFilterOptions}
brandOptions={brandFilterOptions}
clientOptions={clientFilterOptions}
cmOptions={cmFilterOptions}
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
</div>
{/* Grid */}
@@ -280,27 +341,34 @@ export default function BoardPage() {
{loading && projects.length === 0 ? (
<LoadingState />
) : filtered.length === 0 ? (
<EmptyState onNew={openNew} hasProjects={projects.length > 0} filter={filter} query={query} />
) : projects.length === 0 ? (
<EmptyState
onNew={openNew}
hasProjects={
totalProjects > 0 ||
filter !== "todos" ||
query.trim().length > 0 ||
activeAdvancedFilterCount > 0
}
filter={filter}
query={query}
activeAdvancedFilterCount={activeAdvancedFilterCount}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{paginatedProjects.map((p) => (
<ProjectCard
key={p.id}
project={p}
onClick={() => openEdit(p)}
/>
{projects.map((p) => (
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
))}
</div>
)}
{filtered.length > PROJECTS_PER_PAGE && (
{totalProjects > PROJECTS_PER_PAGE && (
<PaginationControls
currentPage={safePage}
totalPages={totalPages}
visibleStart={visibleStart}
visibleEnd={visibleEnd}
totalItems={filtered.length}
totalItems={totalProjects}
onPageChange={goToPage}
/>
)}
@@ -317,6 +385,110 @@ export default function BoardPage() {
);
}
function AdvancedProjectFilters({
filters,
activeCount,
countryOptions,
brandOptions,
clientOptions,
cmOptions,
onFilterChange,
onClear,
}: {
filters: AdvancedFilters;
activeCount: number;
countryOptions: string[];
brandOptions: string[];
clientOptions: string[];
cmOptions: string[];
onFilterChange: (key: keyof AdvancedFilters, value: string) => void;
onClear: () => void;
}) {
return (
<div className="rounded-2xl border border-border bg-card/70 px-3.5 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex items-center justify-between gap-3 lg:w-auto lg:min-w-[120px] lg:justify-start">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
<SlidersHorizontal className="h-4 w-4" />
</span>
<span>Filtros</span>
{activeCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
{activeCount}
</span>
)}
</div>
{activeCount > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
className="h-8 rounded-full px-2.5 text-xs text-muted-foreground hover:text-foreground lg:hidden"
>
<X className="mr-1 h-3.5 w-3.5" />
Limpiar
</Button>
)}
</div>
<div className="grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-4">
<SearchableSelect
value={filters.country}
options={countryOptions}
onValueChange={(value) => onFilterChange("country", value)}
placeholder="País: Todos"
searchPlaceholder="Buscar país…"
emptyText="No se encontró ese país."
clearLabel="Todos"
/>
<SearchableSelect
value={filters.brand}
options={brandOptions}
onValueChange={(value) => onFilterChange("brand", value)}
placeholder="Marca: Todas"
searchPlaceholder="Buscar marca…"
emptyText="No se encontró esa marca."
clearLabel="Todas"
/>
<SearchableSelect
value={filters.client}
options={clientOptions}
onValueChange={(value) => onFilterChange("client", value)}
placeholder="Cliente: Todos"
searchPlaceholder="Buscar cliente…"
emptyText="No se encontró ese cliente."
clearLabel="Todos"
/>
<SearchableSelect
value={filters.cm}
options={cmOptions}
onValueChange={(value) => onFilterChange("cm", value)}
placeholder="CM: Todos"
searchPlaceholder="Buscar CM…"
emptyText="No se encontró ese CM."
clearLabel="Todos"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
disabled={activeCount === 0}
className="hidden h-9 rounded-full px-3 text-xs text-muted-foreground hover:text-foreground lg:inline-flex"
>
<X className="mr-1 h-3.5 w-3.5" />
Limpiar
</Button>
</div>
</div>
);
}
function PaginationControls({
currentPage,
totalPages,
@@ -453,26 +625,35 @@ function EmptyState({
hasProjects,
filter,
query,
activeAdvancedFilterCount,
}: {
onNew: () => void;
hasProjects: boolean;
filter: "todos" | "abiertos" | "cerrados";
query: string;
activeAdvancedFilterCount: number;
}) {
const hasSearch = query.trim().length > 0;
const hasAdvancedFilters = activeAdvancedFilterCount > 0;
let title = "Tu tablero está vacío";
let message = "Crea tu primer proyecto para que el equipo pueda darle seguimiento.";
if (hasProjects && hasSearch) {
if (hasProjects && hasSearch && hasAdvancedFilters) {
title = "Nada coincide con la búsqueda y filtros";
message = "Intenta cambiar la búsqueda, limpiar filtros o seleccionar otras opciones.";
} else if (hasProjects && hasSearch) {
title = "Nada coincide con la búsqueda";
message = "Intenta cambiar la búsqueda o borrar el texto escrito.";
} else if (hasProjects && hasAdvancedFilters) {
title = "Nada coincide con los filtros";
message = "Intenta limpiar filtros o seleccionar otras opciones.";
} else if (hasProjects && filter === "abiertos") {
title = "No hay proyectos activos";
message = "Cuando se cree un proyecto nuevo, aparecerá en esta pestaña.";
} else if (hasProjects && filter === "cerrados") {
title = "No hay proyectos cerrados";
message = "Cuando Gerardo asigne un estatus a un proyecto, aparecerá aquí.";
message = "Cuando el Director Creativo asigne un estatus a un proyecto, aparecerá aquí.";
} else if (hasProjects) {
title = "Nada coincide con el filtro";
message = "Intenta quitar el filtro o cambiar la búsqueda.";
+59
View File
@@ -0,0 +1,59 @@
-- =========================================================
-- TABLERO CDC - HISTORIAL DE ACTIVIDAD POR PROYECTO
-- Ejecutar en Supabase SQL Editor antes de desplegar esta versión.
--
-- Crea una tabla pequeña para registrar comentarios y movimientos
-- dentro de cada proyecto, sin tocar el Google Sheet ni n8n.
-- =========================================================
create table if not exists public.tablero_cdc_project_activity (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.tablero_cdc_projects(id) on delete cascade,
actor_id uuid null,
actor_email text null,
actor_name text null,
activity_type text not null default 'note',
title text not null,
description text null,
extra_data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists tablero_cdc_project_activity_project_created_idx
on public.tablero_cdc_project_activity(project_id, created_at desc);
alter table public.tablero_cdc_project_activity enable row level security;
drop policy if exists "tablero_cdc_project_activity_select_authenticated" on public.tablero_cdc_project_activity;
drop policy if exists "tablero_cdc_project_activity_insert_authenticated" on public.tablero_cdc_project_activity;
create policy "tablero_cdc_project_activity_select_authenticated"
on public.tablero_cdc_project_activity
for select
to authenticated
using (true);
create policy "tablero_cdc_project_activity_insert_authenticated"
on public.tablero_cdc_project_activity
for insert
to authenticated
with check (auth.uid() is not null);
grant select, insert on public.tablero_cdc_project_activity to authenticated;
-- Realtime opcional para futuras mejoras. No es obligatorio para que la app funcione.
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'tablero_cdc_project_activity'
) then
alter publication supabase_realtime add table public.tablero_cdc_project_activity;
end if;
end $$;
-- Validación opcional después de crear la tabla:
-- select count(*) from public.tablero_cdc_project_activity;
+87
View File
@@ -0,0 +1,87 @@
-- Tablero CDC - Tarifario / Estimación interna
-- Crea la tabla que guarda las líneas de costo por proyecto.
-- No modifica proyectos existentes, no toca created_at, no toca Google Sheets ni n8n.
create table if not exists public.tablero_cdc_project_pricing_items (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.tablero_cdc_projects(id) on delete cascade,
source text not null default 'manual' check (source in ('tariff', 'manual')),
category text not null default '',
service_name text not null default '',
work_type text not null default '',
complexity_level text not null default '',
reference_label text not null default '',
reference_min numeric,
reference_max numeric,
amount numeric not null default 0 check (amount >= 0),
description text not null default '',
sort_order integer not null default 0,
created_by uuid references auth.users(id),
updated_by uuid references auth.users(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists tablero_cdc_project_pricing_items_project_id_idx
on public.tablero_cdc_project_pricing_items(project_id);
create index if not exists tablero_cdc_project_pricing_items_created_at_idx
on public.tablero_cdc_project_pricing_items(created_at desc);
alter table public.tablero_cdc_project_pricing_items enable row level security;
drop policy if exists "tablero_cdc_project_pricing_items_select_authenticated" on public.tablero_cdc_project_pricing_items;
drop policy if exists "tablero_cdc_project_pricing_items_insert_authenticated" on public.tablero_cdc_project_pricing_items;
drop policy if exists "tablero_cdc_project_pricing_items_update_authenticated" on public.tablero_cdc_project_pricing_items;
drop policy if exists "tablero_cdc_project_pricing_items_delete_authenticated" on public.tablero_cdc_project_pricing_items;
create policy "tablero_cdc_project_pricing_items_select_authenticated"
on public.tablero_cdc_project_pricing_items
for select
to authenticated
using (true);
create policy "tablero_cdc_project_pricing_items_insert_authenticated"
on public.tablero_cdc_project_pricing_items
for insert
to authenticated
with check (true);
create policy "tablero_cdc_project_pricing_items_update_authenticated"
on public.tablero_cdc_project_pricing_items
for update
to authenticated
using (true)
with check (true);
create policy "tablero_cdc_project_pricing_items_delete_authenticated"
on public.tablero_cdc_project_pricing_items
for delete
to authenticated
using (true);
create or replace function public.set_tablero_cdc_project_pricing_items_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
if tg_op = 'INSERT' then
new.created_by = coalesce(new.created_by, auth.uid());
end if;
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_project_pricing_items_updated_at on public.tablero_cdc_project_pricing_items;
create trigger trg_tablero_cdc_project_pricing_items_updated_at
before insert or update on public.tablero_cdc_project_pricing_items
for each row
execute function public.set_tablero_cdc_project_pricing_items_updated_at();
select
'tablero_cdc_project_pricing_items lista' as status,
count(*) as registros_actuales
from public.tablero_cdc_project_pricing_items;
+204
View File
@@ -0,0 +1,204 @@
-- =========================================================
-- TABLERO CDC - RPC PAGINADO + FILTROS AVANZADOS
-- Ejecutar en Supabase SQL Editor antes de desplegar esta versión.
--
-- La app usa esta función para cargar solo la página actual de:
-- - Todos
-- - Activos
-- - Cerrados
--
-- También filtra desde Supabase por:
-- - País / BU
-- - Marca
-- - Cliente
-- - CM / Country Manager
--
-- No borra datos, no cambia tablas y no toca crear/editar/eliminar.
-- =========================================================
-- Evita conflictos con la versión anterior de 4 parámetros.
drop function if exists public.tablero_cdc_get_projects_paginated(text, text, integer, integer);
drop function if exists public.tablero_cdc_get_projects_paginated(text, text, integer, integer, text, text, text, text);
create or replace function public.tablero_cdc_get_projects_paginated(
p_tab text default 'todos',
p_search text default '',
p_page integer default 1,
p_page_size integer default 24,
p_country text default '',
p_brand text default '',
p_client text default '',
p_cm text default ''
)
returns jsonb
language sql
stable
security invoker
set search_path = public
as $$
with params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
greatest(coalesce(p_page, 1), 1) as page_number,
least(greatest(coalesce(p_page_size, 24), 1), 100) as page_size,
trim(coalesce(p_country, '')) as country_filter,
trim(coalesce(p_brand, '')) as brand_filter,
trim(coalesce(p_client, '')) as client_filter,
trim(coalesce(p_cm, '')) as cm_filter
),
base_filtered as (
select p.*
from public.tablero_cdc_projects p
cross join params prm
where
case
when prm.tab in ('abiertos', 'activos', 'active', 'open') then
coalesce(nullif(trim(p.status), ''), 'Activo') = 'Activo'
when prm.tab in ('cerrados', 'closed') then
coalesce(nullif(trim(p.status), ''), 'Activo') <> 'Activo'
else
true
end
and (
prm.search_text = ''
or coalesce(p.title, '') ilike '%' || prm.search_text || '%'
or coalesce(p.client, '') ilike '%' || prm.search_text || '%'
or coalesce(p.brand, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country, '') ilike '%' || prm.search_text || '%'
or coalesce(p.requested_by, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country_manager, '') ilike '%' || prm.search_text || '%'
or coalesce(p.description, '') ilike '%' || prm.search_text || '%'
)
),
filtered as (
select bf.*
from base_filtered bf
cross join params prm
where
(prm.country_filter = '' or coalesce(bf.country, '') ilike '%' || prm.country_filter || '%')
and (prm.brand_filter = '' or coalesce(bf.brand, '') ilike '%' || prm.brand_filter || '%')
and (prm.client_filter = '' or coalesce(bf.client, '') ilike '%' || prm.client_filter || '%')
and (prm.cm_filter = '' or coalesce(bf.country_manager, '') ilike '%' || prm.cm_filter || '%')
),
total as (
select count(*)::integer as total_count
from filtered
),
paged as (
select f.*
from filtered f
cross join params prm
order by f.created_at desc nulls last, f.id desc
limit (select page_size from params)
offset ((select page_number - 1 from params) * (select page_size from params))
),
option_values as (
select
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(country) as value
from base_filtered
where nullif(trim(coalesce(country, '')), '') is not null
) countries
),
'[]'::jsonb
) as countries,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(brand) as value
from base_filtered
where nullif(trim(coalesce(brand, '')), '') is not null
) brands
),
'[]'::jsonb
) as brands,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(client) as value
from base_filtered
where nullif(trim(coalesce(client, '')), '') is not null
) clients
),
'[]'::jsonb
) as clients,
coalesce(
(
select jsonb_agg(value order by value)
from (
select distinct trim(country_manager) as value
from base_filtered
where nullif(trim(coalesce(country_manager, '')), '') is not null
) cms
),
'[]'::jsonb
) as cms
)
select jsonb_build_object(
'total_count', (select total_count from total),
'filter_options', jsonb_build_object(
'countries', (select countries from option_values),
'brands', (select brands from option_values),
'clients', (select clients from option_values),
'cms', (select cms from option_values)
),
'projects', coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', p.id,
'title', p.title,
'client', p.client,
'brand', p.brand,
'country', p.country,
'requested_by', p.requested_by,
'country_manager', p.country_manager,
'description', p.description,
'status', p.status,
'internal_amount', case
when lower(coalesce(auth.jwt() ->> 'email', '')) = 'gmarrero@gomezleemarketing.com'
then to_jsonb(p.internal_amount)
else 'null'::jsonb
end,
'brief_link', p.brief_link,
'created_at', p.created_at,
'extra_data', p.extra_data,
'project_links', coalesce(
(
select jsonb_agg(
jsonb_build_object(
'id', l.id,
'link_type', l.link_type,
'url', l.url,
'label', l.label
)
order by l.label asc nulls last, l.id asc
)
from public.tablero_cdc_project_links l
where l.project_id = p.id
),
'[]'::jsonb
)
)
order by p.created_at desc nulls last, p.id desc
)
from paged p
),
'[]'::jsonb
)
);
$$;
grant execute on function public.tablero_cdc_get_projects_paginated(text, text, integer, integer, text, text, text, text) to authenticated;
-- Validación rápida opcional después de crear la función:
-- select public.tablero_cdc_get_projects_paginated('todos', '', 1, 24, '', '', '', '');
-- select public.tablero_cdc_get_projects_paginated('abiertos', '', 1, 24, '', '', '', '');
-- select public.tablero_cdc_get_projects_paginated('cerrados', '', 1, 24, '', '', '', '');
-- select public.tablero_cdc_get_projects_paginated('todos', '', 1, 24, 'Republica Dominicana', '', '', '');