mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:28:48 +02:00
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:
87
frontend/src/components/MeterLabelsDialog.tsx
Normal file
87
frontend/src/components/MeterLabelsDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/src/components/PeriodNavigator.tsx
Normal file
52
frontend/src/components/PeriodNavigator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
size="icon"
|
label={`${MONTH_NAMES[selectedMonth]} ${year}`}
|
||||||
className="h-7 w-7"
|
labelClassName="text-sm font-medium w-28 text-center"
|
||||||
disabled={selectedMonth <= 1}
|
onPrev={() => setSelectedMonth(selectedMonth - 1)}
|
||||||
onClick={() => setSelectedMonth(selectedMonth - 1)}
|
onNext={() => setSelectedMonth(selectedMonth + 1)}
|
||||||
>
|
prevDisabled={selectedMonth <= 1}
|
||||||
<ChevronLeft className="w-4 h-4" />
|
nextDisabled={selectedMonth >= 12}
|
||||||
</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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
|
||||||
<Droplets className="w-4 h-4" />
|
<Droplets className="w-4 h-4" />
|
||||||
Consumo de Agua (m³)
|
Consumo de Agua (m³)
|
||||||
</h2>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user