erp-construccion/docs/02-definicion-modulos/MAI-001-fundamentos/historias-usuario/US-FUND-008-ui-ux-base.md

22 KiB
Raw Blame History

US-FUND-008: UI/UX Base y Sistema de Diseño

Epic: MAI-001 - Fundamentos del Sistema Story Points: 3 Prioridad: Baja Dependencias:

  • US-FUND-004 (Infraestructura Base)

Estado: Pendiente Asignado a: Frontend Lead + UI/UX Designer


📋 Historia de Usuario

Como usuario del sistema Quiero una interfaz intuitiva, consistente y visualmente atractiva Para navegar y trabajar de forma eficiente en la plataforma de gestión de obra.


🎯 Contexto y Objetivos

Contexto

Este documento define el sistema de diseño base de la aplicación. Incluye:

  • Paleta de colores (primary, secondary, neutrals)
  • Tipografía (fuentes, tamaños, weights)
  • Espaciado y grid (sistema de 8px)
  • Componentes reutilizables (Button, Input, Card, etc.)
  • Estados de carga (Skeletons, Spinners)
  • Estados vacíos (Empty States)
  • Notificaciones (Toasts, Alerts)
  • Responsive design (mobile-first)

Objetivos

  1. Diseño consistente en toda la aplicación
  2. Componentes reutilizables para acelerar desarrollo
  3. Paleta de colores definida y documentada
  4. Tipografía clara y legible
  5. Estados de loading bien definidos
  6. Responsive en desktop, tablet y mobile
  7. Accesible (WCAG 2.1 AA)

Criterios de Aceptación

CA-1: Paleta de Colores

Dado la aplicación en ejecución Cuando se visualizan componentes Entonces:

  • Color primario (construcción/obra):

    • Primary: #E97A20 (naranja construcción)
    • Primary Hover: #D46B17
    • Primary Light: #FFF4E6
  • Colores de estado:

    • Success: #10B981 (verde)
    • Warning: #F59E0B (amarillo)
    • Error: #EF4444 (rojo)
    • Info: #3B82F6 (azul)
  • Colores neutrales:

    • Gray-50 a Gray-900 (escala de grises)

CA-2: Tipografía

Dado cualquier página de la aplicación Cuando se visualiza texto Entonces:

  • Fuente principal: Inter (Google Fonts)

  • Fallback: system-ui, -apple-system, sans-serif

  • Tamaños de texto:

    • text-xs: 12px
    • text-sm: 14px
    • text-base: 16px
    • text-lg: 18px
    • text-xl: 20px
    • text-2xl: 24px
    • text-3xl: 30px
    • text-4xl: 36px
  • Pesos (weights):

    • Regular: 400
    • Medium: 500
    • Semibold: 600
    • Bold: 700

CA-3: Componentes Reutilizables

Dado el sistema de componentes Cuando se utiliza en cualquier página Entonces están disponibles:

  • Button (variants: primary, secondary, outline, ghost, destructive)
  • Input (text, email, password, number)
  • Select (dropdown)
  • Checkbox y Radio
  • Card (contenedor con sombra)
  • Badge (etiquetas de estado)
  • Table (tablas de datos)
  • Modal/Dialog
  • Dropdown Menu
  • Tabs
  • Tooltip

CA-4: Estados de Loading

Dado una operación asíncrona en ejecución Cuando se están cargando datos Entonces:

  • Botones muestran spinner cuando están en loading
  • Listas muestran skeleton loaders
  • Páginas completas muestran spinner centrado
  • No se permiten doble-clicks durante loading

CA-5: Estados Vacíos

Dado una lista sin datos Cuando se visualiza la página Entonces:

  • Se muestra ilustración o icono grande
  • Mensaje descriptivo: "No hay proyectos todavía"
  • Call-to-action: Botón "Crear Proyecto"
  • No se muestra tabla/grid vacío

CA-6: Notificaciones (Toasts)

Dado una acción exitosa/fallida Cuando se completa Entonces:

  • Toast aparece en top-right
  • Auto-dismiss después de 5 segundos
  • Se puede cerrar manualmente (X)
  • Iconos según tipo (success: ✓, error: ✗, warning: ⚠, info: )
  • Colores según tipo

CA-7: Responsive Design

Dado la aplicación en diferentes dispositivos Cuando se ajusta el viewport Entonces:

  • Desktop (≥1024px):

    • Sidebar visible
    • Grid de 12 columnas
    • Tablas completas
  • Tablet (768px - 1023px):

    • Sidebar colapsable
    • Grid de 8 columnas
    • Tablas con scroll horizontal
  • Mobile (<768px):

    • Sidebar como menú hamburguesa
    • Grid de 4 columnas
    • Tablas adaptadas (cards)
    • Inputs full-width

🔧 Especificación Técnica Detallada

1. Tailwind Configuration

Archivo: apps/frontend/tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  darkMode: ['class'],
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: {
          DEFAULT: '#E97A20',
          foreground: '#FFFFFF',
          hover: '#D46B17',
          light: '#FFF4E6',
        },
        secondary: {
          DEFAULT: 'hsl(var(--secondary))',
          foreground: 'hsl(var(--secondary-foreground))',
        },
        success: {
          DEFAULT: '#10B981',
          foreground: '#FFFFFF',
        },
        warning: {
          DEFAULT: '#F59E0B',
          foreground: '#FFFFFF',
        },
        error: {
          DEFAULT: '#EF4444',
          foreground: '#FFFFFF',
        },
        info: {
          DEFAULT: '#3B82F6',
          foreground: '#FFFFFF',
        },
        muted: {
          DEFAULT: 'hsl(var(--muted))',
          foreground: 'hsl(var(--muted-foreground))',
        },
        accent: {
          DEFAULT: 'hsl(var(--accent))',
          foreground: 'hsl(var(--accent-foreground))',
        },
        destructive: {
          DEFAULT: 'hsl(var(--destructive))',
          foreground: 'hsl(var(--destructive-foreground))',
        },
        card: {
          DEFAULT: 'hsl(var(--card))',
          foreground: 'hsl(var(--card-foreground))',
        },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
      fontFamily: {
        sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
      },
      spacing: {
        18: '4.5rem',
        72: '18rem',
        84: '21rem',
        96: '24rem',
      },
    },
  },
  plugins: [require('tailwindcss-animate')],
};

2. CSS Variables

Archivo: apps/frontend/src/index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;

    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;

    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;

    --muted: 210 40% 96.1%;
    --muted-foreground: 215.4 16.3% 46.9%;

    --accent: 210 40% 96.1%;
    --accent-foreground: 222.2 47.4% 11.2%;

    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;

    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;

    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;

    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;

    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;

    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;

    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;

    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;

    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 212.7 26.8% 83.9%;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
    font-feature-settings: 'rlig' 1, 'calt' 1;
  }
}

3. Button Component (shadcn/ui)

Archivo: apps/frontend/src/components/ui/button.tsx

import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';

const buttonVariants = cva(
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary-hover',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline:
          'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  },
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  loading?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, loading = false, children, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';

    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        disabled={loading || props.disabled}
        {...props}
      >
        {loading && <Loader2 className="h-4 w-4 animate-spin" />}
        {children}
      </Comp>
    );
  },
);
Button.displayName = 'Button';

export { Button, buttonVariants };

Uso:

<Button>Default Button</Button>
<Button variant="outline">Outline Button</Button>
<Button variant="destructive">Delete</Button>
<Button loading>Guardando...</Button>

4. Card Component

Archivo: apps/frontend/src/components/ui/card.tsx

import * as React from 'react';
import { cn } from '@/lib/utils';

const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
      {...props}
    />
  ),
);
Card.displayName = 'Card';

const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
  ),
);
CardHeader.displayName = 'CardHeader';

const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
  ({ className, ...props }, ref) => (
    <h3
      ref={ref}
      className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
      {...props}
    />
  ),
);
CardTitle.displayName = 'CardTitle';

const CardDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';

const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
  ),
);
CardContent.displayName = 'CardContent';

const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
  ),
);
CardFooter.displayName = 'CardFooter';

export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

Uso:

<Card>
  <CardHeader>
    <CardTitle>Proyecto Residencial</CardTitle>
    <CardDescription>150 unidades habitacionales</CardDescription>
  </CardHeader>
  <CardContent>
    <p>Contenido del proyecto...</p>
  </CardContent>
  <CardFooter>
    <Button>Ver Detalles</Button>
  </CardFooter>
</Card>

5. Skeleton Loader

Archivo: apps/frontend/src/components/ui/skeleton.tsx

import { cn } from '@/lib/utils';

function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}

export { Skeleton };

Archivo: apps/frontend/src/components/skeletons/ProjectCardSkeleton.tsx

import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';

export function ProjectCardSkeleton() {
  return (
    <Card>
      <CardHeader>
        <Skeleton className="h-6 w-3/4" />
        <Skeleton className="h-4 w-1/2" />
      </CardHeader>
      <CardContent className="space-y-2">
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-5/6" />
        <Skeleton className="h-4 w-4/6" />
      </CardContent>
      <CardFooter>
        <Skeleton className="h-10 w-24" />
      </CardFooter>
    </Card>
  );
}

Uso:

{isLoading ? (
  <ProjectCardSkeleton />
) : (
  <ProjectCard project={project} />
)}

6. Empty State Component

Archivo: apps/frontend/src/components/ui/empty-state.tsx

import { Button } from '@/components/ui/button';
import { LucideIcon } from 'lucide-react';

interface EmptyStateProps {
  icon: LucideIcon;
  title: string;
  description?: string;
  action?: {
    label: string;
    onClick: () => void;
  };
}

export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center py-12 text-center">
      <div className="rounded-full bg-muted p-4">
        <Icon className="h-10 w-10 text-muted-foreground" />
      </div>

      <h3 className="mt-4 text-lg font-semibold">{title}</h3>

      {description && <p className="mt-2 text-sm text-muted-foreground">{description}</p>}

      {action && (
        <Button onClick={action.onClick} className="mt-6">
          {action.label}
        </Button>
      )}
    </div>
  );
}

Uso:

import { FolderKanban } from 'lucide-react';

{projects.length === 0 && (
  <EmptyState
    icon={FolderKanban}
    title="No hay proyectos todavía"
    description="Comienza creando tu primer proyecto de construcción"
    action={{
      label: 'Crear Proyecto',
      onClick: () => navigate('/projects/new'),
    }}
  />
)}

7. Toast Notifications (Sonner)

Instalación:

npm install sonner

Configuración en App.tsx:

import { Toaster } from 'sonner';

function App() {
  return (
    <>
      {/* App content */}
      <Toaster position="top-right" richColors />
    </>
  );
}

Uso:

import { toast } from 'sonner';

// Success
toast.success('Proyecto creado exitosamente');

// Error
toast.error('Error al guardar los cambios');

// Warning
toast.warning('El presupuesto excede el límite');

// Info
toast.info('Nuevo comentario en el proyecto');

// Loading
const toastId = toast.loading('Guardando cambios...');
// ... después de completar
toast.success('Cambios guardados', { id: toastId });

// Con acción
toast.success('Proyecto actualizado', {
  action: {
    label: 'Ver',
    onClick: () => navigate(`/projects/${id}`),
  },
});

8. Badge Component

Archivo: apps/frontend/src/components/ui/badge.tsx

import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const badgeVariants = cva(
  'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
  {
    variants: {
      variant: {
        default: 'border-transparent bg-primary text-primary-foreground',
        secondary: 'border-transparent bg-secondary text-secondary-foreground',
        success: 'border-transparent bg-success text-success-foreground',
        warning: 'border-transparent bg-warning text-warning-foreground',
        error: 'border-transparent bg-error text-error-foreground',
        outline: 'text-foreground',
      },
    },
    defaultVariants: {
      variant: 'default',
    },
  },
);

export interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

export { Badge, badgeVariants };

Uso para estados de proyecto:

const statusBadgeVariant = {
  planning: 'secondary',
  active: 'success',
  completed: 'default',
  cancelled: 'error',
};

<Badge variant={statusBadgeVariant[project.status]}>
  {project.status}
</Badge>

9. Confirmation Dialog Component

Archivo: apps/frontend/src/components/ui/confirmation-dialog.tsx

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';

interface ConfirmationDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  description: string;
  confirmLabel?: string;
  cancelLabel?: string;
  onConfirm: () => void;
  variant?: 'default' | 'destructive';
}

export function ConfirmationDialog({
  open,
  onOpenChange,
  title,
  description,
  confirmLabel = 'Confirmar',
  cancelLabel = 'Cancelar',
  onConfirm,
  variant = 'default',
}: ConfirmationDialogProps) {
  return (
    <AlertDialog open={open} onOpenChange={onOpenChange}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>{description}</AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
          <AlertDialogAction
            onClick={onConfirm}
            className={variant === 'destructive' ? 'bg-error hover:bg-error/90' : ''}
          >
            {confirmLabel}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

Uso:

const [showDeleteDialog, setShowDeleteDialog] = useState(false);

<ConfirmationDialog
  open={showDeleteDialog}
  onOpenChange={setShowDeleteDialog}
  title="¿Eliminar proyecto?"
  description="Esta acción no se puede deshacer. El proyecto será eliminado permanentemente."
  confirmLabel="Eliminar"
  cancelLabel="Cancelar"
  variant="destructive"
  onConfirm={async () => {
    await deleteProject(projectId);
    toast.success('Proyecto eliminado');
  }}
/>

🧪 Test Cases

TC-UI-001: Botones con Loading

Pasos:

  1. Click en botón "Guardar"
  2. Observar estado durante request

Resultado esperado:

  • Botón muestra spinner
  • Botón está deshabilitado
  • Texto cambia a "Guardando..."
  • Doble-click no ejecuta acción dos veces

TC-UI-002: Empty State

Pasos:

  1. Navegar a /projects sin proyectos creados

Resultado esperado:

  • Se muestra icono de carpeta grande
  • Título: "No hay proyectos todavía"
  • Descripción visible
  • Botón "Crear Proyecto" presente

TC-UI-003: Toast Notifications

Pasos:

  1. Crear un proyecto exitosamente
  2. Observar notificación

Resultado esperado:

  • Toast aparece en top-right
  • Color verde (success)
  • Icono de checkmark
  • Auto-dismiss después de 5 segundos
  • Se puede cerrar manualmente

TC-UI-004: Responsive Design

Pasos:

  1. Abrir app en desktop (1920px)
  2. Reducir viewport a tablet (768px)
  3. Reducir viewport a mobile (375px)

Resultado esperado:

  • Desktop: Sidebar visible, grid 12 columnas
  • Tablet: Sidebar colapsable, grid 8 columnas
  • Mobile: Menú hamburguesa, grid 4 columnas

TC-UI-005: Skeleton Loaders

Pasos:

  1. Navegar a /projects
  2. Observar durante carga

Resultado esperado:

  • Se muestran 3 skeletons de cards
  • Animación de pulse
  • Una vez cargados, se reemplazan por cards reales

📋 Tareas de Implementación

Frontend

  • UI-FE-001: Configurar Tailwind CSS con theme custom

    • Estimado: 1h
  • UI-FE-002: Instalar y configurar shadcn/ui

    • Estimado: 1h
  • UI-FE-003: Crear componentes base (Button, Input, Card)

    • Estimado: 2h
  • UI-FE-004: Crear Skeleton loaders para cards y tablas

    • Estimado: 1.5h
  • UI-FE-005: Crear EmptyState component

    • Estimado: 1h
  • UI-FE-006: Configurar Sonner para toasts

    • Estimado: 0.5h
  • UI-FE-007: Crear Badge component con variants

    • Estimado: 0.5h
  • UI-FE-008: Crear ConfirmationDialog component

    • Estimado: 1h
  • UI-FE-009: Documentar sistema de diseño en Storybook (opcional)

    • Estimado: 3h

Design

  • UI-DESIGN-001: Definir paleta de colores final

    • Estimado: 2h
  • UI-DESIGN-002: Crear design tokens en Figma

    • Estimado: 2h

Total estimado: ~15.5 horas


🔗 Dependencias

Depende de

  • US-FUND-004 (Infraestructura Base)

Bloqueante para

  • Todas las páginas y features del sistema
  • UX completa

📊 Definición de Hecho (DoD)

  • Tailwind configurado con paleta de colores
  • Componentes base instalados (shadcn/ui)
  • Button component con loading state
  • Card component funcional
  • Skeleton loaders implementados
  • EmptyState component reutilizable
  • Toasts configurados (Sonner)
  • Badge component con variants
  • ConfirmationDialog funcional
  • Responsive en desktop, tablet, mobile
  • Todos los test cases (TC-UI-001 a TC-UI-005) pasan

📝 Notas Adicionales

Accesibilidad

  • Contraste de colores WCAG AA (4.5:1)
  • Focus visible en todos los elementos interactivos
  • ARIA labels en iconos
  • Keyboard navigation funcional

Dark Mode (Opcional)

  • CSS variables preparadas para dark mode
  • Toggle en user settings
  • Persistencia en localStorage

Icons

  • Librería: Lucide React
  • Tamaños estándar: 16px, 20px, 24px
  • Stroke width: 2px

Fecha de creación: 2025-11-17 Última actualización: 2025-11-17 Versión: 1.0