feat: agregar control de visibilidad del total tarifado
This commit is contained in:
@@ -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.
|
||||
|
||||
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
+2
-2
@@ -7,8 +7,8 @@
|
||||
<meta name="description" content="Herramienta interna del Departamento de Diseño Creativo." />
|
||||
<meta name="author" content="CDC" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
|
||||
<script type="module" crossorigin src="/tablero-cdc/assets/index-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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user