feat: actualizar Tablero CDC con administracion de listas

This commit is contained in:
2026-08-13 18:21:34 -04:00
parent f4f6ee23df
commit 0567c666d3
65 changed files with 8120 additions and 1863 deletions
+303 -51
View File
@@ -4,10 +4,14 @@ import {
ChevronLeft,
ChevronRight,
AlertCircle,
BadgeDollarSign,
DollarSign,
Eye,
EyeOff,
LayoutGrid,
ListChecks,
Inbox,
FileX2,
LogOut,
Plus,
RefreshCw,
@@ -27,17 +31,30 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { ProjectCard } from "@/components/board/ProjectCard";
import { ProjectDialog } from "@/components/board/ProjectDialog";
import { ProjectTimeDialog } from "@/components/board/ProjectTimeDialog";
import { BriefReviewCard } from "@/components/board/BriefReviewCard";
import { SearchableSelect } from "@/components/board/SearchableSelect";
import { useProjectPricingSummary, useProjects, type Project } from "@/lib/store";
import {
useProjectPricingSummary,
useProjects,
type Project,
type ProjectTab,
} from "@/lib/store";
import { useBriefInbox, useBriefUnreadCount, type BriefReviewStatus } from "@/lib/briefInbox";
import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
import { expandCountryOptions, expandMultiValueOptions } from "@/lib/filterNormalization";
import { GLMLogo } from "@/components/GLMLogo";
import { TariffManagerDialog } from "@/components/tariff/TariffManagerDialog";
import { ListManagerDialog } from "@/components/lists/ListManagerDialog";
/** Extract up-to-2-letter initials from a display name or email */
const PROJECTS_PER_PAGE = 24;
type BoardView = "nuevos" | ProjectTab | "no_aprobados";
type AdvancedFilters = {
country: string;
brand: string;
@@ -66,37 +83,61 @@ export default function BoardPage() {
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Project | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"todos" | "abiertos" | "cerrados">("todos");
const [filter, setFilter] = useState<BoardView>("todos");
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>(EMPTY_ADVANCED_FILTERS);
const [pageByFilter, setPageByFilter] = useState({
const [pageByFilter, setPageByFilter] = useState<Record<BoardView, number>>({
nuevos: 1,
todos: 1,
abiertos: 1,
cerrados: 1,
no_aprobados: 1,
});
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [tariffManagerOpen, setTariffManagerOpen] = useState(false);
const [listManagerOpen, setListManagerOpen] = useState(false);
const [timeProject, setTimeProject] = useState<Project | null>(null);
const [reviewingBriefId, setReviewingBriefId] = useState<string | null>(null);
const isBriefReviewView = filter === "nuevos" || filter === "no_aprobados";
const briefStatus: BriefReviewStatus = filter === "no_aprobados" ? "rejected" : "new";
const projectFilter: ProjectTab = isBriefReviewView ? "todos" : filter;
const currentPage = pageByFilter[filter];
const { lists } = useAppLists();
const { projects, totalProjects, filterOptions, loading, error, refresh, remove } = useProjects({
tab: filter,
tab: projectFilter,
search: isBriefReviewView ? "" : query,
page: isBriefReviewView ? pageByFilter[projectFilter] : currentPage,
pageSize: PROJECTS_PER_PAGE,
country: isBriefReviewView ? "" : advancedFilters.country,
brand: isBriefReviewView ? "" : advancedFilters.brand,
client: isBriefReviewView ? "" : advancedFilters.client,
cm: isBriefReviewView ? "" : advancedFilters.cm,
});
const briefInbox = useBriefInbox({
status: briefStatus,
search: query,
page: currentPage,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
enabled: isBriefReviewView,
});
const { user, isGerardo, canControlPricingSummary, logout } = useAuth();
const {
user,
isGerardo,
canManageInternalPricing,
canControlPricingSummary,
canManageTariffCatalog,
logout,
} = useAuth();
const briefUnread = useBriefUnreadCount(Boolean(user));
const pricingSummaryParams = {
tab: filter,
search: query,
tab: projectFilter,
search: isBriefReviewView ? "" : query,
page: 1,
pageSize: PROJECTS_PER_PAGE,
country: advancedFilters.country,
brand: advancedFilters.brand,
client: advancedFilters.client,
cm: advancedFilters.cm,
country: isBriefReviewView ? "" : advancedFilters.country,
brand: isBriefReviewView ? "" : advancedFilters.brand,
client: isBriefReviewView ? "" : advancedFilters.client,
cm: isBriefReviewView ? "" : advancedFilters.cm,
} as const;
const {
isPublic: isPricingSummaryPublic,
@@ -105,7 +146,8 @@ export default function BoardPage() {
error: pricingSummaryVisibilityError,
setPublicVisibility: setPricingSummaryPublicVisibility,
} = usePricingSummaryVisibility(Boolean(user));
const canViewPricingSummary = canControlPricingSummary || isPricingSummaryPublic;
const canViewPricingSummary =
!isBriefReviewView && (canControlPricingSummary || isPricingSummaryPublic);
const {
summary: pricingSummary,
loading: pricingSummaryLoading,
@@ -115,31 +157,32 @@ export default function BoardPage() {
const cmListOptions = Object.values(lists.buCm);
const countryFilterOptions = dedupeOptions([
...lists.bus,
...filterOptions.countries,
...expandCountryOptions(filterOptions.countries, lists.bus),
advancedFilters.country,
]);
const brandFilterOptions = dedupeOptions([
...lists.marcas,
...filterOptions.brands,
...expandMultiValueOptions(filterOptions.brands, lists.marcas),
advancedFilters.brand,
]);
const clientFilterOptions = dedupeOptions([
...lists.clientes,
...filterOptions.clients,
...expandMultiValueOptions(filterOptions.clients, lists.clientes),
advancedFilters.client,
]);
const cmFilterOptions = dedupeOptions([
...cmListOptions,
...filterOptions.cms,
...expandMultiValueOptions(filterOptions.cms, cmListOptions),
advancedFilters.cm,
]);
const activeAdvancedFilterCount = Object.values(advancedFilters).filter(Boolean).length;
const totalPages = Math.max(1, Math.ceil(totalProjects / PROJECTS_PER_PAGE));
const currentTotalItems = isBriefReviewView ? briefInbox.totalItems : totalProjects;
const totalPages = Math.max(1, Math.ceil(currentTotalItems / PROJECTS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages);
const pageStart = (safePage - 1) * PROJECTS_PER_PAGE;
const visibleStart = totalProjects === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, totalProjects);
const visibleStart = currentTotalItems === 0 ? 0 : pageStart + 1;
const visibleEnd = Math.min(pageStart + PROJECTS_PER_PAGE, currentTotalItems);
useEffect(() => {
setPageByFilter((prev) => ({ ...prev, [filter]: 1 }));
@@ -158,6 +201,18 @@ export default function BoardPage() {
}
}, [currentPage, filter, totalPages]);
// Entering "Nuevos" clears only this user's pending notification. If a new
// brief arrives while this view is open, Realtime increments unreadCount and
// this effect immediately marks that currently visible queue as seen again.
// Other teammates keep their own independent unread state.
useEffect(() => {
if (filter !== "nuevos" || !user || briefUnread.unreadCount <= 0) return;
void briefUnread.markAllSeen().catch((markError) => {
console.error("Error marcando briefs como vistos:", markError);
});
}, [filter, user?.id, briefUnread.unreadCount, briefUnread.markAllSeen]);
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), totalPages);
@@ -212,6 +267,61 @@ export default function BoardPage() {
}
};
const approveBrief = async (briefId: string, title: string, fromRejected = false) => {
const confirmed = window.confirm(
fromRejected
? `¿Aprobar el brief rechazado "${title || "Sin título"}"?\n\nSe recuperará de Rechazados, se creará como proyecto Activo y aparecerá en Todos y Activos.`
: `¿Aprobar el brief "${title || "Sin título"}"?\n\nSe creará como proyecto Activo y aparecerá en Todos y Activos.`,
);
if (!confirmed || reviewingBriefId) return;
setReviewingBriefId(briefId);
try {
const result = await briefInbox.approve(briefId);
await refresh();
if (result.sheetSyncStatus === "synced") {
window.alert("Brief aprobado. El proyecto quedó Activo y sincronizado con Google Sheet.");
} else {
window.alert(result.sheetSyncMessage);
}
} catch (reviewError) {
console.error("Error aprobando brief:", reviewError);
window.alert(
reviewError instanceof Error
? reviewError.message
: "No se pudo aprobar el brief. Revisa Supabase e intenta de nuevo.",
);
} finally {
setReviewingBriefId(null);
}
};
const rejectBrief = async (briefId: string, title: string) => {
const confirmed = window.confirm(
`¿Mover el brief "${title || "Sin título"}" a Rechazados?\n\nNo se creará ningún proyecto en el tablero general.`,
);
if (!confirmed || reviewingBriefId) return;
setReviewingBriefId(briefId);
try {
await briefInbox.reject(briefId);
} catch (reviewError) {
console.error("Error marcando brief como rechazado:", reviewError);
window.alert(
reviewError instanceof Error
? reviewError.message
: "No se pudo mover el brief a Rechazados. Revisa Supabase e intenta de nuevo.",
);
} finally {
setReviewingBriefId(null);
}
};
const displayName = user?.displayName ?? user?.email ?? "Usuario";
const userRole = isGerardo ? "Jefe CDC" : "Usuario CDC";
const userInitials = initials(user?.displayName, user?.email);
@@ -225,7 +335,7 @@ export default function BoardPage() {
<div className="flex items-center gap-4">
<GLMLogo size="sm" />
<div className="h-7 w-px bg-border" />
<h1 className="text-[15px] font-semibold tracking-tight text-[#4F758B]">Tablero CDC</h1>
<h1 className="text-[15px] font-semibold tracking-tight text-[#4F758B]">CDC Project Management</h1>
</div>
{/* Search */}
@@ -234,7 +344,7 @@ export default function BoardPage() {
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar proyecto, cliente, marca…"
placeholder={isBriefReviewView ? "Buscar brief, cliente, marca…" : "Buscar proyecto, cliente, marca…"}
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/>
</div>
@@ -286,6 +396,30 @@ export default function BoardPage() {
</DropdownMenuContent>
</DropdownMenu>
{/* Tariff administration — controlled by Supabase permission */}
{canManageTariffCatalog && (
<Button
type="button"
variant="outline"
onClick={() => setTariffManagerOpen(true)}
className="gap-1.5 h-9 rounded-full px-4 shadow-sm"
>
<BadgeDollarSign className="w-4 h-4" /> Tarifario
</Button>
)}
{/* App lists administration — same trusted admin permission as Tarifario */}
{canManageTariffCatalog && (
<Button
type="button"
variant="outline"
onClick={() => setListManagerOpen(true)}
className="gap-1.5 h-9 rounded-full px-4 shadow-sm"
>
<ListChecks className="w-4 h-4" /> Listas
</Button>
)}
{/* New project button */}
<Button onClick={openNew} className="gap-1.5 h-9 rounded-full px-4 shadow-sm">
<Plus className="w-4 h-4" /> Nuevo
@@ -297,28 +431,54 @@ export default function BoardPage() {
<div className="max-w-[1400px] mx-auto px-6 pt-6 pb-3 space-y-3">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h2 className="pl-3 text-2xl font-semibold tracking-tight">Proyectos</h2>
<h2 className="text-2xl font-semibold tracking-tight">
{filter === "nuevos"
? "Briefs por revisar"
: filter === "no_aprobados"
? "Briefs rechazados"
: "Proyectos"}
</h2>
{filter === "nuevos" && (
<p className="mt-1 text-xs text-muted-foreground">
Los briefs de CDC Brief llegan aquí antes de convertirse en proyectos Activos.
</p>
)}
{filter === "no_aprobados" && (
<p className="mt-1 text-xs text-muted-foreground">
Briefs revisados que no se incorporaron al tablero general.
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="inline-flex bg-muted/60 rounded-full p-1 text-xs">
<div className="inline-flex flex-wrap bg-muted/60 rounded-full p-1 text-xs">
{(
[
["todos", "Todos"],
["abiertos", "Activos"],
["cerrados", "Cerrados"],
["nuevos", "Nuevos"],
["no_aprobados", "Rechazados"],
] as const
).map(([k, l]) => (
<button
key={k}
onClick={() => setFilter(k)}
className={`px-3.5 py-1.5 rounded-full transition-colors font-medium ${
className={`inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full transition-colors font-medium ${
filter === k
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{l}
<span>{l}</span>
{k === "nuevos" && briefUnread.unreadCount > 0 && (
<span
className="inline-flex min-w-[18px] h-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold leading-none text-white shadow-sm"
aria-label={`${briefUnread.unreadCount} briefs nuevos sin revisar`}
>
{briefUnread.unreadCount > 99 ? "99+" : briefUnread.unreadCount}
</span>
)}
</button>
))}
</div>
@@ -330,21 +490,23 @@ export default function BoardPage() {
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar proyecto, cliente, marca…"
placeholder={isBriefReviewView ? "Buscar brief, cliente, marca…" : "Buscar proyecto, cliente, marca…"}
className="pl-9 h-9 bg-muted/40 border-transparent focus-visible:bg-card focus-visible:border-border"
/>
</div>
<AdvancedProjectFilters
filters={advancedFilters}
activeCount={activeAdvancedFilterCount}
countryOptions={countryFilterOptions}
brandOptions={brandFilterOptions}
clientOptions={clientFilterOptions}
cmOptions={cmFilterOptions}
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
{!isBriefReviewView && (
<AdvancedProjectFilters
filters={advancedFilters}
activeCount={activeAdvancedFilterCount}
countryOptions={countryFilterOptions}
brandOptions={brandFilterOptions}
clientOptions={clientFilterOptions}
cmOptions={cmFilterOptions}
onFilterChange={setAdvancedFilter}
onClear={clearAdvancedFilters}
/>
)}
{canViewPricingSummary && (
<PricingSummaryCard
@@ -367,50 +529,86 @@ export default function BoardPage() {
{/* Grid */}
<main className="max-w-[1400px] mx-auto px-6 pb-16">
{error && (
{(isBriefReviewView ? briefInbox.error : error) && (
<div className="mb-4 flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive sm:flex-row sm:items-center sm:justify-between">
<span className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
<span className="font-medium">No se pudieron cargar los proyectos.</span> {error}
<span className="font-medium">
{isBriefReviewView
? "No se pudieron cargar los briefs."
: "No se pudieron cargar los proyectos."}
</span>{" "}
{isBriefReviewView ? briefInbox.error : error}
</span>
</span>
<Button variant="outline" size="sm" onClick={() => void refresh()} className="gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => void (isBriefReviewView ? briefInbox.refresh() : refresh())}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" /> Reintentar
</Button>
</div>
)}
{loading && projects.length === 0 ? (
{isBriefReviewView ? (
briefInbox.loading && briefInbox.items.length === 0 ? (
<LoadingState />
) : briefInbox.items.length === 0 ? (
<BriefEmptyState mode={briefStatus} query={query} />
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{briefInbox.items.map((item) => (
<BriefReviewCard
key={item.id}
item={item}
mode={briefStatus}
busy={reviewingBriefId === item.id}
onApprove={() => void approveBrief(item.id, item.title, briefStatus === "rejected")}
onReject={() => void rejectBrief(item.id, item.title)}
/>
))}
</div>
)
) : loading && projects.length === 0 ? (
<LoadingState />
) : projects.length === 0 ? (
<EmptyState
onNew={openNew}
hasProjects={
totalProjects > 0 ||
filter !== "todos" ||
projectFilter !== "todos" ||
query.trim().length > 0 ||
activeAdvancedFilterCount > 0
}
filter={filter}
filter={projectFilter}
query={query}
activeAdvancedFilterCount={activeAdvancedFilterCount}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projects.map((p) => (
<ProjectCard key={p.id} project={p} onClick={() => openEdit(p)} />
<ProjectCard
key={p.id}
project={p}
onClick={() => openEdit(p)}
onTimeClick={() => setTimeProject(p)}
showInternalAmount={canManageInternalPricing}
/>
))}
</div>
)}
{totalProjects > PROJECTS_PER_PAGE && (
{currentTotalItems > PROJECTS_PER_PAGE && (
<PaginationControls
currentPage={safePage}
totalPages={totalPages}
visibleStart={visibleStart}
visibleEnd={visibleEnd}
totalItems={totalProjects}
totalItems={currentTotalItems}
itemLabel={isBriefReviewView ? "briefs" : "proyectos"}
onPageChange={goToPage}
/>
)}
@@ -423,6 +621,27 @@ export default function BoardPage() {
onDelete={deleteProject}
deleting={!!editing && deletingProjectId === editing.id}
/>
<ProjectTimeDialog
open={!!timeProject}
onOpenChange={(nextOpen) => {
if (!nextOpen) setTimeProject(null);
}}
project={timeProject}
/>
{canManageTariffCatalog && (
<>
<TariffManagerDialog
open={tariffManagerOpen}
onOpenChange={setTariffManagerOpen}
/>
<ListManagerDialog
open={listManagerOpen}
onOpenChange={setListManagerOpen}
/>
</>
)}
</div>
);
}
@@ -632,6 +851,7 @@ function PaginationControls({
visibleStart,
visibleEnd,
totalItems,
itemLabel = "proyectos",
onPageChange,
}: {
currentPage: number;
@@ -639,6 +859,7 @@ function PaginationControls({
visibleStart: number;
visibleEnd: number;
totalItems: number;
itemLabel?: string;
onPageChange: (page: number) => void;
}) {
const pages = getVisiblePages(currentPage, totalPages);
@@ -649,7 +870,7 @@ function PaginationControls({
<span className="font-medium text-foreground">
{visibleStart}-{visibleEnd}
</span>{" "}
de <span className="font-medium text-foreground">{totalItems}</span> proyectos
de <span className="font-medium text-foreground">{totalItems}</span> {itemLabel}
</p>
<div className="flex items-center gap-1.5">
@@ -757,6 +978,37 @@ function LoadingState() {
);
}
function BriefEmptyState({ mode, query }: { mode: BriefReviewStatus; query: string }) {
const hasSearch = query.trim().length > 0;
const isRejected = mode === "rejected";
return (
<div className="rounded-2xl border border-dashed border-border bg-card/40 py-24 text-center">
<div
className={`mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl ${
isRejected ? "bg-red-50 text-red-600" : "bg-amber-50 text-amber-700"
}`}
>
{isRejected ? <FileX2 className="h-6 w-6" /> : <Inbox className="h-6 w-6" />}
</div>
<h3 className="text-lg font-semibold">
{hasSearch
? "No hay briefs que coincidan con la búsqueda"
: isRejected
? "No hay briefs rechazados"
: "No hay briefs pendientes"}
</h3>
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
{hasSearch
? "Prueba con otro nombre, cliente, marca, país o solicitante."
: isRejected
? "Los briefs que el equipo decida no convertir en proyecto aparecerán aquí."
: "Cuando CDC Brief genere un nuevo brief, aparecerá aquí automáticamente para revisión."}
</p>
</div>
);
}
function EmptyState({
onNew,
hasProjects,
@@ -766,7 +1018,7 @@ function EmptyState({
}: {
onNew: () => void;
hasProjects: boolean;
filter: "todos" | "abiertos" | "cerrados";
filter: ProjectTab;
query: string;
activeAdvancedFilterCount: number;
}) {