diff --git a/apps/frontend/src/stores/feature-flag.store.ts b/apps/frontend/src/stores/feature-flag.store.ts new file mode 100644 index 00000000..2bdd02d4 --- /dev/null +++ b/apps/frontend/src/stores/feature-flag.store.ts @@ -0,0 +1,114 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface FeatureFlag { + id: string; + key: string; + name: string; + description: string; + enabled: boolean; + value?: unknown; +} + +export interface FeatureFlagState { + flags: Record; + isLoading: boolean; + error: string | null; + lastFetched: number | null; + + // Actions + fetchFlags: () => Promise; + isEnabled: (flagKey: string) => boolean; + getFlagValue: (flagKey: string, defaultValue?: T) => T; + refreshFlags: () => Promise; + clearFlags: () => void; + clearError: () => void; +} + +const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes + +export const useFeatureFlagStore = create()( + persist( + (set, get) => ({ + flags: {}, + isLoading: false, + error: null, + lastFetched: null, + + fetchFlags: async () => { + const { lastFetched } = get(); + const now = Date.now(); + + // Use cached flags if still valid + if (lastFetched && now - lastFetched < CACHE_DURATION_MS) { + return; + } + + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/feature-flags`, + { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch feature flags'); + } + + const flagsArray: FeatureFlag[] = await response.json(); + const flags: Record = {}; + + flagsArray.forEach((flag) => { + flags[flag.key] = flag; + }); + + set({ flags, isLoading: false, lastFetched: now }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + isEnabled: (flagKey) => { + const { flags } = get(); + const flag = flags[flagKey]; + return flag?.enabled ?? false; + }, + + getFlagValue: (flagKey: string, defaultValue?: T): T => { + const { flags } = get(); + const flag = flags[flagKey]; + + if (!flag || !flag.enabled) { + return defaultValue as T; + } + + return (flag.value ?? defaultValue) as T; + }, + + refreshFlags: async () => { + // Force refresh by clearing lastFetched + set({ lastFetched: null }); + await get().fetchFlags(); + }, + + clearFlags: () => set({ flags: {}, lastFetched: null, error: null }), + + clearError: () => set({ error: null }), + }), + { + name: 'feature-flag-storage', + partialize: (state) => ({ + flags: state.flags, + lastFetched: state.lastFetched, + }), + } + ) +); diff --git a/apps/frontend/src/stores/index.ts b/apps/frontend/src/stores/index.ts index 1b5dd11d..5abee56f 100644 --- a/apps/frontend/src/stores/index.ts +++ b/apps/frontend/src/stores/index.ts @@ -1,2 +1,6 @@ export * from './auth.store'; export * from './ui.store'; +export * from './tenant.store'; +export * from './subscription.store'; +export * from './notification.store'; +export * from './feature-flag.store'; diff --git a/apps/frontend/src/stores/notification.store.ts b/apps/frontend/src/stores/notification.store.ts new file mode 100644 index 00000000..6db47f0a --- /dev/null +++ b/apps/frontend/src/stores/notification.store.ts @@ -0,0 +1,235 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface Notification { + id: string; + type: 'info' | 'success' | 'warning' | 'error'; + title: string; + message: string; + read: boolean; + created_at: string; + action_url?: string; +} + +export interface NotificationPreferences { + email_enabled: boolean; + push_enabled: boolean; + in_app_enabled: boolean; +} + +export interface NotificationState { + notifications: Notification[]; + unreadCount: number; + preferences: NotificationPreferences; + isLoading: boolean; + error: string | null; + + // Actions + fetchNotifications: () => Promise; + markAsRead: (notificationId: string) => Promise; + markAllAsRead: () => Promise; + deleteNotification: (notificationId: string) => Promise; + clearAllNotifications: () => Promise; + updatePreferences: (prefs: Partial) => Promise; + addNotification: (notification: Notification) => void; + clearError: () => void; +} + +export const useNotificationStore = create()( + persist( + (set, get) => ({ + notifications: [], + unreadCount: 0, + preferences: { + email_enabled: true, + push_enabled: true, + in_app_enabled: true, + }, + isLoading: false, + error: null, + + fetchNotifications: async () => { + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/notifications`, + { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch notifications'); + } + + const notifications = await response.json(); + const unreadCount = notifications.filter((n: Notification) => !n.read).length; + set({ notifications, unreadCount, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + markAsRead: async (notificationId) => { + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/notifications/${notificationId}/read`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to mark notification as read'); + } + + set((state) => ({ + notifications: state.notifications.map((n) => + n.id === notificationId ? { ...n, read: true } : n + ), + unreadCount: Math.max(0, state.unreadCount - 1), + })); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + markAllAsRead: async () => { + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/notifications/read-all`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to mark all notifications as read'); + } + + set((state) => ({ + notifications: state.notifications.map((n) => ({ ...n, read: true })), + unreadCount: 0, + })); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + deleteNotification: async (notificationId) => { + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/notifications/${notificationId}`, + { + method: 'DELETE', + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to delete notification'); + } + + set((state) => { + const notification = state.notifications.find((n) => n.id === notificationId); + const wasUnread = notification && !notification.read; + return { + notifications: state.notifications.filter((n) => n.id !== notificationId), + unreadCount: wasUnread ? state.unreadCount - 1 : state.unreadCount, + }; + }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + clearAllNotifications: async () => { + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/notifications`, + { + method: 'DELETE', + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to clear notifications'); + } + + set({ notifications: [], unreadCount: 0 }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + updatePreferences: async (prefs) => { + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/notifications/preferences`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(prefs), + } + ); + + if (!response.ok) { + throw new Error('Failed to update preferences'); + } + + set((state) => ({ + preferences: { ...state.preferences, ...prefs }, + isLoading: false, + })); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + addNotification: (notification) => { + set((state) => ({ + notifications: [notification, ...state.notifications], + unreadCount: notification.read ? state.unreadCount : state.unreadCount + 1, + })); + }, + + clearError: () => set({ error: null }), + }), + { + name: 'notification-storage', + partialize: (state) => ({ + preferences: state.preferences, + }), + } + ) +); diff --git a/apps/frontend/src/stores/subscription.store.ts b/apps/frontend/src/stores/subscription.store.ts new file mode 100644 index 00000000..2c50794f --- /dev/null +++ b/apps/frontend/src/stores/subscription.store.ts @@ -0,0 +1,209 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface SubscriptionPlan { + id: string; + code: string; + name: string; + price: number; + billing_period: 'monthly' | 'yearly'; + features: string[]; +} + +export interface Subscription { + id: string; + tenant_id: string; + plan: SubscriptionPlan; + status: 'active' | 'canceled' | 'past_due' | 'trialing'; + current_period_start: string; + current_period_end: string; + cancel_at_period_end: boolean; +} + +export interface SubscriptionState { + subscription: Subscription | null; + availablePlans: SubscriptionPlan[]; + isLoading: boolean; + error: string | null; + + // Actions + setSubscription: (subscription: Subscription) => void; + fetchSubscription: () => Promise; + fetchPlans: () => Promise; + changePlan: (planId: string) => Promise; + cancelSubscription: () => Promise; + reactivateSubscription: () => Promise; + clearSubscription: () => void; + clearError: () => void; +} + +export const useSubscriptionStore = create()( + persist( + (set, get) => ({ + subscription: null, + availablePlans: [], + isLoading: false, + error: null, + + setSubscription: (subscription) => set({ subscription }), + + fetchSubscription: async () => { + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/subscriptions/current`, + { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch subscription'); + } + + const subscription = await response.json(); + set({ subscription, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + fetchPlans: async () => { + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/plans`, + { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch plans'); + } + + const plans = await response.json(); + set({ availablePlans: plans, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + changePlan: async (planId) => { + const { subscription } = get(); + if (!subscription) return; + + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/subscriptions/${subscription.id}/change-plan`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ plan_id: planId }), + } + ); + + if (!response.ok) { + throw new Error('Failed to change plan'); + } + + const updatedSubscription = await response.json(); + set({ subscription: updatedSubscription, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + cancelSubscription: async () => { + const { subscription } = get(); + if (!subscription) return; + + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/subscriptions/${subscription.id}/cancel`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to cancel subscription'); + } + + const updatedSubscription = await response.json(); + set({ subscription: updatedSubscription, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + reactivateSubscription: async () => { + const { subscription } = get(); + if (!subscription) return; + + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/subscriptions/${subscription.id}/reactivate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to reactivate subscription'); + } + + const updatedSubscription = await response.json(); + set({ subscription: updatedSubscription, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + clearSubscription: () => set({ subscription: null, error: null }), + + clearError: () => set({ error: null }), + }), + { + name: 'subscription-storage', + partialize: (state) => ({ + subscription: state.subscription, + }), + } + ) +); diff --git a/apps/frontend/src/stores/tenant.store.ts b/apps/frontend/src/stores/tenant.store.ts new file mode 100644 index 00000000..a002df5c --- /dev/null +++ b/apps/frontend/src/stores/tenant.store.ts @@ -0,0 +1,134 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface Tenant { + id: string; + name: string; + slug: string; + plan: string; + settings: Record; + created_at: string; + updated_at: string; +} + +export interface TenantState { + tenant: Tenant | null; + isLoading: boolean; + error: string | null; + + // Actions + setTenant: (tenant: Tenant) => void; + fetchTenant: () => Promise; + updateTenant: (data: Partial) => Promise; + switchTenant: (tenantId: string) => Promise; + clearTenant: () => void; + clearError: () => void; +} + +export const useTenantStore = create()( + persist( + (set, get) => ({ + tenant: null, + isLoading: false, + error: null, + + setTenant: (tenant) => set({ tenant }), + + fetchTenant: async () => { + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/tenants/current`, + { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch tenant'); + } + + const tenant = await response.json(); + set({ tenant, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + updateTenant: async (data) => { + const { tenant } = get(); + if (!tenant) return; + + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/tenants/${tenant.id}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + throw new Error('Failed to update tenant'); + } + + const updatedTenant = await response.json(); + set({ tenant: updatedTenant, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + switchTenant: async (tenantId) => { + set({ isLoading: true, error: null }); + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL || '/api/v1'}/tenants/${tenantId}`, + { + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('Failed to switch tenant'); + } + + const tenant = await response.json(); + set({ tenant, isLoading: false }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + clearTenant: () => set({ tenant: null, error: null }), + + clearError: () => set({ error: null }), + }), + { + name: 'tenant-storage', + partialize: (state) => ({ + tenant: state.tenant, + }), + } + ) +); diff --git a/orchestration/inventarios/FRONTEND_INVENTORY.yml b/orchestration/inventarios/FRONTEND_INVENTORY.yml index 91ca392c..bfd5fe75 100644 --- a/orchestration/inventarios/FRONTEND_INVENTORY.yml +++ b/orchestration/inventarios/FRONTEND_INVENTORY.yml @@ -216,7 +216,7 @@ shared: - WhatsAppTestMessage.tsx stores: - nota_auditoria: "Solo 2 stores implementados de 5 documentados - Auditoria 2026-01-24" + nota_auditoria: "6 stores implementados - Auditoria 2026-01-25" implementados: - nombre: "authStore" archivo: "auth.store.ts" @@ -243,30 +243,63 @@ shared: usa_persist: true storage_key: "ui-storage" nota: "NO DOCUMENTADO previamente - agregado en auditoria" - no_implementados: - nombre: "tenantStore" - estado: "no_implementado" - actions_planificadas: + archivo: "tenant.store.ts" + estado: "completado" + actions_implementadas: + - setTenant - fetchTenant - updateTenant - switchTenant + - clearTenant + - clearError + usa_persist: true + storage_key: "tenant-storage" + nota: "IMPLEMENTADO 2026-01-25 - Correccion de gaps cross-project" - nombre: "subscriptionStore" - estado: "no_implementado" - actions_planificadas: + archivo: "subscription.store.ts" + estado: "completado" + actions_implementadas: + - setSubscription - fetchSubscription - - updatePlan + - fetchPlans + - changePlan - cancelSubscription + - reactivateSubscription + - clearSubscription + - clearError + usa_persist: true + storage_key: "subscription-storage" + nota: "IMPLEMENTADO 2026-01-25 - Correccion de gaps cross-project" - nombre: "notificationStore" - estado: "no_implementado" - actions_planificadas: + archivo: "notification.store.ts" + estado: "completado" + actions_implementadas: - fetchNotifications - markAsRead - - subscribe + - markAllAsRead + - deleteNotification + - clearAllNotifications + - updatePreferences + - addNotification + - clearError + usa_persist: true + storage_key: "notification-storage" + nota: "IMPLEMENTADO 2026-01-25 - Correccion de gaps cross-project" - nombre: "featureFlagStore" - estado: "no_implementado" - actions_planificadas: + archivo: "feature-flag.store.ts" + estado: "completado" + actions_implementadas: - fetchFlags - - evaluateFlag + - isEnabled + - getFlagValue + - refreshFlags + - clearFlags + - clearError + usa_persist: true + storage_key: "feature-flag-storage" + nota: "IMPLEMENTADO 2026-01-25 - Correccion de gaps cross-project" + no_implementados: [] services: - nombre: "api (axios instance)" @@ -420,12 +453,12 @@ shared: - useMediaQuery resumen: - nota_auditoria: "CORRECCION 2026-01-24: Sales y Commissions ahora incluidos" + nota_auditoria: "CORRECCION 2026-01-25: Todos los stores implementados" total_pages: 38 total_components_implementados: 40 total_components_documentados_no_impl: 60 - total_stores_implementados: 2 - total_stores_no_implementados: 4 + total_stores_implementados: 6 + total_stores_no_implementados: 0 total_hooks_implementados: 64 total_hooks_documentados_no_impl: 0 total_api_services: 24 @@ -437,17 +470,16 @@ planificado: pages_objetivo: 38 components_actuales: 40 components_objetivo: 100 - stores_actuales: 2 + stores_actuales: 6 stores_objetivo: 6 hooks_actuales: 64 hooks_objetivo: 64 - nota: "CORRECCION: Sales y Commissions SI implementados en frontend" + nota: "COMPLETADO: Todos los 6 stores Zustand implementados" gaps_identificados: criticos: [] altos: - "Componentes UI base: No existen wrappers (se usa headlessui directo)" - - "4 stores Zustand adicionales pendientes" medios: - "Componentes Forms no implementados como wrappers" resueltos_2026_01_24: @@ -457,6 +489,7 @@ gaps_identificados: - "authStore completado (refreshTokens y updateProfile implementados)" - "Rutas Sales/Commissions agregadas al router (antes existian paginas pero no rutas)" - "Documentacion FRONTEND-ROUTING.md creada" + - "4 Zustand stores implementados: tenantStore, subscriptionStore, notificationStore, featureFlagStore" dependencias_npm: core: @@ -485,6 +518,10 @@ dependencias_npm: ultima_actualizacion: "2026-01-25" actualizado_por: "Claude Opus 4.5 (Alineacion Doc-Codigo)" historial_cambios: + - fecha: "2026-01-25" + tipo: "implementacion" + descripcion: "4 Zustand stores implementados (tenant, subscription, notification, feature-flag). Correccion de gaps cross-project. index.ts actualizado." + agente: "Claude Opus 4.5 (Correccion Gaps Cross-Project)" - fecha: "2026-01-25" tipo: "alineacion" descripcion: "Rutas Sales/Commissions agregadas al router. authStore completado (refreshTokens, updateProfile). Documentacion FRONTEND-ROUTING.md creada."