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="description" content="Herramienta interna del Departamento de Diseño Creativo." />
|
||||||
<meta name="author" content="CDC" />
|
<meta name="author" content="CDC" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
|
||||||
<script type="module" crossorigin src="/tablero-cdc/assets/index-ZKG15NE5.js"></script>
|
<script type="module" crossorigin src="/tablero-cdc/assets/index-DkJPw2yo.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-7ZtFhhZY.css">
|
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-O1Ai8omu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useCallback, useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -21,12 +21,34 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { AlertCircle, Loader2, Lock, DollarSign, Calendar, Trash2 } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
DollarSign,
|
||||||
|
Calendar,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
History,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Clock3,
|
||||||
|
} from "lucide-react";
|
||||||
import { ColorPicker } from "./ColorPicker";
|
import { ColorPicker } from "./ColorPicker";
|
||||||
import { LinkList } from "./LinkList";
|
import { LinkList } from "./LinkList";
|
||||||
|
import { SearchableSelect } from "./SearchableSelect";
|
||||||
|
import { SearchableMultiSelect } from "./SearchableMultiSelect";
|
||||||
|
import { ProjectPricingPanel } from "./ProjectPricingPanel";
|
||||||
import { MESES, type Status } from "@/data/lists";
|
import { MESES, type Status } from "@/data/lists";
|
||||||
import { useAppLists } from "@/lib/appLists";
|
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 { useAuth } from "@/context/AuthContext";
|
||||||
import { colorHex } from "@/lib/colors";
|
import { colorHex } from "@/lib/colors";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -47,6 +69,149 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WALMART_CONNECT_CLIENT = "Walmart Connect WMC";
|
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) {
|
function isWalmartConnectClient(client: string | null | undefined) {
|
||||||
return normalizeOptionKey(client) === normalizeOptionKey(WALMART_CONNECT_CLIENT);
|
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) {
|
export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting = false }: Props) {
|
||||||
const { add, update } = useProjects();
|
const { add, update } = useProjects();
|
||||||
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
|
const { lists, loading: listsLoading, error: listsError, refresh: refreshLists } = useAppLists();
|
||||||
const { isGerardo, canDeleteProjects } = useAuth();
|
const { isGerardo, canDeleteProjects, canManageInternalPricing } = useAuth();
|
||||||
const isEdit = !!project;
|
const isEdit = !!project;
|
||||||
|
|
||||||
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
|
const [form, setForm] = useState<Omit<Project, "id" | "createdAt">>(empty());
|
||||||
const [selectedWmcCountries, setSelectedWmcCountries] = useState<string[]>([]);
|
const [selectedWmcCountries, setSelectedWmcCountries] = useState<string[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [wmcCountrySearch, setWmcCountrySearch] = useState("");
|
||||||
const [savingStep, setSavingStep] = useState<string | null>(null);
|
const [savingStep, setSavingStep] = useState<string | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
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 clienteOptions = dedupeOptions(ensureOption(lists.clientes, form.cliente));
|
||||||
const marcaOptions = dedupeOptions(ensureOption(lists.marcas, form.marca));
|
const marcaOptions = dedupeOptions([...lists.marcas, ...selectedBrands]);
|
||||||
const buOptions = dedupeOptions(ensureOption(lists.bus, form.bu));
|
const buOptions = dedupeOptions([...lists.bus, ...selectedCountries]);
|
||||||
const statusOptions = dedupeOptions(ensureOption(lists.status, form.status)) as Status[];
|
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(() => {
|
useEffect(() => {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -102,15 +285,129 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
bu: cleanBu,
|
bu: cleanBu,
|
||||||
});
|
});
|
||||||
setSelectedWmcCountries(cleanBu ? [cleanBu] : []);
|
setSelectedWmcCountries(cleanBu ? [cleanBu] : []);
|
||||||
|
setWmcCountrySearch("");
|
||||||
|
setActivityItems([]);
|
||||||
|
setActivityHasMore(false);
|
||||||
|
setActivityError(null);
|
||||||
|
setActivityNote("");
|
||||||
|
setPricingItems([]);
|
||||||
|
setPricingError(null);
|
||||||
|
setInternalAmountManuallyEdited(false);
|
||||||
} else if (open) {
|
} else if (open) {
|
||||||
setForm(empty());
|
setForm(empty());
|
||||||
setSelectedWmcCountries([]);
|
setSelectedWmcCountries([]);
|
||||||
|
setWmcCountrySearch("");
|
||||||
|
setActivityItems([]);
|
||||||
|
setActivityHasMore(false);
|
||||||
|
setActivityError(null);
|
||||||
|
setActivityNote("");
|
||||||
|
setPricingItems([]);
|
||||||
|
setPricingError(null);
|
||||||
|
setInternalAmountManuallyEdited(false);
|
||||||
}
|
}
|
||||||
}, [project, open]);
|
}, [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]) =>
|
const set = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
|
||||||
setForm((f) => ({ ...f, [k]: v }));
|
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 getCountryManager = (bu: string) => {
|
||||||
const direct = lists.buCm[bu];
|
const direct = lists.buCm[bu];
|
||||||
if (direct) return direct;
|
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.
|
// BU → Country Manager automático desde Supabase tablero_cdc_app_lists, con fallback local.
|
||||||
const onBU = (bu: string) => {
|
const onCountries = (countries: string[]) => {
|
||||||
const cleanBu = canonicalOptionLabel(bu);
|
const cleanCountries = dedupeOptions(
|
||||||
setForm((f) => ({ ...f, bu: cleanBu, cm: getCountryManager(cleanBu) }));
|
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) => {
|
const onCliente = (cliente: string) => {
|
||||||
@@ -142,10 +447,13 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
|
|
||||||
if (!isWalmartConnectClient(cleanCliente)) {
|
if (!isWalmartConnectClient(cleanCliente)) {
|
||||||
setSelectedWmcCountries([]);
|
setSelectedWmcCountries([]);
|
||||||
|
setWmcCountrySearch("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWalmartConnectCreate = !isEdit && isWalmartConnectClient(form.cliente);
|
const isWalmartConnectCreate = !isEdit && isWalmartConnectClient(form.cliente);
|
||||||
|
const selectedCreateCountries = isWalmartConnectCreate ? selectedWmcCountries : selectedCountries;
|
||||||
|
const isMultiCountryCreate = !isEdit && selectedCreateCountries.length > 1;
|
||||||
|
|
||||||
const toggleWmcCountry = (country: string, checked: boolean) => {
|
const toggleWmcCountry = (country: string, checked: boolean) => {
|
||||||
const cleanCountry = canonicalOptionLabel(country);
|
const cleanCountry = canonicalOptionLabel(country);
|
||||||
@@ -165,13 +473,17 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
country,
|
country,
|
||||||
manager: getCountryManager(country),
|
manager: getCountryManager(country),
|
||||||
}));
|
}));
|
||||||
|
const selectedCountryManagers = selectedCountries.map((country) => ({
|
||||||
|
country,
|
||||||
|
manager: getCountryManager(country),
|
||||||
|
}));
|
||||||
|
|
||||||
const requiredOk =
|
const requiredOk =
|
||||||
form.nombre.trim() &&
|
form.nombre.trim() &&
|
||||||
form.cliente &&
|
form.cliente &&
|
||||||
form.marca &&
|
form.marca &&
|
||||||
form.solicitante.trim() &&
|
form.solicitante.trim() &&
|
||||||
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : form.bu);
|
(isWalmartConnectCreate ? selectedWmcCountries.length > 0 : selectedCountries.length > 0);
|
||||||
|
|
||||||
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
|
const showSaveToast = (result: Awaited<ReturnType<typeof add>>) => {
|
||||||
if (result.sheetSyncStatus === "synced") {
|
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 () => {
|
const submit = async () => {
|
||||||
if (!requiredOk || saving) return;
|
if (!requiredOk || saving) return;
|
||||||
|
|
||||||
const safeForm = isGerardo
|
const safeForm = isGerardo
|
||||||
? form
|
? form
|
||||||
: {
|
: canManageInternalPricing
|
||||||
...form,
|
? {
|
||||||
status: project?.status ?? "",
|
...form,
|
||||||
monto: project?.monto ?? null,
|
status: project?.status ?? "",
|
||||||
};
|
}
|
||||||
|
: {
|
||||||
|
...form,
|
||||||
|
status: project?.status ?? "",
|
||||||
|
monto: project?.monto ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setSavingStep("Guardando y sincronizando con Google Sheet…");
|
setSavingStep("Guardando y sincronizando con Google Sheet…");
|
||||||
|
|
||||||
if (isWalmartConnectCreate) {
|
if (isMultiCountryCreate) {
|
||||||
const countries = selectedWmcCountries;
|
const countries = selectedCreateCountries;
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const [index, country] of countries.entries()) {
|
for (const [index, country] of countries.entries()) {
|
||||||
setSavingStep(
|
setSavingStep(`Guardando país ${index + 1} de ${countries.length}: ${country}…`);
|
||||||
`Guardando Walmart Connect WMC ${index + 1} de ${countries.length}: ${country}…`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const countryProject = {
|
const countryProject = {
|
||||||
...safeForm,
|
...safeForm,
|
||||||
@@ -226,25 +575,38 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
createdAt: Date.now() + index,
|
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");
|
const failedSyncs = results.filter((result) => result.sheetSyncStatus !== "synced");
|
||||||
|
|
||||||
if (failedSyncs.length === 0) {
|
if (failedSyncs.length === 0) {
|
||||||
toast.success("Proyectos Walmart Connect creados", {
|
toast.success("Proyectos multipaís creados", {
|
||||||
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet.`,
|
description: `Se crearon ${countries.length} proyectos y ${countries.length} filas en Google Sheet. El Interno Cargado se aplicó por país.`,
|
||||||
});
|
});
|
||||||
} else {
|
} 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.`,
|
description: `${failedSyncs.length} de ${countries.length} sincronizaciones con Google Sheet requieren revisión.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result =
|
const result =
|
||||||
isEdit && project
|
isEdit && project
|
||||||
? await update(project.id, safeForm)
|
? await update(project.id, {
|
||||||
: await add({ ...safeForm, id: crypto.randomUUID(), createdAt: Date.now() });
|
...safeForm,
|
||||||
|
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||||
|
})
|
||||||
|
: await add({
|
||||||
|
...safeForm,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
pricingItems: canManageInternalPricing ? pricingItems : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
showSaveToast(result);
|
showSaveToast(result);
|
||||||
}
|
}
|
||||||
@@ -360,18 +722,14 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
<Label>
|
<Label>
|
||||||
Cliente <span className="text-destructive">*</span>
|
Cliente <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={form.cliente} onValueChange={onCliente}>
|
<SearchableSelect
|
||||||
<SelectTrigger className="h-10">
|
value={form.cliente}
|
||||||
<SelectValue placeholder="Seleccionar…" />
|
options={clienteOptions}
|
||||||
</SelectTrigger>
|
onValueChange={onCliente}
|
||||||
<SelectContent>
|
placeholder="Seleccionar…"
|
||||||
{clienteOptions.map((c) => (
|
searchPlaceholder="Buscar cliente…"
|
||||||
<SelectItem key={c} value={c}>
|
emptyText="No se encontró ese cliente."
|
||||||
{c}
|
/>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Marca */}
|
{/* Marca */}
|
||||||
@@ -379,18 +737,15 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
<Label>
|
<Label>
|
||||||
Marca <span className="text-destructive">*</span>
|
Marca <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={form.marca} onValueChange={(v) => set("marca", v)}>
|
<SearchableMultiSelect
|
||||||
<SelectTrigger className="h-10">
|
values={selectedBrands}
|
||||||
<SelectValue placeholder="Seleccionar…" />
|
options={marcaOptions}
|
||||||
</SelectTrigger>
|
onValuesChange={(values) => set("marca", joinBrandValues(values))}
|
||||||
<SelectContent>
|
placeholder="Seleccionar una o varias marcas…"
|
||||||
{marcaOptions.map((m) => (
|
searchPlaceholder="Buscar marca…"
|
||||||
<SelectItem key={m} value={m}>
|
emptyText="No se encontró esa marca."
|
||||||
{m}
|
summaryLabel="marcas seleccionadas"
|
||||||
</SelectItem>
|
/>
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* BU */}
|
{/* BU */}
|
||||||
@@ -406,10 +761,19 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Para Walmart Connect WMC puedes seleccionar varios países. La app creará un
|
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>
|
</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">
|
<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(
|
const checked = selectedWmcCountries.some(
|
||||||
(country) => normalizeOptionKey(country) === normalizeOptionKey(b),
|
(country) => normalizeOptionKey(country) === normalizeOptionKey(b),
|
||||||
);
|
);
|
||||||
@@ -442,22 +806,23 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="md:col-span-2 space-y-1.5">
|
||||||
<Label>
|
<Label>
|
||||||
BU Solicita (País) <span className="text-destructive">*</span>
|
BU Solicita (Países) <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={form.bu} onValueChange={onBU}>
|
<SearchableMultiSelect
|
||||||
<SelectTrigger className="h-10">
|
values={selectedCountries}
|
||||||
<SelectValue placeholder="Seleccionar…" />
|
options={buOptions}
|
||||||
</SelectTrigger>
|
onValuesChange={onCountries}
|
||||||
<SelectContent>
|
placeholder="Seleccionar uno o varios países…"
|
||||||
{buOptions.map((b) => (
|
searchPlaceholder="Buscar país…"
|
||||||
<SelectItem key={b} value={b}>
|
emptyText="No se encontró ese país."
|
||||||
{b}
|
summaryLabel="países seleccionados"
|
||||||
</SelectItem>
|
/>
|
||||||
))}
|
<p className="text-xs text-muted-foreground">
|
||||||
</SelectContent>
|
Si seleccionas varios países al crear, la app creará un proyecto y una fila del
|
||||||
</Select>
|
Sheet por cada país.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -481,16 +846,29 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<div className="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||||
value={form.cm}
|
{selectedCountryManagers.length === 0 ? (
|
||||||
readOnly
|
<span className="text-muted-foreground">Selecciona uno o más países.</span>
|
||||||
disabled
|
) : (
|
||||||
className="h-10 bg-muted/50"
|
<div className="flex flex-wrap gap-1.5">
|
||||||
placeholder="—"
|
{selectedCountryManagers.map(({ country, manager }) => (
|
||||||
/>
|
<Badge key={country} variant="secondary" className="font-normal">
|
||||||
|
{country}: {manager || "—"}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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 */}
|
{/* Solicitante */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>
|
<Label>
|
||||||
@@ -504,16 +882,20 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comentarios */}
|
<ProjectActivityPanel
|
||||||
<div className="md:col-span-2 space-y-1.5">
|
isEdit={isEdit}
|
||||||
<Label>Comentarios</Label>
|
items={activityItems}
|
||||||
<Textarea
|
loading={activityLoading}
|
||||||
value={form.comentarios}
|
error={activityError}
|
||||||
onChange={(e) => set("comentarios", e.target.value)}
|
note={activityNote}
|
||||||
placeholder="Notas adicionales del proyecto…"
|
hasMore={activityHasMore}
|
||||||
rows={2}
|
loadingMore={activityLoadingMore}
|
||||||
/>
|
addingNote={addingActivityNote}
|
||||||
</div>
|
onNoteChange={setActivityNote}
|
||||||
|
onAddNote={addActivityNote}
|
||||||
|
onRefresh={refreshActivity}
|
||||||
|
onLoadMore={loadMoreActivity}
|
||||||
|
/>
|
||||||
|
|
||||||
<Separator className="md:col-span-2 my-1" />
|
<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" />
|
<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">
|
<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
|
Estatus
|
||||||
{!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />}
|
{!isGerardo && <Lock className="w-3 h-3 text-muted-foreground" />}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -587,28 +980,36 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isGerardo && (
|
{!isGerardo && (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="pl-1 text-[11px] text-muted-foreground">
|
||||||
Solo Gerardo Marrero puede cambiar el estatus.
|
Solo el Director Creativo puede cambiar el estatus.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monto: SOLO visible para Gerardo Marrero */}
|
{/* Monto: SOLO visible para usuarios internos autorizados */}
|
||||||
{isGerardo && (
|
{canManageInternalPricing && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="flex items-center gap-1.5">
|
<Label className="flex items-center gap-1.5 pl-1">
|
||||||
<DollarSign className="w-3.5 h-3.5" /> Interno Cargado
|
Interno Cargado <DollarSign className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={form.monto ?? ""}
|
value={form.monto ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
set("monto", e.target.value === "" ? null : Number(e.target.value))
|
setInternalAmountManuallyEdited(true);
|
||||||
}
|
set("monto", e.target.value === "" ? null : Number(e.target.value));
|
||||||
|
}}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
className="h-10"
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -654,7 +1055,11 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={submit} disabled={!requiredOk || saving || deleting}>
|
<Button onClick={submit} disabled={!requiredOk || saving || deleting}>
|
||||||
@@ -663,7 +1068,7 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
? "Guardando…"
|
? "Guardando…"
|
||||||
: isEdit
|
: isEdit
|
||||||
? "Guardar cambios"
|
? "Guardar cambios"
|
||||||
: isWalmartConnectCreate
|
: isMultiCountryCreate
|
||||||
? "Crear proyectos"
|
? "Crear proyectos"
|
||||||
: "Crear proyecto"}
|
: "Crear proyecto"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -674,3 +1079,173 @@ export function ProjectDialog({ open, onOpenChange, project, onDelete, deleting
|
|||||||
</Dialog>
|
</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 ALLOWED_DOMAIN = "gomezleemarketing.com";
|
||||||
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
||||||
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "areyes@gomezleemarketing.com"];
|
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 {
|
function isAllowedEmail(email: string | null | undefined): boolean {
|
||||||
if (!email) return false;
|
if (!email) return false;
|
||||||
@@ -61,6 +70,7 @@ interface AuthContextValue {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
isGerardo: boolean;
|
isGerardo: boolean;
|
||||||
canDeleteProjects: boolean;
|
canDeleteProjects: boolean;
|
||||||
|
canManageInternalPricing: boolean;
|
||||||
loginWithGoogle: () => Promise<void>;
|
loginWithGoogle: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -162,6 +172,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const normalizedEmail = user?.email?.toLowerCase() || "";
|
const normalizedEmail = user?.email?.toLowerCase() || "";
|
||||||
const isGerardo = useMemo(() => normalizedEmail === GERARDO_EMAIL, [normalizedEmail]);
|
const isGerardo = useMemo(() => normalizedEmail === GERARDO_EMAIL, [normalizedEmail]);
|
||||||
|
const canManageInternalPricing = useMemo(
|
||||||
|
() => INTERNAL_PRICING_ALLOWED_EMAILS.includes(normalizedEmail),
|
||||||
|
[normalizedEmail],
|
||||||
|
);
|
||||||
const canDeleteProjects = useMemo(
|
const canDeleteProjects = useMemo(
|
||||||
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
|
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
|
||||||
[normalizedEmail],
|
[normalizedEmail],
|
||||||
@@ -169,7 +183,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ user, loading, error, isGerardo, canDeleteProjects, loginWithGoogle, logout }}
|
value={{
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isGerardo,
|
||||||
|
canDeleteProjects,
|
||||||
|
canManageInternalPricing,
|
||||||
|
loginWithGoogle,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</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) {
|
export function cleanOptionLabel(value: string | null | undefined) {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF]/g, "")
|
.replace(INVISIBLE_CONTROL_CHARS, "")
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.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 {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
SlidersHorizontal,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -22,11 +24,28 @@ import {
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { ProjectCard } from "@/components/board/ProjectCard";
|
import { ProjectCard } from "@/components/board/ProjectCard";
|
||||||
import { ProjectDialog } from "@/components/board/ProjectDialog";
|
import { ProjectDialog } from "@/components/board/ProjectDialog";
|
||||||
|
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
||||||
import { useProjects, type Project } from "@/lib/store";
|
import { useProjects, type Project } from "@/lib/store";
|
||||||
|
import { useAppLists } from "@/lib/appLists";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { dedupeOptions } from "@/lib/optionUtils";
|
||||||
|
|
||||||
/** Extract up-to-2-letter initials from a display name or email */
|
/** 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 {
|
function initials(name: string | null | undefined, email: string | null | undefined): string {
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -39,15 +58,11 @@ function initials(name: string | null | undefined, email: string | null | undefi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BoardPage() {
|
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 [open, setOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
|
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
|
||||||
const [pageByFilter, setPageByFilter] = useState({
|
const [pageByFilter, setPageByFilter] = useState({
|
||||||
todos: 1,
|
todos: 1,
|
||||||
abiertos: 1,
|
abiertos: 1,
|
||||||
@@ -55,32 +70,59 @@ export default function BoardPage() {
|
|||||||
});
|
});
|
||||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
|
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 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 safePage = Math.min(currentPage, totalPages);
|
||||||
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
|
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
|
||||||
const paginatedProjects = filtered.slice(pageStart, pageStart + PROJECTS_PER_PAGE);
|
const visibleStart = totalProjects === 0 ? 0 : pageStart + 1;
|
||||||
const visibleStart = filtered.length === 0 ? 0 : pageStart + 1;
|
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, totalProjects);
|
||||||
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, filtered.length);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
|
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
|
||||||
}, [query, filter]);
|
}, [
|
||||||
|
query,
|
||||||
|
filter,
|
||||||
|
advancedFilters.country,
|
||||||
|
advancedFilters.brand,
|
||||||
|
advancedFilters.client,
|
||||||
|
advancedFilters.cm,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage > totalPages) {
|
if (currentPage > totalPages) {
|
||||||
@@ -90,11 +132,19 @@ export default function BoardPage() {
|
|||||||
|
|
||||||
const goToPage = (page: number) => {
|
const goToPage = (page: number) => {
|
||||||
const nextPage = Math.min(Math.max(page, 1), totalPages);
|
const nextPage = Math.min(Math.max(page, 1), totalPages);
|
||||||
setPageByFilter((prev) => ({ ...prev, [filter]: nextPage }));
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
if (nextPage === currentPage) return;
|
||||||
projectsTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
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 = () => {
|
const openNew = () => {
|
||||||
@@ -150,7 +200,7 @@ export default function BoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<h1 className="text-[15px] font-semibold tracking-tight">Tablero CDC</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -220,10 +270,10 @@ export default function BoardPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* 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 className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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"
|
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AdvancedProjectFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
activeCount={activeAdvancedFilterCount}
|
||||||
|
countryOptions={countryFilterOptions}
|
||||||
|
brandOptions={brandFilterOptions}
|
||||||
|
clientOptions={clientFilterOptions}
|
||||||
|
cmOptions={cmFilterOptions}
|
||||||
|
onFilterChange={setAdvancedFilter}
|
||||||
|
onClear={clearAdvancedFilters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
@@ -280,27 +341,34 @@ export default function BoardPage() {
|
|||||||
|
|
||||||
{loading && projects.length === 0 ? (
|
{loading && projects.length === 0 ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : filtered.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<EmptyState onNew={openNew} hasProjects={projects.length > 0} filter={filter} query={query} />
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{paginatedProjects.map((p) => (
|
{projects.map((p) => (
|
||||||
<ProjectCard
|
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
|
||||||
key={p.id}
|
|
||||||
project={p}
|
|
||||||
onClick={() => openEdit(p)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.length > PROJECTS_PER_PAGE && (
|
{totalProjects > PROJECTS_PER_PAGE && (
|
||||||
<PaginationControls
|
<PaginationControls
|
||||||
currentPage={safePage}
|
currentPage={safePage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
visibleStart={visibleStart}
|
visibleStart={visibleStart}
|
||||||
visibleEnd={visibleEnd}
|
visibleEnd={visibleEnd}
|
||||||
totalItems={filtered.length}
|
totalItems={totalProjects}
|
||||||
onPageChange={goToPage}
|
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({
|
function PaginationControls({
|
||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
@@ -453,26 +625,35 @@ function EmptyState({
|
|||||||
hasProjects,
|
hasProjects,
|
||||||
filter,
|
filter,
|
||||||
query,
|
query,
|
||||||
|
activeAdvancedFilterCount,
|
||||||
}: {
|
}: {
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
hasProjects: boolean;
|
hasProjects: boolean;
|
||||||
filter: "todos" | "abiertos" | "cerrados";
|
filter: "todos" | "abiertos" | "cerrados";
|
||||||
query: string;
|
query: string;
|
||||||
|
activeAdvancedFilterCount: number;
|
||||||
}) {
|
}) {
|
||||||
const hasSearch = query.trim().length > 0;
|
const hasSearch = query.trim().length > 0;
|
||||||
|
const hasAdvancedFilters = activeAdvancedFilterCount > 0;
|
||||||
|
|
||||||
let title = "Tu tablero está vacío";
|
let title = "Tu tablero está vacío";
|
||||||
let message = "Crea tu primer proyecto para que el equipo pueda darle seguimiento.";
|
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";
|
title = "Nada coincide con la búsqueda";
|
||||||
message = "Intenta cambiar la búsqueda o borrar el texto escrito.";
|
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") {
|
} else if (hasProjects && filter === "abiertos") {
|
||||||
title = "No hay proyectos activos";
|
title = "No hay proyectos activos";
|
||||||
message = "Cuando se cree un proyecto nuevo, aparecerá en esta pestaña.";
|
message = "Cuando se cree un proyecto nuevo, aparecerá en esta pestaña.";
|
||||||
} else if (hasProjects && filter === "cerrados") {
|
} else if (hasProjects && filter === "cerrados") {
|
||||||
title = "No hay proyectos 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) {
|
} else if (hasProjects) {
|
||||||
title = "Nada coincide con el filtro";
|
title = "Nada coincide con el filtro";
|
||||||
message = "Intenta quitar el filtro o cambiar la búsqueda.";
|
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