feat: finalizar Tablero CDC con multipais, Fulgencio y tarifario
This commit is contained in:
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+388
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="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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+227
-46
@@ -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.";
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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', '', '', '');
|
||||
Reference in New Issue
Block a user