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:
230
frontend/src/lib/reportRange.tsx
Normal file
230
frontend/src/lib/reportRange.tsx
Normal 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) };
|
||||
};
|
||||
Reference in New Issue
Block a user