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 |
- |
- |
[ ] |