feat: agregar control de visibilidad del total tarifado

This commit is contained in:
2026-06-22 10:05:21 -04:00
parent 5adf487bd0
commit 5f5b05e8fd
13 changed files with 1593 additions and 432 deletions
+17 -1
View File
@@ -196,7 +196,6 @@ supabase_realtime_projects.sql
Luego abre la app en dos pestañas o dos navegadores: al crear, editar o eliminar un proyecto en una ventana, la otra debe actualizarse automáticamente.
## Desplegables dinámicos
La app intenta cargar estos desplegables desde `public.tablero_cdc_app_lists`:
@@ -208,3 +207,20 @@ La app intenta cargar estos desplegables desde `public.tablero_cdc_app_lists`:
- `country_manager` / `cm` / `bu_cm` para mapear BU → Country Manager
Si una categoría aún no existe en Supabase, se usa el respaldo local del frontend. Más adelante n8n sincronizará la hoja `listas` del Sheet original hacia `tablero_cdc_app_lists`.
## Actualización: Tarifario dinámico desde Sheet/n8n, permisos internos y total tarifado por RPC
Esta versión mantiene la app como consumidora del tarifario, pero elimina la administración manual dentro de la app:
1. **Tarifario desde Google Sheets/n8n**: ya no aparece la pantalla privada de “Administrar tarifario”. El catálogo visible en los proyectos se carga desde `tablero_cdc_tariff_catalog`, que deberá sincronizarse desde el Google Sheet del tarifario mediante n8n. La app conserva el tarifario base como respaldo si la tabla está vacía o aún no existe.
2. **Total tarifado global/filtrado por RPC**: la tarjeta de total tarifado ya no necesita traer todos los proyectos para sumar en el navegador. La app llama la RPC `tablero_cdc_get_pricing_summary`, que calcula en Supabase el total de `Interno Cargado` y la cantidad de proyectos incluidos según los filtros activos. Si no hay filtros, devuelve el total global.
3. **Permisos internos ajustados**: el total tarifado, `Interno Cargado` y eliminar proyecto quedan restringidos en frontend a `gmarrero@gomezleemarketing.com` e `iaracena@gomezleemarketing.com`.
Antes de usar esta versión en producción, ejecutar en Supabase SQL Editor:
```sql
-- Archivo incluido en este ZIP:
-- supabase_tariff_catalog_and_safe_links.sql
```
Ese SQL crea/actualiza `tablero_cdc_tariff_catalog`, la función transaccional `tablero_cdc_replace_project_links`, la RPC `tablero_cdc_get_pricing_summary` y actualiza `tablero_cdc_get_projects_paginated` para que el monto interno solo viaje a Marrero e Isaac.
File diff suppressed because one or more lines are too long
-388
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<meta name="description" content="Herramienta interna del Departamento de Diseño Creativo." />
<meta name="author" content="CDC" />
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
<script type="module" crossorigin src="/tablero-cdc/assets/index-UvAfOJww.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-O1Ai8omu.css">
<script type="module" crossorigin src="/tablero-cdc/assets/index-wNfOppvm.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-CF6PF3ek.css">
</head>
<body>
<div id="root"></div>
+18 -12
View File
@@ -9,7 +9,6 @@ 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,
@@ -17,6 +16,7 @@ import {
type TariffLevel,
type TariffWorkType,
} from "@/data/tariff";
import { useTariffCatalog } from "@/lib/tariffCatalog";
import type { ProjectPricingItemInput } from "@/lib/store";
import { cn } from "@/lib/utils";
@@ -63,27 +63,32 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const [manualName, setManualName] = useState<string>("");
const [manualDescription, setManualDescription] = useState<string>("");
const [manualAmount, setManualAmount] = useState<string>("");
const {
catalog: tariffCatalog,
loading: tariffLoading,
error: tariffError,
} = useTariffCatalog();
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],
tariffCatalog
.filter((item) => item.section === section)
.map((item) => `${item.category} · ${item.service}`),
[section, tariffCatalog],
);
const selectedItem = useMemo(() => {
const selectedLabel = serviceId;
return (
TARIFF_CATALOG.find(
tariffCatalog.find(
(item) =>
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
) || null
);
}, [section, serviceId]);
}, [section, serviceId, tariffCatalog]);
const selectedWorkType = useMemo(() => {
if (!selectedItem) return null;
@@ -113,7 +118,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const onServiceChange = (label: string) => {
setServiceId(label);
const item = TARIFF_CATALOG.find(
const item = tariffCatalog.find(
(catalogItem) =>
catalogItem.section === section &&
`${catalogItem.category} · ${catalogItem.service}` === label,
@@ -225,16 +230,17 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
</div>
</div>
{error && (
{(error || tariffError) && (
<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>
<span>{error || tariffError}</span>
</div>
)}
{loading && (
{(loading || tariffLoading) && (
<div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
Cargando costos guardados
{loading ? "Cargando costos guardados…" : "Cargando tarifario…"}
</div>
)}
+11 -10
View File
@@ -12,16 +12,11 @@ import { supabase } from "@/lib/supabase";
const ALLOWED_DOMAIN = "gomezleemarketing.com";
const GERARDO_EMAIL = "gmarrero@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",
];
const JOSE_LEOPOLDO_EMAIL = "jgomez@gomezleemarketing.com";
const ISAAC_EMAIL = "iaracena@gomezleemarketing.com";
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, ISAAC_EMAIL];
const PRICING_SUMMARY_CONTROLLER_EMAILS = [GERARDO_EMAIL, JOSE_LEOPOLDO_EMAIL, ISAAC_EMAIL];
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
function isAllowedEmail(email: string | null | undefined): boolean {
if (!email) return false;
@@ -71,6 +66,7 @@ interface AuthContextValue {
isGerardo: boolean;
canDeleteProjects: boolean;
canManageInternalPricing: boolean;
canControlPricingSummary: boolean;
loginWithGoogle: () => Promise<void>;
logout: () => Promise<void>;
}
@@ -180,6 +176,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
[normalizedEmail],
);
const canControlPricingSummary = useMemo(
() => PRICING_SUMMARY_CONTROLLER_EMAILS.includes(normalizedEmail),
[normalizedEmail],
);
return (
<AuthContext.Provider
@@ -190,6 +190,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isGerardo,
canDeleteProjects,
canManageInternalPricing,
canControlPricingSummary,
loginWithGoogle,
logout,
}}
+127
View File
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
const PRICING_SUMMARY_SETTING_KEY = "pricing_summary_public";
type SettingRow = {
key: string;
value: {
enabled?: boolean;
} | null;
};
function parseVisibility(row: SettingRow | null | undefined) {
return Boolean(row?.value?.enabled);
}
async function fetchPricingSummaryPublicVisibility() {
const { data, error } = await supabase
.from("tablero_cdc_app_settings")
.select("key, value")
.eq("key", PRICING_SUMMARY_SETTING_KEY)
.maybeSingle();
if (error) throw error;
return parseVisibility(data as SettingRow | null);
}
async function savePricingSummaryPublicVisibility(enabled: boolean) {
const { error } = await supabase.rpc("tablero_cdc_set_pricing_summary_public", {
p_enabled: enabled,
});
if (error) throw error;
}
export function usePricingSummaryVisibility(enabled = true) {
const [isPublic, setIsPublic] = useState(false);
const [loading, setLoading] = useState(enabled);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled) return false;
setLoading(true);
setError(null);
try {
const nextIsPublic = await fetchPricingSummaryPublicVisibility();
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo leer la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo leer la visibilidad del total tarifado.",
);
return false;
} finally {
setLoading(false);
}
}, [enabled]);
const setPublicVisibility = useCallback(
async (nextIsPublic: boolean) => {
setSaving(true);
setError(null);
try {
await savePricingSummaryPublicVisibility(nextIsPublic);
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo actualizar la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo actualizar la visibilidad del total tarifado.",
);
throw err;
} finally {
setSaving(false);
}
},
[],
);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!enabled) return;
const channel = supabase
.channel("tablero-cdc-pricing-summary-visibility")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "tablero_cdc_app_settings",
filter: `key=eq.${PRICING_SUMMARY_SETTING_KEY}`,
},
(payload) => {
const nextRow = (payload.new || payload.old) as SettingRow | null;
setIsPublic(parseVisibility(nextRow));
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [enabled]);
return {
isPublic,
loading,
saving,
error,
refresh,
setPublicVisibility,
};
}
+228 -15
View File
@@ -76,6 +76,12 @@ export type ProjectFilterOptions = {
cms: string[];
};
export type ProjectPricingSummary = {
totalAmount: number;
projectCount: number;
loadedAt: number | null;
};
export type ProjectQueryParams = {
tab: ProjectTab;
search: string;
@@ -150,6 +156,11 @@ type RpcProjectsResponse = {
filter_options?: Partial<ProjectFilterOptions> | null;
};
type RpcPricingSummaryResponse = {
total_amount?: number | string | null;
project_count?: number | string | null;
};
type DirectProjectQuery = {
or: (filters: string) => DirectProjectQuery;
not: (column: string, operator: string, value: unknown) => DirectProjectQuery;
@@ -160,19 +171,16 @@ type DirectProjectQuery = {
) => Promise<{ data: unknown[] | null; error: { message: string } | null; count: number | null }>;
};
type DirectProjectSummaryRow = {
id?: string | null;
internal_amount?: number | string | null;
};
const DEFAULT_COLOR: ColorId = "blue";
const OPEN_STATUS = "Activo";
const GERARDO_EMAIL = "gmarrero@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",
];
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
const DEFAULT_PROJECT_PARAMS: ProjectQueryParams = {
tab: "todos",
search: "",
@@ -715,6 +723,25 @@ async function replaceProjectLinks(
projectId: string,
project: Pick<Project, "propuestaLinks" | "afLinks">,
) {
const linkRows = toLinkRows(projectId, project);
try {
const { error: rpcError } = await supabase.rpc("tablero_cdc_replace_project_links", {
p_project_id: projectId,
p_links: linkRows.map(({ link_type, url, label }) => ({ link_type, url, label })),
});
if (rpcError) throw rpcError;
return;
} catch (error) {
if (!isMissingReplaceLinksRpc(error)) throw error;
console.warn(
"RPC tablero_cdc_replace_project_links no existe todavía. Usando reemplazo de links por delete+insert.",
error,
);
}
const { error: deleteError } = await supabase
.from("tablero_cdc_project_links")
.delete()
@@ -722,8 +749,6 @@ async function replaceProjectLinks(
if (deleteError) throw deleteError;
const linkRows = toLinkRows(projectId, project);
if (linkRows.length === 0) return;
const { error: insertError } = await supabase.from("tablero_cdc_project_links").insert(linkRows);
@@ -962,14 +987,40 @@ function getSelectColumns(includeInternalAmount: boolean) {
`;
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (error && typeof error === "object" && "message" in error) {
return String((error as { message?: unknown }).message || "");
}
return String(error || "");
}
function isMissingRpcFunction(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
const message = getErrorMessage(error);
return (
message.includes("tablero_cdc_get_projects_paginated") ||
message.includes("Could not find the function")
);
}
function isMissingReplaceLinksRpc(error: unknown) {
const message = getErrorMessage(error);
return (
message.includes("tablero_cdc_replace_project_links") ||
message.includes("Could not find the function")
);
}
function isMissingPricingSummaryRpc(error: unknown) {
const message = getErrorMessage(error);
return (
message.includes("tablero_cdc_get_pricing_summary") ||
message.includes("Could not find the function")
);
}
function escapePostgrestFilterValue(value: string) {
return value.replace(/[%_]/g, "\\$&").replace(/,/g, " ");
}
@@ -1084,7 +1135,7 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
notify();
try {
const isGerardo = await currentUserIsGerardo();
const canManageInternalPricing = await currentUserCanManageInternalPricing();
let result: ProjectLoadResult;
try {
@@ -1096,7 +1147,7 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
"RPC tablero_cdc_get_projects_paginated no existe todavía. Usando consulta directa como respaldo temporal.",
rpcError,
);
result = await loadProjectsDirect(nextParams, isGerardo);
result = await loadProjectsDirect(nextParams, canManageInternalPricing);
}
if (requestId !== loadRequestSeq) return;
@@ -1307,6 +1358,168 @@ async function deleteProject(id: string) {
}
}
async function loadProjectPricingSummaryFromRpc(
params: ProjectQueryParams,
): Promise<ProjectPricingSummary> {
const { data, error } = await supabase.rpc("tablero_cdc_get_pricing_summary", {
p_tab: params.tab,
p_search: params.search,
p_country: params.country || "",
p_brand: params.brand || "",
p_client: params.client || "",
p_cm: params.cm || "",
});
if (error) throw error;
const result = (data || {}) as RpcPricingSummaryResponse;
const totalAmount = Number(result.total_amount || 0);
const projectCount = Number(result.project_count || 0);
return {
totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0,
projectCount: Number.isFinite(projectCount) ? projectCount : 0,
loadedAt: Date.now(),
};
}
async function loadProjectPricingSummaryDirect(
params: ProjectQueryParams,
): Promise<ProjectPricingSummary> {
const pageSize = 1000;
let from = 0;
let totalCount: number | null = null;
let totalAmount = 0;
while (totalCount === null || from < totalCount) {
let query = supabase
.from("tablero_cdc_projects")
.select("id, internal_amount", { count: "exact" })
.order("created_at", { ascending: false }) as unknown as DirectProjectQuery;
query = applyDirectFilters(query, params);
const { data, error, count } = await query.range(from, from + pageSize - 1);
if (error) throw error;
if (totalCount === null) {
totalCount = count ?? (data || []).length;
}
const rows = (data || []) as DirectProjectSummaryRow[];
rows.forEach((row) => {
const amount = Number(row.internal_amount ?? 0);
if (Number.isFinite(amount) && amount > 0) {
totalAmount += amount;
}
});
if (rows.length < pageSize) break;
from += pageSize;
}
return {
totalAmount,
projectCount: totalCount ?? 0,
loadedAt: Date.now(),
};
}
async function loadProjectPricingSummary(
params?: Partial<ProjectQueryParams>,
): Promise<ProjectPricingSummary> {
const nextParams = normalizeProjectParams({
...params,
page: 1,
pageSize: 1000,
});
try {
return await loadProjectPricingSummaryFromRpc(nextParams);
} catch (rpcError) {
if (!isMissingPricingSummaryRpc(rpcError)) throw rpcError;
console.warn(
"RPC tablero_cdc_get_pricing_summary no existe todavía. Usando suma directa como respaldo temporal.",
rpcError,
);
return loadProjectPricingSummaryDirect(nextParams);
}
}
export function useProjectPricingSummary(params: ProjectQueryParams, enabled: boolean) {
const [summary, setSummary] = useState<ProjectPricingSummary>({
totalAmount: 0,
projectCount: 0,
loadedAt: null,
});
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const paramsTab = params.tab;
const paramsSearch = params.search;
const paramsCountry = params.country;
const paramsBrand = params.brand;
const paramsClient = params.client;
const paramsCm = params.cm;
useEffect(() => {
if (!enabled) {
setSummary({ totalAmount: 0, projectCount: 0, loadedAt: null });
setSummaryError(null);
setSummaryLoading(false);
return;
}
let cancelled = false;
setSummaryLoading(true);
setSummaryError(null);
loadProjectPricingSummary({
tab: paramsTab,
search: paramsSearch,
country: paramsCountry,
brand: paramsBrand,
client: paramsClient,
cm: paramsCm,
page: 1,
pageSize: 1000,
})
.then((result) => {
if (!cancelled) setSummary(result);
})
.catch((error) => {
if (cancelled) return;
console.error("No se pudo calcular el total tarifado filtrado:", error);
setSummaryError(
error instanceof Error
? error.message
: "No se pudo calcular el total tarifado filtrado.",
);
})
.finally(() => {
if (!cancelled) setSummaryLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled, paramsTab, paramsSearch, paramsCountry, paramsBrand, paramsClient, paramsCm]);
return {
summary,
loading: summaryLoading,
error: summaryError,
refresh: () =>
loadProjectPricingSummary(params).then((result) => {
setSummary(result);
return result;
}),
};
}
export function useProjects(params?: ProjectQueryParams) {
const [, force] = useState(0);
const paramsTab = params?.tab;
+366
View File
@@ -0,0 +1,366 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import {
TARIFF_CATALOG,
WORK_TYPE_REFERENCE,
type TariffCatalogItem,
type TariffLevel,
type TariffWorkType,
} from "@/data/tariff";
export type TariffCatalogSource = "supabase" | "fallback";
export type TariffCatalogRow = {
id: string;
catalog_item_id: string;
section: "grafico" | "estrategia";
category: string;
service: string;
notes: string;
work_type_id: string;
work_type_label: string;
work_type_short_label: string;
hour_reference: string;
level_id: string;
level_label: string;
reference: string;
reference_min: number | null;
reference_max: number | null;
level_hint: string;
sort_order: number;
is_active: boolean;
created_at?: string | null;
updated_at?: string | null;
};
export type TariffCatalogUpsertInput = Omit<TariffCatalogRow, "created_at" | "updated_at">;
type TariffCatalogState = {
rows: TariffCatalogRow[];
catalog: TariffCatalogItem[];
source: TariffCatalogSource;
loading: boolean;
error: string | null;
};
const TABLE_NAME = "tablero_cdc_tariff_catalog";
const DEFAULT_WORK_TYPE_LABEL = "Referencia";
const DEFAULT_LEVEL_LABEL = "Precio único";
let cache: TariffCatalogState = {
rows: flattenTariffCatalog(TARIFF_CATALOG),
catalog: TARIFF_CATALOG,
source: "fallback",
loading: false,
error: null,
};
let initialized = false;
let listeners: Array<() => void> = [];
let loadRequestSeq = 0;
function notify() {
listeners.forEach((listener) => listener());
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value.trim());
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function slugify(value: string) {
return String(value || "")
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function rowId(itemId: string, workTypeId: string, levelId: string) {
return [itemId, workTypeId, levelId].map(slugify).filter(Boolean).join("__");
}
function normalizeDbRow(row: Record<string, unknown>): TariffCatalogRow {
const section = String(row.section || "grafico") === "estrategia" ? "estrategia" : "grafico";
const category = String(row.category || "").trim();
const service = String(row.service || "").trim();
const catalogItemId = String(
row.catalog_item_id || slugify(`${section}-${category}-${service}`),
).trim();
const workTypeLabel = String(row.work_type_label || DEFAULT_WORK_TYPE_LABEL).trim();
const levelLabel = String(row.level_label || DEFAULT_LEVEL_LABEL).trim();
const workTypeId = String(
row.work_type_id || slugify(workTypeLabel) || WORK_TYPE_REFERENCE,
).trim();
const levelId = String(row.level_id || slugify(levelLabel) || "project").trim();
return {
id: String(row.id || rowId(catalogItemId, workTypeId, levelId)).trim(),
catalog_item_id: catalogItemId,
section,
category,
service,
notes: String(row.notes || "").trim(),
work_type_id: workTypeId,
work_type_label: workTypeLabel,
work_type_short_label: String(row.work_type_short_label || workTypeLabel).trim(),
hour_reference: String(row.hour_reference || "").trim(),
level_id: levelId,
level_label: levelLabel,
reference: String(row.reference || "").trim(),
reference_min: asNumber(row.reference_min),
reference_max: asNumber(row.reference_max),
level_hint: String(row.level_hint || "").trim(),
sort_order: Number(row.sort_order || 0),
is_active: row.is_active !== false,
created_at: typeof row.created_at === "string" ? row.created_at : null,
updated_at: typeof row.updated_at === "string" ? row.updated_at : null,
};
}
export function buildTariffCatalogFromRows(rows: TariffCatalogRow[]): TariffCatalogItem[] {
const itemMap = new Map<string, TariffCatalogItem>();
const workTypeMaps = new Map<string, Map<string, TariffWorkType>>();
const activeRows = rows
.filter((row) => row.is_active)
.sort((a, b) => a.sort_order - b.sort_order || a.category.localeCompare(b.category));
for (const row of activeRows) {
const itemId = row.catalog_item_id || slugify(`${row.section}-${row.category}-${row.service}`);
let item = itemMap.get(itemId);
if (!item) {
item = {
id: itemId,
section: row.section,
category: row.category,
service: row.service,
notes: row.notes,
workTypes: [],
};
itemMap.set(itemId, item);
workTypeMaps.set(itemId, new Map());
}
const workTypeKey = row.work_type_id || WORK_TYPE_REFERENCE;
const workTypeMap = workTypeMaps.get(itemId)!;
let workType = workTypeMap.get(workTypeKey);
if (!workType) {
workType = {
id: workTypeKey,
label: row.work_type_label || DEFAULT_WORK_TYPE_LABEL,
shortLabel: row.work_type_short_label || row.work_type_label || DEFAULT_WORK_TYPE_LABEL,
hourReference: row.hour_reference || undefined,
levels: [],
};
workTypeMap.set(workTypeKey, workType);
item.workTypes.push(workType);
}
const level: TariffLevel = {
id: row.level_id || "project",
label: row.level_label || DEFAULT_LEVEL_LABEL,
reference: row.reference || "",
min: row.reference_min ?? undefined,
max: row.reference_max ?? undefined,
hint: row.level_hint || undefined,
};
if (!workType.levels.some((existing) => existing.id === level.id)) {
workType.levels.push(level);
}
}
return Array.from(itemMap.values());
}
export function flattenTariffCatalog(catalog: TariffCatalogItem[]): TariffCatalogRow[] {
const rows: TariffCatalogRow[] = [];
catalog.forEach((item, itemIndex) => {
item.workTypes.forEach((workType, workTypeIndex) => {
workType.levels.forEach((level, levelIndex) => {
rows.push({
id: rowId(item.id, workType.id, level.id),
catalog_item_id: item.id,
section: item.section,
category: item.category,
service: item.service,
notes: item.notes || "",
work_type_id: workType.id,
work_type_label: workType.label,
work_type_short_label: workType.shortLabel,
hour_reference: workType.hourReference || "",
level_id: level.id,
level_label: level.label,
reference: level.reference,
reference_min: level.min ?? null,
reference_max: level.max ?? null,
level_hint: level.hint || "",
sort_order: itemIndex * 1000 + workTypeIndex * 100 + levelIndex,
is_active: true,
});
});
});
});
return rows;
}
export function buildTariffRowId(
input: Pick<TariffCatalogRow, "catalog_item_id" | "work_type_id" | "level_id">,
) {
return rowId(input.catalog_item_id, input.work_type_id, input.level_id);
}
function missingTariffTable(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return (
message.includes(TABLE_NAME) ||
message.includes("Could not find the table") ||
message.includes("relation") ||
message.includes("does not exist")
);
}
export async function loadTariffCatalog(force = false): Promise<TariffCatalogState> {
if (initialized && !force) return cache;
const requestId = (loadRequestSeq += 1);
cache = { ...cache, loading: true, error: null };
notify();
try {
const { data, error } = await supabase
.from(TABLE_NAME)
.select(
"id, catalog_item_id, section, category, service, notes, work_type_id, work_type_label, work_type_short_label, hour_reference, level_id, level_label, reference, reference_min, reference_max, level_hint, sort_order, is_active, created_at, updated_at",
)
.order("sort_order", { ascending: true })
.order("category", { ascending: true });
if (error) throw error;
const dbRows = (data || []).map((row) => normalizeDbRow(row as Record<string, unknown>));
const fallbackRows = flattenTariffCatalog(TARIFF_CATALOG);
const rowMap = new Map<string, TariffCatalogRow>();
fallbackRows.forEach((row) => rowMap.set(row.id, row));
dbRows.forEach((row) => rowMap.set(row.id, row));
const mergedRows = Array.from(rowMap.values()).sort(
(a, b) => a.sort_order - b.sort_order || a.category.localeCompare(b.category),
);
const catalog = buildTariffCatalogFromRows(mergedRows);
if (requestId === loadRequestSeq) {
cache = {
rows: mergedRows,
catalog,
source: dbRows.length ? "supabase" : "fallback",
loading: false,
error: dbRows.length
? null
: "La tabla del tarifario está vacía. Se está usando el tarifario base de la app como respaldo.",
};
initialized = true;
notify();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
const friendlyMessage = missingTariffTable(error)
? "Falta crear la tabla tablero_cdc_tariff_catalog en Supabase. Se está usando el tarifario base de la app como respaldo."
: message || "No se pudo cargar el tarifario dinámico.";
if (requestId === loadRequestSeq) {
cache = {
rows: flattenTariffCatalog(TARIFF_CATALOG),
catalog: TARIFF_CATALOG,
source: "fallback",
loading: false,
error: friendlyMessage,
};
initialized = true;
notify();
}
}
return cache;
}
export async function upsertTariffCatalogRow(input: TariffCatalogUpsertInput) {
const row = normalizeDbRow(input as unknown as Record<string, unknown>);
const id = row.id || buildTariffRowId(row);
const { error } = await supabase.from(TABLE_NAME).upsert(
{
id,
catalog_item_id: row.catalog_item_id,
section: row.section,
category: row.category,
service: row.service,
notes: row.notes,
work_type_id: row.work_type_id,
work_type_label: row.work_type_label,
work_type_short_label: row.work_type_short_label,
hour_reference: row.hour_reference,
level_id: row.level_id,
level_label: row.level_label,
reference: row.reference,
reference_min: row.reference_min,
reference_max: row.reference_max,
level_hint: row.level_hint,
sort_order: row.sort_order,
is_active: row.is_active,
},
{ onConflict: "id" },
);
if (error) throw error;
initialized = false;
await loadTariffCatalog(true);
}
export async function setTariffCatalogRowActive(row: TariffCatalogRow, isActive: boolean) {
const { error } = await supabase
.from(TABLE_NAME)
.update({ is_active: isActive })
.eq("id", row.id);
if (error) throw error;
initialized = false;
await loadTariffCatalog(true);
}
export function useTariffCatalog() {
const [, force] = useState(0);
useEffect(() => {
const listener = () => force((value) => value + 1);
listeners.push(listener);
if (!initialized && !cache.loading) {
void loadTariffCatalog();
}
return () => {
listeners = listeners.filter((current) => current !== listener);
};
}, []);
return {
...cache,
refresh: () => loadTariffCatalog(true),
};
}
+142 -2
View File
@@ -4,6 +4,9 @@ import {
ChevronLeft,
ChevronRight,
AlertCircle,
DollarSign,
Eye,
EyeOff,
LayoutGrid,
LogOut,
Plus,
@@ -25,7 +28,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { useProjects, type Project } from "@/lib/store";
import { useProjectPricingSummary, useProjects, type Project } from "@/lib/store";
import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
@@ -82,7 +86,30 @@ export default function BoardPage() {
client: advancedFilters.client,
cm: advancedFilters.cm,
});
const { user, isGerardo, logout } = useAuth();
const { user, isGerardo, canControlPricingSummary, logout } = useAuth();
const pricingSummaryParams = {
tab: filter,
search: query,
page: 1,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
} as const;
const {
isPublic: isPricingSummaryPublic,
loading: pricingSummaryVisibilityLoading,
saving: pricingSummaryVisibilitySaving,
error: pricingSummaryVisibilityError,
setPublicVisibility: setPricingSummaryPublicVisibility,
} = usePricingSummaryVisibility(Boolean(user));
const canViewPricingSummary = canControlPricingSummary || isPricingSummaryPublic;
const {
summary: pricingSummary,
loading: pricingSummaryLoading,
error: pricingSummaryError,
} = useProjectPricingSummary(pricingSummaryParams, canViewPricingSummary);
const cmListOptions = Object.values(lists.buCm);
const countryFilterOptions = dedupeOptions([
@@ -321,6 +348,24 @@ export default function BoardPage() {
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
{canViewPricingSummary && (
<PricingSummaryCard
totalAmount={pricingSummary.totalAmount}
projectCount={pricingSummary.projectCount}
loading={pricingSummaryLoading || pricingSummaryVisibilityLoading}
error={pricingSummaryError || pricingSummaryVisibilityError}
hasFilters={
filter !== "todos" || query.trim().length > 0 || activeAdvancedFilterCount > 0
}
canControlVisibility={canControlPricingSummary}
isPublic={isPricingSummaryPublic}
visibilitySaving={pricingSummaryVisibilitySaving}
onToggleVisibility={() =>
setPricingSummaryPublicVisibility(!isPricingSummaryPublic).catch(() => undefined)
}
/>
)}
</div>
{/* Grid */}
@@ -385,6 +430,101 @@ export default function BoardPage() {
);
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value || 0);
}
function PricingSummaryCard({
totalAmount,
projectCount,
loading,
error,
hasFilters,
canControlVisibility,
isPublic,
visibilitySaving,
onToggleVisibility,
}: {
totalAmount: number;
projectCount: number;
loading: boolean;
error: string | null;
hasFilters: boolean;
canControlVisibility: boolean;
isPublic: boolean;
visibilitySaving: boolean;
onToggleVisibility: () => void;
}) {
return (
<div className="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<DollarSign className="h-4 w-4" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{hasFilters ? "Total tarifado filtrado" : "Total tarifado global"}
</p>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${
isPublic
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-primary/20 bg-card text-primary"
}`}
>
{isPublic ? "Visible para todos" : "Privado"}
</span>
</div>
<p className="mt-0.5 text-2xl font-semibold tracking-tight text-foreground">
{loading ? "Calculando…" : formatCurrency(totalAmount)}
</p>
<p className="text-xs text-muted-foreground">
{error
? `No se pudo calcular el total: ${error}`
: `${projectCount} proyecto(s) incluidos según los filtros actuales.`}
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
<div className="rounded-full border border-primary/20 bg-card px-3 py-1 text-xs font-medium text-primary">
{hasFilters ? "Según filtros activos" : "Sin filtros aplicados"}
</div>
{canControlVisibility && (
<Button
type="button"
variant={isPublic ? "secondary" : "outline"}
size="sm"
className="gap-1.5 rounded-full"
disabled={visibilitySaving}
onClick={onToggleVisibility}
title={
isPublic
? "Ocultar este total para los demás usuarios"
: "Mostrar este total temporalmente a todos los usuarios"
}
>
{isPublic ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{visibilitySaving
? "Actualizando…"
: isPublic
? "Ocultar a todos"
: "Mostrar a todos"}
</Button>
)}
</div>
</div>
</div>
);
}
function AdvancedProjectFilters({
filters,
activeCount,
+248
View File
@@ -0,0 +1,248 @@
-- =========================================================
-- TABLERO CDC - Visibilidad del Total Tarifado
-- Ejecutar en Supabase SQL Editor antes de desplegar esta versión.
--
-- Objetivo:
-- - Por defecto, el Total Tarifado solo lo ven:
-- * gmarrero@gomezleemarketing.com
-- * jgomez@gomezleemarketing.com
-- * iaracena@gomezleemarketing.com
-- - Ellos pueden activar/desactivar un botón para mostrarlo temporalmente a todos.
-- - No expone montos internos por proyecto; solo habilita/oculta el resumen global/filtrado.
-- =========================================================
create table if not exists public.tablero_cdc_app_settings (
key text primary key,
value jsonb not null default '{}'::jsonb,
updated_by uuid references auth.users(id),
updated_at timestamptz not null default now()
);
insert into public.tablero_cdc_app_settings (key, value)
values ('pricing_summary_public', jsonb_build_object('enabled', false))
on conflict (key) do nothing;
alter table public.tablero_cdc_app_settings enable row level security;
drop policy if exists "tablero_cdc_app_settings_select_authenticated" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_insert_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_update_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_delete_none" on public.tablero_cdc_app_settings;
create policy "tablero_cdc_app_settings_select_authenticated"
on public.tablero_cdc_app_settings
for select
to authenticated
using (true);
create policy "tablero_cdc_app_settings_insert_summary_controllers"
on public.tablero_cdc_app_settings
for insert
to authenticated
with check (
key = 'pricing_summary_public'
and lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
create policy "tablero_cdc_app_settings_update_summary_controllers"
on public.tablero_cdc_app_settings
for update
to authenticated
using (
key = 'pricing_summary_public'
and lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
)
with check (
key = 'pricing_summary_public'
and lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
create policy "tablero_cdc_app_settings_delete_none"
on public.tablero_cdc_app_settings
for delete
to authenticated
using (false);
create or replace function public.set_tablero_cdc_app_settings_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_app_settings_updated_at on public.tablero_cdc_app_settings;
create trigger trg_tablero_cdc_app_settings_updated_at
before insert or update on public.tablero_cdc_app_settings
for each row
execute function public.set_tablero_cdc_app_settings_updated_at();
create or replace function public.tablero_cdc_set_pricing_summary_public(p_enabled boolean)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := lower(coalesce(auth.jwt() ->> 'email', ''));
v_enabled boolean := coalesce(p_enabled, false);
begin
if v_email not in (
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
) then
raise exception 'No tienes permiso para cambiar la visibilidad del total tarifado.';
end if;
insert into public.tablero_cdc_app_settings (key, value, updated_by, updated_at)
values (
'pricing_summary_public',
jsonb_build_object('enabled', v_enabled),
auth.uid(),
now()
)
on conflict (key)
do update set
value = excluded.value,
updated_by = auth.uid(),
updated_at = now();
return jsonb_build_object('enabled', v_enabled);
end;
$$;
grant select on public.tablero_cdc_app_settings to authenticated;
grant execute on function public.tablero_cdc_set_pricing_summary_public(boolean) to authenticated;
-- Reemplaza la RPC de resumen para respetar visibilidad pública temporal.
create or replace function public.tablero_cdc_get_pricing_summary(
p_tab text default 'todos',
p_search text default '',
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 visibility as (
select coalesce((value ->> 'enabled')::boolean, false) as is_public
from public.tablero_cdc_app_settings
where key = 'pricing_summary_public'
),
allowed as (
select
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
or coalesce((select is_public from visibility), false) as can_view_pricing
),
params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
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 || '%')
),
totals as (
select
count(*)::integer as project_count,
coalesce(
sum(
greatest(
coalesce(nullif(trim(internal_amount::text), '')::numeric, 0),
0
)
),
0
)::numeric as total_amount
from filtered
)
select case
when (select can_view_pricing from allowed) then
jsonb_build_object(
'total_amount', (select total_amount from totals),
'project_count', (select project_count from totals)
)
else
jsonb_build_object(
'total_amount', 0,
'project_count', 0
)
end;
$$;
grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated;
-- Intenta habilitar realtime para que el cambio de visible/oculto se refleje sin recargar.
-- Si la publicación ya existe o la tabla ya está agregada, no hace nada.
do $$
begin
alter publication supabase_realtime add table public.tablero_cdc_app_settings;
exception
when duplicate_object then null;
when undefined_object then null;
end $$;
select
'Visibilidad del Total Tarifado lista' as status,
(select value from public.tablero_cdc_app_settings where key = 'pricing_summary_public') as pricing_summary_public;
+4 -1
View File
@@ -162,7 +162,10 @@ as $$
'description', p.description,
'status', p.status,
'internal_amount', case
when lower(coalesce(auth.jwt() ->> 'email', '')) = 'gmarrero@gomezleemarketing.com'
when lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
then to_jsonb(p.internal_amount)
else 'null'::jsonb
end,
+430
View File
@@ -0,0 +1,430 @@
-- =========================================================
-- TABLERO CDC - Catálogo dinámico de tarifario + guardado seguro de links + RPC de totales
-- Seguro para producción: no borra proyectos, no altera Google Sheets, no cambia n8n.
-- La fuente de mantenimiento del tarifario será Google Sheets; n8n sincroniza hacia esta tabla.
-- =========================================================
-- 1) Catálogo dinámico de tarifas estándar.
-- La app mantiene el tarifario base como respaldo. Esta tabla es el destino normalizado
-- que n8n llenará leyendo el Google Sheet del tarifario. La app solo consume esta tabla.
create table if not exists public.tablero_cdc_tariff_catalog (
id text primary key,
catalog_item_id text not null,
section text not null check (section in ('grafico', 'estrategia')),
category text not null default '',
service text not null default '',
notes text not null default '',
work_type_id text not null default 'reference',
work_type_label text not null default 'Referencia',
work_type_short_label text not null default 'Ref.',
hour_reference text not null default '',
level_id text not null default 'project',
level_label text not null default 'Precio único',
reference text not null default '',
reference_min numeric,
reference_max numeric,
level_hint text not null default '',
sort_order integer not null default 9999,
is_active boolean not null default true,
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_tariff_catalog_section_idx
on public.tablero_cdc_tariff_catalog(section, is_active, sort_order);
create index if not exists tablero_cdc_tariff_catalog_item_idx
on public.tablero_cdc_tariff_catalog(catalog_item_id);
alter table public.tablero_cdc_tariff_catalog enable row level security;
drop policy if exists "tablero_cdc_tariff_catalog_select_authenticated" on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_insert_pricing_admins" on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_update_pricing_admins" on public.tablero_cdc_tariff_catalog;
drop policy if exists "tablero_cdc_tariff_catalog_delete_pricing_admins" on public.tablero_cdc_tariff_catalog;
create policy "tablero_cdc_tariff_catalog_select_authenticated"
on public.tablero_cdc_tariff_catalog
for select
to authenticated
using (true);
create policy "tablero_cdc_tariff_catalog_insert_pricing_admins"
on public.tablero_cdc_tariff_catalog
for insert
to authenticated
with check (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
create policy "tablero_cdc_tariff_catalog_update_pricing_admins"
on public.tablero_cdc_tariff_catalog
for update
to authenticated
using (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
)
with check (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
-- No recomendamos borrar tarifas usadas históricamente. La app usa activar/desactivar.
-- Aun así dejamos delete restringido por si en pruebas se necesita limpiar un registro creado por error.
create policy "tablero_cdc_tariff_catalog_delete_pricing_admins"
on public.tablero_cdc_tariff_catalog
for delete
to authenticated
using (
lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
);
create or replace function public.set_tablero_cdc_tariff_catalog_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_tariff_catalog_updated_at on public.tablero_cdc_tariff_catalog;
create trigger trg_tablero_cdc_tariff_catalog_updated_at
before insert or update on public.tablero_cdc_tariff_catalog
for each row
execute function public.set_tablero_cdc_tariff_catalog_updated_at();
-- 2) Guardado seguro de links de Propuesta / Artes Finales.
-- La app llama esta función para mover links de un campo a otro en una sola operación transaccional.
-- Si cualquier insert falla, el delete también se revierte automáticamente.
create or replace function public.tablero_cdc_replace_project_links(
p_project_id uuid,
p_links jsonb default '[]'::jsonb
)
returns void
language plpgsql
security invoker
set search_path = public
as $$
begin
delete from public.tablero_cdc_project_links
where project_id = p_project_id;
insert into public.tablero_cdc_project_links (project_id, link_type, url, label)
select
p_project_id,
nullif(trim(link_type), ''),
trim(url),
nullif(trim(label), '')
from jsonb_to_recordset(coalesce(p_links, '[]'::jsonb)) as x(
link_type text,
url text,
label text
)
where nullif(trim(url), '') is not null;
end;
$$;
grant execute on function public.tablero_cdc_replace_project_links(uuid, jsonb) to authenticated;
-- 3) RPC paginado actualizado para que el monto interno solo viaje a Marrero e Isaac.
-- No cambia datos. Solo reemplaza la función de lectura si ya existe.
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', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@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;
-- 4) RPC de total tarifado global/filtrado.
-- Calcula en Supabase el total de Interno Cargado y la cantidad de proyectos según los mismos filtros de la pantalla.
-- Solo Marrero e Isaac reciben valores reales; otros usuarios reciben 0.
create or replace function public.tablero_cdc_get_pricing_summary(
p_tab text default 'todos',
p_search text default '',
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 allowed as (
select lower(coalesce(auth.jwt() ->> 'email', '')) in (
'gmarrero@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
) as can_view_pricing
),
params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
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 || '%')
),
totals as (
select
count(*)::integer as project_count,
coalesce(
sum(
greatest(
coalesce(nullif(trim(internal_amount::text), '')::numeric, 0),
0
)
),
0
)::numeric as total_amount
from filtered
)
select case
when (select can_view_pricing from allowed) then
jsonb_build_object(
'total_amount', (select total_amount from totals),
'project_count', (select project_count from totals)
)
else
jsonb_build_object(
'total_amount', 0,
'project_count', 0
)
end;
$$;
grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated;
select
'tablero_cdc_tariff_catalog, safe links y RPC de totales listos' as status,
(select count(*) from public.tablero_cdc_tariff_catalog) as tarifas_dinamicas_actuales;