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

@@ -23,7 +23,8 @@ export type Permission =
| 'cms.write'
| 'admin-users.read'
| 'admin-users.write'
| 'audit.read';
| 'audit.read'
| 'reporting.read';
export function can(role: Role, permission: Permission): boolean {
if (role === 'admin') return true;
@@ -41,6 +42,7 @@ export interface NavItem {
export const NAV_ITEMS: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },

View File

@@ -0,0 +1,155 @@
/**
* F-147 — Reporting API client (frontend admin).
*
* Wraps the backend reporting endpoints (F-143, F-146).
* All paths go through /api/* (Next.js proxy).
*/
import { api } from './api-client';
export type ReportingChannel = 'all' | 'ecommerce' | 'pos' | 'admin';
export type ComparisonMode = 'none' | 'previous_equal' | 'previous_calendar';
export type GroupBy = 'day' | 'week' | 'month' | 'hour' | 'store' | 'channel' | 'terminal' | 'cashier' | 'payment';
export interface DateRange {
from: string;
to: string;
}
export interface ReportingFilters {
from: string;
to: string;
compare?: ComparisonMode;
channel?: ReportingChannel;
storeId?: string | string[];
terminalId?: string | string[];
state?: string | string[];
groupBy?: GroupBy;
page?: number;
pageSize?: number;
}
export type Availability = 'available' | 'unavailable';
export interface DataAvailability {
grossSales: Availability;
netSales: Availability;
discounts: Availability;
tax: Availability;
unitsSold: Availability;
orders: Availability;
customers: Availability;
margin: Availability;
paymentMethod: Availability;
refunds: Availability;
shipping: Availability;
}
export interface Metrics {
orders: number;
customers: number;
grossSalesCents: number;
discountsCents: number;
taxCents: number;
unitsSold: number;
shippingCents: number;
}
export interface Comparison {
previous: Metrics;
variation?: number;
}
export interface SummaryResponse {
range: DateRange;
filters: {
channel: ReportingChannel;
storeIds: string[];
terminalIds: string[];
};
comparison: Comparison | null;
dataAvailability: DataAvailability;
totals: Metrics;
updatedAt: string;
cache: { hit: boolean; maxAgeSeconds: number };
}
export interface SalesRow {
period: string | null;
channel: ReportingChannel | null;
storeId: string | null;
terminalId: string | null;
metrics: Metrics;
}
export interface SalesResponse extends SummaryResponse {
filters: {
channel: ReportingChannel;
storeIds: string[];
terminalIds: string[];
groupBy: GroupBy | null;
};
items: SalesRow[];
totals: Metrics;
pagination: {
page: number;
pageSize: number;
totalRows: number;
};
}
export interface FilterSchema {
filterSchema: {
filters: Array<{
name: string;
type: string;
required: boolean;
repeatable: boolean;
options?: readonly string[];
description: string;
}>;
comparison: { modes: ComparisonMode[]; rangeBounds: string };
groupBy: GroupBy[];
pagination: { pageMin: number; pageSizeMin: number; pageSizeMax: number };
dataAvailability: DataAvailability;
};
permissions: { role: string; grants: string[] };
}
// ── API calls ───────────────────────────────────────────────────────────────
function filtersToQueryString(filters: ReportingFilters): string {
const params = new URLSearchParams();
params.set('from', filters.from);
params.set('to', filters.to);
if (filters.compare && filters.compare !== 'none') params.set('compare', filters.compare);
if (filters.channel && filters.channel !== 'all') params.set('channel', filters.channel);
if (filters.groupBy) params.set('groupBy', filters.groupBy);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
if (filters.storeId) {
const ids = Array.isArray(filters.storeId) ? filters.storeId : [filters.storeId];
ids.forEach((id) => params.append('storeId', id));
}
if (filters.terminalId) {
const ids = Array.isArray(filters.terminalId) ? filters.terminalId : [filters.terminalId];
ids.forEach((id) => params.append('terminalId', id));
}
return params.toString();
}
export const reportingClient = {
async fetchSchema(): Promise<FilterSchema> {
return api.get<FilterSchema>('/api/reporting/filters/schema');
},
async fetchSummary(filters: ReportingFilters): Promise<SummaryResponse> {
const qs = filtersToQueryString(filters);
return api.get<SummaryResponse>(`/api/reporting/summary?${qs}`);
},
async fetchSales(filters: ReportingFilters): Promise<SalesResponse> {
const qs = filtersToQueryString(filters);
return api.get<SalesResponse>(`/api/reporting/sales?${qs}`);
},
};