Cosmetics: shared PeriodNavigator, breadcrumb, meter labels

One chevron component now drives year/month stepping in Budget and
Proyecciones (UX-03). Jumping from Proyecciones to a Budget month shows
a back affordance (UX-22). Water meters can be named (Casa, Cochera…)
via a settings-persisted dialog; the chart legend uses the names
(UX-24).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-12 15:27:06 -06:00
parent 7dfe2da2a9
commit fe62e50a85
5 changed files with 211 additions and 50 deletions

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { type UserSettingsData } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
meterIds: string[];
current: Record<string, string>;
settingsData: UserSettingsData;
onClose: () => void;
}
/** Human names for water meters, persisted in UserSettings (review UX-24). */
export default function MeterLabelsDialog({
meterIds,
current,
settingsData,
onClose,
}: Props) {
const queryClient = useQueryClient();
const [labels, setLabels] = useState<Record<string, string>>(() => ({
...current,
}));
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const cleaned = Object.fromEntries(
Object.entries(labels).filter(([, v]) => v.trim() !== ''),
);
await api.patch('/settings/', {
data: { ...settingsData, meter_labels: cleaned },
});
toast.success('Etiquetas guardadas');
queryClient.invalidateQueries({ queryKey: ['settings'] });
onClose();
} catch {
toast.error('No se pudieron guardar las etiquetas');
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Etiquetas de medidores</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{meterIds.map((id) => (
<div key={id} className="space-y-1">
<Label className="text-xs font-mono">Medidor {id}</Label>
<Input
value={labels[id] ?? ''}
placeholder="p. ej. Casa, Cochera…"
onChange={(e) =>
setLabels((prev) => ({ ...prev, [id]: e.target.value }))
}
/>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Props {
label: string;
onPrev: () => void;
onNext: () => void;
prevDisabled?: boolean;
nextDisabled?: boolean;
/** 'outline' for page-level (year), 'ghost' for inline (month) */
variant?: 'outline' | 'ghost';
labelClassName?: string;
}
/** The one way to step through periods (UX-03) — same chevron pattern for
* years and months across Budget and Proyecciones. */
export default function PeriodNavigator({
label,
onPrev,
onNext,
prevDisabled,
nextDisabled,
variant = 'outline',
labelClassName = 'w-16 text-center font-semibold tabular-nums',
}: Props) {
return (
<div className="flex items-center gap-1">
<Button
variant={variant}
size="icon"
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
disabled={prevDisabled}
onClick={onPrev}
aria-label={`Período anterior (${label})`}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className={labelClassName}>{label}</span>
<Button
variant={variant}
size="icon"
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
disabled={nextDisabled}
onClick={onNext}
aria-label={`Período siguiente (${label})`}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator, Download } from 'lucide-react'; import { ArrowLeft, Calculator, Download } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -9,6 +10,7 @@ import { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator';
import MonthlyDetail from '@/components/budget/MonthlyDetail'; import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager'; import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList'; import TransactionList from '@/components/TransactionList';
@@ -34,6 +36,10 @@ export default function Budget() {
refresh, refresh,
} = useBudget(currentYear); } = useBudget(currentYear);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const cameFromProyecciones =
(location.state as { from?: string } | null)?.from === 'proyecciones';
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail'); const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
const [txSearch, setTxSearch] = useState(''); const [txSearch, setTxSearch] = useState('');
@@ -103,18 +109,27 @@ export default function Budget() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{cameFromProyecciones && (
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/proyecciones')}
aria-label="Volver a Proyecciones"
>
<ArrowLeft className="w-4 h-4 mr-1" aria-hidden="true" />
Proyecciones
</Button>
)}
<Calculator className="w-6 h-6 text-primary" /> <Calculator className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1> <h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div> </div>
<div className="flex items-center gap-1"> <PeriodNavigator
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}> label={String(year)}
<ChevronLeft className="w-4 h-4" /> onPrev={() => setYear(year - 1)}
</Button> onNext={() => setYear(year + 1)}
<span className="w-16 text-center font-semibold tabular-nums">{year}</span> prevDisabled={year <= MIN_YEAR}
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}> nextDisabled={year >= MAX_YEAR}
<ChevronRight className="w-4 h-4" /> />
</Button>
</div>
</div> </div>
<Tabs defaultValue="overview"> <Tabs defaultValue="overview">
@@ -133,29 +148,15 @@ export default function Budget() {
<TabsTrigger value="detail">Detalle</TabsTrigger> <TabsTrigger value="detail">Detalle</TabsTrigger>
<TabsTrigger value="transactions">Transacciones</TabsTrigger> <TabsTrigger value="transactions">Transacciones</TabsTrigger>
</TabsList> </TabsList>
<div className="flex items-center gap-1"> <PeriodNavigator
<Button variant="ghost"
variant="ghost" label={`${MONTH_NAMES[selectedMonth]} ${year}`}
size="icon" labelClassName="text-sm font-medium w-28 text-center"
className="h-7 w-7" onPrev={() => setSelectedMonth(selectedMonth - 1)}
disabled={selectedMonth <= 1} onNext={() => setSelectedMonth(selectedMonth + 1)}
onClick={() => setSelectedMonth(selectedMonth - 1)} prevDisabled={selectedMonth <= 1}
> nextDisabled={selectedMonth >= 12}
<ChevronLeft className="w-4 h-4" /> />
</Button>
<span className="text-sm font-medium w-28 text-center">
{MONTH_NAMES[selectedMonth]} {year}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth >= 12}
onClick={() => setSelectedMonth(selectedMonth + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div> </div>
<TabsContent value="detail" className="space-y-6 mt-4"> <TabsContent value="detail" className="space-y-6 mt-4">

View File

@@ -1,12 +1,12 @@
import { ChevronLeft, ChevronRight, Loader2, TrendingUp } from 'lucide-react'; import { Loader2, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useBudget } from '@/hooks/useBudget'; import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import PeriodNavigator from '@/components/PeriodNavigator';
import YearlyOverview from '@/components/budget/YearlyOverview'; import YearlyOverview from '@/components/budget/YearlyOverview';
const MIN_YEAR = 2026; const MIN_YEAR = 2026;
@@ -36,15 +36,13 @@ export default function Proyecciones() {
<TrendingUp className="w-6 h-6 text-primary" /> <TrendingUp className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1> <h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
</div> </div>
<div className="flex items-center gap-1"> <PeriodNavigator
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}> label={String(year)}
<ChevronLeft className="w-4 h-4" /> onPrev={() => setYear(year - 1)}
</Button> onNext={() => setYear(year + 1)}
<span className="w-16 text-center font-semibold tabular-nums">{year}</span> prevDisabled={year <= MIN_YEAR}
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}> nextDisabled={year >= MAX_YEAR}
<ChevronRight className="w-4 h-4" /> />
</Button>
</div>
</div> </div>
{/* Annual summary cards */} {/* Annual summary cards */}
@@ -101,7 +99,7 @@ export default function Proyecciones() {
year={year} year={year}
onSelectMonth={(m) => { onSelectMonth={(m) => {
setSelectedMonth(m); setSelectedMonth(m);
navigate('/budget'); navigate('/budget', { state: { from: 'proyecciones' } });
}} }}
onSaveOverride={async (month, value) => { onSaveOverride={async (month, value) => {
await saveBalanceOverride(year, month, value); await saveBalanceOverride(year, month, value);

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useMemo } from 'react'; import { useState, useCallback, useRef, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates'; import { MONTH_NAMES_ES_SHORT as MONTH_NAMES_ES } from '@/lib/dates';
import MeterLabelsDialog from '@/components/MeterLabelsDialog';
import ErrorState from '@/components/ErrorState'; import ErrorState from '@/components/ErrorState';
import { import {
BarChart, BarChart,
@@ -44,6 +45,7 @@ import {
type MunicipalReceipt, type MunicipalReceipt,
type MunicipalReceiptUploadResult, type MunicipalReceiptUploadResult,
type WaterMeterReading, type WaterMeterReading,
getUserSettings,
} from '@/lib/api'; } from '@/lib/api';
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
@@ -195,6 +197,14 @@ export default function ServiciosMunicipales() {
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]); const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const settingsQ = useQuery({
queryKey: ['settings'],
queryFn: () => getUserSettings().then((r) => r.data),
});
const meterLabels = ((settingsQ.data?.data?.meter_labels ?? {}) as Record<string, string>);
const meterLabel = (id: string) => meterLabels[id] || `Medidor ${id}`;
const [showLabelEditor, setShowLabelEditor] = useState(false);
const municipalQ = useQuery({ const municipalQ = useQuery({
queryKey: ['municipal'], queryKey: ['municipal'],
queryFn: async () => { queryFn: async () => {
@@ -396,10 +406,15 @@ export default function ServiciosMunicipales() {
{/* ── Water Consumption Chart ─────────────────────────────────────── */} {/* ── Water Consumption Chart ─────────────────────────────────────── */}
{chartData.length > 0 && ( {chartData.length > 0 && (
<section className="space-y-3"> <section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider"> <div className="flex items-center justify-between">
<Droplets className="w-4 h-4" /> <h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
Consumo de Agua (m³) <Droplets className="w-4 h-4" />
</h2> Consumo de Agua (m³)
</h2>
<Button variant="outline" size="sm" onClick={() => setShowLabelEditor(true)}>
Etiquetas
</Button>
</div>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={280}>
@@ -435,7 +450,7 @@ export default function ServiciosMunicipales() {
opacity: hiddenMeters.has(value) ? 0.4 : 1, opacity: hiddenMeters.has(value) ? 0.4 : 1,
cursor: 'pointer', cursor: 'pointer',
}}> }}>
Medidor {value} {meterLabel(value)}
</span> </span>
)} )}
/> />
@@ -806,6 +821,14 @@ export default function ServiciosMunicipales() {
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
{showLabelEditor && (
<MeterLabelsDialog
meterIds={meterIds}
current={meterLabels}
settingsData={settingsQ.data?.data ?? {}}
onClose={() => setShowLabelEditor(false)}
/>
)}
</div> </div>
); );
} }