feat(F-147): completed feature
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
129
project/apps/admin/src/components/reporting/DateRangePicker.tsx
Normal file
129
project/apps/admin/src/components/reporting/DateRangePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
project/apps/admin/src/components/reporting/KpiCard.tsx
Normal file
40
project/apps/admin/src/components/reporting/KpiCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user