feat(F-157): completed feature
This commit is contained in:
@@ -6675,6 +6675,23 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-22T15:35:43Z"
|
"completed_at": "2026-08-22T15:35:43Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-157",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Admin Reporting sections live on Reporting page",
|
||||||
|
"description": "Keep only Reporting in main sidebar; clicking it shows Dashboard, Sales and Products section list inside Reporting page like Settings.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "done",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": true,
|
||||||
|
"security": true,
|
||||||
|
"qa": true,
|
||||||
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T15:42:35Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
||||||
import { topNavItems, subNavItems, type NavItem } from '@/lib/permissions';
|
import { visibleNavItems, type NavItem } from '@/lib/permissions';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
|
||||||
function NavItemRow({ item }: { item: NavItem }) {
|
function NavItemRow({ item }: { item: NavItem }) {
|
||||||
const pathname = window?.location?.pathname ?? '';
|
const pathname = usePathname();
|
||||||
const active = item.href === '/'
|
const active = item.href === '/'
|
||||||
? pathname === '/'
|
? pathname === '/'
|
||||||
: pathname.startsWith(item.href);
|
: pathname.startsWith(item.href);
|
||||||
@@ -35,7 +35,7 @@ function NavItemRow({ item }: { item: NavItem }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({ role, email }: { role: Role; email: string }) {
|
function Sidebar({ role, email }: { role: Role; email: string }) {
|
||||||
const topItems = topNavItems(role);
|
const items = visibleNavItems(role);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-60 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0">
|
<div className="w-60 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0">
|
||||||
@@ -50,22 +50,9 @@ function Sidebar({ role, email }: { role: Role; email: string }) {
|
|||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||||
{topItems.map((item) => {
|
{items.map((item) => (
|
||||||
const subs = subNavItems(item.href, role);
|
<NavItemRow key={item.href} item={item} />
|
||||||
|
))}
|
||||||
return (
|
|
||||||
<div key={item.href}>
|
|
||||||
<NavItemRow item={item} />
|
|
||||||
{subs.length > 0 && (
|
|
||||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
|
||||||
{subs.map((sub) => (
|
|
||||||
<NavItemRow key={sub.href} item={sub} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User footer */}
|
{/* User footer */}
|
||||||
|
|||||||
@@ -295,12 +295,9 @@ function DashboardContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<a href="/reporting" className="text-sm text-[#2D6A4F] hover:underline">← Reporting</a>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard de ventas</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard de ventas</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
Tendencias, canales y rendimiento por tienda/terminal
|
Tendencias, canales y rendimiento por tienda/terminal
|
||||||
|
|||||||
46
project/apps/admin/src/app/(dashboard)/reporting/layout.tsx
Normal file
46
project/apps/admin/src/app/(dashboard)/reporting/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ href: '/reporting/dashboard', label: 'Dashboard', icon: '📊' },
|
||||||
|
{ href: '/reporting/sales', label: 'Ventas', icon: '🧾' },
|
||||||
|
{ href: '/reporting/products', label: 'Productos', icon: '📦' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function ReportingLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Reporting</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-500">Informes de ventas y rendimiento del negocio.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<nav aria-label="Apartados de Reporting" className="w-48 shrink-0 space-y-1">
|
||||||
|
{SECTIONS.map((section) => {
|
||||||
|
const active = pathname === section.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={section.href}
|
||||||
|
href={section.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={`block w-full rounded-xl px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
active ? 'bg-[#2D6A4F] text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-2" aria-hidden="true">{section.icon}</span>
|
||||||
|
{section.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,299 +1,5 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
/**
|
|
||||||
* F-147 — Reporting shell (main page).
|
|
||||||
*
|
|
||||||
* Layout: header + filter bar + KPI grid + data table.
|
|
||||||
* Filters are persisted in URL searchParams.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { DateRangePicker, DATE_PRESETS } from '@/components/reporting/DateRangePicker';
|
|
||||||
import { KpiCard } from '@/components/reporting/KpiCard';
|
|
||||||
import { reportingClient, type Availability, type ReportingChannel, type SummaryResponse } from '@/lib/reporting-client';
|
|
||||||
|
|
||||||
function formatCents(cents: number) {
|
|
||||||
return `€${(cents / 100).toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricAvailability(
|
|
||||||
data: SummaryResponse | null,
|
|
||||||
metric: keyof SummaryResponse['dataAvailability'],
|
|
||||||
): Availability {
|
|
||||||
return data?.dataAvailability?.[metric] ?? 'unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDefaultRange() {
|
|
||||||
const preset = DATE_PRESETS[1]; // "Últimos 30 días"
|
|
||||||
return preset.getValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterState {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
channel: ReportingChannel;
|
|
||||||
compare: 'none' | 'previous_equal' | 'previous_calendar';
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportingContent() {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
// Initialize filters from URL or defaults
|
|
||||||
const getInitialFilters = (): FilterState => {
|
|
||||||
const from = searchParams.get('from') ?? buildDefaultRange().from;
|
|
||||||
const to = searchParams.get('to') ?? buildDefaultRange().to;
|
|
||||||
const channel = (searchParams.get('channel') ?? 'all') as ReportingChannel;
|
|
||||||
const compare = (searchParams.get('compare') ?? 'none') as FilterState['compare'];
|
|
||||||
return { from, to, channel, compare };
|
|
||||||
};
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState<FilterState>(getInitialFilters);
|
|
||||||
const [data, setData] = useState<SummaryResponse | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
// Sync filters to URL
|
|
||||||
const updateSearchParams = useCallback(
|
|
||||||
(newFilters: FilterState) => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('from', newFilters.from);
|
|
||||||
params.set('to', newFilters.to);
|
|
||||||
if (newFilters.channel !== 'all') params.set('channel', newFilters.channel);
|
|
||||||
if (newFilters.compare !== 'none') params.set('compare', newFilters.compare);
|
|
||||||
router.replace(`/reporting?${params.toString()}`, { scroll: false });
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFiltersChange = useCallback(
|
|
||||||
(newFilters: FilterState) => {
|
|
||||||
setFilters(newFilters);
|
|
||||||
updateSearchParams(newFilters);
|
|
||||||
},
|
|
||||||
[updateSearchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const result = await reportingClient.fetchSummary({
|
|
||||||
from: filters.from,
|
|
||||||
to: filters.to,
|
|
||||||
channel: filters.channel,
|
|
||||||
compare: filters.compare,
|
|
||||||
});
|
|
||||||
setData(result);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Error al cargar datos');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filters]);
|
|
||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
|
||||||
|
|
||||||
const da = data?.dataAvailability;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 flex flex-col gap-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Reporting</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
|
||||||
{data ? `Actualizado: ${new Date(data.updatedAt).toLocaleString('es-ES')}` : 'Cargando...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter bar */}
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-4 flex flex-col gap-4">
|
|
||||||
<div className="flex flex-wrap gap-4 items-end">
|
|
||||||
{/* Channel */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="channel-filter" className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
|
||||||
Canal
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="channel-filter"
|
|
||||||
value={filters.channel}
|
|
||||||
onChange={(e) => handleFiltersChange({ ...filters, channel: e.target.value as ReportingChannel })}
|
|
||||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
|
|
||||||
>
|
|
||||||
<option value="all">Todos</option>
|
|
||||||
<option value="ecommerce">Ecommerce</option>
|
|
||||||
<option value="pos">TPV</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compare */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label htmlFor="compare-filter" className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
|
||||||
Comparar
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="compare-filter"
|
|
||||||
value={filters.compare}
|
|
||||||
onChange={(e) => handleFiltersChange({ ...filters, compare: e.target.value as FilterState['compare'] })}
|
|
||||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
|
|
||||||
>
|
|
||||||
<option value="none">Sin comparar</option>
|
|
||||||
<option value="previous_equal">Período anterior (misma duración)</option>
|
|
||||||
<option value="previous_calendar">Mes anterior (calendario)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Refresh */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={loadData}
|
|
||||||
disabled={loading}
|
|
||||||
className="px-4 py-2 text-sm font-medium bg-[#2D6A4F] text-white rounded-lg hover:bg-[#245a42] disabled:opacity-50 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
<span>🔄</span>
|
|
||||||
)}
|
|
||||||
Actualizar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date range */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Rango de fechas</p>
|
|
||||||
<DateRangePicker
|
|
||||||
from={filters.from}
|
|
||||||
to={filters.to}
|
|
||||||
onChange={(from, to) => handleFiltersChange({ ...filters, from, to })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI grid */}
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
|
|
||||||
<div className="h-3 bg-gray-200 rounded w-1/2 mb-3" />
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-3/4" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
|
||||||
<p className="text-red-700 font-medium mb-3">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={loadData}
|
|
||||||
className="text-sm text-[#2D6A4F] font-medium hover:underline"
|
|
||||||
>
|
|
||||||
Reintentar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : !data ? (
|
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-12 text-center">
|
|
||||||
<p className="text-4xl mb-3">📊</p>
|
|
||||||
<p className="text-gray-500 text-sm">Cargando datos...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Comparison banner */}
|
|
||||||
{data.comparison && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-800">
|
|
||||||
<strong>Comparación activa:</strong>{' '}
|
|
||||||
Comparando con el período {filters.compare === 'previous_equal' ? 'anterior (misma duración)' : 'mes anterior (calendario)'}.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<KpiCard
|
|
||||||
label="Pedidos"
|
|
||||||
value={String(data.totals.orders)}
|
|
||||||
availability={metricAvailability(data, 'orders')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Ventas brutas"
|
|
||||||
value={formatCents(data.totals.grossSalesCents)}
|
|
||||||
availability={metricAvailability(data, 'grossSales')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Clientes"
|
|
||||||
value={String(data.totals.customers)}
|
|
||||||
availability={metricAvailability(data, 'customers')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Unidades"
|
|
||||||
value={String(data.totals.unitsSold)}
|
|
||||||
availability={metricAvailability(data, 'unitsSold')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Descuentos"
|
|
||||||
value={formatCents(data.totals.discountsCents)}
|
|
||||||
availability={metricAvailability(data, 'discounts')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="IVA"
|
|
||||||
value={formatCents(data.totals.taxCents)}
|
|
||||||
availability={metricAvailability(data, 'tax')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Envíos"
|
|
||||||
value={formatCents(data.totals.shippingCents)}
|
|
||||||
availability={metricAvailability(data, 'shipping')}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Margen"
|
|
||||||
value="—"
|
|
||||||
availability={metricAvailability(data, 'margin')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data availability legend */}
|
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4">
|
|
||||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
|
||||||
Estado de métricas
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{Object.entries(data.dataAvailability).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs text-gray-600 capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}:</span>
|
|
||||||
<span
|
|
||||||
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
|
|
||||||
value === 'available'
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value === 'available' ? '✓' : '✗'} {value === 'available' ? 'Disponible' : 'No disponible'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReportingPage() {
|
export default function ReportingPage() {
|
||||||
return (
|
redirect('/reporting/dashboard');
|
||||||
<Suspense fallback={
|
|
||||||
<div className="p-8">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4" />
|
|
||||||
<div className="h-32 bg-gray-200 rounded-xl" />
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<ReportingContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function ProductsContent() {
|
|||||||
from: filters.from,
|
from: filters.from,
|
||||||
to: filters.to,
|
to: filters.to,
|
||||||
channel: filters.channel,
|
channel: filters.channel,
|
||||||
groupBy: filters.sort === 'revenue' ? 'revenue' : undefined,
|
sort: filters.sort,
|
||||||
page: filters.page,
|
page: filters.page,
|
||||||
pageSize: filters.pageSize,
|
pageSize: filters.pageSize,
|
||||||
});
|
});
|
||||||
@@ -76,12 +76,9 @@ function ProductsContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<a href="/reporting" className="text-sm text-[#2D6A4F] hover:underline">← Reporting</a>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
Ranking de productos más vendidos
|
Ranking de productos más vendidos
|
||||||
|
|||||||
@@ -114,12 +114,9 @@ function SalesContent() {
|
|||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<a href="/reporting" className="text-sm text-[#2D6A4F] hover:underline">← Reporting</a>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Ventas agrupadas</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Ventas agrupadas</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
{data ? `${data.pagination.totalRows} filas · Actualizado: ${new Date(data.updatedAt).toLocaleString('es-ES')}` : 'Cargando...'}
|
{data ? `${data.pagination.totalRows} filas · Actualizado: ${new Date(data.updatedAt).toLocaleString('es-ES')}` : 'Cargando...'}
|
||||||
|
|||||||
@@ -37,15 +37,11 @@ export interface NavItem {
|
|||||||
icon: string;
|
icon: string;
|
||||||
permission: Permission;
|
permission: Permission;
|
||||||
badge?: number;
|
badge?: number;
|
||||||
parentHref?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
||||||
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
|
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
|
||||||
{ href: '/reporting/dashboard', label: 'Dashboard', icon: '📊', permission: 'reporting.read', parentHref: '/reporting' },
|
|
||||||
{ href: '/reporting/sales', label: 'Ventas', icon: '🧾', permission: 'reporting.read', parentHref: '/reporting' },
|
|
||||||
{ href: '/reporting/products', label: 'Productos', icon: '📦', permission: 'reporting.read', parentHref: '/reporting' },
|
|
||||||
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
||||||
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
||||||
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
||||||
@@ -64,10 +60,6 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
|
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function topNavItems(role: Role): NavItem[] {
|
export function visibleNavItems(role: Role): NavItem[] {
|
||||||
return NAV_ITEMS.filter((item) => !item.parentHref && can(role, item.permission));
|
return NAV_ITEMS.filter((item) => can(role, item.permission));
|
||||||
}
|
|
||||||
|
|
||||||
export function subNavItems(parentHref: string, role: Role): NavItem[] {
|
|
||||||
return NAV_ITEMS.filter((item) => item.parentHref === parentHref && can(role, item.permission));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface ReportingFilters {
|
|||||||
terminalId?: string | string[];
|
terminalId?: string | string[];
|
||||||
state?: string | string[];
|
state?: string | string[];
|
||||||
groupBy?: GroupBy;
|
groupBy?: GroupBy;
|
||||||
|
sort?: 'units' | 'revenue';
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
@@ -138,6 +139,7 @@ function filtersToQueryString(filters: ReportingFilters): string {
|
|||||||
if (filters.compare && filters.compare !== 'none') params.set('compare', filters.compare);
|
if (filters.compare && filters.compare !== 'none') params.set('compare', filters.compare);
|
||||||
if (filters.channel && filters.channel !== 'all') params.set('channel', filters.channel);
|
if (filters.channel && filters.channel !== 'all') params.set('channel', filters.channel);
|
||||||
if (filters.groupBy) params.set('groupBy', filters.groupBy);
|
if (filters.groupBy) params.set('groupBy', filters.groupBy);
|
||||||
|
if (filters.sort) params.set('sort', filters.sort);
|
||||||
if (filters.page) params.set('page', String(filters.page));
|
if (filters.page) params.set('page', String(filters.page));
|
||||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
||||||
if (filters.storeId) {
|
if (filters.storeId) {
|
||||||
|
|||||||
15
work/artifacts/F-157/architect.md
Normal file
15
work/artifacts/F-157/architect.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# F-157 — Diseño
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
Usar un layout anidado de Next.js en `app/(dashboard)/reporting/layout.tsx` para alojar navegación local persistente, siguiendo el patrón visual de las pestañas internas de Ajustes.
|
||||||
|
|
||||||
|
## Cambios
|
||||||
|
1. `permissions.ts`: el modelo del sidebar vuelve a ser plano; solo existe la entrada principal `/reporting`.
|
||||||
|
2. Dashboard layout: renderiza únicamente entradas del menú principal, sin subitems.
|
||||||
|
3. Reporting nested layout: lista local Dashboard, Ventas y Productos con estado activo según `usePathname`.
|
||||||
|
4. `/reporting`: redirige a `/reporting/dashboard`, que actúa como apartado inicial.
|
||||||
|
|
||||||
|
## Riesgos
|
||||||
|
- Evitar que `/reporting` se marque distinto: el sidebar usa `pathname.startsWith('/reporting')`.
|
||||||
|
- Evitar duplicar paddings: el layout local solo añade estructura flex y deja a cada página su contenido.
|
||||||
|
- Mantener RBAC existente; no se introducen rutas ni permisos nuevos.
|
||||||
7
work/artifacts/F-157/documenter.md
Normal file
7
work/artifacts/F-157/documenter.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# F-157 — Documentación
|
||||||
|
|
||||||
|
Cambio de comportamiento visible:
|
||||||
|
|
||||||
|
- El menú principal contiene únicamente **Reporting**.
|
||||||
|
- Al abrir Reporting se selecciona **Dashboard** por defecto.
|
||||||
|
- **Dashboard**, **Ventas** y **Productos** son apartados internos del módulo y se muestran en la navegación local de Reporting, igual que los apartados de Ajustes.
|
||||||
23
work/artifacts/F-157/implementer.md
Normal file
23
work/artifacts/F-157/implementer.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# F-157 — Implementer
|
||||||
|
|
||||||
|
## Implementación
|
||||||
|
- Eliminados Dashboard, Ventas y Productos de `NAV_ITEMS`; el menú principal conserva una única entrada Reporting.
|
||||||
|
- Simplificado el sidebar para renderizar solo `visibleNavItems`.
|
||||||
|
- Añadido `reporting/layout.tsx` con navegación local persistente, estilo Ajustes, y estado activo accesible mediante `aria-current`.
|
||||||
|
- `/reporting` redirige al apartado inicial `/reporting/dashboard`.
|
||||||
|
- Eliminados los enlaces redundantes «← Reporting» de las páginas internas.
|
||||||
|
- Corregido el contrato frontend de `sort` en productos para que el build del admin use el filtro backend existente en vez de enviar `groupBy=revenue`.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
- `cd project/apps/admin && npm run build`: PASS, 29 rutas generadas.
|
||||||
|
- `cd project && node_modules/.bin/tsc --noEmit`: PASS.
|
||||||
|
- `git diff --check`: PASS.
|
||||||
|
- `./scripts/verify.sh`: PASS.
|
||||||
|
|
||||||
|
## Archivos
|
||||||
|
- `project/apps/admin/src/app/(dashboard)/layout.tsx`
|
||||||
|
- `project/apps/admin/src/lib/permissions.ts`
|
||||||
|
- `project/apps/admin/src/app/(dashboard)/reporting/layout.tsx`
|
||||||
|
- `project/apps/admin/src/app/(dashboard)/reporting/page.tsx`
|
||||||
|
- `project/apps/admin/src/app/(dashboard)/reporting/{dashboard,sales,products}/page.tsx`
|
||||||
|
- `project/apps/admin/src/lib/reporting-client.ts`
|
||||||
15
work/artifacts/F-157/leader-close.json
Normal file
15
work/artifacts/F-157/leader-close.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-157",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Reporting queda como una entrada única del sidebar y sus tres apartados viven dentro del módulo.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Reviewer approved", "ok": true },
|
||||||
|
{ "item": "Security approved", "ok": true },
|
||||||
|
{ "item": "QA approved", "ok": true },
|
||||||
|
{ "item": "Admin build passed", "ok": true },
|
||||||
|
{ "item": "verify.sh passed", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
15
work/artifacts/F-157/qa.json
Normal file
15
work/artifacts/F-157/qa.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-157",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Los cuatro criterios de aceptación quedan cubiertos.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Only one Reporting sidebar entry", "ok": true, "evidence": "Static acceptance check passed" },
|
||||||
|
{ "item": "Three page-local sections", "ok": true, "evidence": "Nested layout contains Dashboard, Ventas, Productos" },
|
||||||
|
{ "item": "Active section marker", "ok": true, "evidence": "usePathname plus aria-current=page" },
|
||||||
|
{ "item": "Admin build", "ok": true, "evidence": "Next.js production build passed" },
|
||||||
|
{ "item": "Harness", "ok": true, "evidence": "verify.sh passed" }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
14
work/artifacts/F-157/reviewer.json
Normal file
14
work/artifacts/F-157/reviewer.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-157",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "El sidebar queda plano con una sola entrada Reporting y la navegación de secciones vive en el layout interno.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Sidebar has only Reporting", "ok": true, "evidence": "permissions.ts contains no reporting child NAV_ITEMS" },
|
||||||
|
{ "item": "Local section navigation", "ok": true, "evidence": "reporting/layout.tsx lists Dashboard, Ventas and Productos" },
|
||||||
|
{ "item": "Admin production build", "ok": true, "evidence": "Next.js build generated 29 routes" },
|
||||||
|
{ "item": "TypeScript", "ok": true, "evidence": "project tsc --noEmit passed" }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
13
work/artifacts/F-157/security.json
Normal file
13
work/artifacts/F-157/security.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-157",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Sin cambios de autenticación ni autorización; Reporting conserva el permiso reporting.read del menú principal y las rutas backend mantienen RBAC existente.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "RBAC unchanged", "ok": true, "evidence": "Only navigation presentation changed" },
|
||||||
|
{ "item": "Static internal links", "ok": true, "evidence": "All section hrefs are hard-coded same-origin paths" },
|
||||||
|
{ "item": "No secret or dependency change", "ok": true, "evidence": "No config secrets or packages modified" }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
270
work/current.md
270
work/current.md
@@ -1,253 +1,17 @@
|
|||||||
# Sprint completo: Todas las 270 features cerradas
|
# Feature activa: F-157 — Reporting sections live on Reporting page
|
||||||
|
|
||||||
## Estado final (2026-08-22)
|
## Problema
|
||||||
- **Backlog**: 270/270 features done ✅
|
El menú principal muestra Dashboard, Ventas y Productos de Reporting como entradas indentadas. El operador requiere una única entrada principal **Reporting**. Al abrirla, sus apartados deben aparecer dentro del área de Reporting, igual que las pestañas internas de Ajustes.
|
||||||
- **verify.sh**: verde
|
|
||||||
- **tsc**: 0 errores
|
## Alcance
|
||||||
|
- El sidebar principal muestra solo `Reporting`.
|
||||||
## Sprint POS (F-003..F-010, POS-003..POS-046)
|
- `/reporting` abre por defecto el apartado Dashboard.
|
||||||
|
- Dashboard, Ventas y Productos se muestran como navegación local dentro de Reporting.
|
||||||
### Fase 1 POS (POS-003 a POS-010) — COMPLETADO
|
- La navegación local permanece visible en las páginas de los tres apartados.
|
||||||
- **POS-003**: Módulo POS backend (domain types, repos, use cases, unit tests, build-app.ts)
|
- No se cambian APIs ni permisos de Reporting.
|
||||||
- **POS-004**: Registro de rutas POS (12 rutas administrativas + terminales + sesiones)
|
|
||||||
- **POS-005**: Búsqueda de productos + CRUD métodos de pago (admin)
|
## Aceptación
|
||||||
- **POS-006**: App Next.js `apps/pos` (15 archivos: configs, layouts, middleware, API client, utils)
|
1. No aparecen `/reporting/dashboard`, `/reporting/sales` ni `/reporting/products` en el menú principal.
|
||||||
- **POS-007**: Pantalla principal del TPV (productos, carrito, descuentos, pago efectivo/tarjeta)
|
2. Al pulsar Reporting aparece una lista local con Dashboard, Ventas y Productos.
|
||||||
- **POS-008**: POST /pos/sales idempotente (transacción atómica con PostgreSQL)
|
3. El apartado activo queda visualmente marcado.
|
||||||
- **POS-009**: Búsqueda y detalle de clientes para asociación
|
4. El build del admin y `verify.sh` pasan.
|
||||||
- **POS-010**: Panel de descuentos (API validación + componente React)
|
|
||||||
|
|
||||||
### Fase 2/3 POS (POS-011 a POS-022) — COMPLETADO
|
|
||||||
- **POS-011**: Lista de ventas, void, receipts, historial de sesiones
|
|
||||||
- **POS-012**: Reembolsos, impresión de tickets, analytics summary
|
|
||||||
- **POS-013**: Alertas stock bajo, loyalty, settings, atajos de teclado
|
|
||||||
- **POS-014**: Turnos, tasas IVA, multi-tienda, notificaciones, reports diarios y EOD
|
|
||||||
- **POS-015**: Kitchen display, cajón, importación pedidos, integraciones, exportación CSV/JSON
|
|
||||||
- **POS-016**: Users management, audit log, sync catálogo, time tracking, lookup EAN/SKU
|
|
||||||
- **POS-017..POS-022**: Split payments, holds, quotes, tips, gift cards, multi-currency, y features restantes de Phase 2/3
|
|
||||||
|
|
||||||
### Fase 4/5+6/7 POS (POS-023 a POS-046) — COMPLETADO
|
|
||||||
- 23 rutas adicionales: advanced analytics, payroll, suppliers, kitchen display status, inventory forecast, supplier orders, order status management, hourly/products/employees reports, categories, tags, stock alerts, y más
|
|
||||||
|
|
||||||
## F-149 cerrada (2026-08-22) — Reporting: product category and brand reports
|
|
||||||
|
|
||||||
- `reporting-service.ts`: ReportingService con summary() + sales() usando CTEs SQL parametrizados.
|
|
||||||
- `GET /reporting/summary` + `GET /reporting/sales` con filtros/channel/storeId/terminalId/groupBy/pagination.
|
|
||||||
- 44 tests reporting (14 unit + 15 route + 15 existing); npm run build 0; boundaries 0 nuevas; verify.sh verde; commit `91044da`.
|
|
||||||
- Gates: reviewer ✅ / security ✅ / qa ✅ / document ✅ / leader-close ✅.
|
|
||||||
- **Siguiente**: F-147 (Admin: reporting shell and global filters).
|
|
||||||
|
|
||||||
- `049_reporting_payment_lines.js`: tabla `reporting_payment_lines` (13 columnas, FK→orders_orders+pos_stores, 3 CHECK, 3 índices), inmutable (refunds como nuevas filas). patrón: INSERT-only.
|
|
||||||
- `reporting-payment-lines.itest.ts` 16/16 ✅ (DB real).
|
|
||||||
- npm run build 0; boundaries 0 nuevas; verify.sh verde; commit `62a368f`.
|
|
||||||
- Gates: reviewer ✅ / security ✅ / qa ✅ / document ✅ / leader-close ✅.
|
|
||||||
- **Siguiente**: F-146 (Reporting: service summary and sales API).
|
|
||||||
|
|
||||||
## F-148 cerrada (2026-08-22) — Admin: sales dashboard and channel views
|
|
||||||
|
|
||||||
## F-147 cerrada (2026-08-22) — Admin: reporting shell and global filters
|
|
||||||
|
|
||||||
## F-146 cerrada (2026-08-22) — Reporting: service summary and sales API
|
|
||||||
|
|
||||||
## F-145 cerrada (2026-08-22) — Reporting: payment lines and POS cash-safe capture
|
|
||||||
|
|
||||||
## F-144 cerrada (2026-08-22) — Reporting snapshots: store/VAT/cost/shipping
|
|
||||||
|
|
||||||
- `048_reporting_store_shipping_snapshots.js`: `orders_orders.store_id` (uuid NOT NULL DEFAULT default-store + FK→pos_stores + idx), `orders_orders.shipping_cents` (integer NOT NULL DEFAULT 0), `orders_items.cost_at_sale_cents` (bigint nullable), `orders_items.vat_rate` (text nullable).
|
|
||||||
- `reporting-snapshots.itest.ts` 3/3 ✅ (DB real, migration 001→048 aplicada).
|
|
||||||
- tsc 0; boundaries 0 nuevas; verify.sh verde; commit `2ea628f`.
|
|
||||||
- Gates: reviewer ✅ / security ✅ / qa ✅ / document ✅ / leader-close ✅.
|
|
||||||
- **Siguiente**: F-145 (Reporting: payment lines and POS cash-safe capture).
|
|
||||||
|
|
||||||
## F-138 cerrada (2026-08-22) — auto-seed default price row on variant creation
|
|
||||||
|
|
||||||
- `POST /products(:id/variants)` create ahora inserta inmediatamente una fila default en `pricing_variant_prices` (`net_unit_amount_cents=0`, `vat_rate='general'`, `currency='EUR'`) vía `PricingService.seedVariantPrice` (inyectado en `CreateProductVariant`, best-effort, `ON CONFLICT DO NOTHING`). Cierra la ventana 404 de `GET /pricing/variants/:id`. Backend-only, sin migración.
|
|
||||||
- Fuente: pricing `seedVariantPrice` (ports+service+PgPricingRepository), catalog `CreateProductVariant` seed (best-effort), `CatalogRoutesDeps += pricing`, `build-app.ts` hoist `pricing` const; `+` tests `variant-use-cases.test.ts` (3) + AC itest en `catalog.itest.ts`.
|
|
||||||
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
|
|
||||||
- Verificación: `tsc --noEmit` 0 errores; `npm test` 209 passed / 57 skipped (+3 nuevos); `lint:boundaries` sin violaciones nuevas (R1 preexistente `security.routes.ts → log-broadcaster` no introducido); `./scripts/verify.sh` verde (pre-close + post-close tras corregir `leader-close.json` verdict CLOSED→APPROVED).
|
|
||||||
- Commit: `feat(F-138): completed feature` (fb01593, amendado).
|
|
||||||
|
|
||||||
## F-154 cerrada (2026-08-22) — separate customers from internal users
|
|
||||||
|
|
||||||
- `GET /users` (Customers) now returns **storefront customers only** (`identity_users.role = 'customer'` filtro literal en `PgProfileRepository.listCustomers`, COUNT + SELECT). `GET /users/:id` owner-or-admin inalterado.
|
|
||||||
- `GET /admin/users` (Users) now returns **internos/backoffice only** (`role <> 'customer'` base literal; `?role=admin|editor` afinando dentro de internos; `?role=customer` → vacío).
|
|
||||||
- Frontend: dropdown de Users quita opción `customer` (backend ya fuerza la separación). Customers page sin cambio (llama /users → ahora customer-only).
|
|
||||||
- Tests: 6 nuevos unitarios (mock, sin DB) en `pg-profile-repository.test.ts` (3) + `security.routes.test.ts` (3). `users.itest.ts` AC2/AC3 flip: /users devuelve ben (customer) no ana (admin). Full suite 206 passed / 56 skipped, sin regresiones.
|
|
||||||
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
|
|
||||||
- `tsc --noEmit` 0 errores (API + apps/admin); eslint/prettier limpios en archivos tocados; `lint:boundaries` sin violaciones NUEVAS (R1 preexistente en `security.routes.ts → log-broadcaster`, no introducido por F-154, verificado via git diff); `verify.sh` exit 0.
|
|
||||||
- Commit: `feat(F-154): completed feature`.
|
|
||||||
|
|
||||||
## F-153 cerrada (2026-08-22) — customer email on order view
|
|
||||||
|
|
||||||
- Asocia el email del cliente (`identity_users.email`, NOT NULL) al order read model vía `LEFT JOIN identity_users` en el repo, y lo expone en `serializeOrder` (detail + lista) y en la notificación del force-transition admin (usa `order.email`; se elimina el lookup inline).
|
|
||||||
- `OrderView.email?: string | null` (additivo, opcional en fixtures de test → serializado como `null`). Sin migración. Boundary intacta (orders→identity_users es referencia SQL, no import TS).
|
|
||||||
- Tests: 3 nuevos en `pg-order-repository.test.ts` (email resuelto vía JOIN, `null` sin usuario vinculado, `undefined` cuando no existe). Suite 200 passed / 56 skipped, sin regresiones.
|
|
||||||
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
|
|
||||||
- `tsc --noEmit` 0 errores; eslint 0; prettier baseline-only; `lint:boundaries` sin violaciones nuevas; `verify.sh` exit 0.
|
|
||||||
- Commit: `feat(F-153): completed feature` + `chore: reset runtime after F-153`.
|
|
||||||
- Pendiente siguiente por orden: **F-154** (separate customers from internal users).
|
|
||||||
|
|
||||||
## Sesión 2026-08-22 — F-152 cerrada (emails on account creation + order confirmation)
|
|
||||||
|
|
||||||
- `F-152` cerrada: welcome email (`account_created`) on `POST /auth/register` y order confirmation email on `POST /payments/webhook` (PaymentSucceeded).
|
|
||||||
Best-effort (fire-and-forget + `request.log.warn`), idempotent (gate `outcome.kind === "processed"`),
|
|
||||||
XSS-safe (`buildWelcomeEmail` escapea name), SMTP config de `store_settings`.
|
|
||||||
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
|
|
||||||
- `tsc --noEmit` 0 errores; prettier+eslint limpios en archivos tocados; `lint:boundaries` sin nuevas violaciones;
|
|
||||||
197 tests ✅; `git diff --check` ✅; `verify.sh` exit 0.
|
|
||||||
- Commits: `feat(F-152): completed feature` + `chore: reset runtime after F-152` (push omitido, sin remote `origin`).
|
|
||||||
- `runtime-status.json` reseteado a idle.
|
|
||||||
- Pendiente siguiente por orden: `F-153` (customer email missing).
|
|
||||||
|
|
||||||
## Sesión 2026-08-22 — F-156 cerrada (CMS dynamic rendering)
|
|
||||||
|
|
||||||
Backlog: **269 features, 212 done, 57 pending, 0 in_progress, 0 blocked**.
|
|
||||||
|
|
||||||
- `F-156` cerrada: CMS content-managed pages (`/about`, `/contact`, `/shipping`) dejan de servir caché stale. `fetchPage` usa `{ cache: 'no-store' }` y las páginas exportan `dynamic = 'force-dynamic'`; el fallback estático y el RBAC se mantienen.
|
|
||||||
- Gates: implementer ✅ / reviewer APPROVED ✅ / security APPROVED ✅ / qa APPROVED ✅ / leader close ✅.
|
|
||||||
- Verificado runtime: `curl -sI /about` → 200 con `Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate`; verificador temporal de DB apareció y desapareció sin rebuild.
|
|
||||||
- `verify.sh` green; cambios commiteados como `feat(F-156): completed feature`; `runtime-status.json` reseteado a idle.
|
|
||||||
- Pendientes inmediatos por orden sugerido: `F-152` (emails account creation/order confirmation) → `F-153` (customer email missing) → `F-154` (separate customers from internal users). Los tickets Reporting P0 pendientes son `F-143`..`F-150`.
|
|
||||||
|
|
||||||
## Sesión 2026-08-21 — Reporting preparado y builds Next estabilizados
|
|
||||||
|
|
||||||
Backlog: **264 features, 209 done, 55 pending, 0 in_progress, 0 blocked**.
|
|
||||||
|
|
||||||
- `F-141` cerrada: raíces `turbopack`/`outputFileTracingRoot` explícitas en customer frontend y páginas de catálogo dinámicas para que el build no dependa del API.
|
|
||||||
- `F-142` cerrada: `docs/reporting/REPORTING_ARCHITECTURE.md` y `docs/reporting/REPORTING_TASKS.md` entregados tras analizar modelos reales, datos faltantes, API, rendimiento, caché, RBAC y roadmap P0-P3.
|
|
||||||
- Tickets Reporting P0 abiertos: `F-143`..`F-150`; todos `pending`.
|
|
||||||
- `verify.sh` exit 0 y `runtime-status.json` reseteado a idle.
|
|
||||||
- Próximo ciclo: iniciar `F-143` (contracts, filtros, comparación y RBAC de Reporting) antes de implementar dashboards.
|
|
||||||
|
|
||||||
## Sesión 2026-08-21 — backlog cerrado (nota inicial, desfasada)
|
|
||||||
|
|
||||||
Backlog: 185 features (185 done, 0 pending, 0 in_progress) según la nota original.
|
|
||||||
|
|
||||||
Últimas features cerradas en esa nota: **F-117**, **F-116**, **F-115**, **F-112**, **F-100**.
|
|
||||||
|
|
||||||
Tras esa nota se cerraron **18 features adicionales** (F-118..F-135) sin actualizar `current.md`. Quedan reflejadas en `backlog/features.json` y en `work/history.md`.
|
|
||||||
|
|
||||||
## F-117 cerrada (2026-08-21)
|
|
||||||
Fix de F-116: renombre las 9 categorías que quedaron en mayúsculas (FRUTAS Y VERDURAS, SNACKS, GRANOLA, SUPLEMENTS, FACIAL, CORPORAL, ASEO PERSONAL, HIERBAS MEDICINALES, PROVEEDORES). Re-ejecución idempotente.
|
|
||||||
|
|
||||||
## F-116 cerrada (2026-08-21)
|
|
||||||
Traducción de las 30 categorías legacy a Español Title Case (Alimentación, Cosmética, Bebidas, Vino, Cerveza, Macrobiótica, etc.). Slugs conservados.
|
|
||||||
|
|
||||||
## F-115 cerrada (2026-08-21)
|
|
||||||
Rever de F-100: SKU-MV-{uuid} restaurado en creación de producto y lazy migration. Eliminado endpoint `/products/sku:generate` y el editor de SKU en admin.
|
|
||||||
|
|
||||||
## F-112 cerrada (2026-08-21)
|
|
||||||
Disclaimer de contenido asistido por IA en la ficha de producto. Migración 040 añade `catalog_products.ai_assisted`. Flag se activa automáticamente al generar contenido con IA. Frontend muestra el `<aside>` con los dos avisos.
|
|
||||||
|
|
||||||
## F-100 cerrada (2026-08-21)
|
|
||||||
SKU automático desde el título. Helper puro `sku.ts` con `generateSkuFromTitle` + `uniqueSku` (9 tests). Endpoint `POST /products/sku:generate` y helper mantenido como utilidad para F-117.
|
|
||||||
|
|
||||||
## F-112 cerrada (2026-08-21)
|
|
||||||
Disclaimer de contenido asistido por IA en la ficha de producto. Migración 040 añade `catalog_products.ai_assisted boolean NOT NULL DEFAULT false`. Flag se activa automáticamente al generar contenido con IA (`POST /products/:id/generate-seo`). Frontend (`apps/frontend`) muestra el `<aside>` con los dos avisos requeridos cuando el flag está en true. Tests: 160 → 169.
|
|
||||||
|
|
||||||
## F-100 cerrada (2026-08-21)
|
|
||||||
SKU automático desde el título. Helper puro `src/modules/catalog/domain/sku.ts` con `generateSkuFromTitle` + `uniqueSku` (9 tests). `POST /products` y la migración perezosa de variantes ahora generan SKUs tipo `MV-ESPELTA-ECOLOGICA` en lugar de `SKU-MV-${uuid}`. Endpoint admin `POST /products/sku:generate` para preview. UI admin: SKU editable + botón regenerar en `PriceStockSection`.
|
|
||||||
|
|
||||||
## F-114 cerrada (2026-08-21)
|
|
||||||
Importa las 76 categorías de `oc_category_description` de OpenCart. Crea 39 categorías nuevas y 32 marcas (nombres de marca van al módulo `brands_brands`), excluye marcadores legacy y duplicados. Script idempotente en `scripts/seed-legacy-categories.mjs`. Helpers puros en `src/modules/categories/legacy/legacy-catalog.ts` con 15 tests. Catálogo: 12→51 categorías, 5→37 marcas.
|
|
||||||
|
|
||||||
## F-113 cerrada y desplegada (2026-08-21)
|
|
||||||
Email al cliente en procesando/enviado con tracking y courier editable. Bug fixed: UI admin → `/orders/:id/transitions/admin`. Courier persistido (`orders_orders.courier`, migración 039). Lista editable en `store_settings.shipping_couriers`. `SHIPPED` exige tracking **y** courier. Tests: 135 → 145. Deploy manual.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-099 en build. El sistema deja de simular el envío de recuperación en logs y añade SMTP configurable desde Ajustes → SMTP / Email. También se incorporan generación de descripción normal vacía, proxy de logs con cookie httpOnly y navegación de login/logout solo con icono y tooltip.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-095 cerrada con todos los gates aprobados. Las imágenes URL se descargan y almacenan en uploads locales antes de adjuntarse.
|
|
||||||
|
|
||||||
## Incidencia anterior (2026-08-20)
|
|
||||||
|
|
||||||
Añadir una imagen por URL devolvía 400 porque el flujo hacía un PATCH vacío del producto y adjuntaba directamente la URL remota.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-094 cerrada con todos los gates aprobados. Publicar permite crear variantes y explica SKU/EAN, precios y stock.
|
|
||||||
|
|
||||||
## Incidencia anterior (2026-08-20)
|
|
||||||
|
|
||||||
Precios e Inventario indicaban que las variantes se creaban desde Publicar, pero Publicar no ofrecía creación ni explicación.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-093 cerrada con todos los gates aprobados. Inventario busca ahora por EAN o nombre de producto.
|
|
||||||
|
|
||||||
## Incidencia anterior (2026-08-20)
|
|
||||||
|
|
||||||
La búsqueda de Inventario enviaba `q`, pero el listado admin solo filtraba por nombre de producto.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-092 cerrada con todos los gates aprobados. `Fecha de caducidad` está ahora en General; backend y admin fueron reconstruidos y desplegados.
|
|
||||||
|
|
||||||
## Incidencia anterior (2026-08-20)
|
|
||||||
|
|
||||||
`Fecha de caducidad` estaba en la pestaña SEO de la ficha de producto. F-092 la movió a General sin cambiar el estado, payload ni guardado.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-091 cerrada con todos los gates aprobados. Los conflictos de SKU/EAN indican ahora qué campo está duplicado y Enter + blur no generan PATCH simultáneos.
|
|
||||||
|
|
||||||
## Incidencia anterior (2026-08-20)
|
|
||||||
|
|
||||||
El PATCH de variantes devolvía `409 PRODUCT_VARIANT_CODE_EXISTS` cuando el SKU o EAN ya estaba usado por otra variante. El editor mostraba solo `Error` y la combinación Enter + blur podía intentar enviar dos veces.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-090 cerrada con todos los gates aprobados. El listado ya recibe marca/caducidad y los valores SKU/EAN guardados permanecen visibles tras salir del campo.
|
|
||||||
|
|
||||||
## Incidencia anterior (2026-08-20)
|
|
||||||
|
|
||||||
El listado admin mostraba `—` para marca/caducidad porque la serialización del catálogo omitía ambos campos. En el editor de producto, SKU/EAN se guardaban en `rows` pero la vista no editable usaba el array `variants` inicial y volvía a mostrar valores antiguos.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-089 cerrada con todos los gates aprobados. El enlace del listado admin apunta ahora a `http://192.168.18.93:3003/products/<slug>`, ruta del frontend customer.
|
|
||||||
|
|
||||||
## Incidencia anterior (2026-08-20)
|
|
||||||
|
|
||||||
El enlace de producto del listado admin apuntaba a `http://192.168.18.93:3003/productos/<slug>`, pero el frontend customer usa `/products/<slug>`. F-089 corrigió únicamente ese path.
|
|
||||||
|
|
||||||
## Última incidencia resuelta (2026-08-20)
|
|
||||||
|
|
||||||
F-088 cerrada con todos los gates aprobados. El admin responde HTTP 200 en `http://192.168.18.93:3004/`.
|
|
||||||
|
|
||||||
## Symptom reportado por el operador (2026-08-20)
|
|
||||||
|
|
||||||
`http://192.168.18.93:3004/` → `ERR_CONNECTION_REFUSED`. El servicio admin del monolito no se mantiene en pie. Diagnóstico:
|
|
||||||
|
|
||||||
- `monolith.sh prod status` → admin `exited during startup`, log: `Could not find a production build in the '.next' directory`.
|
|
||||||
- `apps/admin/.next/` existe pero no contiene `BUILD_ID` ni `required-server-files.json`.
|
|
||||||
- `npx tsc --noEmit` en `apps/admin` → `error TS2353` en `tax-rates/page.tsx(59,33)`: `'appliesTo' does not exist in type 'Partial<{ name: string; ratePercent: number; active: boolean; }>'`.
|
|
||||||
|
|
||||||
## Root cause
|
|
||||||
|
|
||||||
F-085 añadió el handler `saveTipo` que llama a `taxApi.update(id, { appliesTo })` y extendió la página con UI de edición inline, pero olvidó extender la firma de `taxApi.update` en `apps/admin/src/lib/api-client.ts`. Resultado: `next build` aborta por typecheck, no se genera `BUILD_ID`, `next start` falla y el admin no puede servir nada en :3004. El backend en `pricing.routes.ts` ya valida y persiste `appliesTo`, así que el fix es 100% frontend (type only).
|
|
||||||
|
|
||||||
## Fix
|
|
||||||
|
|
||||||
Ampliar la firma `Partial<{...}>` de `taxApi.update` para aceptar `appliesTo: 'general' | 'reduced' | 'super-reduced'` (mismo enum que el backend). Después: `npm run build` en `apps/admin` produce `BUILD_ID`, `monolith.sh prod` mantiene el admin vivo y `http://192.168.18.93:3004/` responde 200.
|
|
||||||
|
|
||||||
## Pending tickets (2026-08-21)
|
|
||||||
|
|
||||||
Pendientes: F-100 (descartable, sustituida por F-109), F-101, F-102, F-107, F-108, F-109, F-110, F-111, F-112.
|
|
||||||
|
|
||||||
Orden sugerido: F-108 → F-109 → F-107 → F-110 → F-111 → F-101 → F-102 → F-112.
|
|
||||||
|
|
||||||
## Redefiniciones de intake del operador (2026-08-21)
|
|
||||||
|
|
||||||
- **F-101 (alcance reducido)**: El renderizado actual de descripciones IA ya funciona. Solo queda justificar el texto de la descripción en la ficha de producto del frontend. NO hace falta conversión Markdown→HTML.
|
|
||||||
- **F-102 (redefinida)**: El peso del producto es 1 por unidad (si pide 3, peso = 3 × peso unitario). Lo llamado "pack" es en realidad un **selector de compra mínima**: cantidad mínima de compra por producto; el frontend bloquea la compra por debajo de ese mínimo. Además, nueva opción de envío: límite de envío gratuito por rango de peso en cada tipo de envío y un **max weight** por tipo de envío.
|
|
||||||
- **F-112 (nueva)**: Disclaimer de contenido IA en fichas de producto del frontend:
|
|
||||||
- "Parte del contenido de esta ficha puede haber sido generado o asistido mediante inteligencia artificial y revisado antes de su publicación."
|
|
||||||
- "La composición y características del producto pueden cambiar. Consulta siempre la etiqueta y la información del fabricante antes de consumirlo o utilizarlo."
|
|
||||||
|
|
||||||
## Nota de intake (2026-08-20)
|
|
||||||
|
|
||||||
El operador reportó `ERR_CONNECTION_REFUSED` en :3004. El triage lo une a F-085 (cambio parcial sin actualizar la firma TS). Se crea F-088 para reparar la regresión sin reabrir F-085, y se ejecuta por orquestra secuencial.
|
|
||||||
|
|||||||
@@ -1,19 +1,68 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "FIX-160",
|
"feature_id": "F-157",
|
||||||
"stage": "build",
|
"stage": "close",
|
||||||
"agent": "implementer",
|
"agent": "leader",
|
||||||
"action": "Fix POS hydration: suppressHydrationWarning",
|
"action": "Close F-157 after approved gates",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-22T15:35:19Z",
|
"updated_at": "2026-08-22T15:42:24Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:35:19Z",
|
"ts": "2026-08-22T15:38:27Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "intake",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Define Reporting navigation as page-local sections"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:39:01Z",
|
||||||
|
"agent": "architect",
|
||||||
|
"stage": "design",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Design page-local Reporting navigation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:39:21Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Fix POS hydration: suppressHydrationWarning"
|
"message": "Move Reporting sections from sidebar into Reporting layout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:41:38Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review Reporting navigation and build evidence"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:41:52Z",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Check navigation RBAC and client URL safety"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:42:06Z",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Validate Reporting sidebar and local section acceptance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:42:15Z",
|
||||||
|
"agent": "documenter",
|
||||||
|
"stage": "document",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Document Reporting navigation behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:42:24Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Close F-157 after approved gates"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user