Oleada UX 6: Reportes agrupados en familias con rango global persistente y favoritos; Configuracion con auditoria de cambios y simulador de comisiones

This commit is contained in:
2026-09-17 22:06:11 +00:00
parent cd945df6a5
commit 31c92ac419
21 changed files with 1325 additions and 137 deletions

View File

@@ -0,0 +1,230 @@
import type { FC, ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { CalendarRange, RotateCcw } from 'lucide-react';
import { rangeForPreset, type DateRange } from './dateRange';
import { useAuth } from './auth';
/**
* Rango de fecha global y persistente para el módulo de Reportes.
*
* El control vive en la cabecera de Reportes y alimenta a todas las pestañas.
* Cada reporte puede fijar su propio rango (override) con los campos Inicio/Fin
* que ya tenía; ese ajuste se descarta en cuanto cambia el rango global.
*/
export type ReportPreset = 'hoy' | 'ayer' | 'semana' | 'mes' | 'mes_pasado' | 'anio' | 'custom';
export const REPORT_PRESETS: { value: ReportPreset; label: string }[] = [
{ value: 'hoy', label: 'Hoy' },
{ value: 'ayer', label: 'Ayer' },
{ value: 'semana', label: 'Esta semana' },
{ value: 'mes', label: 'Este mes' },
{ value: 'mes_pasado', label: 'Mes pasado' },
{ value: 'anio', label: 'Este año' },
{ value: 'custom', label: 'Personalizado' },
];
const STORAGE_KEY = 'skeen_reportes_rango';
interface RangePrefs extends DateRange {
preset: ReportPreset;
}
const readPrefs = (): RangePrefs | null => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as Partial<RangePrefs>;
if (!p || typeof p.start !== 'string' || typeof p.end !== 'string') return null;
const preset = (REPORT_PRESETS.some((x) => x.value === p.preset) ? p.preset : 'custom') as ReportPreset;
return { preset, start: p.start, end: p.end };
} catch {
return null;
}
};
const writePrefs = (prefs: RangePrefs): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// localStorage no disponible: se mantiene solo en memoria
}
};
const hoyISO = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
/** Rango de un preset. Los presets que terminan en el futuro se recortan a hoy. */
export const rangeForReportPreset = (preset: ReportPreset): DateRange => {
const hoy = hoyISO();
if (preset === 'anio') {
return { start: `${new Date().getFullYear()}-01-01`, end: hoy };
}
const r = rangeForPreset((preset === 'custom' ? 'hoy' : preset) as 'hoy' | 'ayer' | 'semana' | 'mes' | 'mes_pasado');
return { start: r.start, end: r.end > hoy ? hoy : r.end };
};
interface GlobalRange {
preset: ReportPreset;
start: string;
end: string;
label: string;
/** Cambia cuando el usuario mueve el rango global: invalida los overrides. */
generation: number;
setRange: (preset: ReportPreset, start?: string, end?: string) => void;
/** Rangos propios por reporte (clave = pestaña), válidos hasta que cambia el global. */
overrides: Record<string, DateRange>;
setOverride: (key: string, range: DateRange | null) => void;
}
const GlobalRangeContext = createContext<GlobalRange | null>(null);
export const ReportRangeProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [preset, setPreset] = useState<ReportPreset>(() => readPrefs()?.preset || 'mes');
const [range, setRangeState] = useState<DateRange>(() => {
const saved = readPrefs();
if (saved && saved.preset === 'custom') return { start: saved.start, end: saved.end };
return rangeForReportPreset(saved?.preset || 'mes');
});
const [generation, setGeneration] = useState(0);
const [overrides, setOverrides] = useState<Record<string, DateRange>>({});
const setRange = useCallback((nuevo: ReportPreset, start?: string, end?: string) => {
const r = nuevo === 'custom' && start && end ? { start, end } : rangeForReportPreset(nuevo);
setPreset(nuevo);
setRangeState(r);
setGeneration((g) => g + 1);
setOverrides({});
writePrefs({ preset: nuevo, start: r.start, end: r.end });
}, []);
const setOverride = useCallback((key: string, range: DateRange | null) => {
setOverrides((prev) => {
const next = { ...prev };
if (range) next[key] = range;
else delete next[key];
return next;
});
}, []);
const label = useMemo(() => {
const p = REPORT_PRESETS.find((x) => x.value === preset)?.label || 'Rango';
return `${p} · ${range.start} a ${range.end}`;
}, [preset, range]);
const value = useMemo(
() => ({ preset, start: range.start, end: range.end, label, generation, setRange, overrides, setOverride }),
[preset, range, label, generation, setRange, overrides, setOverride],
);
return <GlobalRangeContext.Provider value={value}>{children}</GlobalRangeContext.Provider>;
};
/** Rango global tal cual (lo usa la cabecera de Reportes). */
export const useGlobalReportRange = (): GlobalRange => {
const ctx = useContext(GlobalRangeContext);
if (!ctx) throw new Error('useGlobalReportRange requiere ReportRangeProvider');
return ctx;
};
export interface ReportRange extends DateRange {
setStart: (v: string) => void;
setEnd: (v: string) => void;
/** El reporte está usando un rango propio distinto al global. */
overridden: boolean;
resetOverride: () => void;
globalStart: string;
globalEnd: string;
}
const inicioDeMesISO = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
};
/**
* Rango efectivo de un reporte: el global, salvo que ese reporte (identificado
* por `key`) haya fijado uno propio con sus campos Inicio/Fin. El rango propio
* se descarta en cuanto cambia el rango global.
*/
export const useReportRange = (key: string): ReportRange => {
const ctx = useContext(GlobalRangeContext);
const override = ctx?.overrides?.[key] ?? null;
const gStart = ctx?.start ?? inicioDeMesISO();
const gEnd = ctx?.end ?? hoyISO();
const setStart = useCallback((v: string) => {
ctx?.setOverride(key, { start: v, end: override?.end ?? gEnd });
}, [ctx, key, gEnd, override]);
const setEnd = useCallback((v: string) => {
ctx?.setOverride(key, { start: override?.start ?? gStart, end: v });
}, [ctx, key, gStart, override]);
return {
start: override?.start ?? gStart,
end: override?.end ?? gEnd,
setStart,
setEnd,
overridden: override !== null,
resetOverride: () => ctx?.setOverride(key, null),
globalStart: gStart,
globalEnd: gEnd,
};
};
/** Aviso (en la cabecera) de que un reporte usa su propio rango. */
export const RangeOverrideHint: FC<{ reportKey: string }> = ({ reportKey }) => {
const ctx = useContext(GlobalRangeContext);
if (!ctx?.overrides?.[reportKey]) return null;
return (
<button
type="button"
onClick={() => ctx.setOverride(reportKey, null)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-medium border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 transition"
title={`Volver al rango global (${ctx.start} a ${ctx.end})`}
>
<RotateCcw size={11} />
Este reporte usa un rango propio · usar el global
</button>
);
};
/** Etiqueta corta del rango global (para la cabecera de Reportes). */
export const RangeIcon = CalendarRange;
// ---------------------------------------------------------------------------
// Favoritos de reportes (por usuario, en localStorage)
// ---------------------------------------------------------------------------
const FAV_KEY = (uid: number | string) => `skeen_reportes_favs_${uid}`;
export const useReportFavorites = () => {
const { user } = useAuth();
const uid = user?.id ?? 'anon';
const [favs, setFavs] = useState<string[]>([]);
useEffect(() => {
try {
const raw = localStorage.getItem(FAV_KEY(uid));
const parsed = raw ? JSON.parse(raw) : [];
setFavs(Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []);
} catch {
setFavs([]);
}
}, [uid]);
const persist = (next: string[]) => {
setFavs(next);
try {
localStorage.setItem(FAV_KEY(uid), JSON.stringify(next));
} catch {
// localStorage no disponible: queda solo en memoria
}
};
const toggle = (key: string) => {
persist(favs.includes(key) ? favs.filter((k) => k !== key) : [...favs, key]);
};
return { favs, toggle, isFav: (key: string) => favs.includes(key) };
};