feat: agregar control de visibilidad del total tarifado

This commit is contained in:
Isaac_Aracena
2026-06-22 10:05:21 -04:00
parent 5adf487bd0
commit 5f5b05e8fd
13 changed files with 1593 additions and 432 deletions
+18 -12
View File
@@ -9,7 +9,6 @@ import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import {
TARIFF_CATALOG,
TARIFF_SECTIONS,
defaultTariffAmount,
formatTariffRange,
@@ -17,6 +16,7 @@ import {
type TariffLevel,
type TariffWorkType,
} from "@/data/tariff";
import { useTariffCatalog } from "@/lib/tariffCatalog";
import type { ProjectPricingItemInput } from "@/lib/store";
import { cn } from "@/lib/utils";
@@ -63,27 +63,32 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const [manualName, setManualName] = useState<string>("");
const [manualDescription, setManualDescription] = useState<string>("");
const [manualAmount, setManualAmount] = useState<string>("");
const {
catalog: tariffCatalog,
loading: tariffLoading,
error: tariffError,
} = useTariffCatalog();
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
const serviceOptions = useMemo(
() =>
TARIFF_CATALOG.filter((item) => item.section === section).map(
(item) => `${item.category} · ${item.service}`,
),
[section],
tariffCatalog
.filter((item) => item.section === section)
.map((item) => `${item.category} · ${item.service}`),
[section, tariffCatalog],
);
const selectedItem = useMemo(() => {
const selectedLabel = serviceId;
return (
TARIFF_CATALOG.find(
tariffCatalog.find(
(item) =>
item.section === section && `${item.category} · ${item.service}` === selectedLabel,
) || null
);
}, [section, serviceId]);
}, [section, serviceId, tariffCatalog]);
const selectedWorkType = useMemo(() => {
if (!selectedItem) return null;
@@ -113,7 +118,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const onServiceChange = (label: string) => {
setServiceId(label);
const item = TARIFF_CATALOG.find(
const item = tariffCatalog.find(
(catalogItem) =>
catalogItem.section === section &&
`${catalogItem.category} · ${catalogItem.service}` === label,
@@ -225,16 +230,17 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
</div>
</div>
{error && (
{(error || tariffError) && (
<div className="mb-3 flex items-start gap-2 rounded-xl border border-amber-300/50 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<span>{error}</span>
<span>{error || tariffError}</span>
</div>
)}
{loading && (
{(loading || tariffLoading) && (
<div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
Cargando costos guardados
{loading ? "Cargando costos guardados…" : "Cargando tarifario…"}
</div>
)}
+11 -10
View File
@@ -12,16 +12,11 @@ import { supabase } from "@/lib/supabase";
const ALLOWED_DOMAIN = "gomezleemarketing.com";
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
const INTERNAL_PRICING_ALLOWED_EMAILS = [
GERARDO_EMAIL,
"areyes@gomezleemarketing.com",
"iaracena@gomezleemarketing.com",
];
const DELETE_ALLOWED_EMAILS = [
GERARDO_EMAIL,
"areyes@gomezleemarketing.com",
"iaracena@gomezleemarketing.com",
];
const JOSE_LEOPOLDO_EMAIL = "jgomez@gomezleemarketing.com";
const ISAAC_EMAIL = "iaracena@gomezleemarketing.com";
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, ISAAC_EMAIL];
const PRICING_SUMMARY_CONTROLLER_EMAILS = [GERARDO_EMAIL, JOSE_LEOPOLDO_EMAIL, ISAAC_EMAIL];
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
function isAllowedEmail(email: string | null | undefined): boolean {
if (!email) return false;
@@ -71,6 +66,7 @@ interface AuthContextValue {
isGerardo: boolean;
canDeleteProjects: boolean;
canManageInternalPricing: boolean;
canControlPricingSummary: boolean;
loginWithGoogle: () => Promise<void>;
logout: () => Promise<void>;
}
@@ -180,6 +176,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
[normalizedEmail],
);
const canControlPricingSummary = useMemo(
() => PRICING_SUMMARY_CONTROLLER_EMAILS.includes(normalizedEmail),
[normalizedEmail],
);
return (
<AuthContext.Provider
@@ -190,6 +190,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isGerardo,
canDeleteProjects,
canManageInternalPricing,
canControlPricingSummary,
loginWithGoogle,
logout,
}}
+127
View File
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
const PRICING_SUMMARY_SETTING_KEY = "pricing_summary_public";
type SettingRow = {
key: string;
value: {
enabled?: boolean;
} | null;
};
function parseVisibility(row: SettingRow | null | undefined) {
return Boolean(row?.value?.enabled);
}
async function fetchPricingSummaryPublicVisibility() {
const { data, error } = await supabase
.from("tablero_cdc_app_settings")
.select("key, value")
.eq("key", PRICING_SUMMARY_SETTING_KEY)
.maybeSingle();
if (error) throw error;
return parseVisibility(data as SettingRow | null);
}
async function savePricingSummaryPublicVisibility(enabled: boolean) {
const { error } = await supabase.rpc("tablero_cdc_set_pricing_summary_public", {
p_enabled: enabled,
});
if (error) throw error;
}
export function usePricingSummaryVisibility(enabled = true) {
const [isPublic, setIsPublic] = useState(false);
const [loading, setLoading] = useState(enabled);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!enabled) return false;
setLoading(true);
setError(null);
try {
const nextIsPublic = await fetchPricingSummaryPublicVisibility();
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo leer la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo leer la visibilidad del total tarifado.",
);
return false;
} finally {
setLoading(false);
}
}, [enabled]);
const setPublicVisibility = useCallback(
async (nextIsPublic: boolean) => {
setSaving(true);
setError(null);
try {
await savePricingSummaryPublicVisibility(nextIsPublic);
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo actualizar la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo actualizar la visibilidad del total tarifado.",
);
throw err;
} finally {
setSaving(false);
}
},
[],
);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!enabled) return;
const channel = supabase
.channel("tablero-cdc-pricing-summary-visibility")
.on(
"postgres_changes",
{
event: "*",
schema: "public",
table: "tablero_cdc_app_settings",
filter: `key=eq.${PRICING_SUMMARY_SETTING_KEY}`,
},
(payload) => {
const nextRow = (payload.new || payload.old) as SettingRow | null;
setIsPublic(parseVisibility(nextRow));
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [enabled]);
return {
isPublic,
loading,
saving,
error,
refresh,
setPublicVisibility,
};
}
+228 -15
View File
@@ -76,6 +76,12 @@ export type ProjectFilterOptions = {
cms: string[];
};
export type ProjectPricingSummary = {
totalAmount: number;
projectCount: number;
loadedAt: number | null;
};
export type ProjectQueryParams = {
tab: ProjectTab;
search: string;
@@ -150,6 +156,11 @@ type RpcProjectsResponse = {
filter_options?: Partial<ProjectFilterOptions> | null;
};
type RpcPricingSummaryResponse = {
total_amount?: number | string | null;
project_count?: number | string | null;
};
type DirectProjectQuery = {
or: (filters: string) => DirectProjectQuery;
not: (column: string, operator: string, value: unknown) => DirectProjectQuery;
@@ -160,19 +171,16 @@ type DirectProjectQuery = {
) => Promise<{ data: unknown[] | null; error: { message: string } | null; count: number | null }>;
};
type DirectProjectSummaryRow = {
id?: string | null;
internal_amount?: number | string | null;
};
const DEFAULT_COLOR: ColorId = "blue";
const OPEN_STATUS = "Activo";
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
const INTERNAL_PRICING_ALLOWED_EMAILS = [
GERARDO_EMAIL,
"areyes@gomezleemarketing.com",
"iaracena@gomezleemarketing.com",
];
const DELETE_ALLOWED_EMAILS = [
GERARDO_EMAIL,
"areyes@gomezleemarketing.com",
"iaracena@gomezleemarketing.com",
];
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
const DEFAULT_PROJECT_PARAMS: ProjectQueryParams = {
tab: "todos",
search: "",
@@ -715,6 +723,25 @@ async function replaceProjectLinks(
projectId: string,
project: Pick<Project, "propuestaLinks" | "afLinks">,
) {
const linkRows = toLinkRows(projectId, project);
try {
const { error: rpcError } = await supabase.rpc("tablero_cdc_replace_project_links", {
p_project_id: projectId,
p_links: linkRows.map(({ link_type, url, label }) => ({ link_type, url, label })),
});
if (rpcError) throw rpcError;
return;
} catch (error) {
if (!isMissingReplaceLinksRpc(error)) throw error;
console.warn(
"RPC tablero_cdc_replace_project_links no existe todavía. Usando reemplazo de links por delete+insert.",
error,
);
}
const { error: deleteError } = await supabase
.from("tablero_cdc_project_links")
.delete()
@@ -722,8 +749,6 @@ async function replaceProjectLinks(
if (deleteError) throw deleteError;
const linkRows = toLinkRows(projectId, project);
if (linkRows.length === 0) return;
const { error: insertError } = await supabase.from("tablero_cdc_project_links").insert(linkRows);
@@ -962,14 +987,40 @@ function getSelectColumns(includeInternalAmount: boolean) {
`;
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (error && typeof error === "object" && "message" in error) {
return String((error as { message?: unknown }).message || "");
}
return String(error || "");
}
function isMissingRpcFunction(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
const message = getErrorMessage(error);
return (
message.includes("tablero_cdc_get_projects_paginated") ||
message.includes("Could not find the function")
);
}
function isMissingReplaceLinksRpc(error: unknown) {
const message = getErrorMessage(error);
return (
message.includes("tablero_cdc_replace_project_links") ||
message.includes("Could not find the function")
);
}
function isMissingPricingSummaryRpc(error: unknown) {
const message = getErrorMessage(error);
return (
message.includes("tablero_cdc_get_pricing_summary") ||
message.includes("Could not find the function")
);
}
function escapePostgrestFilterValue(value: string) {
return value.replace(/[%_]/g, "\\$&").replace(/,/g, " ");
}
@@ -1084,7 +1135,7 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
notify();
try {
const isGerardo = await currentUserIsGerardo();
const canManageInternalPricing = await currentUserCanManageInternalPricing();
let result: ProjectLoadResult;
try {
@@ -1096,7 +1147,7 @@ async function loadProjects(params?: Partial<ProjectQueryParams>) {
"RPC tablero_cdc_get_projects_paginated no existe todavía. Usando consulta directa como respaldo temporal.",
rpcError,
);
result = await loadProjectsDirect(nextParams, isGerardo);
result = await loadProjectsDirect(nextParams, canManageInternalPricing);
}
if (requestId !== loadRequestSeq) return;
@@ -1307,6 +1358,168 @@ async function deleteProject(id: string) {
}
}
async function loadProjectPricingSummaryFromRpc(
params: ProjectQueryParams,
): Promise<ProjectPricingSummary> {
const { data, error } = await supabase.rpc("tablero_cdc_get_pricing_summary", {
p_tab: params.tab,
p_search: params.search,
p_country: params.country || "",
p_brand: params.brand || "",
p_client: params.client || "",
p_cm: params.cm || "",
});
if (error) throw error;
const result = (data || {}) as RpcPricingSummaryResponse;
const totalAmount = Number(result.total_amount || 0);
const projectCount = Number(result.project_count || 0);
return {
totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0,
projectCount: Number.isFinite(projectCount) ? projectCount : 0,
loadedAt: Date.now(),
};
}
async function loadProjectPricingSummaryDirect(
params: ProjectQueryParams,
): Promise<ProjectPricingSummary> {
const pageSize = 1000;
let from = 0;
let totalCount: number | null = null;
let totalAmount = 0;
while (totalCount === null || from < totalCount) {
let query = supabase
.from("tablero_cdc_projects")
.select("id, internal_amount", { count: "exact" })
.order("created_at", { ascending: false }) as unknown as DirectProjectQuery;
query = applyDirectFilters(query, params);
const { data, error, count } = await query.range(from, from + pageSize - 1);
if (error) throw error;
if (totalCount === null) {
totalCount = count ?? (data || []).length;
}
const rows = (data || []) as DirectProjectSummaryRow[];
rows.forEach((row) => {
const amount = Number(row.internal_amount ?? 0);
if (Number.isFinite(amount) && amount > 0) {
totalAmount += amount;
}
});
if (rows.length < pageSize) break;
from += pageSize;
}
return {
totalAmount,
projectCount: totalCount ?? 0,
loadedAt: Date.now(),
};
}
async function loadProjectPricingSummary(
params?: Partial<ProjectQueryParams>,
): Promise<ProjectPricingSummary> {
const nextParams = normalizeProjectParams({
...params,
page: 1,
pageSize: 1000,
});
try {
return await loadProjectPricingSummaryFromRpc(nextParams);
} catch (rpcError) {
if (!isMissingPricingSummaryRpc(rpcError)) throw rpcError;
console.warn(
"RPC tablero_cdc_get_pricing_summary no existe todavía. Usando suma directa como respaldo temporal.",
rpcError,
);
return loadProjectPricingSummaryDirect(nextParams);
}
}
export function useProjectPricingSummary(params: ProjectQueryParams, enabled: boolean) {
const [summary, setSummary] = useState<ProjectPricingSummary>({
totalAmount: 0,
projectCount: 0,
loadedAt: null,
});
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const paramsTab = params.tab;
const paramsSearch = params.search;
const paramsCountry = params.country;
const paramsBrand = params.brand;
const paramsClient = params.client;
const paramsCm = params.cm;
useEffect(() => {
if (!enabled) {
setSummary({ totalAmount: 0, projectCount: 0, loadedAt: null });
setSummaryError(null);
setSummaryLoading(false);
return;
}
let cancelled = false;
setSummaryLoading(true);
setSummaryError(null);
loadProjectPricingSummary({
tab: paramsTab,
search: paramsSearch,
country: paramsCountry,
brand: paramsBrand,
client: paramsClient,
cm: paramsCm,
page: 1,
pageSize: 1000,
})
.then((result) => {
if (!cancelled) setSummary(result);
})
.catch((error) => {
if (cancelled) return;
console.error("No se pudo calcular el total tarifado filtrado:", error);
setSummaryError(
error instanceof Error
? error.message
: "No se pudo calcular el total tarifado filtrado.",
);
})
.finally(() => {
if (!cancelled) setSummaryLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled, paramsTab, paramsSearch, paramsCountry, paramsBrand, paramsClient, paramsCm]);
return {
summary,
loading: summaryLoading,
error: summaryError,
refresh: () =>
loadProjectPricingSummary(params).then((result) => {
setSummary(result);
return result;
}),
};
}
export function useProjects(params?: ProjectQueryParams) {
const [, force] = useState(0);
const paramsTab = params?.tab;
+366
View File
@@ -0,0 +1,366 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import {
TARIFF_CATALOG,
WORK_TYPE_REFERENCE,
type TariffCatalogItem,
type TariffLevel,
type TariffWorkType,
} from "@/data/tariff";
export type TariffCatalogSource = "supabase" | "fallback";
export type TariffCatalogRow = {
id: string;
catalog_item_id: string;
section: "grafico" | "estrategia";
category: string;
service: string;
notes: string;
work_type_id: string;
work_type_label: string;
work_type_short_label: string;
hour_reference: string;
level_id: string;
level_label: string;
reference: string;
reference_min: number | null;
reference_max: number | null;
level_hint: string;
sort_order: number;
is_active: boolean;
created_at?: string | null;
updated_at?: string | null;
};
export type TariffCatalogUpsertInput = Omit<TariffCatalogRow, "created_at" | "updated_at">;
type TariffCatalogState = {
rows: TariffCatalogRow[];
catalog: TariffCatalogItem[];
source: TariffCatalogSource;
loading: boolean;
error: string | null;
};
const TABLE_NAME = "tablero_cdc_tariff_catalog";
const DEFAULT_WORK_TYPE_LABEL = "Referencia";
const DEFAULT_LEVEL_LABEL = "Precio único";
let cache: TariffCatalogState = {
rows: flattenTariffCatalog(TARIFF_CATALOG),
catalog: TARIFF_CATALOG,
source: "fallback",
loading: false,
error: null,
};
let initialized = false;
let listeners: Array<() => void> = [];
let loadRequestSeq = 0;
function notify() {
listeners.forEach((listener) => listener());
}
function asNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value.trim());
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function slugify(value: string) {
return String(value || "")
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function rowId(itemId: string, workTypeId: string, levelId: string) {
return [itemId, workTypeId, levelId].map(slugify).filter(Boolean).join("__");
}
function normalizeDbRow(row: Record<string, unknown>): TariffCatalogRow {
const section = String(row.section || "grafico") === "estrategia" ? "estrategia" : "grafico";
const category = String(row.category || "").trim();
const service = String(row.service || "").trim();
const catalogItemId = String(
row.catalog_item_id || slugify(`${section}-${category}-${service}`),
).trim();
const workTypeLabel = String(row.work_type_label || DEFAULT_WORK_TYPE_LABEL).trim();
const levelLabel = String(row.level_label || DEFAULT_LEVEL_LABEL).trim();
const workTypeId = String(
row.work_type_id || slugify(workTypeLabel) || WORK_TYPE_REFERENCE,
).trim();
const levelId = String(row.level_id || slugify(levelLabel) || "project").trim();
return {
id: String(row.id || rowId(catalogItemId, workTypeId, levelId)).trim(),
catalog_item_id: catalogItemId,
section,
category,
service,
notes: String(row.notes || "").trim(),
work_type_id: workTypeId,
work_type_label: workTypeLabel,
work_type_short_label: String(row.work_type_short_label || workTypeLabel).trim(),
hour_reference: String(row.hour_reference || "").trim(),
level_id: levelId,
level_label: levelLabel,
reference: String(row.reference || "").trim(),
reference_min: asNumber(row.reference_min),
reference_max: asNumber(row.reference_max),
level_hint: String(row.level_hint || "").trim(),
sort_order: Number(row.sort_order || 0),
is_active: row.is_active !== false,
created_at: typeof row.created_at === "string" ? row.created_at : null,
updated_at: typeof row.updated_at === "string" ? row.updated_at : null,
};
}
export function buildTariffCatalogFromRows(rows: TariffCatalogRow[]): TariffCatalogItem[] {
const itemMap = new Map<string, TariffCatalogItem>();
const workTypeMaps = new Map<string, Map<string, TariffWorkType>>();
const activeRows = rows
.filter((row) => row.is_active)
.sort((a, b) => a.sort_order - b.sort_order || a.category.localeCompare(b.category));
for (const row of activeRows) {
const itemId = row.catalog_item_id || slugify(`${row.section}-${row.category}-${row.service}`);
let item = itemMap.get(itemId);
if (!item) {
item = {
id: itemId,
section: row.section,
category: row.category,
service: row.service,
notes: row.notes,
workTypes: [],
};
itemMap.set(itemId, item);
workTypeMaps.set(itemId, new Map());
}
const workTypeKey = row.work_type_id || WORK_TYPE_REFERENCE;
const workTypeMap = workTypeMaps.get(itemId)!;
let workType = workTypeMap.get(workTypeKey);
if (!workType) {
workType = {
id: workTypeKey,
label: row.work_type_label || DEFAULT_WORK_TYPE_LABEL,
shortLabel: row.work_type_short_label || row.work_type_label || DEFAULT_WORK_TYPE_LABEL,
hourReference: row.hour_reference || undefined,
levels: [],
};
workTypeMap.set(workTypeKey, workType);
item.workTypes.push(workType);
}
const level: TariffLevel = {
id: row.level_id || "project",
label: row.level_label || DEFAULT_LEVEL_LABEL,
reference: row.reference || "",
min: row.reference_min ?? undefined,
max: row.reference_max ?? undefined,
hint: row.level_hint || undefined,
};
if (!workType.levels.some((existing) => existing.id === level.id)) {
workType.levels.push(level);
}
}
return Array.from(itemMap.values());
}
export function flattenTariffCatalog(catalog: TariffCatalogItem[]): TariffCatalogRow[] {
const rows: TariffCatalogRow[] = [];
catalog.forEach((item, itemIndex) => {
item.workTypes.forEach((workType, workTypeIndex) => {
workType.levels.forEach((level, levelIndex) => {
rows.push({
id: rowId(item.id, workType.id, level.id),
catalog_item_id: item.id,
section: item.section,
category: item.category,
service: item.service,
notes: item.notes || "",
work_type_id: workType.id,
work_type_label: workType.label,
work_type_short_label: workType.shortLabel,
hour_reference: workType.hourReference || "",
level_id: level.id,
level_label: level.label,
reference: level.reference,
reference_min: level.min ?? null,
reference_max: level.max ?? null,
level_hint: level.hint || "",
sort_order: itemIndex * 1000 + workTypeIndex * 100 + levelIndex,
is_active: true,
});
});
});
});
return rows;
}
export function buildTariffRowId(
input: Pick<TariffCatalogRow, "catalog_item_id" | "work_type_id" | "level_id">,
) {
return rowId(input.catalog_item_id, input.work_type_id, input.level_id);
}
function missingTariffTable(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return (
message.includes(TABLE_NAME) ||
message.includes("Could not find the table") ||
message.includes("relation") ||
message.includes("does not exist")
);
}
export async function loadTariffCatalog(force = false): Promise<TariffCatalogState> {
if (initialized && !force) return cache;
const requestId = (loadRequestSeq += 1);
cache = { ...cache, loading: true, error: null };
notify();
try {
const { data, error } = await supabase
.from(TABLE_NAME)
.select(
"id, catalog_item_id, section, category, service, notes, work_type_id, work_type_label, work_type_short_label, hour_reference, level_id, level_label, reference, reference_min, reference_max, level_hint, sort_order, is_active, created_at, updated_at",
)
.order("sort_order", { ascending: true })
.order("category", { ascending: true });
if (error) throw error;
const dbRows = (data || []).map((row) => normalizeDbRow(row as Record<string, unknown>));
const fallbackRows = flattenTariffCatalog(TARIFF_CATALOG);
const rowMap = new Map<string, TariffCatalogRow>();
fallbackRows.forEach((row) => rowMap.set(row.id, row));
dbRows.forEach((row) => rowMap.set(row.id, row));
const mergedRows = Array.from(rowMap.values()).sort(
(a, b) => a.sort_order - b.sort_order || a.category.localeCompare(b.category),
);
const catalog = buildTariffCatalogFromRows(mergedRows);
if (requestId === loadRequestSeq) {
cache = {
rows: mergedRows,
catalog,
source: dbRows.length ? "supabase" : "fallback",
loading: false,
error: dbRows.length
? null
: "La tabla del tarifario está vacía. Se está usando el tarifario base de la app como respaldo.",
};
initialized = true;
notify();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
const friendlyMessage = missingTariffTable(error)
? "Falta crear la tabla tablero_cdc_tariff_catalog en Supabase. Se está usando el tarifario base de la app como respaldo."
: message || "No se pudo cargar el tarifario dinámico.";
if (requestId === loadRequestSeq) {
cache = {
rows: flattenTariffCatalog(TARIFF_CATALOG),
catalog: TARIFF_CATALOG,
source: "fallback",
loading: false,
error: friendlyMessage,
};
initialized = true;
notify();
}
}
return cache;
}
export async function upsertTariffCatalogRow(input: TariffCatalogUpsertInput) {
const row = normalizeDbRow(input as unknown as Record<string, unknown>);
const id = row.id || buildTariffRowId(row);
const { error } = await supabase.from(TABLE_NAME).upsert(
{
id,
catalog_item_id: row.catalog_item_id,
section: row.section,
category: row.category,
service: row.service,
notes: row.notes,
work_type_id: row.work_type_id,
work_type_label: row.work_type_label,
work_type_short_label: row.work_type_short_label,
hour_reference: row.hour_reference,
level_id: row.level_id,
level_label: row.level_label,
reference: row.reference,
reference_min: row.reference_min,
reference_max: row.reference_max,
level_hint: row.level_hint,
sort_order: row.sort_order,
is_active: row.is_active,
},
{ onConflict: "id" },
);
if (error) throw error;
initialized = false;
await loadTariffCatalog(true);
}
export async function setTariffCatalogRowActive(row: TariffCatalogRow, isActive: boolean) {
const { error } = await supabase
.from(TABLE_NAME)
.update({ is_active: isActive })
.eq("id", row.id);
if (error) throw error;
initialized = false;
await loadTariffCatalog(true);
}
export function useTariffCatalog() {
const [, force] = useState(0);
useEffect(() => {
const listener = () => force((value) => value + 1);
listeners.push(listener);
if (!initialized && !cache.loading) {
void loadTariffCatalog();
}
return () => {
listeners = listeners.filter((current) => current !== listener);
};
}, []);
return {
...cache,
refresh: () => loadTariffCatalog(true),
};
}
+142 -2
View File
@@ -4,6 +4,9 @@ import {
ChevronLeft,
ChevronRight,
AlertCircle,
DollarSign,
Eye,
EyeOff,
LayoutGrid,
LogOut,
Plus,
@@ -25,7 +28,8 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { useProjects, type Project } from "@/lib/store";
import { useProjectPricingSummary, useProjects, type Project } from "@/lib/store";
import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
@@ -82,7 +86,30 @@ export default function BoardPage() {
client: advancedFilters.client,
cm: advancedFilters.cm,
});
const { user, isGerardo, logout } = useAuth();
const { user, isGerardo, canControlPricingSummary, logout } = useAuth();
const pricingSummaryParams = {
tab: filter,
search: query,
page: 1,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
} as const;
const {
isPublic: isPricingSummaryPublic,
loading: pricingSummaryVisibilityLoading,
saving: pricingSummaryVisibilitySaving,
error: pricingSummaryVisibilityError,
setPublicVisibility: setPricingSummaryPublicVisibility,
} = usePricingSummaryVisibility(Boolean(user));
const canViewPricingSummary = canControlPricingSummary || isPricingSummaryPublic;
const {
summary: pricingSummary,
loading: pricingSummaryLoading,
error: pricingSummaryError,
} = useProjectPricingSummary(pricingSummaryParams, canViewPricingSummary);
const cmListOptions = Object.values(lists.buCm);
const countryFilterOptions = dedupeOptions([
@@ -321,6 +348,24 @@ export default function BoardPage() {
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
{canViewPricingSummary && (
<PricingSummaryCard
totalAmount={pricingSummary.totalAmount}
projectCount={pricingSummary.projectCount}
loading={pricingSummaryLoading || pricingSummaryVisibilityLoading}
error={pricingSummaryError || pricingSummaryVisibilityError}
hasFilters={
filter !== "todos" || query.trim().length > 0 || activeAdvancedFilterCount > 0
}
canControlVisibility={canControlPricingSummary}
isPublic={isPricingSummaryPublic}
visibilitySaving={pricingSummaryVisibilitySaving}
onToggleVisibility={() =>
setPricingSummaryPublicVisibility(!isPricingSummaryPublic).catch(() => undefined)
}
/>
)}
</div>
{/* Grid */}
@@ -385,6 +430,101 @@ export default function BoardPage() {
);
}
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(value || 0);
}
function PricingSummaryCard({
totalAmount,
projectCount,
loading,
error,
hasFilters,
canControlVisibility,
isPublic,
visibilitySaving,
onToggleVisibility,
}: {
totalAmount: number;
projectCount: number;
loading: boolean;
error: string | null;
hasFilters: boolean;
canControlVisibility: boolean;
isPublic: boolean;
visibilitySaving: boolean;
onToggleVisibility: () => void;
}) {
return (
<div className="rounded-2xl border border-primary/15 bg-primary/5 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<DollarSign className="h-4 w-4" />
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{hasFilters ? "Total tarifado filtrado" : "Total tarifado global"}
</p>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${
isPublic
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-primary/20 bg-card text-primary"
}`}
>
{isPublic ? "Visible para todos" : "Privado"}
</span>
</div>
<p className="mt-0.5 text-2xl font-semibold tracking-tight text-foreground">
{loading ? "Calculando…" : formatCurrency(totalAmount)}
</p>
<p className="text-xs text-muted-foreground">
{error
? `No se pudo calcular el total: ${error}`
: `${projectCount} proyecto(s) incluidos según los filtros actuales.`}
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
<div className="rounded-full border border-primary/20 bg-card px-3 py-1 text-xs font-medium text-primary">
{hasFilters ? "Según filtros activos" : "Sin filtros aplicados"}
</div>
{canControlVisibility && (
<Button
type="button"
variant={isPublic ? "secondary" : "outline"}
size="sm"
className="gap-1.5 rounded-full"
disabled={visibilitySaving}
onClick={onToggleVisibility}
title={
isPublic
? "Ocultar este total para los demás usuarios"
: "Mostrar este total temporalmente a todos los usuarios"
}
>
{isPublic ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{visibilitySaving
? "Actualizando…"
: isPublic
? "Ocultar a todos"
: "Mostrar a todos"}
</Button>
)}
</div>
</div>
</div>
);
}
function AdvancedProjectFilters({
filters,
activeCount,