feat(ADM-018): completed feature
This commit is contained in:
314
project/apps/admin/src/lib/api-client.ts
Normal file
314
project/apps/admin/src/lib/api-client.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { ApiError } from '@/types';
|
||||
|
||||
/**
|
||||
* All requests go to /api/* (relative paths) — the Next.js catch-all
|
||||
* route handler proxies them to the backend. This keeps all traffic
|
||||
* within the same origin, avoiding CORS preflights entirely.
|
||||
*/
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new ApiError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
|
||||
if (res.status === 403) {
|
||||
throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
(body as { code?: string }).code ?? 'REQUEST_FAILED',
|
||||
(body as { message?: string }).message ?? 'Request failed',
|
||||
);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api.post<{ id: string; email: string; role: string }>('/api/auth/login', { email, password }),
|
||||
logout: () => api.post('/api/auth/logout'),
|
||||
me: () =>
|
||||
api.get<{ id: string; email: string; role: string } | { user: null }>('/api/auth/me'),
|
||||
};
|
||||
|
||||
// ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const productsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: import('@/types').Product[]; total: number }>(
|
||||
`/api/catalog/products${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Product>(`/api/catalog/products/${id}`),
|
||||
getVariants: (id: string) =>
|
||||
api.get<{ items: import('@/types').ProductVariant[] }>(`/api/catalog/products/${id}/variants`),
|
||||
create: (data: unknown) => api.post<import('@/types').Product>('/api/catalog/products', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Product>(`/api/catalog/products/${id}`, data),
|
||||
setState: (id: string, state: 'active' | 'archived') =>
|
||||
api.patch(`/api/catalog/products/${id}/state`, { state }),
|
||||
delete: (id: string) => api.delete(`/api/catalog/products/${id}`),
|
||||
};
|
||||
|
||||
// ── Orders ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ordersApi = {
|
||||
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.status) sp.set('status', params.status);
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: import('@/types').Order[]; total: number }>(
|
||||
`/api/orders${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
|
||||
transition: (id: string, state: string) =>
|
||||
api.post<import('@/types').Order>(`/api/orders/${id}/transitions`, { state }),
|
||||
};
|
||||
|
||||
// ── Customers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const customersApi = {
|
||||
list: (params?: { offset?: number; limit?: number; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.offset !== undefined) sp.set('offset', String(params.offset));
|
||||
if (params?.limit !== undefined) sp.set('limit', String(params.limit));
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: import('@/types').Customer[]; total: number }>(`/api/users${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Customer>(`/api/users/${id}`),
|
||||
update: (id: string, data: { displayName?: string; phone?: string }) =>
|
||||
api.patch<import('@/types').Customer>(`/api/users/${id}`, data),
|
||||
create: (data: { email: string; password: string; displayName?: string; phone?: string }) =>
|
||||
api.post<import('@/types').Customer>('/api/auth/register', data),
|
||||
};
|
||||
|
||||
// ── Brands ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const brandsApi = {
|
||||
list: () => api.get<{ items: import('@/types').Brand[] }>('/api/brands'),
|
||||
create: (data: unknown) => api.post<import('@/types').Brand>('/api/brands', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Brand>(`/api/brands/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/brands/${id}`),
|
||||
};
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const categoriesApi = {
|
||||
list: () => api.get<{ items: import('@/types').Category[] }>('/api/categories/tree'),
|
||||
create: (data: unknown) => api.post<import('@/types').Category>('/api/categories', data),
|
||||
update: (id: string, data: unknown) =>
|
||||
api.patch<import('@/types').Category>(`/api/categories/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/categories/${id}`),
|
||||
};
|
||||
|
||||
// ── Inventory ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const inventoryApi = {
|
||||
getAvailability: (variantId: string) =>
|
||||
api.get<import('@/types').StockAvailability>(`/api/inventory/${variantId}/availability`),
|
||||
setStock: (id: string, quantity: number) =>
|
||||
api.put<import('@/types').StockItem>(`/api/inventory/${id}/stock`, { quantity }),
|
||||
};
|
||||
|
||||
// ── Pricing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const pricingApi = {
|
||||
getVariantPrice: (id: string) => api.get<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`),
|
||||
setVariantPrice: (
|
||||
id: string,
|
||||
netUnitAmountCents: number,
|
||||
vatRate: 'general' | 'reduced',
|
||||
offerCents?: number | null,
|
||||
costCents?: number | null,
|
||||
) =>
|
||||
api.put<import('@/types').VariantPrice>(`/api/pricing/variants/${id}`, {
|
||||
netUnitAmountCents,
|
||||
vatRate,
|
||||
offerCents: offerCents ?? null,
|
||||
costCents: costCents ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
// ── Promotions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const promotionsApi = {
|
||||
list: () => api.get<{ items: unknown[] }>('/api/promotions'),
|
||||
create: (data: unknown) => api.post('/api/promotions', data),
|
||||
update: (code: string, data: unknown) => api.patch(`/api/promotions/${code}`, data),
|
||||
delete: (code: string) => api.delete(`/api/promotions/${code}`),
|
||||
};
|
||||
|
||||
// ── Reviews ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const reviewsApi = {
|
||||
listAdmin: (params?: { status?: string; limit?: number; offset?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.status) sp.set('status', params.status);
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: unknown[]; total: number }>(`/api/reviews/admin${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
moderate: (id: string, status: 'published' | 'rejected') =>
|
||||
api.patch(`/api/reviews/${id}/moderate`, { status }),
|
||||
};
|
||||
|
||||
// ── CMS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const cmsApi = {
|
||||
list: () => api.get<{ items: unknown[] }>('/api/cms/pages'),
|
||||
get: (slug: string) => api.get(`/api/cms/pages/${slug}`),
|
||||
create: (data: unknown) => api.post('/api/cms/pages', data),
|
||||
update: (id: string, data: unknown) => api.patch(`/api/cms/pages/${id}`, data),
|
||||
publish: (id: string) => api.post(`/api/cms/pages/${id}/publish`, {}),
|
||||
unpublish: (id: string) => api.post(`/api/cms/pages/${id}/unpublish`, {}),
|
||||
};
|
||||
|
||||
// ── Admin Users ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const adminUsersApi = {
|
||||
list: (params?: { limit?: number; offset?: number; role?: string; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.role) sp.set('role', params.role);
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: { id: string; email: string; role: string; createdAt: string }[]; total: number }>(
|
||||
`/api/admin/users${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
create: (data: { email: string; password: string; role: string }) =>
|
||||
api.post<{ id: string; email: string; role: string; createdAt: string }>('/api/admin/users', data),
|
||||
update: (id: string, data: { role?: string; password?: string }) =>
|
||||
api.patch<{ id: string; email: string; role: string; createdAt: string }>(`/api/admin/users/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/admin/users/${id}`),
|
||||
};
|
||||
|
||||
// ── Tax Rates ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TaxRate {
|
||||
id: string; name: string; ratePercent: number; country: string; appliesTo: string; active: boolean;
|
||||
}
|
||||
export const taxApi = {
|
||||
list: () => api.get<{ items: TaxRate[] }>('/api/admin/tax-rates'),
|
||||
update: (id: string, data: Partial<{ name: string; ratePercent: number; active: boolean }>) =>
|
||||
api.patch('/api/admin/tax-rates/' + id, data),
|
||||
};
|
||||
|
||||
// ── Payments ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaymentTransaction {
|
||||
id: string; provider: string; providerPaymentId: string | null;
|
||||
orderId: string | null; amountCents: number; currency: string;
|
||||
status: string; raw: unknown; createdAt: string;
|
||||
}
|
||||
export const paymentsApi = {
|
||||
list: (params?: { limit?: number; offset?: number; status?: string; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
if (params?.status) sp.set('status', params.status);
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: PaymentTransaction[]; total: number }>(`/api/admin/payments${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
refund: (id: string) => api.post<{ ok: boolean }>(`/api/admin/payments/${id}/refund`, {}),
|
||||
};
|
||||
|
||||
// ── Shipping ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ShippingZone {
|
||||
id: string; name: string; country: string; postalCodePrefix: string | null; active: boolean;
|
||||
}
|
||||
export interface ShippingMethod {
|
||||
id: string; zoneId: string; zoneName: string; name: string;
|
||||
baseCostCents: number; freeShippingThresholdCents: number | null; active: boolean;
|
||||
}
|
||||
export const shippingApi = {
|
||||
listZones: () => api.get<{ items: ShippingZone[] }>('/api/admin/shipping/zones'),
|
||||
createZone: (data: { name: string; country: string; postalCodePrefix?: string | null; active?: boolean }) =>
|
||||
api.post<{ id: string }>('/api/admin/shipping/zones', data),
|
||||
updateZone: (id: string, data: Partial<{ name: string; country: string; postalCodePrefix?: string | null; active: boolean }>) =>
|
||||
api.patch('/api/admin/shipping/zones/' + id, data),
|
||||
deleteZone: (id: string) => api.delete<void>('/api/admin/shipping/zones/' + id),
|
||||
listMethods: () => api.get<{ items: ShippingMethod[] }>('/api/admin/shipping/methods'),
|
||||
createMethod: (data: { zoneId: string; name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active?: boolean }) =>
|
||||
api.post<{ id: string }>('/api/admin/shipping/methods', data),
|
||||
updateMethod: (id: string, data: Partial<{ name: string; baseCostCents: number; freeShippingThresholdCents?: number | null; active: boolean }>) =>
|
||||
api.patch('/api/admin/shipping/methods/' + id, data),
|
||||
deleteMethod: (id: string) => api.delete<void>('/api/admin/shipping/methods/' + id),
|
||||
};
|
||||
|
||||
// ── Store Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const auditApi = {
|
||||
list: (params?: { actorId?: string; action?: string; limit?: number; offset?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.action) sp.set('action', params.action);
|
||||
if (params?.limit) sp.set('limit', String(params.limit));
|
||||
if (params?.offset) sp.set('offset', String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return api.get<{ items: AuditEntry[]; total: number }>(`/api/admin/audit${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
};
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
target: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StoreSettings {
|
||||
storeName: string;
|
||||
storeTagline: string;
|
||||
contactEmail: string;
|
||||
contactPhone: string;
|
||||
contactAddress: string;
|
||||
footerText: string;
|
||||
facebookUrl: string;
|
||||
instagramUrl: string;
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => api.get<StoreSettings>('/api/admin/settings'),
|
||||
update: (data: Partial<StoreSettings>) => api.patch<StoreSettings>('/api/admin/settings', data),
|
||||
};
|
||||
63
project/apps/admin/src/lib/permissions.ts
Normal file
63
project/apps/admin/src/lib/permissions.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Role } from '@/types';
|
||||
|
||||
export type Permission =
|
||||
| 'dashboard'
|
||||
| 'products.read'
|
||||
| 'products.write'
|
||||
| 'orders.read'
|
||||
| 'orders.write'
|
||||
| 'inventory.read'
|
||||
| 'inventory.write'
|
||||
| 'customers.read'
|
||||
| 'customers.write'
|
||||
| 'categories.read'
|
||||
| 'categories.write'
|
||||
| 'categories.delete'
|
||||
| 'brands.read'
|
||||
| 'brands.write'
|
||||
| 'promotions.read'
|
||||
| 'promotions.write'
|
||||
| 'reviews.read'
|
||||
| 'reviews.moderate'
|
||||
| 'cms.read'
|
||||
| 'cms.write'
|
||||
| 'admin-users.read'
|
||||
| 'admin-users.write'
|
||||
| 'audit.read';
|
||||
|
||||
export function can(role: Role, permission: Permission): boolean {
|
||||
if (role === 'admin') return true;
|
||||
// Future: granular permission checks when backend supports them
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
permission: Permission;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
||||
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
||||
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
||||
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
||||
{ href: '/inventory', label: 'Inventario', icon: '📊', permission: 'inventory.read' },
|
||||
{ href: '/customers', label: 'Clientes', icon: '👥', permission: 'customers.read' },
|
||||
{ href: '/categories', label: 'Categorías', icon: '🏷️', permission: 'categories.read' },
|
||||
{ href: '/brands', label: 'Marcas', icon: '🏷️', permission: 'brands.read' },
|
||||
{ href: '/promotions', label: 'Promociones', icon: '🏷️', permission: 'promotions.read' },
|
||||
{ href: '/shipping', label: 'Envíos', icon: '📦', permission: 'orders.read' },
|
||||
{ href: '/reviews', label: 'Reseñas', icon: '⭐', permission: 'reviews.read' },
|
||||
{ href: '/cms', label: 'CMS', icon: '📄', permission: 'cms.read' },
|
||||
{ href: '/users', label: 'Usuarios', icon: '🔐', permission: 'admin-users.read' },
|
||||
{ href: '/tax-rates', label: 'IVA', icon: '📊', permission: 'orders.read' },
|
||||
{ href: '/audit', label: 'Auditoría', icon: '📋', permission: 'audit.read' },
|
||||
{ href: '/settings', label: 'Ajustes', icon: '⚙️', permission: 'dashboard' },
|
||||
];
|
||||
|
||||
export function visibleNavItems(role: Role): NavItem[] {
|
||||
return NAV_ITEMS.filter((item) => can(role, item.permission));
|
||||
}
|
||||
Reference in New Issue
Block a user