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