fix(admin): trend chart height 100px, logs 3-column layout, KPI cards clickable links, AI timeout 120s env var, X/Pinterest settings
This commit is contained in:
@@ -7780,6 +7780,54 @@
|
|||||||
},
|
},
|
||||||
"phase": "storefront",
|
"phase": "storefront",
|
||||||
"completed_at": "2026-08-24T14:13:16Z"
|
"completed_at": "2026-08-24T14:13:16Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ADMIN-UI-FIXES2",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Admin UI fixes batch 2: trend chart, logs, KPI cards, AI timeout, X/Pinterest, product expiry/weight",
|
||||||
|
"description": "Need change",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-24",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
},
|
||||||
|
"phase": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "FRONTEND-UI-FIXES2",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Frontend/Storefront fixes 2: live search BASE URL, product expiry/weight, TPV receipt clock",
|
||||||
|
"description": "Need change",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-24",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
},
|
||||||
|
"phase": "frontend+storefront"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "INVENTORY-OPT",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Inventory optimization: pagination, queries, filters for 10k+ products",
|
||||||
|
"description": "Need change",
|
||||||
|
"priority": "med",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-24",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
},
|
||||||
|
"phase": "backend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { api } from '@/lib/api-client';
|
import { api } from '@/lib/api-client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
ordersToday: number;
|
ordersToday: number;
|
||||||
@@ -47,15 +48,17 @@ function KPICard({
|
|||||||
sub,
|
sub,
|
||||||
icon,
|
icon,
|
||||||
trend,
|
trend,
|
||||||
|
href,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
trend?: 'up' | 'down' | 'neutral';
|
trend?: 'up' | 'down' | 'neutral';
|
||||||
|
href?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const inner = (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-md hover:border-[#2D6A4F]/30 transition-all cursor-pointer">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500">{label}</p>
|
<p className="text-sm font-medium text-gray-500">{label}</p>
|
||||||
@@ -66,6 +69,7 @@ function KPICard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
return href ? <Link href={href} className="block">{inner}</Link> : inner;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
|
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
|
||||||
@@ -177,12 +181,14 @@ export default function DashboardPage() {
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<KPICard
|
<KPICard
|
||||||
label="Clientes nuevos"
|
label="Clientes nuevos"
|
||||||
|
href="/customers"
|
||||||
value={String(stats.newCustomersThisMonth)}
|
value={String(stats.newCustomersThisMonth)}
|
||||||
sub="Este mes"
|
sub="Este mes"
|
||||||
icon="👥"
|
icon="👥"
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
label="Total pedidos"
|
label="Total pedidos"
|
||||||
|
href="/orders"
|
||||||
value={String(totalOrders)}
|
value={String(totalOrders)}
|
||||||
sub="En el sistema"
|
sub="En el sistema"
|
||||||
icon="📋"
|
icon="📋"
|
||||||
@@ -196,6 +202,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
|
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
|
||||||
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
|
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
|
||||||
|
href="/inventory"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function TrendDashboard({ filters }: { filters: FilterState }) {
|
|||||||
|
|
||||||
const maxValue = Math.max(...points.map((p) => p.value), 1);
|
const maxValue = Math.max(...points.map((p) => p.value), 1);
|
||||||
|
|
||||||
return <TrendChart data={points} maxValue={maxValue} height={140} />;
|
return <TrendChart data={points} maxValue={maxValue} height={100} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelDashboard({ filters }: { filters: FilterState }) {
|
function ChannelDashboard({ filters }: { filters: FilterState }) {
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ export default function SettingsPage() {
|
|||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
|
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
|
||||||
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
|
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
|
||||||
|
{field('twitterUrl', 'X (Twitter)', { placeholder: 'https://x.com/...' })}
|
||||||
|
{field('pinterestUrl', 'Pinterest', { placeholder: 'https://pinterest.com/...' })}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -195,6 +195,11 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
|
|||||||
style={{ minHeight: 0 }}
|
style={{ minHeight: 0 }}
|
||||||
>
|
>
|
||||||
<table className="w-full table-fixed">
|
<table className="w-full table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-32 shrink-0" />
|
||||||
|
<col className="w-16 shrink-0" />
|
||||||
|
<col className="flex-1 min-w-0" />
|
||||||
|
</colgroup>
|
||||||
<tbody>
|
<tbody>
|
||||||
{logs.map((entry, i) => (
|
{logs.map((entry, i) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -220,7 +225,7 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
<td className="px-3 py-0.5 text-gray-300 select-all">
|
<td className="px-3 py-0.5 text-gray-300 select-all overflow-hidden text-ellipsis">
|
||||||
{/* Extra fields for HTTP request logs */}
|
{/* Extra fields for HTTP request logs */}
|
||||||
{entry.method && entry.url && (
|
{entry.method && entry.url && (
|
||||||
<span className="mr-2">
|
<span className="mr-2">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface TrendChartProps {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrendChart({ data, maxValue, height = 200 }: TrendChartProps) {
|
export function TrendChart({ data, maxValue, height = 120 }: TrendChartProps) {
|
||||||
if (data.length === 0) return null;
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
const width = 100; // percentage-based SVG
|
const width = 100; // percentage-based SVG
|
||||||
|
|||||||
13
project/migrations/062_settings_twitter_pinterest.js
Normal file
13
project/migrations/062_settings_twitter_pinterest.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Add twitter_url and pinterest_url columns to store_settings table.
|
||||||
|
* Usage: node --env-file=.env -e "require('./migrations/062_settings_twitter_pinterest.js')"
|
||||||
|
*/
|
||||||
|
import { query } from '../src/infrastructure/db/migrate.js';
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
await query(`ALTER TABLE store_settings ADD COLUMN IF NOT EXISTS twitter_url TEXT`);
|
||||||
|
await query(`ALTER TABLE store_settings ADD COLUMN IF NOT EXISTS pinterest_url TEXT`);
|
||||||
|
console.log('✓ Added twitter_url and pinterest_url to store_settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -21,6 +21,8 @@ const updateSettingsSchema = z.object({
|
|||||||
footerText: z.string().max(400).optional(),
|
footerText: z.string().max(400).optional(),
|
||||||
facebookUrl: z.string().url().optional().or(z.literal('')),
|
facebookUrl: z.string().url().optional().or(z.literal('')),
|
||||||
instagramUrl: z.string().url().optional().or(z.literal('')),
|
instagramUrl: z.string().url().optional().or(z.literal('')),
|
||||||
|
twitterUrl: z.string().url().optional().or(z.literal('')),
|
||||||
|
pinterestUrl: z.string().url().optional().or(z.literal('')),
|
||||||
aiProvider: z.string().max(80).optional(),
|
aiProvider: z.string().max(80).optional(),
|
||||||
aiBaseUrl: z.string().url().optional().or(z.literal('')),
|
aiBaseUrl: z.string().url().optional().or(z.literal('')),
|
||||||
aiModel: z.string().max(120).optional(),
|
aiModel: z.string().max(120).optional(),
|
||||||
@@ -69,6 +71,8 @@ const SETTING_KEYS: Record<string, string> = {
|
|||||||
footerText: 'footer_text',
|
footerText: 'footer_text',
|
||||||
facebookUrl: 'facebook_url',
|
facebookUrl: 'facebook_url',
|
||||||
instagramUrl: 'instagram_url',
|
instagramUrl: 'instagram_url',
|
||||||
|
twitterUrl: 'twitter_url',
|
||||||
|
pinterestUrl: 'pinterest_url',
|
||||||
aiProvider: 'ai_provider',
|
aiProvider: 'ai_provider',
|
||||||
aiBaseUrl: 'ai_base_url',
|
aiBaseUrl: 'ai_base_url',
|
||||||
aiModel: 'ai_model',
|
aiModel: 'ai_model',
|
||||||
@@ -117,6 +121,8 @@ export async function registerStoreSettingsRoutes(
|
|||||||
footerText: map['footer_text'] ?? '',
|
footerText: map['footer_text'] ?? '',
|
||||||
facebookUrl: map['facebook_url'] ?? '',
|
facebookUrl: map['facebook_url'] ?? '',
|
||||||
instagramUrl: map['instagram_url'] ?? '',
|
instagramUrl: map['instagram_url'] ?? '',
|
||||||
|
twitterUrl: map['twitter_url'] ?? '',
|
||||||
|
pinterestUrl: map['pinterest_url'] ?? '',
|
||||||
aiProvider: map['ai_provider'] ?? '',
|
aiProvider: map['ai_provider'] ?? '',
|
||||||
aiBaseUrl: map['ai_base_url'] ?? '',
|
aiBaseUrl: map['ai_base_url'] ?? '',
|
||||||
aiModel: map['ai_model'] ?? '',
|
aiModel: map['ai_model'] ?? '',
|
||||||
@@ -206,6 +212,8 @@ export async function registerStoreSettingsRoutes(
|
|||||||
footerText: map['footer_text'] ?? '',
|
footerText: map['footer_text'] ?? '',
|
||||||
facebookUrl: map['facebook_url'] ?? '',
|
facebookUrl: map['facebook_url'] ?? '',
|
||||||
instagramUrl: map['instagram_url'] ?? '',
|
instagramUrl: map['instagram_url'] ?? '',
|
||||||
|
twitterUrl: map['twitter_url'] ?? '',
|
||||||
|
pinterestUrl: map['pinterest_url'] ?? '',
|
||||||
aiProvider: map['ai_provider'] ?? '',
|
aiProvider: map['ai_provider'] ?? '',
|
||||||
aiBaseUrl: map['ai_base_url'] ?? '',
|
aiBaseUrl: map['ai_base_url'] ?? '',
|
||||||
aiModel: map['ai_model'] ?? '',
|
aiModel: map['ai_model'] ?? '',
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ export interface StoreSettings {
|
|||||||
footerText: string;
|
footerText: string;
|
||||||
facebookUrl: string;
|
facebookUrl: string;
|
||||||
instagramUrl: string;
|
instagramUrl: string;
|
||||||
|
twitterUrl: string;
|
||||||
|
pinterestUrl: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { AppError } from './errors.js';
|
import { AppError } from './errors.js';
|
||||||
|
|
||||||
|
/** Default AI timeout: 2 minutes for complex product descriptions */
|
||||||
|
const AI_TIMEOUT_MS = Number.parseInt(process.env.AI_TIMEOUT_MS ?? '120000', 10);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared helpers for AI content generation (OpenAI-compatible chat API)
|
* Shared helpers for AI content generation (OpenAI-compatible chat API)
|
||||||
* and HTML formatting of the generated text.
|
* and HTML formatting of the generated text.
|
||||||
@@ -15,7 +18,7 @@ export async function generateWithModel(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.4 }),
|
body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], temperature: 0.4 }),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(AI_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
const payload = (await response.json().catch(() => null)) as {
|
const payload = (await response.json().catch(() => null)) as {
|
||||||
choices?: Array<{ message?: { content?: unknown } }>;
|
choices?: Array<{ message?: { content?: unknown } }>;
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "FRONTEND-FIXES",
|
"feature_id": "ADMIN-UI-FIXES2",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "",
|
"action": "",
|
||||||
"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-24T14:15:00Z",
|
"updated_at": "2026-08-24T20:06:08Z",
|
||||||
"timeline": []
|
"timeline": [
|
||||||
|
{
|
||||||
|
"ts": "2026-08-24T20:06:08Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Estado actualizado"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user