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.
|
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
|
## Desplegables dinámicos
|
||||||
|
|
||||||
La app intenta cargar estos desplegables desde `public.tablero_cdc_app_lists`:
|
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
|
- `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`.
|
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="description" content="Herramienta interna del Departamento de Diseño Creativo." />
|
||||||
<meta name="author" content="CDC" />
|
<meta name="author" content="CDC" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" />
|
||||||
<script type="module" crossorigin src="/tablero-cdc/assets/index-UvAfOJww.js"></script>
|
<script type="module" crossorigin src="/tablero-cdc/assets/index-wNfOppvm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-O1Ai8omu.css">
|
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-CF6PF3ek.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
||||||
import {
|
import {
|
||||||
TARIFF_CATALOG,
|
|
||||||
TARIFF_SECTIONS,
|
TARIFF_SECTIONS,
|
||||||
defaultTariffAmount,
|
defaultTariffAmount,
|
||||||
formatTariffRange,
|
formatTariffRange,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
type TariffLevel,
|
type TariffLevel,
|
||||||
type TariffWorkType,
|
type TariffWorkType,
|
||||||
} from "@/data/tariff";
|
} from "@/data/tariff";
|
||||||
|
import { useTariffCatalog } from "@/lib/tariffCatalog";
|
||||||
import type { ProjectPricingItemInput } from "@/lib/store";
|
import type { ProjectPricingItemInput } from "@/lib/store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -63,27 +63,32 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
|||||||
const [manualName, setManualName] = useState<string>("");
|
const [manualName, setManualName] = useState<string>("");
|
||||||
const [manualDescription, setManualDescription] = useState<string>("");
|
const [manualDescription, setManualDescription] = useState<string>("");
|
||||||
const [manualAmount, setManualAmount] = useState<string>("");
|
const [manualAmount, setManualAmount] = useState<string>("");
|
||||||
|
const {
|
||||||
|
catalog: tariffCatalog,
|
||||||
|
loading: tariffLoading,
|
||||||
|
error: tariffError,
|
||||||
|
} = useTariffCatalog();
|
||||||
|
|
||||||
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
|
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
|
||||||
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
|
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
|
||||||
|
|
||||||
const serviceOptions = useMemo(
|
const serviceOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
TARIFF_CATALOG.filter((item) => item.section === section).map(
|
tariffCatalog
|
||||||
(item) => `${item.category} · ${item.service}`,
|
.filter((item) => item.section === section)
|
||||||
),
|
.map((item) => `${item.category} · ${item.service}`),
|
||||||
[section],
|
[section, tariffCatalog],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedItem = useMemo(() => {
|
const selectedItem = useMemo(() => {
|
||||||
const selectedLabel = serviceId;
|
const selectedLabel = serviceId;
|
||||||
return (
|
return (
|
||||||
TARIFF_CATALOG.find(
|
tariffCatalog.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
|
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
|
||||||
) || null
|
) || null
|
||||||
);
|
);
|
||||||
}, [section, serviceId]);
|
}, [section, serviceId, tariffCatalog]);
|
||||||
|
|
||||||
const selectedWorkType = useMemo(() => {
|
const selectedWorkType = useMemo(() => {
|
||||||
if (!selectedItem) return null;
|
if (!selectedItem) return null;
|
||||||
@@ -113,7 +118,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
|||||||
|
|
||||||
const onServiceChange = (label: string) => {
|
const onServiceChange = (label: string) => {
|
||||||
setServiceId(label);
|
setServiceId(label);
|
||||||
const item = TARIFF_CATALOG.find(
|
const item = tariffCatalog.find(
|
||||||
(catalogItem) =>
|
(catalogItem) =>
|
||||||
catalogItem.section === section &&
|
catalogItem.section === section &&
|
||||||
`${catalogItem.category} · ${catalogItem.service}` === label,
|
`${catalogItem.category} · ${catalogItem.service}` === label,
|
||||||
@@ -225,16 +230,17 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||||
<span>{error}</span>
|
<span>{error || tariffError}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && (
|
|
||||||
|
{(loading || tariffLoading) && (
|
||||||
<div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -12,16 +12,11 @@ import { supabase } from "@/lib/supabase";
|
|||||||
|
|
||||||
const ALLOWED_DOMAIN = "gomezleemarketing.com";
|
const ALLOWED_DOMAIN = "gomezleemarketing.com";
|
||||||
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
||||||
const INTERNAL_PRICING_ALLOWED_EMAILS = [
|
const JOSE_LEOPOLDO_EMAIL = "jgomez@gomezleemarketing.com";
|
||||||
GERARDO_EMAIL,
|
const ISAAC_EMAIL = "iaracena@gomezleemarketing.com";
|
||||||
"areyes@gomezleemarketing.com",
|
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, ISAAC_EMAIL];
|
||||||
"iaracena@gomezleemarketing.com",
|
const PRICING_SUMMARY_CONTROLLER_EMAILS = [GERARDO_EMAIL, JOSE_LEOPOLDO_EMAIL, ISAAC_EMAIL];
|
||||||
];
|
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
|
||||||
const DELETE_ALLOWED_EMAILS = [
|
|
||||||
GERARDO_EMAIL,
|
|
||||||
"areyes@gomezleemarketing.com",
|
|
||||||
"iaracena@gomezleemarketing.com",
|
|
||||||
];
|
|
||||||
|
|
||||||
function isAllowedEmail(email: string | null | undefined): boolean {
|
function isAllowedEmail(email: string | null | undefined): boolean {
|
||||||
if (!email) return false;
|
if (!email) return false;
|
||||||
@@ -71,6 +66,7 @@ interface AuthContextValue {
|
|||||||
isGerardo: boolean;
|
isGerardo: boolean;
|
||||||
canDeleteProjects: boolean;
|
canDeleteProjects: boolean;
|
||||||
canManageInternalPricing: boolean;
|
canManageInternalPricing: boolean;
|
||||||
|
canControlPricingSummary: boolean;
|
||||||
loginWithGoogle: () => Promise<void>;
|
loginWithGoogle: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -180,6 +176,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
|
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
|
||||||
[normalizedEmail],
|
[normalizedEmail],
|
||||||
);
|
);
|
||||||
|
const canControlPricingSummary = useMemo(
|
||||||
|
() => PRICING_SUMMARY_CONTROLLER_EMAILS.includes(normalizedEmail),
|
||||||
|
[normalizedEmail],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
@@ -190,6 +190,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
isGerardo,
|
isGerardo,
|
||||||
canDeleteProjects,
|
canDeleteProjects,
|
||||||
canManageInternalPricing,
|
canManageInternalPricing,
|
||||||
|
canControlPricingSummary,
|
||||||
loginWithGoogle,
|
loginWithGoogle,
|
||||||
logout,
|
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[];
|
cms: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectPricingSummary = {
|
||||||
|
totalAmount: number;
|
||||||
|
projectCount: number;
|
||||||
|
loadedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectQueryParams = {
|
export type ProjectQueryParams = {
|
||||||
tab: ProjectTab;
|
tab: ProjectTab;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -150,6 +156,11 @@ type RpcProjectsResponse = {
|
|||||||
filter_options?: Partial<ProjectFilterOptions> | null;
|
filter_options?: Partial<ProjectFilterOptions> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RpcPricingSummaryResponse = {
|
||||||
|
total_amount?: number | string | null;
|
||||||
|
project_count?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type DirectProjectQuery = {
|
type DirectProjectQuery = {
|
||||||
or: (filters: string) => DirectProjectQuery;
|
or: (filters: string) => DirectProjectQuery;
|
||||||
not: (column: string, operator: string, value: unknown) => 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 }>;
|
) => 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 DEFAULT_COLOR: ColorId = "blue";
|
||||||
const OPEN_STATUS = "Activo";
|
const OPEN_STATUS = "Activo";
|
||||||
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
|
||||||
const INTERNAL_PRICING_ALLOWED_EMAILS = [
|
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
|
||||||
GERARDO_EMAIL,
|
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
|
||||||
"areyes@gomezleemarketing.com",
|
|
||||||
"iaracena@gomezleemarketing.com",
|
|
||||||
];
|
|
||||||
const DELETE_ALLOWED_EMAILS = [
|
|
||||||
GERARDO_EMAIL,
|
|
||||||
"areyes@gomezleemarketing.com",
|
|
||||||
"iaracena@gomezleemarketing.com",
|
|
||||||
];
|
|
||||||
const DEFAULT_PROJECT_PARAMS: ProjectQueryParams = {
|
const DEFAULT_PROJECT_PARAMS: ProjectQueryParams = {
|
||||||
tab: "todos",
|
tab: "todos",
|
||||||
search: "",
|
search: "",
|
||||||
@@ -715,6 +723,25 @@ async function replaceProjectLinks(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
project: Pick<Project, "propuestaLinks" | "afLinks">,
|
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
|
const { error: deleteError } = await supabase
|
||||||
.from("tablero_cdc_project_links")
|
.from("tablero_cdc_project_links")
|
||||||
.delete()
|
.delete()
|
||||||
@@ -722,8 +749,6 @@ async function replaceProjectLinks(
|
|||||||
|
|
||||||
if (deleteError) throw deleteError;
|
if (deleteError) throw deleteError;
|
||||||
|
|
||||||
const linkRows = toLinkRows(projectId, project);
|
|
||||||
|
|
||||||
if (linkRows.length === 0) return;
|
if (linkRows.length === 0) return;
|
||||||
|
|
||||||
const { error: insertError } = await supabase.from("tablero_cdc_project_links").insert(linkRows);
|
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) {
|
function isMissingRpcFunction(error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
const message = getErrorMessage(error);
|
||||||
return (
|
return (
|
||||||
message.includes("tablero_cdc_get_projects_paginated") ||
|
message.includes("tablero_cdc_get_projects_paginated") ||
|
||||||
message.includes("Could not find the function")
|
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) {
|
function escapePostgrestFilterValue(value: string) {
|
||||||
return value.replace(/[%_]/g, "\\$&").replace(/,/g, " ");
|
return value.replace(/[%_]/g, "\\$&").replace(/,/g, " ");
|
||||||
}
|
}
|
||||||
@@ -1084,7 +1135,7 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
|
|||||||
notify();
|
notify();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isGerardo = await currentUserIsGerardo();
|
const canManageInternalPricing = await currentUserCanManageInternalPricing();
|
||||||
let result: ProjectLoadResult;
|
let result: ProjectLoadResult;
|
||||||
|
|
||||||
try {
|
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.",
|
"RPC tablero_cdc_get_projects_paginated no existe todavía. Usando consulta directa como respaldo temporal.",
|
||||||
rpcError,
|
rpcError,
|
||||||
);
|
);
|
||||||
result = await loadProjectsDirect(nextParams, isGerardo);
|
result = await loadProjectsDirect(nextParams, canManageInternalPricing);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestId !== loadRequestSeq) return;
|
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) {
|
export function useProjects(params?: ProjectQueryParams) {
|
||||||
const [, force] = useState(0);
|
const [, force] = useState(0);
|
||||||
const paramsTab = params?.tab;
|
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,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
DollarSign,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
LogOut,
|
LogOut,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -25,7 +28,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||||||
import { ProjectCard } from "@/components/board/ProjectCard";
|
import { ProjectCard } from "@/components/board/ProjectCard";
|
||||||
import { ProjectDialog } from "@/components/board/ProjectDialog";
|
import { ProjectDialog } from "@/components/board/ProjectDialog";
|
||||||
import { SearchableSelect } from "@/components/board/SearchableSelect";
|
import { 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 { useAppLists } from "@/lib/appLists";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { dedupeOptions } from "@/lib/optionUtils";
|
import { dedupeOptions } from "@/lib/optionUtils";
|
||||||
@@ -82,7 +86,30 @@ export default function BoardPage() {
|
|||||||
client: advancedFilters.client,
|
client: advancedFilters.client,
|
||||||
cm: advancedFilters.cm,
|
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 cmListOptions = Object.values(lists.buCm);
|
||||||
const countryFilterOptions = dedupeOptions([
|
const countryFilterOptions = dedupeOptions([
|
||||||
@@ -321,6 +348,24 @@ export default function BoardPage() {
|
|||||||
onFilterChange={setAdvancedFilter}
|
onFilterChange={setAdvancedFilter}
|
||||||
onClear={clearAdvancedFilters}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* 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({
|
function AdvancedProjectFilters({
|
||||||
filters,
|
filters,
|
||||||
activeCount,
|
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,
|
'description', p.description,
|
||||||
'status', p.status,
|
'status', p.status,
|
||||||
'internal_amount', case
|
'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)
|
then to_jsonb(p.internal_amount)
|
||||||
else 'null'::jsonb
|
else 'null'::jsonb
|
||||||
end,
|
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