[GAPS] feat(frontend): Implement 4 missing Zustand stores (tenant, subscription, notification, feature-flag)
Some checks are pending
CI / Backend CI (push) Waiting to run
CI / Frontend CI (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / CI Summary (push) Blocked by required conditions

This commit is contained in:
Adrian Flores Cortes 2026-01-25 02:02:59 -06:00
parent 0f9899570b
commit 07064e3346
6 changed files with 752 additions and 19 deletions

View File

@ -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<string, FeatureFlag>;
isLoading: boolean;
error: string | null;
lastFetched: number | null;
// Actions
fetchFlags: () => Promise<void>;
isEnabled: (flagKey: string) => boolean;
getFlagValue: <T = unknown>(flagKey: string, defaultValue?: T) => T;
refreshFlags: () => Promise<void>;
clearFlags: () => void;
clearError: () => void;
}
const CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes
export const useFeatureFlagStore = create<FeatureFlagState>()(
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<string, FeatureFlag> = {};
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: <T = unknown>(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,
}),
}
)
);

View File

@ -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';

View File

@ -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<void>;
markAsRead: (notificationId: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
deleteNotification: (notificationId: string) => Promise<void>;
clearAllNotifications: () => Promise<void>;
updatePreferences: (prefs: Partial<NotificationPreferences>) => Promise<void>;
addNotification: (notification: Notification) => void;
clearError: () => void;
}
export const useNotificationStore = create<NotificationState>()(
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,
}),
}
)
);

View File

@ -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<void>;
fetchPlans: () => Promise<void>;
changePlan: (planId: string) => Promise<void>;
cancelSubscription: () => Promise<void>;
reactivateSubscription: () => Promise<void>;
clearSubscription: () => void;
clearError: () => void;
}
export const useSubscriptionStore = create<SubscriptionState>()(
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,
}),
}
)
);

View File

@ -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<string, unknown>;
created_at: string;
updated_at: string;
}
export interface TenantState {
tenant: Tenant | null;
isLoading: boolean;
error: string | null;
// Actions
setTenant: (tenant: Tenant) => void;
fetchTenant: () => Promise<void>;
updateTenant: (data: Partial<Tenant>) => Promise<void>;
switchTenant: (tenantId: string) => Promise<void>;
clearTenant: () => void;
clearError: () => void;
}
export const useTenantStore = create<TenantState>()(
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,
}),
}
)
);

View File

@ -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."