feat(F-147): completed feature

This commit is contained in:
chattie
2026-08-22 12:56:09 +02:00
parent 91044da178
commit d39342cdeb
19 changed files with 1138 additions and 23 deletions

View File

@@ -0,0 +1,33 @@
/**
* F-147 — AvailabilityBadge component.
* Shows a colored badge indicating whether a metric is available.
*/
import type { Availability } from '@/lib/reporting-client';
const LABELS: Record<Availability, { label: string; className: string }> = {
available: { label: 'Disponible', className: 'bg-green-100 text-green-800' },
unavailable: { label: 'No disponible', className: 'bg-gray-100 text-gray-500' },
};
interface AvailabilityBadgeProps {
availability: Availability;
label?: string;
}
export function AvailabilityBadge({ availability, label }: AvailabilityBadgeProps) {
const { label: badgeLabel, className } = LABELS[availability];
return (
<span
title={availability === 'unavailable' ? 'Esta métrica aún no está disponible con los datos actuales' : undefined}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium ${className}`}
>
{availability === 'available' ? (
<span className="text-green-600" aria-hidden></span>
) : (
<span className="text-gray-400" aria-hidden></span>
)}
{label ?? badgeLabel}
</span>
);
}

View File

@@ -0,0 +1,129 @@
/**
* F-147 — DateRangePicker component.
* Preset date ranges + custom from/to inputs.
*/
import { useCallback } from 'react';
export interface DatePreset {
label: string;
getValue: () => { from: string; to: string };
}
const toInputValue = (iso: string) => iso.slice(0, 16); // YYYY-MM-DDTHH:MM
function todayAt(hour: number, minute = 0) {
const d = new Date();
d.setUTCHours(hour, minute, 0, 0);
return d.toISOString();
}
function startOfDay(daysAgo: number) {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
d.setUTCHours(0, 0, 0, 0);
return d.toISOString();
}
function startOfMonth() {
const d = new Date();
d.setDate(1);
d.setUTCHours(0, 0, 0, 0);
return d.toISOString();
}
function startOfPrevMonth() {
const d = new Date();
d.setDate(0); // last day of prev month
d.setDate(1);
d.setUTCHours(0, 0, 0, 0);
return d.toISOString();
}
function endOfPrevMonth() {
const d = new Date();
d.setDate(0); // last day of prev month
d.setUTCHours(23, 59, 59, 999);
return d.toISOString();
}
export const DATE_PRESETS: DatePreset[] = [
{
label: 'Últimos 7 días',
getValue: () => ({ from: startOfDay(7), to: todayAt(23) }),
},
{
label: 'Últimos 30 días',
getValue: () => ({ from: startOfDay(30), to: todayAt(23) }),
},
{
label: 'Últimos 90 días',
getValue: () => ({ from: startOfDay(90), to: todayAt(23) }),
},
{
label: 'Mes actual',
getValue: () => ({ from: startOfMonth(), to: todayAt(23) }),
},
{
label: 'Mes anterior',
getValue: () => ({ from: startOfPrevMonth(), to: endOfPrevMonth() }),
},
];
interface DateRangePickerProps {
from: string;
to: string;
onChange: (from: string, to: string) => void;
}
export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
const handlePreset = useCallback(
(preset: DatePreset) => {
const { from: f, to: t } = preset.getValue();
onChange(f, t);
},
[onChange],
);
const handleFrom = (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value, to);
const handleTo = (e: React.ChangeEvent<HTMLInputElement>) => onChange(from, e.target.value);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1">
{DATE_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => handlePreset(preset)}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition-colors"
>
{preset.label}
</button>
))}
</div>
<div className="flex gap-2 items-center">
<div className="flex flex-col gap-1">
<label htmlFor="date-from" className="text-xs text-gray-500">Desde</label>
<input
id="date-from"
type="datetime-local"
value={toInputValue(from)}
onChange={handleFrom}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="date-to" className="text-xs text-gray-500">Hasta</label>
<input
id="date-to"
type="datetime-local"
value={toInputValue(to)}
onChange={handleTo}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
/**
* F-147 — KpiCard component.
* Shows a key metric with label, optional comparison, and availability badge.
*/
import type { Availability } from '@/lib/reporting-client';
import { AvailabilityBadge } from './AvailabilityBadge';
interface KpiCardProps {
label: string;
value: string;
availability: Availability;
comparison?: { label: string; value: string; positive?: boolean };
}
function formatComparison(val: string) {
const num = parseFloat(val);
if (isNaN(num)) return val;
const sign = num > 0 ? '+' : '';
return `${sign}${num.toFixed(1)}%`;
}
export function KpiCard({ label, value, availability, comparison }: KpiCardProps) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-gray-500">{label}</p>
<AvailabilityBadge availability={availability} />
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
{comparison && availability === 'available' && (
<p className={`text-xs font-medium ${comparison.positive === false ? 'text-red-600' : 'text-green-600'}`}>
{comparison.label}: {formatComparison(comparison.value)}
</p>
)}
</div>
);
}