erp-core/docs/02-fase-core-business/MGN-006-settings/especificaciones/ET-SETTINGS-frontend.md

53 KiB

ET-SETTINGS-FRONTEND: Componentes React

Identificacion

Campo Valor
ID ET-SETTINGS-FRONTEND
Modulo MGN-006 Settings
Version 1.0
Estado En Diseno
Framework React + TypeScript
UI Library shadcn/ui
State Zustand
Autor Requirements-Analyst
Fecha 2025-12-05

Descripcion General

Especificacion tecnica del modulo frontend de Settings. Incluye paginas de configuracion del sistema, tenant, preferencias de usuario y gestion de feature flags con interfaces jerarquicas y validacion en tiempo real.

Estructura de Archivos

apps/frontend/src/modules/settings/
├── index.ts
├── routes.tsx
├── pages/
│   ├── SettingsLayout.tsx
│   ├── SystemSettingsPage.tsx
│   ├── TenantSettingsPage.tsx
│   ├── UserPreferencesPage.tsx
│   ├── FeatureFlagsPage.tsx
│   └── SettingsOverviewPage.tsx
├── components/
│   ├── system/
│   │   ├── SystemSettingsForm.tsx
│   │   ├── SettingsCategoryNav.tsx
│   │   ├── SettingsSearch.tsx
│   │   └── SettingsResetConfirm.tsx
│   ├── tenant/
│   │   ├── TenantSettingsForm.tsx
│   │   ├── TenantBrandingSection.tsx
│   │   ├── TenantLocaleSection.tsx
│   │   └── TenantLimitsSection.tsx
│   ├── preferences/
│   │   ├── UserPreferencesForm.tsx
│   │   ├── ThemeSelector.tsx
│   │   ├── LocaleSelector.tsx
│   │   ├── NotificationPrefs.tsx
│   │   └── TimezoneSelector.tsx
│   ├── feature-flags/
│   │   ├── FeatureFlagList.tsx
│   │   ├── FeatureFlagForm.tsx
│   │   ├── FeatureFlagCard.tsx
│   │   ├── FeatureFlagToggle.tsx
│   │   ├── RolloutSlider.tsx
│   │   ├── VariantEditor.tsx
│   │   └── FeatureFlagHistory.tsx
│   └── shared/
│       ├── SettingItem.tsx
│       ├── SettingGroup.tsx
│       ├── SettingDescription.tsx
│       ├── SettingInput.tsx
│       └── SettingToggle.tsx
├── stores/
│   ├── system-settings.store.ts
│   ├── tenant-settings.store.ts
│   ├── user-preferences.store.ts
│   └── feature-flags.store.ts
├── hooks/
│   ├── useSystemSettings.ts
│   ├── useTenantSettings.ts
│   ├── useUserPreferences.ts
│   ├── useFeatureFlag.ts
│   └── useEffectiveSetting.ts
├── services/
│   ├── system-settings.service.ts
│   ├── tenant-settings.service.ts
│   ├── user-preferences.service.ts
│   └── feature-flags.service.ts
└── types/
    ├── system-settings.types.ts
    ├── tenant-settings.types.ts
    ├── user-preferences.types.ts
    └── feature-flag.types.ts

Types

System Settings Types

// types/system-settings.types.ts

export type SettingDataType = 'string' | 'integer' | 'boolean' | 'json' | 'decimal';

export interface SystemSettingDefinition {
  id: string;
  key: string;
  category: string;
  label: string;
  description?: string;
  dataType: SettingDataType;
  defaultValue: string;
  validationRules?: ValidationRule[];
  options?: SettingOption[];
  isEncrypted: boolean;
  requiresRestart: boolean;
}

export interface SystemSettingValue {
  id: string;
  definitionId: string;
  definition?: SystemSettingDefinition;
  value: string;
  level: 'system' | 'plan' | 'tenant';
  levelId?: string;
  createdAt: string;
  updatedAt: string;
}

export interface SettingOption {
  value: string;
  label: string;
}

export interface ValidationRule {
  type: 'min' | 'max' | 'regex' | 'enum';
  value: string | number;
  message: string;
}

export interface SettingCategory {
  key: string;
  label: string;
  icon: string;
  order: number;
  settings: SystemSettingDefinition[];
}

export interface UpdateSettingDto {
  value: string;
}

export interface SettingsFilters {
  category?: string;
  search?: string;
  level?: 'system' | 'plan' | 'tenant';
}

Tenant Settings Types

// types/tenant-settings.types.ts

export interface TenantSettings {
  id: string;
  tenantId: string;

  // Branding
  companyName: string;
  legalName?: string;
  logo?: string;
  favicon?: string;
  primaryColor: string;
  secondaryColor?: string;

  // Locale
  defaultLocale: string;
  timezone: string;
  dateFormat: string;
  timeFormat: string;
  currencyId: string;

  // Business
  fiscalYearStart: number; // 1-12
  weekStart: number; // 0-6

  // Limits
  maxUsers?: number;
  maxStorage?: number; // MB
  features: string[];

  // Contact
  supportEmail?: string;
  website?: string;

  createdAt: string;
  updatedAt: string;
}

export interface UpdateTenantSettingsDto {
  companyName?: string;
  legalName?: string;
  logo?: string;
  primaryColor?: string;
  secondaryColor?: string;
  defaultLocale?: string;
  timezone?: string;
  dateFormat?: string;
  timeFormat?: string;
  currencyId?: string;
  fiscalYearStart?: number;
  weekStart?: number;
  supportEmail?: string;
  website?: string;
}

export interface BrandingPreview {
  primaryColor: string;
  secondaryColor: string;
  logo?: string;
}

User Preferences Types

// types/user-preferences.types.ts

export type ThemeMode = 'light' | 'dark' | 'system';

export interface UserPreferences {
  id: string;
  userId: string;

  // UI
  theme: ThemeMode;
  locale: string;
  timezone: string;
  dateFormat?: string;
  timeFormat?: string;
  density: 'compact' | 'normal' | 'comfortable';

  // Notifications
  emailNotifications: boolean;
  pushNotifications: boolean;
  inAppNotifications: boolean;
  digestFrequency: 'realtime' | 'daily' | 'weekly' | 'never';
  notificationCategories: Record<string, boolean>;

  // Navigation
  sidebarCollapsed: boolean;
  defaultLandingPage?: string;
  recentItems: string[];

  // Accessibility
  highContrast: boolean;
  reducedMotion: boolean;
  fontSize: 'small' | 'medium' | 'large';

  createdAt: string;
  updatedAt: string;
}

export interface UpdateUserPreferencesDto {
  theme?: ThemeMode;
  locale?: string;
  timezone?: string;
  dateFormat?: string;
  timeFormat?: string;
  density?: 'compact' | 'normal' | 'comfortable';
  emailNotifications?: boolean;
  pushNotifications?: boolean;
  inAppNotifications?: boolean;
  digestFrequency?: 'realtime' | 'daily' | 'weekly' | 'never';
  notificationCategories?: Record<string, boolean>;
  sidebarCollapsed?: boolean;
  defaultLandingPage?: string;
  highContrast?: boolean;
  reducedMotion?: boolean;
  fontSize?: 'small' | 'medium' | 'large';
}

Feature Flag Types

// types/feature-flag.types.ts

export type FlagType = 'boolean' | 'percentage' | 'variant';
export type FlagStatus = 'active' | 'inactive' | 'scheduled';

export interface FeatureFlag {
  id: string;
  key: string;
  name: string;
  description?: string;
  flagType: FlagType;
  status: FlagStatus;

  // Boolean
  enabled?: boolean;

  // Percentage (rollout)
  percentage?: number;

  // Variant (A/B)
  variants?: FlagVariant[];

  // Targeting
  tenantIds?: string[];
  userIds?: string[];
  planIds?: string[];
  rules?: FlagRule[];

  // Scheduling
  startDate?: string;
  endDate?: string;

  // Metadata
  tags?: string[];
  createdBy: string;
  createdAt: string;
  updatedAt: string;
}

export interface FlagVariant {
  key: string;
  name: string;
  weight: number; // 0-100, suma = 100
  payload?: Record<string, any>;
}

export interface FlagRule {
  id: string;
  attribute: string; // 'userId', 'tenantId', 'plan', 'country', etc
  operator: 'equals' | 'contains' | 'startsWith' | 'in' | 'notIn';
  value: string | string[];
  enabled: boolean;
}

export interface CreateFeatureFlagDto {
  key: string;
  name: string;
  description?: string;
  flagType: FlagType;
  enabled?: boolean;
  percentage?: number;
  variants?: FlagVariant[];
}

export interface UpdateFeatureFlagDto {
  name?: string;
  description?: string;
  status?: FlagStatus;
  enabled?: boolean;
  percentage?: number;
  variants?: FlagVariant[];
  tenantIds?: string[];
  userIds?: string[];
  planIds?: string[];
  rules?: FlagRule[];
  startDate?: string;
  endDate?: string;
  tags?: string[];
}

export interface FlagEvaluation {
  flagKey: string;
  enabled: boolean;
  variant?: string;
  payload?: Record<string, any>;
  reason: 'default' | 'targeting' | 'rollout' | 'scheduled' | 'disabled';
}

export interface FlagHistory {
  id: string;
  flagId: string;
  action: 'created' | 'updated' | 'enabled' | 'disabled';
  changes: Record<string, { old: any; new: any }>;
  userId: string;
  userName: string;
  createdAt: string;
}

Stores (Zustand)

User Preferences Store

// stores/user-preferences.store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { UserPreferences, UpdateUserPreferencesDto, ThemeMode } from '../types/user-preferences.types';
import { userPreferencesService } from '../services/user-preferences.service';

interface UserPreferencesState {
  preferences: UserPreferences | null;
  isLoading: boolean;
  error: string | null;

  // Computed
  effectiveTheme: 'light' | 'dark';

  // Actions
  fetchPreferences: () => Promise<void>;
  updatePreferences: (dto: UpdateUserPreferencesDto) => Promise<void>;
  setTheme: (theme: ThemeMode) => Promise<void>;
  setLocale: (locale: string) => Promise<void>;
  toggleSidebar: () => Promise<void>;
  resetPreferences: () => Promise<void>;
}

export const useUserPreferencesStore = create<UserPreferencesState>()(
  persist(
    (set, get) => ({
      preferences: null,
      isLoading: false,
      error: null,
      effectiveTheme: 'light',

      fetchPreferences: async () => {
        set({ isLoading: true, error: null });
        try {
          const preferences = await userPreferencesService.get();
          const effectiveTheme = resolveTheme(preferences.theme);
          set({ preferences, effectiveTheme, isLoading: false });
          applyTheme(effectiveTheme);
          applyAccessibility(preferences);
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
        }
      },

      updatePreferences: async (dto: UpdateUserPreferencesDto) => {
        const current = get().preferences;
        if (!current) return;

        // Optimistic update
        set({
          preferences: { ...current, ...dto },
          error: null
        });

        try {
          const updated = await userPreferencesService.update(dto);
          const effectiveTheme = resolveTheme(updated.theme);
          set({ preferences: updated, effectiveTheme });

          if (dto.theme) applyTheme(effectiveTheme);
          if (dto.highContrast !== undefined || dto.reducedMotion !== undefined || dto.fontSize) {
            applyAccessibility(updated);
          }
        } catch (error: any) {
          // Revert on error
          set({ preferences: current, error: error.message });
          throw error;
        }
      },

      setTheme: async (theme: ThemeMode) => {
        await get().updatePreferences({ theme });
      },

      setLocale: async (locale: string) => {
        await get().updatePreferences({ locale });
      },

      toggleSidebar: async () => {
        const current = get().preferences;
        if (!current) return;
        await get().updatePreferences({
          sidebarCollapsed: !current.sidebarCollapsed
        });
      },

      resetPreferences: async () => {
        set({ isLoading: true });
        try {
          const preferences = await userPreferencesService.reset();
          const effectiveTheme = resolveTheme(preferences.theme);
          set({ preferences, effectiveTheme, isLoading: false });
          applyTheme(effectiveTheme);
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
        }
      },
    }),
    {
      name: 'user-preferences',
      partialize: (state) => ({
        preferences: state.preferences,
        effectiveTheme: state.effectiveTheme,
      }),
    }
  )
);

function resolveTheme(theme: ThemeMode): 'light' | 'dark' {
  if (theme === 'system') {
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }
  return theme;
}

function applyTheme(theme: 'light' | 'dark') {
  document.documentElement.classList.remove('light', 'dark');
  document.documentElement.classList.add(theme);
}

function applyAccessibility(prefs: UserPreferences) {
  document.documentElement.classList.toggle('high-contrast', prefs.highContrast);
  document.documentElement.classList.toggle('reduce-motion', prefs.reducedMotion);
  document.documentElement.setAttribute('data-font-size', prefs.fontSize);
}

Feature Flags Store

// stores/feature-flags.store.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { FeatureFlag, CreateFeatureFlagDto, UpdateFeatureFlagDto, FlagEvaluation } from '../types/feature-flag.types';
import { featureFlagsService } from '../services/feature-flags.service';

interface FeatureFlagsState {
  flags: FeatureFlag[];
  evaluations: Record<string, FlagEvaluation>;
  isLoading: boolean;
  error: string | null;

  // Actions
  fetchFlags: () => Promise<void>;
  createFlag: (dto: CreateFeatureFlagDto) => Promise<FeatureFlag>;
  updateFlag: (id: string, dto: UpdateFeatureFlagDto) => Promise<FeatureFlag>;
  deleteFlag: (id: string) => Promise<void>;
  toggleFlag: (id: string) => Promise<void>;
  evaluateFlag: (key: string) => Promise<FlagEvaluation>;
  evaluateAllFlags: () => Promise<void>;

  // Helpers
  isEnabled: (key: string) => boolean;
  getVariant: (key: string) => string | undefined;
}

export const useFeatureFlagsStore = create<FeatureFlagsState>()(
  devtools(
    (set, get) => ({
      flags: [],
      evaluations: {},
      isLoading: false,
      error: null,

      fetchFlags: async () => {
        set({ isLoading: true, error: null });
        try {
          const flags = await featureFlagsService.getAll();
          set({ flags, isLoading: false });
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
        }
      },

      createFlag: async (dto: CreateFeatureFlagDto) => {
        set({ isLoading: true, error: null });
        try {
          const flag = await featureFlagsService.create(dto);
          set((state) => ({
            flags: [...state.flags, flag],
            isLoading: false,
          }));
          return flag;
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
          throw error;
        }
      },

      updateFlag: async (id: string, dto: UpdateFeatureFlagDto) => {
        set({ isLoading: true, error: null });
        try {
          const flag = await featureFlagsService.update(id, dto);
          set((state) => ({
            flags: state.flags.map((f) => (f.id === id ? flag : f)),
            isLoading: false,
          }));
          return flag;
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
          throw error;
        }
      },

      deleteFlag: async (id: string) => {
        set({ isLoading: true, error: null });
        try {
          await featureFlagsService.delete(id);
          set((state) => ({
            flags: state.flags.filter((f) => f.id !== id),
            isLoading: false,
          }));
        } catch (error: any) {
          set({ error: error.message, isLoading: false });
          throw error;
        }
      },

      toggleFlag: async (id: string) => {
        const flag = get().flags.find((f) => f.id === id);
        if (!flag) return;

        await get().updateFlag(id, { enabled: !flag.enabled });
      },

      evaluateFlag: async (key: string) => {
        try {
          const evaluation = await featureFlagsService.evaluate(key);
          set((state) => ({
            evaluations: { ...state.evaluations, [key]: evaluation },
          }));
          return evaluation;
        } catch (error: any) {
          // Return disabled on error
          return { flagKey: key, enabled: false, reason: 'disabled' as const };
        }
      },

      evaluateAllFlags: async () => {
        try {
          const evaluations = await featureFlagsService.evaluateAll();
          const evaluationsMap: Record<string, FlagEvaluation> = {};
          evaluations.forEach((e) => {
            evaluationsMap[e.flagKey] = e;
          });
          set({ evaluations: evaluationsMap });
        } catch (error: any) {
          console.error('Failed to evaluate flags:', error);
        }
      },

      isEnabled: (key: string) => {
        const evaluation = get().evaluations[key];
        return evaluation?.enabled ?? false;
      },

      getVariant: (key: string) => {
        const evaluation = get().evaluations[key];
        return evaluation?.variant;
      },
    }),
    { name: 'feature-flags-store' }
  )
);

Custom Hooks

useFeatureFlag

// hooks/useFeatureFlag.ts
import { useEffect, useState } from 'react';
import { useFeatureFlagsStore } from '../stores/feature-flags.store';

export interface UseFeatureFlagOptions {
  fallback?: boolean;
}

export function useFeatureFlag(
  key: string,
  options: UseFeatureFlagOptions = {}
): boolean {
  const { evaluations, evaluateFlag, isEnabled } = useFeatureFlagsStore();
  const [loading, setLoading] = useState(!evaluations[key]);

  useEffect(() => {
    if (!evaluations[key]) {
      setLoading(true);
      evaluateFlag(key).finally(() => setLoading(false));
    }
  }, [key, evaluations, evaluateFlag]);

  if (loading) {
    return options.fallback ?? false;
  }

  return isEnabled(key);
}

export function useFeatureFlagVariant(key: string): {
  enabled: boolean;
  variant?: string;
  payload?: Record<string, any>;
  loading: boolean;
} {
  const { evaluations, evaluateFlag } = useFeatureFlagsStore();
  const [loading, setLoading] = useState(!evaluations[key]);

  useEffect(() => {
    if (!evaluations[key]) {
      setLoading(true);
      evaluateFlag(key).finally(() => setLoading(false));
    }
  }, [key, evaluations, evaluateFlag]);

  const evaluation = evaluations[key];

  return {
    enabled: evaluation?.enabled ?? false,
    variant: evaluation?.variant,
    payload: evaluation?.payload,
    loading,
  };
}

useEffectiveSetting

// hooks/useEffectiveSetting.ts
import { useMemo } from 'react';
import { useSystemSettingsStore } from '../stores/system-settings.store';
import { useTenantSettingsStore } from '../stores/tenant-settings.store';
import { useUserPreferencesStore } from '../stores/user-preferences.store';

/**
 * Resuelve el valor efectivo de una configuracion
 * considerando la jerarquia: user > tenant > plan > system
 */
export function useEffectiveSetting<T>(
  key: string,
  options?: {
    userOverride?: T;
    tenantOverride?: T;
    defaultValue?: T;
  }
): T | undefined {
  const { getSystemValue } = useSystemSettingsStore();
  const { settings: tenantSettings } = useTenantSettingsStore();
  const { preferences } = useUserPreferencesStore();

  return useMemo(() => {
    // 1. Check user preference override
    if (options?.userOverride !== undefined) {
      return options.userOverride;
    }

    // 2. Check user preferences
    const userValue = preferences?.[key as keyof typeof preferences];
    if (userValue !== undefined) {
      return userValue as T;
    }

    // 3. Check tenant override
    if (options?.tenantOverride !== undefined) {
      return options.tenantOverride;
    }

    // 4. Check tenant settings
    const tenantValue = tenantSettings?.[key as keyof typeof tenantSettings];
    if (tenantValue !== undefined) {
      return tenantValue as T;
    }

    // 5. Check system setting
    const systemValue = getSystemValue(key);
    if (systemValue !== undefined) {
      return systemValue as T;
    }

    // 6. Return default
    return options?.defaultValue;
  }, [key, preferences, tenantSettings, getSystemValue, options]);
}

Components

ThemeSelector

// components/preferences/ThemeSelector.tsx
import { Moon, Sun, Monitor } from 'lucide-react';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { ThemeMode } from '../../types/user-preferences.types';
import { cn } from '@/lib/utils';

interface ThemeSelectorProps {
  value: ThemeMode;
  onChange: (theme: ThemeMode) => void;
}

const themes: { value: ThemeMode; label: string; icon: any; description: string }[] = [
  {
    value: 'light',
    label: 'Claro',
    icon: Sun,
    description: 'Tema claro para uso diurno',
  },
  {
    value: 'dark',
    label: 'Oscuro',
    icon: Moon,
    description: 'Tema oscuro para reducir fatiga visual',
  },
  {
    value: 'system',
    label: 'Sistema',
    icon: Monitor,
    description: 'Sigue la preferencia del sistema operativo',
  },
];

export function ThemeSelector({ value, onChange }: ThemeSelectorProps) {
  return (
    <RadioGroup
      value={value}
      onValueChange={onChange}
      className="grid grid-cols-3 gap-4"
    >
      {themes.map((theme) => {
        const Icon = theme.icon;
        const isSelected = value === theme.value;

        return (
          <Label key={theme.value} htmlFor={theme.value} className="cursor-pointer">
            <Card
              className={cn(
                'transition-colors hover:bg-muted/50',
                isSelected && 'border-primary bg-primary/5'
              )}
            >
              <CardContent className="flex flex-col items-center p-6">
                <RadioGroupItem
                  value={theme.value}
                  id={theme.value}
                  className="sr-only"
                />
                <Icon
                  className={cn(
                    'h-8 w-8 mb-2',
                    isSelected ? 'text-primary' : 'text-muted-foreground'
                  )}
                />
                <span className="font-medium">{theme.label}</span>
                <span className="text-xs text-muted-foreground text-center mt-1">
                  {theme.description}
                </span>
              </CardContent>
            </Card>
          </Label>
        );
      })}
    </RadioGroup>
  );
}

FeatureFlagCard

// components/feature-flags/FeatureFlagCard.tsx
import { Clock, Users, Percent, ToggleLeft, ToggleRight, Layers } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { FeatureFlag, FlagType } from '../../types/feature-flag.types';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
import { es } from 'date-fns/locale';

interface FeatureFlagCardProps {
  flag: FeatureFlag;
  onToggle: (id: string) => void;
  onEdit: (flag: FeatureFlag) => void;
}

const flagTypeConfig: Record<FlagType, { icon: any; label: string; color: string }> = {
  boolean: { icon: ToggleLeft, label: 'Boolean', color: 'bg-blue-500' },
  percentage: { icon: Percent, label: 'Rollout', color: 'bg-amber-500' },
  variant: { icon: Layers, label: 'Variante', color: 'bg-purple-500' },
};

export function FeatureFlagCard({ flag, onToggle, onEdit }: FeatureFlagCardProps) {
  const config = flagTypeConfig[flag.flagType];
  const Icon = config.icon;

  const isScheduled = flag.startDate || flag.endDate;
  const hasTargeting = (flag.tenantIds?.length ?? 0) > 0 ||
                       (flag.userIds?.length ?? 0) > 0 ||
                       (flag.planIds?.length ?? 0) > 0;

  return (
    <Card className="hover:shadow-md transition-shadow">
      <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
        <div className="space-y-1">
          <div className="flex items-center gap-2">
            <Badge variant="secondary" className={cn('text-white', config.color)}>
              <Icon className="h-3 w-3 mr-1" />
              {config.label}
            </Badge>
            {flag.status === 'scheduled' && (
              <Badge variant="outline" className="text-amber-600">
                <Clock className="h-3 w-3 mr-1" />
                Programado
              </Badge>
            )}
          </div>
          <CardTitle className="text-lg font-semibold">
            {flag.name}
          </CardTitle>
          <code className="text-sm text-muted-foreground">
            {flag.key}
          </code>
        </div>

        <Switch
          checked={flag.enabled ?? false}
          onCheckedChange={() => onToggle(flag.id)}
          disabled={flag.status === 'scheduled'}
        />
      </CardHeader>

      <CardContent className="space-y-4">
        {flag.description && (
          <p className="text-sm text-muted-foreground">
            {flag.description}
          </p>
        )}

        {/* Rollout Progress */}
        {flag.flagType === 'percentage' && flag.percentage !== undefined && (
          <div className="space-y-2">
            <div className="flex justify-between text-sm">
              <span>Rollout</span>
              <span className="font-medium">{flag.percentage}%</span>
            </div>
            <Progress value={flag.percentage} className="h-2" />
          </div>
        )}

        {/* Variants */}
        {flag.flagType === 'variant' && flag.variants && (
          <div className="space-y-2">
            <span className="text-sm font-medium">Variantes:</span>
            <div className="flex flex-wrap gap-2">
              {flag.variants.map((variant) => (
                <Badge key={variant.key} variant="outline">
                  {variant.name} ({variant.weight}%)
                </Badge>
              ))}
            </div>
          </div>
        )}

        {/* Targeting */}
        {hasTargeting && (
          <div className="flex items-center gap-2 text-sm text-muted-foreground">
            <Users className="h-4 w-4" />
            <span>
              {[
                flag.tenantIds?.length && `${flag.tenantIds.length} tenants`,
                flag.userIds?.length && `${flag.userIds.length} usuarios`,
                flag.planIds?.length && `${flag.planIds.length} planes`,
              ]
                .filter(Boolean)
                .join(', ')}
            </span>
          </div>
        )}

        {/* Tags */}
        {flag.tags && flag.tags.length > 0 && (
          <div className="flex flex-wrap gap-1">
            {flag.tags.map((tag) => (
              <Badge key={tag} variant="secondary" className="text-xs">
                {tag}
              </Badge>
            ))}
          </div>
        )}

        {/* Footer */}
        <div className="flex items-center justify-between pt-2 border-t">
          <span className="text-xs text-muted-foreground">
            Actualizado {formatDistanceToNow(new Date(flag.updatedAt), {
              addSuffix: true,
              locale: es,
            })}
          </span>
          <Button variant="ghost" size="sm" onClick={() => onEdit(flag)}>
            Editar
          </Button>
        </div>
      </CardContent>
    </Card>
  );
}

RolloutSlider

// components/feature-flags/RolloutSlider.tsx
import { useState, useEffect } from 'react';
import { Slider } from '@/components/ui/slider';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';

interface RolloutSliderProps {
  value: number;
  onChange: (value: number) => void;
  disabled?: boolean;
}

export function RolloutSlider({ value, onChange, disabled }: RolloutSliderProps) {
  const [localValue, setLocalValue] = useState(value);

  useEffect(() => {
    setLocalValue(value);
  }, [value]);

  const handleSliderChange = (values: number[]) => {
    const newValue = values[0];
    setLocalValue(newValue);
    onChange(newValue);
  };

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = Math.min(100, Math.max(0, parseInt(e.target.value) || 0));
    setLocalValue(newValue);
    onChange(newValue);
  };

  const getColor = (percent: number) => {
    if (percent === 0) return 'bg-gray-200';
    if (percent < 25) return 'bg-red-500';
    if (percent < 50) return 'bg-orange-500';
    if (percent < 75) return 'bg-yellow-500';
    if (percent < 100) return 'bg-blue-500';
    return 'bg-green-500';
  };

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-4">
        <div className="flex-1">
          <Slider
            value={[localValue]}
            onValueChange={handleSliderChange}
            max={100}
            step={1}
            disabled={disabled}
            className={cn('cursor-pointer', disabled && 'opacity-50')}
          />
        </div>
        <div className="w-20">
          <Input
            type="number"
            min={0}
            max={100}
            value={localValue}
            onChange={handleInputChange}
            disabled={disabled}
            className="text-center"
          />
        </div>
        <span className="text-sm text-muted-foreground">%</span>
      </div>

      {/* Visual indicator */}
      <div className="relative h-8 bg-muted rounded-md overflow-hidden">
        <div
          className={cn('h-full transition-all duration-300', getColor(localValue))}
          style={{ width: `${localValue}%` }}
        />
        <div className="absolute inset-0 flex items-center justify-center text-sm font-medium">
          {localValue === 0 && 'Desactivado'}
          {localValue > 0 && localValue < 100 && `${localValue}% de usuarios`}
          {localValue === 100 && 'Todos los usuarios'}
        </div>
      </div>

      {/* Quick buttons */}
      <div className="flex gap-2">
        {[0, 10, 25, 50, 75, 100].map((percent) => (
          <button
            key={percent}
            type="button"
            onClick={() => {
              setLocalValue(percent);
              onChange(percent);
            }}
            disabled={disabled}
            className={cn(
              'px-3 py-1 text-sm rounded-md border transition-colors',
              localValue === percent
                ? 'bg-primary text-primary-foreground border-primary'
                : 'hover:bg-muted',
              disabled && 'opacity-50 cursor-not-allowed'
            )}
          >
            {percent}%
          </button>
        ))}
      </div>
    </div>
  );
}

SettingItem

// components/shared/SettingItem.tsx
import { ReactNode } from 'react';
import { Info } from 'lucide-react';
import { Label } from '@/components/ui/label';
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/ui/tooltip';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';

interface SettingItemProps {
  label: string;
  description?: string;
  children: ReactNode;
  badge?: string;
  badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
  required?: boolean;
  disabled?: boolean;
  className?: string;
}

export function SettingItem({
  label,
  description,
  children,
  badge,
  badgeVariant = 'secondary',
  required,
  disabled,
  className,
}: SettingItemProps) {
  return (
    <div
      className={cn(
        'flex items-start justify-between py-4 border-b last:border-b-0',
        disabled && 'opacity-50',
        className
      )}
    >
      <div className="space-y-1 flex-1 mr-4">
        <div className="flex items-center gap-2">
          <Label className="font-medium">
            {label}
            {required && <span className="text-destructive ml-1">*</span>}
          </Label>
          {badge && (
            <Badge variant={badgeVariant} className="text-xs">
              {badge}
            </Badge>
          )}
        </div>
        {description && (
          <p className="text-sm text-muted-foreground">
            {description}
          </p>
        )}
      </div>
      <div className="flex-shrink-0 w-64">
        {children}
      </div>
    </div>
  );
}

Pages

UserPreferencesPage

// pages/UserPreferencesPage.tsx
import { useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { ThemeSelector } from '../components/preferences/ThemeSelector';
import { LocaleSelector } from '../components/preferences/LocaleSelector';
import { TimezoneSelector } from '../components/preferences/TimezoneSelector';
import { NotificationPrefs } from '../components/preferences/NotificationPrefs';
import { SettingItem } from '../components/shared/SettingItem';
import { useUserPreferencesStore } from '../stores/user-preferences.store';
import { toast } from 'sonner';

export function UserPreferencesPage() {
  const {
    preferences,
    isLoading,
    fetchPreferences,
    updatePreferences,
    resetPreferences,
  } = useUserPreferencesStore();

  useEffect(() => {
    fetchPreferences();
  }, [fetchPreferences]);

  const handleUpdate = async (updates: Partial<typeof preferences>) => {
    try {
      await updatePreferences(updates);
      toast.success('Preferencias actualizadas');
    } catch (error) {
      toast.error('Error al actualizar preferencias');
    }
  };

  const handleReset = async () => {
    if (confirm('¿Restaurar todas las preferencias a valores predeterminados?')) {
      await resetPreferences();
      toast.success('Preferencias restauradas');
    }
  };

  if (isLoading && !preferences) {
    return (
      <div className="flex items-center justify-center h-64">
        <Loader2 className="h-8 w-8 animate-spin" />
      </div>
    );
  }

  if (!preferences) return null;

  return (
    <div className="container max-w-4xl mx-auto py-6 space-y-8">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">Preferencias</h1>
          <p className="text-muted-foreground">
            Personaliza tu experiencia en la aplicacion
          </p>
        </div>
        <Button variant="outline" onClick={handleReset}>
          Restaurar Predeterminados
        </Button>
      </div>

      {/* Apariencia */}
      <Card>
        <CardHeader>
          <CardTitle>Apariencia</CardTitle>
          <CardDescription>
            Personaliza el aspecto visual de la aplicacion
          </CardDescription>
        </CardHeader>
        <CardContent className="space-y-6">
          <div>
            <h4 className="text-sm font-medium mb-4">Tema</h4>
            <ThemeSelector
              value={preferences.theme}
              onChange={(theme) => handleUpdate({ theme })}
            />
          </div>

          <Separator />

          <SettingItem
            label="Densidad de Contenido"
            description="Ajusta el espaciado de los elementos"
          >
            <Select
              value={preferences.density}
              onValueChange={(density: any) => handleUpdate({ density })}
            >
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="compact">Compacto</SelectItem>
                <SelectItem value="normal">Normal</SelectItem>
                <SelectItem value="comfortable">Espacioso</SelectItem>
              </SelectContent>
            </Select>
          </SettingItem>

          <SettingItem
            label="Barra Lateral Colapsada"
            description="Inicia con la barra lateral minimizada"
          >
            <Switch
              checked={preferences.sidebarCollapsed}
              onCheckedChange={(sidebarCollapsed) => handleUpdate({ sidebarCollapsed })}
            />
          </SettingItem>
        </CardContent>
      </Card>

      {/* Regional */}
      <Card>
        <CardHeader>
          <CardTitle>Regional</CardTitle>
          <CardDescription>
            Configura idioma, zona horaria y formatos
          </CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          <SettingItem label="Idioma" description="Idioma de la interfaz">
            <LocaleSelector
              value={preferences.locale}
              onChange={(locale) => handleUpdate({ locale })}
            />
          </SettingItem>

          <SettingItem label="Zona Horaria" description="Tu zona horaria local">
            <TimezoneSelector
              value={preferences.timezone}
              onChange={(timezone) => handleUpdate({ timezone })}
            />
          </SettingItem>

          <SettingItem label="Formato de Fecha" description="Como se muestran las fechas">
            <Select
              value={preferences.dateFormat ?? 'DD/MM/YYYY'}
              onValueChange={(dateFormat) => handleUpdate({ dateFormat })}
            >
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="DD/MM/YYYY">31/12/2025</SelectItem>
                <SelectItem value="MM/DD/YYYY">12/31/2025</SelectItem>
                <SelectItem value="YYYY-MM-DD">2025-12-31</SelectItem>
              </SelectContent>
            </Select>
          </SettingItem>
        </CardContent>
      </Card>

      {/* Notificaciones */}
      <Card>
        <CardHeader>
          <CardTitle>Notificaciones</CardTitle>
          <CardDescription>
            Controla como y cuando recibes notificaciones
          </CardDescription>
        </CardHeader>
        <CardContent>
          <NotificationPrefs
            preferences={preferences}
            onUpdate={handleUpdate}
          />
        </CardContent>
      </Card>

      {/* Accesibilidad */}
      <Card>
        <CardHeader>
          <CardTitle>Accesibilidad</CardTitle>
          <CardDescription>
            Opciones para mejorar la accesibilidad
          </CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          <SettingItem
            label="Alto Contraste"
            description="Aumenta el contraste de colores"
          >
            <Switch
              checked={preferences.highContrast}
              onCheckedChange={(highContrast) => handleUpdate({ highContrast })}
            />
          </SettingItem>

          <SettingItem
            label="Reducir Movimiento"
            description="Minimiza animaciones y transiciones"
          >
            <Switch
              checked={preferences.reducedMotion}
              onCheckedChange={(reducedMotion) => handleUpdate({ reducedMotion })}
            />
          </SettingItem>

          <SettingItem
            label="Tamano de Fuente"
            description="Ajusta el tamano del texto"
          >
            <Select
              value={preferences.fontSize}
              onValueChange={(fontSize: any) => handleUpdate({ fontSize })}
            >
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="small">Pequeno</SelectItem>
                <SelectItem value="medium">Mediano</SelectItem>
                <SelectItem value="large">Grande</SelectItem>
              </SelectContent>
            </Select>
          </SettingItem>
        </CardContent>
      </Card>
    </div>
  );
}

FeatureFlagsPage

// pages/FeatureFlagsPage.tsx
import { useEffect, useState } from 'react';
import { Plus, Search, Filter } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { FeatureFlagCard } from '../components/feature-flags/FeatureFlagCard';
import { FeatureFlagForm } from '../components/feature-flags/FeatureFlagForm';
import { useFeatureFlagsStore } from '../stores/feature-flags.store';
import { FeatureFlag, FlagType } from '../types/feature-flag.types';

export function FeatureFlagsPage() {
  const {
    flags,
    isLoading,
    fetchFlags,
    createFlag,
    updateFlag,
    toggleFlag,
  } = useFeatureFlagsStore();

  const [search, setSearch] = useState('');
  const [typeFilter, setTypeFilter] = useState<FlagType | 'all'>('all');
  const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
  const [isFormOpen, setIsFormOpen] = useState(false);
  const [editingFlag, setEditingFlag] = useState<FeatureFlag | null>(null);

  useEffect(() => {
    fetchFlags();
  }, [fetchFlags]);

  const filteredFlags = flags.filter((flag) => {
    const matchesSearch =
      flag.name.toLowerCase().includes(search.toLowerCase()) ||
      flag.key.toLowerCase().includes(search.toLowerCase());
    const matchesType = typeFilter === 'all' || flag.flagType === typeFilter;
    const matchesStatus =
      statusFilter === 'all' ||
      (statusFilter === 'active' && flag.enabled) ||
      (statusFilter === 'inactive' && !flag.enabled);

    return matchesSearch && matchesType && matchesStatus;
  });

  const handleEdit = (flag: FeatureFlag) => {
    setEditingFlag(flag);
    setIsFormOpen(true);
  };

  const handleClose = () => {
    setIsFormOpen(false);
    setEditingFlag(null);
  };

  // Stats
  const activeCount = flags.filter((f) => f.enabled).length;
  const booleanCount = flags.filter((f) => f.flagType === 'boolean').length;
  const rolloutCount = flags.filter((f) => f.flagType === 'percentage').length;
  const variantCount = flags.filter((f) => f.flagType === 'variant').length;

  return (
    <div className="container mx-auto py-6 space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">Feature Flags</h1>
          <p className="text-muted-foreground">
            Gestiona funcionalidades y experimentos
          </p>
        </div>
        <Button onClick={() => setIsFormOpen(true)}>
          <Plus className="mr-2 h-4 w-4" />
          Nueva Feature Flag
        </Button>
      </div>

      {/* Stats */}
      <div className="grid grid-cols-4 gap-4">
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold">{flags.length}</div>
            <p className="text-sm text-muted-foreground">Total Flags</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold text-green-600">{activeCount}</div>
            <p className="text-sm text-muted-foreground">Activas</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold text-amber-600">{rolloutCount}</div>
            <p className="text-sm text-muted-foreground">En Rollout</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold text-purple-600">{variantCount}</div>
            <p className="text-sm text-muted-foreground">Con Variantes</p>
          </CardContent>
        </Card>
      </div>

      {/* Filters */}
      <Card>
        <CardContent className="pt-6">
          <div className="flex items-center gap-4">
            <div className="relative flex-1">
              <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
              <Input
                placeholder="Buscar por nombre o clave..."
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                className="pl-10"
              />
            </div>

            <Select value={typeFilter} onValueChange={(v: any) => setTypeFilter(v)}>
              <SelectTrigger className="w-40">
                <SelectValue placeholder="Tipo" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="all">Todos los tipos</SelectItem>
                <SelectItem value="boolean">Boolean</SelectItem>
                <SelectItem value="percentage">Rollout</SelectItem>
                <SelectItem value="variant">Variante</SelectItem>
              </SelectContent>
            </Select>

            <Select value={statusFilter} onValueChange={(v: any) => setStatusFilter(v)}>
              <SelectTrigger className="w-40">
                <SelectValue placeholder="Estado" />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="all">Todos</SelectItem>
                <SelectItem value="active">Activas</SelectItem>
                <SelectItem value="inactive">Inactivas</SelectItem>
              </SelectContent>
            </Select>
          </div>
        </CardContent>
      </Card>

      {/* Flags Grid */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
        {filteredFlags.map((flag) => (
          <FeatureFlagCard
            key={flag.id}
            flag={flag}
            onToggle={toggleFlag}
            onEdit={handleEdit}
          />
        ))}
      </div>

      {filteredFlags.length === 0 && (
        <Card>
          <CardContent className="py-12 text-center">
            <p className="text-muted-foreground">
              No se encontraron feature flags
            </p>
          </CardContent>
        </Card>
      )}

      {/* Form Dialog */}
      <Dialog open={isFormOpen} onOpenChange={handleClose}>
        <DialogContent className="max-w-2xl">
          <DialogHeader>
            <DialogTitle>
              {editingFlag ? 'Editar Feature Flag' : 'Nueva Feature Flag'}
            </DialogTitle>
          </DialogHeader>
          <FeatureFlagForm
            flag={editingFlag}
            onSubmit={async (data) => {
              if (editingFlag) {
                await updateFlag(editingFlag.id, data);
              } else {
                await createFlag(data);
              }
              handleClose();
            }}
            onCancel={handleClose}
          />
        </DialogContent>
      </Dialog>
    </div>
  );
}

Routes

// routes.tsx
import { lazy } from 'react';
import { RouteObject, Navigate } from 'react-router-dom';

const SettingsLayout = lazy(() => import('./pages/SettingsLayout'));
const SettingsOverviewPage = lazy(() => import('./pages/SettingsOverviewPage'));
const SystemSettingsPage = lazy(() => import('./pages/SystemSettingsPage'));
const TenantSettingsPage = lazy(() => import('./pages/TenantSettingsPage'));
const UserPreferencesPage = lazy(() => import('./pages/UserPreferencesPage'));
const FeatureFlagsPage = lazy(() => import('./pages/FeatureFlagsPage'));

export const settingsRoutes: RouteObject[] = [
  {
    path: 'settings',
    element: <SettingsLayout />,
    children: [
      {
        index: true,
        element: <SettingsOverviewPage />,
      },
      {
        path: 'system',
        element: <SystemSettingsPage />,
      },
      {
        path: 'tenant',
        element: <TenantSettingsPage />,
      },
      {
        path: 'preferences',
        element: <UserPreferencesPage />,
      },
      {
        path: 'feature-flags',
        element: <FeatureFlagsPage />,
      },
    ],
  },
];

Wireframes

Preferencias de Usuario

+------------------------------------------------------------------+
| Preferencias                              [Restaurar Predeterminados] |
| Personaliza tu experiencia en la aplicacion                       |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| Apariencia                                                        |
| Personaliza el aspecto visual de la aplicacion                    |
+------------------------------------------------------------------+
| Tema                                                              |
| +------------------+ +------------------+ +------------------+    |
| |      [☀]        | |      [🌙]        | |      [🖥]        |    |
| |     Claro       | |     Oscuro       | |     Sistema      |    |
| |  Tema claro ... | |  Tema oscuro ... | |  Sigue sistema...|    |
| +------------------+ +------------------+ +------------------+    |
|                                                                   |
| Densidad de Contenido          | [Normal          v]             |
| Ajusta el espaciado            |                                 |
|                                                                   |
| Barra Lateral Colapsada        | [  O  ]                        |
| Inicia minimizada              |                                 |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| Regional                                                          |
| Configura idioma, zona horaria y formatos                        |
+------------------------------------------------------------------+
| Idioma                         | [Espanol (Mexico) v]            |
| Idioma de la interfaz          |                                 |
|                                                                   |
| Zona Horaria                   | [America/Mexico_City v]         |
| Tu zona horaria local          |                                 |
|                                                                   |
| Formato de Fecha               | [DD/MM/YYYY      v]             |
| Como se muestran las fechas    |                                 |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
| Notificaciones                                                    |
| Controla como y cuando recibes notificaciones                    |
+------------------------------------------------------------------+
| Notificaciones por Email       | [  O  ]                        |
| Recibe alertas en tu correo    |                                 |
|                                                                   |
| Notificaciones Push            | [  O  ]                        |
| Alertas en tiempo real         |                                 |
|                                                                   |
| Frecuencia de Resumen          | [Diario          v]            |
| Consolida multiples alertas    |                                 |
+------------------------------------------------------------------+

Feature Flags

+------------------------------------------------------------------+
| Feature Flags                                      [+ Nueva Flag]  |
| Gestiona funcionalidades y experimentos                           |
+------------------------------------------------------------------+

+--------+ +--------+ +--------+ +--------+
| 12     | | 8      | | 3      | | 1      |
| Total  | | Activas| | Rollout| | Variant|
+--------+ +--------+ +--------+ +--------+

+------------------------------------------------------------------+
| [🔍 Buscar por nombre o clave...   ]  [Tipo ▼]  [Estado ▼]       |
+------------------------------------------------------------------+

+--------------------------------+ +--------------------------------+
| [Boolean] NEW_CHECKOUT         | | [Rollout] DARK_MODE            |
| new-checkout-flow              | | dark-mode-beta                 |
|                         [ON O] | |                         [ON O] |
| Nuevo flujo de checkout con    | | Modo oscuro para usuarios      |
| proceso simplificado           | | seleccionados                  |
|                                | |                                |
|                                | | Rollout [=====>    ] 35%       |
| Actualizado hace 2 dias [Editar] | Actualizado hace 1 hora [Editar] |
+--------------------------------+ +--------------------------------+

+--------------------------------+ +--------------------------------+
| [Variant] PRICING_TEST         | | [Boolean] BULK_EXPORT          |
| pricing-ab-test                | | bulk-export-feature            |
|                        [ON  O] | |                        [OFF O] |
| Test A/B de pagina de precios  | | Exportacion masiva de datos    |
|                                | |                                |
| Variantes:                     | |                                |
| [Control (50%)] [New (50%)]    | |                                |
|                                | |                                |
| Actualizado hace 5 dias [Editar] | Actualizado hace 1 mes [Editar] |
+--------------------------------+ +--------------------------------+

Historial de Cambios

Version Fecha Autor Cambios
1.0 2025-12-05 Requirements-Analyst Creacion inicial

Aprobaciones

Rol Nombre Fecha Firma
Frontend Lead - - [ ]
UX Designer - - [ ]