feat(F-159): completed feature
This commit is contained in:
@@ -6709,6 +6709,83 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-22T15:50:54Z"
|
"completed_at": "2026-08-22T15:50:54Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-159",
|
||||||
|
"type": "feature",
|
||||||
|
"title": "Admin responsive collapsible sidebar",
|
||||||
|
"description": "Add accessible persisted SidebarToggle: desktop icon-only collapse and mobile/tablet navigation drawer without duplicated navigation logic.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "med",
|
||||||
|
"status": "done",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": true,
|
||||||
|
"security": true,
|
||||||
|
"qa": true,
|
||||||
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T16:02:11Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-160",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Fix Reporting sales grouped queries returning 500",
|
||||||
|
"description": "Diagnose and correct SQL/runtime failures for day, channel, store and terminal grouped reporting sales endpoints.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "high",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-161",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Complete admin order detail information",
|
||||||
|
"description": "Order detail must show shipping address, payment method, customer details and correct customer email without false missing-email warning.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "med",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-162",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "Add storefront product link to inventory",
|
||||||
|
"description": "Add final Tienda column to inventory, matching product list, linking to storefront product page.",
|
||||||
|
"priority": "med",
|
||||||
|
"risk": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-163",
|
||||||
|
"type": "feature",
|
||||||
|
"title": "Make POS terminal and cash session setup usable",
|
||||||
|
"description": "Clarify and expose terminal binding and cash session workflow so operator can configure and open TPV without manual database/API steps.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "med",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": false,
|
||||||
|
"security": false,
|
||||||
|
"qa": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
4
project/apps/admin/next-env.d.ts
vendored
4
project/apps/admin/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
import "./.next/dev/types/root-params.d.ts";
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,32 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
||||||
import { visibleNavItems, 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 }) {
|
const SIDEBAR_STORAGE_KEY = 'mdv.admin.sidebar.collapsed';
|
||||||
|
const SIDEBAR_ID = 'admin-sidebar';
|
||||||
|
|
||||||
|
interface SidebarToggleProps {
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
mode: 'desktop' | 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarToggle({ expanded, onToggle, mode }: SidebarToggleProps) {
|
||||||
|
const label = mode === 'mobile'
|
||||||
|
? expanded ? 'Cerrar menú de navegación' : 'Abrir menú de navegación'
|
||||||
|
: expanded ? 'Colapsar menú lateral' : 'Expandir menú lateral';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={label}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={SIDEBAR_ID}
|
||||||
|
title={label}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{mode === 'mobile' ? (
|
||||||
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
{expanded
|
||||||
|
? <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
: <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />}
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg aria-hidden="true" className={`h-5 w-5 transition-transform duration-300 ${expanded ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItemRow({ item, collapsed, onNavigate }: { item: NavItem; collapsed: boolean; onNavigate: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const active = item.href === '/'
|
const active = item.href === '/' ? pathname === '/' : pathname.startsWith(item.href);
|
||||||
? pathname === '/'
|
|
||||||
: pathname.startsWith(item.href);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`
|
onClick={onNavigate}
|
||||||
flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all
|
aria-label={item.label}
|
||||||
${active
|
aria-current={active ? 'page' : undefined}
|
||||||
? 'bg-[#2D6A4F]/10 text-[#2D6A4F] border-l-[3px] border-[#2D6A4F]'
|
title={collapsed ? item.label : undefined}
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] focus-visible:ring-offset-1 ${
|
||||||
`}
|
collapsed ? 'lg:justify-center lg:px-2' : ''
|
||||||
|
} ${
|
||||||
|
active
|
||||||
|
? 'border-l-[3px] border-[#2D6A4F] bg-[#2D6A4F]/10 text-[#2D6A4F]'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-base">{item.icon}</span>
|
<span aria-hidden="true" className="shrink-0 text-base">{item.icon}</span>
|
||||||
<span className="truncate">{item.label}</span>
|
<span className={`truncate ${collapsed ? 'lg:hidden' : ''}`}>{item.label}</span>
|
||||||
{item.badge != null && item.badge > 0 && (
|
{item.badge != null && item.badge > 0 && (
|
||||||
<span className="ml-auto bg-[#E76F51] text-white text-xs font-bold rounded-full px-1.5 py-0.5 min-w-[18px] text-center">
|
<span className={`ml-auto min-w-[18px] rounded-full bg-[#E76F51] px-1.5 py-0.5 text-center text-xs font-bold text-white ${collapsed ? 'lg:hidden' : ''}`}>
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -34,51 +76,107 @@ function NavItemRow({ item }: { item: NavItem }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sidebar({ role, email }: { role: Role; email: string }) {
|
interface SidebarProps {
|
||||||
|
role: Role;
|
||||||
|
email: string;
|
||||||
|
collapsed: boolean;
|
||||||
|
mobileOpen: boolean;
|
||||||
|
onCloseMobile: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ role, email, collapsed, mobileOpen, onCloseMobile, onLogout }: SidebarProps) {
|
||||||
const items = visibleNavItems(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">
|
<aside
|
||||||
{/* Logo */}
|
id={SIDEBAR_ID}
|
||||||
<div className="px-4 py-5 border-b border-gray-100">
|
aria-label="Navegación principal"
|
||||||
|
className={`fixed inset-y-0 left-0 z-50 flex h-screen w-60 shrink-0 flex-col border-r border-gray-200 bg-white transition-[width,transform] duration-300 ease-in-out lg:sticky lg:top-0 lg:visible lg:translate-x-0 ${
|
||||||
|
mobileOpen ? 'visible translate-x-0' : 'invisible -translate-x-full'
|
||||||
|
} ${collapsed ? 'lg:w-20' : 'lg:w-60'}`}
|
||||||
|
>
|
||||||
|
<div className="relative border-b border-gray-100 px-4 py-5">
|
||||||
<img
|
<img
|
||||||
src="/images/logo-main.png"
|
src="/images/logo-main.png"
|
||||||
alt="mercadodevida"
|
alt="mercadodevida"
|
||||||
className="h-9 w-auto object-contain mx-auto"
|
className={`mx-auto h-9 w-auto object-contain ${collapsed ? 'lg:hidden' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
<span className={`hidden h-9 items-center justify-center text-2xl ${collapsed ? 'lg:flex' : ''}`} aria-hidden="true">🌿</span>
|
||||||
|
<div className="absolute right-2 top-4 lg:hidden">
|
||||||
|
<SidebarToggle expanded={mobileOpen} onToggle={onCloseMobile} mode="mobile" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
<nav aria-label="Secciones del panel" className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<NavItemRow key={item.href} item={item} />
|
<NavItemRow key={item.href} item={item} collapsed={collapsed} onNavigate={onCloseMobile} />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User footer */}
|
<div className="border-t border-gray-100 px-3 py-4">
|
||||||
<div className="px-3 py-4 border-t border-gray-100">
|
<div className={`px-3 py-2 ${collapsed ? 'lg:hidden' : ''}`}>
|
||||||
<div className="px-3 py-2 mb-2">
|
<p className="truncate text-xs text-gray-400">{email}</p>
|
||||||
<p className="text-xs text-gray-400 truncate">{email}</p>
|
<p className="text-xs capitalize text-gray-500">{role}</p>
|
||||||
<p className="text-xs text-gray-500 capitalize">{role}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
|
title={collapsed ? 'Cerrar sesión' : undefined}
|
||||||
|
className={`mt-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] ${
|
||||||
|
collapsed ? 'lg:justify-center lg:px-2' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" />
|
||||||
|
</svg>
|
||||||
|
<span className={collapsed ? 'lg:hidden' : ''}>Cerrar sesión</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashboardShell({ children }: { children: React.ReactNode }) {
|
function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading, logout } = useAuth();
|
const { user, loading, logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !user) {
|
if (!loading && !user) router.push('/login');
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDesktopCollapsed(localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobileOpen) return;
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMobileOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [mobileOpen]);
|
||||||
|
|
||||||
|
const toggleDesktop = () => {
|
||||||
|
setDesktopCollapsed((current) => {
|
||||||
|
const next = !current;
|
||||||
|
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||||
<div className="text-gray-500">Cargando...</div>
|
<div className="text-gray-500">Cargando...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -88,12 +186,35 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-gray-50">
|
<div className="flex min-h-screen bg-gray-50">
|
||||||
<Sidebar role={user.role} email={user.email} />
|
{mobileOpen && (
|
||||||
<main className="flex-1 min-w-0">
|
<button
|
||||||
<div className="w-full px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
|
type="button"
|
||||||
{children}
|
aria-label="Cerrar menú de navegación"
|
||||||
</div>
|
onClick={() => setMobileOpen(false)}
|
||||||
</main>
|
className="fixed inset-0 z-40 bg-gray-900/40 backdrop-blur-[1px] lg:hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
role={user.role}
|
||||||
|
email={user.email}
|
||||||
|
collapsed={desktopCollapsed}
|
||||||
|
mobileOpen={mobileOpen}
|
||||||
|
onCloseMobile={() => setMobileOpen(false)}
|
||||||
|
onLogout={logout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-gray-200 bg-white/95 px-4 backdrop-blur sm:px-6 lg:px-10">
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<SidebarToggle expanded={mobileOpen} onToggle={() => setMobileOpen(true)} mode="mobile" />
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<SidebarToggle expanded={!desktopCollapsed} onToggle={toggleDesktop} mode="desktop" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="w-full px-4 py-6 sm:px-6 lg:px-10 lg:py-8">{children}</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
24
work/artifacts/F-159/architect.md
Normal file
24
work/artifacts/F-159/architect.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# F-159 — Diseño
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
El estado vive en `DashboardShell`, única composición del layout autenticado:
|
||||||
|
|
||||||
|
- `desktopCollapsed`: preferencia persistida en `localStorage` bajo `mdv.admin.sidebar.collapsed`.
|
||||||
|
- `mobileOpen`: estado efímero del drawer.
|
||||||
|
|
||||||
|
`Sidebar` y `NavItemRow` siguen consumiendo la misma lista `visibleNavItems(role)`. No se crean menús alternativos.
|
||||||
|
|
||||||
|
## Responsive
|
||||||
|
- Sidebar base: drawer `fixed`, 240px y desplazado fuera de viewport cuando está cerrado.
|
||||||
|
- `lg+`: pasa a `sticky`, siempre visible, ancho 240px/80px según preferencia.
|
||||||
|
- Etiquetas usan `lg:hidden` solo durante collapse, por lo que el drawer móvil siempre conserva texto.
|
||||||
|
- Backdrop solo se renderiza con drawer abierto.
|
||||||
|
|
||||||
|
## Accesibilidad
|
||||||
|
- Toggle es `<button>` nativo con `aria-expanded`, `aria-controls` y etiqueta dinámica.
|
||||||
|
- Escape cierra drawer.
|
||||||
|
- Drawer móvil cerrado usa `invisible` para excluir enlaces del foco.
|
||||||
|
- Enlaces tienen `aria-label`; modo icon-only añade `title`.
|
||||||
|
|
||||||
|
## Transiciones
|
||||||
|
`transition-[width,transform] duration-300 ease-in-out`, respetando las utilidades existentes de Tailwind.
|
||||||
5
work/artifacts/F-159/documenter.md
Normal file
5
work/artifacts/F-159/documenter.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# F-159 — Uso
|
||||||
|
|
||||||
|
- En desktop, el botón del header colapsa/expande el sidebar. La preferencia se conserva al recargar.
|
||||||
|
- Colapsado muestra iconos; cada icono mantiene tooltip y nombre accesible.
|
||||||
|
- En mobile/tablet, el botón abre un drawer. Se cierra al navegar, pulsar el fondo, el botón de cierre o Escape.
|
||||||
20
work/artifacts/F-159/implementer.md
Normal file
20
work/artifacts/F-159/implementer.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# F-159 — Implementer
|
||||||
|
|
||||||
|
## Implementación
|
||||||
|
- `SidebarToggle` reutilizable para desktop y mobile.
|
||||||
|
- Estado desktop `expanded/collapsed` persistido en `localStorage` (`mdv.admin.sidebar.collapsed`).
|
||||||
|
- Desktop colapsado reduce 240px→80px y oculta etiquetas solo en breakpoint `lg`.
|
||||||
|
- Mobile/tablet usa el mismo sidebar como drawer con backdrop y cierre por navegación, botón o Escape.
|
||||||
|
- Drawer cerrado queda `invisible`, evitando foco en enlaces fuera de pantalla.
|
||||||
|
- Transiciones suaves de ancho, transformación e icono.
|
||||||
|
- Accesibilidad: botones nativos, `aria-label`, `aria-expanded`, `aria-controls`, `aria-current`, focus rings y tooltips.
|
||||||
|
- Restaurado acceso visible a cerrar sesión dentro del sidebar.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
- `cd project/apps/admin && npm run build`: PASS (29 rutas).
|
||||||
|
- `cd project && node_modules/.bin/tsc --noEmit`: PASS.
|
||||||
|
- `git diff --check`: PASS.
|
||||||
|
- `./scripts/verify.sh`: PASS.
|
||||||
|
|
||||||
|
## Archivo
|
||||||
|
- `project/apps/admin/src/app/(dashboard)/layout.tsx`
|
||||||
14
work/artifacts/F-159/leader-close.json
Normal file
14
work/artifacts/F-159/leader-close.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id":"F-159",
|
||||||
|
"agent":"leader",
|
||||||
|
"stage":"close",
|
||||||
|
"verdict":"APPROVED",
|
||||||
|
"summary":"SidebarToggle responsive, persistente y accesible completado.",
|
||||||
|
"checks":[
|
||||||
|
{"item":"reviewer","ok":true},
|
||||||
|
{"item":"security","ok":true},
|
||||||
|
{"item":"qa","ok":true},
|
||||||
|
{"item":"build and verify","ok":true}
|
||||||
|
],
|
||||||
|
"issues":[]
|
||||||
|
}
|
||||||
15
work/artifacts/F-159/qa.json
Normal file
15
work/artifacts/F-159/qa.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-159",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Cumple persistencia, icon-only desktop, drawer mobile, transición y accesibilidad.",
|
||||||
|
"checks": [
|
||||||
|
{"item":"Desktop collapse","ok":true,"evidence":"lg width 60→20 and lg-only label hiding"},
|
||||||
|
{"item":"Mobile/tablet drawer","ok":true,"evidence":"fixed transform drawer plus backdrop"},
|
||||||
|
{"item":"Keyboard","ok":true,"evidence":"Native buttons, focus rings and Escape handler"},
|
||||||
|
{"item":"Persistence","ok":true,"evidence":"Stable localStorage key"},
|
||||||
|
{"item":"Regression","ok":true,"evidence":"Admin build, tsc and verify passed"}
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
14
work/artifacts/F-159/reviewer.json
Normal file
14
work/artifacts/F-159/reviewer.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-159",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Una única jerarquía de navegación cubre collapse desktop y drawer mobile sin duplicación.",
|
||||||
|
"checks": [
|
||||||
|
{"item":"Single navigation source","ok":true,"evidence":"Sidebar maps visibleNavItems once"},
|
||||||
|
{"item":"Persisted desktop state","ok":true,"evidence":"localStorage key read on mount and written on toggle"},
|
||||||
|
{"item":"Mobile drawer","ok":true,"evidence":"fixed drawer, backdrop, route close and Escape close"},
|
||||||
|
{"item":"Admin build","ok":true,"evidence":"Next.js production build passed"}
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
13
work/artifacts/F-159/security.json
Normal file
13
work/artifacts/F-159/security.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-159",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Solo se persiste una preferencia booleana no sensible; no cambian auth, permisos ni destinos de navegación.",
|
||||||
|
"checks": [
|
||||||
|
{"item":"No sensitive storage","ok":true,"evidence":"localStorage contains only collapsed boolean"},
|
||||||
|
{"item":"RBAC preserved","ok":true,"evidence":"Navigation remains filtered by visibleNavItems(role)"},
|
||||||
|
{"item":"No dependency change","ok":true,"evidence":"Uses React and existing Tailwind only"}
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
# Feature activa: F-158 — POS use same-origin API proxy on LAN
|
# Feature activa: F-159 — Admin responsive collapsible sidebar
|
||||||
|
|
||||||
## Problema
|
## Objetivo
|
||||||
El TPV abierto en `http://192.168.18.93:3006` ejecuta peticiones del navegador contra `http://localhost:3000`. En un cliente LAN, `localhost` apunta al propio cliente y produce `ERR_CONNECTION_REFUSED`.
|
Añadir un único `SidebarToggle` reutilizando el layout y `NAV_ITEMS` existentes.
|
||||||
|
|
||||||
## Alcance
|
## Comportamiento
|
||||||
- El cliente POS usa rutas same-origin `/api/...`.
|
- Desktop (`lg+`): expandido a 240px o colapsado a 80px mostrando solo iconos; preferencia guardada en `localStorage`.
|
||||||
- El route handler de Next.js reenvía las peticiones al backend privado (`127.0.0.1:3000`).
|
- Mobile/tablet: sidebar como drawer superpuesto con backdrop; siempre muestra iconos y etiquetas.
|
||||||
- El proxy conserva cookies de sesión y `x-terminal-id`.
|
- El estado del drawer no duplica ni sustituye el estado persistido de desktop.
|
||||||
- El login propaga `Set-Cookie` al navegador.
|
- Transición suave de ancho y desplazamiento.
|
||||||
- Next dev permite el origen LAN `192.168.18.93`.
|
|
||||||
|
## Accesibilidad
|
||||||
|
- Botones con `aria-label`, `aria-expanded` y `aria-controls`.
|
||||||
|
- Drawer cerrado no es visible ni alcanzable por teclado.
|
||||||
|
- Escape cierra el drawer.
|
||||||
|
- Los enlaces conservan etiquetas accesibles y tooltips al colapsar.
|
||||||
|
|
||||||
## Aceptación
|
## Aceptación
|
||||||
1. El navegador no solicita directamente `localhost:3000`.
|
1. Toggle visible y operable por teclado.
|
||||||
2. `GET /api/pos/config` llega al backend mediante el proxy.
|
2. Preferencia desktop persiste tras recarga.
|
||||||
3. Login conserva la cookie de sesión.
|
3. Sidebar desktop colapsado muestra solo iconos.
|
||||||
4. POS build, TypeScript y `verify.sh` pasan.
|
4. Mobile/tablet usa drawer con backdrop y cierre por Escape.
|
||||||
|
5. Una sola fuente de navegación y build responsive sin regresiones.
|
||||||
|
|||||||
@@ -1,11 +1,68 @@
|
|||||||
{
|
{
|
||||||
"feature_id": null,
|
"feature_id": "F-159",
|
||||||
"stage": "idle",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Sin ejecución activa",
|
"action": "Close SidebarToggle feature",
|
||||||
"state": "waiting",
|
"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:51:06Z",
|
"updated_at": "2026-08-22T16:01:58Z",
|
||||||
"timeline": []
|
"timeline": [
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:58:39Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "intake",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Define responsive persisted SidebarToggle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:59:15Z",
|
||||||
|
"agent": "architect",
|
||||||
|
"stage": "design",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Design shared desktop collapse and mobile drawer state"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T15:59:47Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Implement accessible persisted SidebarToggle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T16:01:08Z",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Review shared responsive sidebar implementation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T16:01:19Z",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Audit localStorage and navigation accessibility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T16:01:30Z",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Validate desktop/mobile/accessibility acceptance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T16:01:48Z",
|
||||||
|
"agent": "documenter",
|
||||||
|
"stage": "document",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Document sidebar responsive behavior"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-22T16:01:58Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Close SidebarToggle feature"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user