Start integrating the onboarding flow with backend 6
This commit is contained in:
176
frontend/src/api/client/apiClient.ts
Normal file
176
frontend/src/api/client/apiClient.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Core HTTP client for React Query integration
|
||||
*
|
||||
* Architecture:
|
||||
* - Axios: HTTP client for making requests
|
||||
* - This Client: Handles auth tokens, tenant context, and error formatting
|
||||
* - Services: Business logic that uses this client
|
||||
* - React Query Hooks: Data fetching layer that uses services
|
||||
*
|
||||
* React Query doesn't replace HTTP clients - it manages data fetching/caching/sync
|
||||
*/
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private baseURL: string;
|
||||
private authToken: string | null = null;
|
||||
private tenantId: string | null = null;
|
||||
|
||||
constructor(baseURL: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1') {
|
||||
this.baseURL = baseURL;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
// Request interceptor to add auth headers
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.authToken) {
|
||||
config.headers.Authorization = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
if (this.tenantId) {
|
||||
config.headers['X-Tenant-ID'] = this.tenantId;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(error: AxiosError): ApiError {
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
const { status, data } = error.response;
|
||||
return {
|
||||
message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`,
|
||||
status,
|
||||
code: (data as any)?.code,
|
||||
details: data,
|
||||
};
|
||||
} else if (error.request) {
|
||||
// Network error
|
||||
return {
|
||||
message: 'Network error - please check your connection',
|
||||
status: 0,
|
||||
};
|
||||
} else {
|
||||
// Other error
|
||||
return {
|
||||
message: error.message || 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration methods
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string | null) {
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
getAuthToken(): string | null {
|
||||
return this.authToken;
|
||||
}
|
||||
|
||||
getTenantId(): string | null {
|
||||
return this.tenantId;
|
||||
}
|
||||
|
||||
// HTTP Methods - Return direct data for React Query
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response: AxiosResponse<T> = await this.client.get(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T = any, D = any>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> = await this.client.post(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async put<T = any, D = any>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> = await this.client.put(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async patch<T = any, D = any>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const response: AxiosResponse<T> = await this.client.patch(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response: AxiosResponse<T> = await this.client.delete(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// File upload helper
|
||||
async uploadFile<T = any>(
|
||||
url: string,
|
||||
file: File | FormData,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const formData = file instanceof FormData ? file : new FormData();
|
||||
if (file instanceof File) {
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
return this.post<T>(url, formData, {
|
||||
...config,
|
||||
headers: {
|
||||
...config?.headers,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Raw axios instance for advanced usage
|
||||
getAxiosInstance(): AxiosInstance {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const apiClient = new ApiClient();
|
||||
export default apiClient;
|
||||
2
frontend/src/api/client/index.ts
Normal file
2
frontend/src/api/client/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { apiClient, default } from './apiClient';
|
||||
export type { ApiError } from './apiClient';
|
||||
171
frontend/src/api/hooks/auth.ts
Normal file
171
frontend/src/api/hooks/auth.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Auth React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { authService } from '../services/auth';
|
||||
import {
|
||||
UserRegistration,
|
||||
UserLogin,
|
||||
TokenResponse,
|
||||
PasswordChange,
|
||||
PasswordReset,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
TokenVerificationResponse,
|
||||
AuthHealthResponse
|
||||
} from '../types/auth';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const authKeys = {
|
||||
all: ['auth'] as const,
|
||||
profile: () => [...authKeys.all, 'profile'] as const,
|
||||
health: () => [...authKeys.all, 'health'] as const,
|
||||
verify: (token?: string) => [...authKeys.all, 'verify', token] as const,
|
||||
} as const;
|
||||
|
||||
// Queries
|
||||
export const useAuthProfile = (
|
||||
options?: Omit<UseQueryOptions<UserResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<UserResponse, ApiError>({
|
||||
queryKey: authKeys.profile(),
|
||||
queryFn: () => authService.getProfile(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAuthHealth = (
|
||||
options?: Omit<UseQueryOptions<AuthHealthResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<AuthHealthResponse, ApiError>({
|
||||
queryKey: authKeys.health(),
|
||||
queryFn: () => authService.healthCheck(),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyToken = (
|
||||
token?: string,
|
||||
options?: Omit<UseQueryOptions<TokenVerificationResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TokenVerificationResponse, ApiError>({
|
||||
queryKey: authKeys.verify(token),
|
||||
queryFn: () => authService.verifyToken(token),
|
||||
enabled: !!token,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutations
|
||||
export const useRegister = (
|
||||
options?: UseMutationOptions<TokenResponse, ApiError, UserRegistration>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TokenResponse, ApiError, UserRegistration>({
|
||||
mutationFn: (userData: UserRegistration) => authService.register(userData),
|
||||
onSuccess: (data) => {
|
||||
// Update profile query with new user data
|
||||
if (data.user) {
|
||||
queryClient.setQueryData(authKeys.profile(), data.user);
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useLogin = (
|
||||
options?: UseMutationOptions<TokenResponse, ApiError, UserLogin>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TokenResponse, ApiError, UserLogin>({
|
||||
mutationFn: (loginData: UserLogin) => authService.login(loginData),
|
||||
onSuccess: (data) => {
|
||||
// Update profile query with new user data
|
||||
if (data.user) {
|
||||
queryClient.setQueryData(authKeys.profile(), data.user);
|
||||
}
|
||||
// Invalidate all queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRefreshToken = (
|
||||
options?: UseMutationOptions<TokenResponse, ApiError, string>
|
||||
) => {
|
||||
return useMutation<TokenResponse, ApiError, string>({
|
||||
mutationFn: (refreshToken: string) => authService.refreshToken(refreshToken),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useLogout = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, string>({
|
||||
mutationFn: (refreshToken: string) => authService.logout(refreshToken),
|
||||
onSuccess: () => {
|
||||
// Clear all queries on logout
|
||||
queryClient.clear();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useChangePassword = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, PasswordChange>
|
||||
) => {
|
||||
return useMutation<{ message: string }, ApiError, PasswordChange>({
|
||||
mutationFn: (passwordData: PasswordChange) => authService.changePassword(passwordData),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useResetPassword = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, PasswordReset>
|
||||
) => {
|
||||
return useMutation<{ message: string }, ApiError, PasswordReset>({
|
||||
mutationFn: (resetData: PasswordReset) => authService.resetPassword(resetData),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProfile = (
|
||||
options?: UseMutationOptions<UserResponse, ApiError, UserUpdate>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<UserResponse, ApiError, UserUpdate>({
|
||||
mutationFn: (updateData: UserUpdate) => authService.updateProfile(updateData),
|
||||
onSuccess: (data) => {
|
||||
// Update the profile cache
|
||||
queryClient.setQueryData(authKeys.profile(), data);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyEmail = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, { userId: string; verificationToken: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, { userId: string; verificationToken: string }>({
|
||||
mutationFn: ({ userId, verificationToken }) =>
|
||||
authService.verifyEmail(userId, verificationToken),
|
||||
onSuccess: () => {
|
||||
// Invalidate profile to get updated verification status
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.profile() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
200
frontend/src/api/hooks/classification.ts
Normal file
200
frontend/src/api/hooks/classification.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Classification React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { classificationService } from '../services/classification';
|
||||
import {
|
||||
ProductClassificationRequest,
|
||||
BatchClassificationRequest,
|
||||
ProductSuggestionResponse,
|
||||
BusinessModelAnalysisResponse,
|
||||
ClassificationApprovalRequest,
|
||||
ClassificationApprovalResponse,
|
||||
} from '../types/classification';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const classificationKeys = {
|
||||
all: ['classification'] as const,
|
||||
suggestions: {
|
||||
all: () => [...classificationKeys.all, 'suggestions'] as const,
|
||||
pending: (tenantId: string) => [...classificationKeys.suggestions.all(), 'pending', tenantId] as const,
|
||||
history: (tenantId: string, limit?: number, offset?: number) =>
|
||||
[...classificationKeys.suggestions.all(), 'history', tenantId, { limit, offset }] as const,
|
||||
},
|
||||
analysis: {
|
||||
all: () => [...classificationKeys.all, 'analysis'] as const,
|
||||
businessModel: (tenantId: string) => [...classificationKeys.analysis.all(), 'business-model', tenantId] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Queries
|
||||
export const usePendingSuggestions = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<ProductSuggestionResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<ProductSuggestionResponse[], ApiError>({
|
||||
queryKey: classificationKeys.suggestions.pending(tenantId),
|
||||
queryFn: () => classificationService.getPendingSuggestions(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSuggestionHistory = (
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
options?: Omit<UseQueryOptions<{ items: ProductSuggestionResponse[]; total: number }, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<{ items: ProductSuggestionResponse[]; total: number }, ApiError>({
|
||||
queryKey: classificationKeys.suggestions.history(tenantId, limit, offset),
|
||||
queryFn: () => classificationService.getSuggestionHistory(tenantId, limit, offset),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useBusinessModelAnalysis = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<BusinessModelAnalysisResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<BusinessModelAnalysisResponse, ApiError>({
|
||||
queryKey: classificationKeys.analysis.businessModel(tenantId),
|
||||
queryFn: () => classificationService.getBusinessModelAnalysis(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutations
|
||||
export const useClassifyProduct = (
|
||||
options?: UseMutationOptions<
|
||||
ProductSuggestionResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; classificationData: ProductClassificationRequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
ProductSuggestionResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; classificationData: ProductClassificationRequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, classificationData }) =>
|
||||
classificationService.classifyProduct(tenantId, classificationData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate pending suggestions to include the new one
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useClassifyProductsBatch = (
|
||||
options?: UseMutationOptions<
|
||||
ProductSuggestionResponse[],
|
||||
ApiError,
|
||||
{ tenantId: string; batchData: BatchClassificationRequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
ProductSuggestionResponse[],
|
||||
ApiError,
|
||||
{ tenantId: string; batchData: BatchClassificationRequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, batchData }) =>
|
||||
classificationService.classifyProductsBatch(tenantId, batchData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate pending suggestions to include the new ones
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveClassification = (
|
||||
options?: UseMutationOptions<
|
||||
ClassificationApprovalResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; approvalData: ClassificationApprovalRequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
ClassificationApprovalResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; approvalData: ClassificationApprovalRequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, approvalData }) =>
|
||||
classificationService.approveClassification(tenantId, approvalData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate suggestions lists
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) });
|
||||
|
||||
// If approved and ingredient was created, invalidate inventory queries
|
||||
if (data.approved && data.created_ingredient) {
|
||||
queryClient.invalidateQueries({ queryKey: ['inventory', 'ingredients'] });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateSuggestion = (
|
||||
options?: UseMutationOptions<
|
||||
ProductSuggestionResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; suggestionId: string; updateData: Partial<ProductSuggestionResponse> }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
ProductSuggestionResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; suggestionId: string; updateData: Partial<ProductSuggestionResponse> }
|
||||
>({
|
||||
mutationFn: ({ tenantId, suggestionId, updateData }) =>
|
||||
classificationService.updateSuggestion(tenantId, suggestionId, updateData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate suggestions lists
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSuggestion = (
|
||||
options?: UseMutationOptions<
|
||||
{ message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; suggestionId: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; suggestionId: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, suggestionId }) =>
|
||||
classificationService.deleteSuggestion(tenantId, suggestionId),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate suggestions lists
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
384
frontend/src/api/hooks/foodSafety.ts
Normal file
384
frontend/src/api/hooks/foodSafety.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Food Safety React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { foodSafetyService } from '../services/foodSafety';
|
||||
import {
|
||||
FoodSafetyComplianceCreate,
|
||||
FoodSafetyComplianceUpdate,
|
||||
FoodSafetyComplianceResponse,
|
||||
TemperatureLogCreate,
|
||||
BulkTemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
FoodSafetyAlertCreate,
|
||||
FoodSafetyAlertUpdate,
|
||||
FoodSafetyAlertResponse,
|
||||
FoodSafetyFilter,
|
||||
TemperatureMonitoringFilter,
|
||||
FoodSafetyMetrics,
|
||||
TemperatureAnalytics,
|
||||
FoodSafetyDashboard,
|
||||
} from '../types/foodSafety';
|
||||
import { PaginatedResponse } from '../types/inventory';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const foodSafetyKeys = {
|
||||
all: ['food-safety'] as const,
|
||||
compliance: {
|
||||
all: () => [...foodSafetyKeys.all, 'compliance'] as const,
|
||||
lists: () => [...foodSafetyKeys.compliance.all(), 'list'] as const,
|
||||
list: (tenantId: string, filter?: FoodSafetyFilter) =>
|
||||
[...foodSafetyKeys.compliance.lists(), tenantId, filter] as const,
|
||||
details: () => [...foodSafetyKeys.compliance.all(), 'detail'] as const,
|
||||
detail: (tenantId: string, recordId: string) =>
|
||||
[...foodSafetyKeys.compliance.details(), tenantId, recordId] as const,
|
||||
},
|
||||
temperature: {
|
||||
all: () => [...foodSafetyKeys.all, 'temperature'] as const,
|
||||
lists: () => [...foodSafetyKeys.temperature.all(), 'list'] as const,
|
||||
list: (tenantId: string, filter?: TemperatureMonitoringFilter) =>
|
||||
[...foodSafetyKeys.temperature.lists(), tenantId, filter] as const,
|
||||
analytics: (tenantId: string, location: string, startDate?: string, endDate?: string) =>
|
||||
[...foodSafetyKeys.temperature.all(), 'analytics', tenantId, location, { startDate, endDate }] as const,
|
||||
violations: (tenantId: string, limit?: number) =>
|
||||
[...foodSafetyKeys.temperature.all(), 'violations', tenantId, limit] as const,
|
||||
},
|
||||
alerts: {
|
||||
all: () => [...foodSafetyKeys.all, 'alerts'] as const,
|
||||
lists: () => [...foodSafetyKeys.alerts.all(), 'list'] as const,
|
||||
list: (tenantId: string, status?: string, severity?: string, limit?: number, offset?: number) =>
|
||||
[...foodSafetyKeys.alerts.lists(), tenantId, { status, severity, limit, offset }] as const,
|
||||
details: () => [...foodSafetyKeys.alerts.all(), 'detail'] as const,
|
||||
detail: (tenantId: string, alertId: string) =>
|
||||
[...foodSafetyKeys.alerts.details(), tenantId, alertId] as const,
|
||||
},
|
||||
dashboard: (tenantId: string) =>
|
||||
[...foodSafetyKeys.all, 'dashboard', tenantId] as const,
|
||||
metrics: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
[...foodSafetyKeys.all, 'metrics', tenantId, { startDate, endDate }] as const,
|
||||
complianceRate: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
[...foodSafetyKeys.all, 'compliance-rate', tenantId, { startDate, endDate }] as const,
|
||||
} as const;
|
||||
|
||||
// Compliance Queries
|
||||
export const useComplianceRecords = (
|
||||
tenantId: string,
|
||||
filter?: FoodSafetyFilter,
|
||||
options?: Omit<UseQueryOptions<PaginatedResponse<FoodSafetyComplianceResponse>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PaginatedResponse<FoodSafetyComplianceResponse>, ApiError>({
|
||||
queryKey: foodSafetyKeys.compliance.list(tenantId, filter),
|
||||
queryFn: () => foodSafetyService.getComplianceRecords(tenantId, filter),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useComplianceRecord = (
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
options?: Omit<UseQueryOptions<FoodSafetyComplianceResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<FoodSafetyComplianceResponse, ApiError>({
|
||||
queryKey: foodSafetyKeys.compliance.detail(tenantId, recordId),
|
||||
queryFn: () => foodSafetyService.getComplianceRecord(tenantId, recordId),
|
||||
enabled: !!tenantId && !!recordId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Temperature Monitoring Queries
|
||||
export const useTemperatureLogs = (
|
||||
tenantId: string,
|
||||
filter?: TemperatureMonitoringFilter,
|
||||
options?: Omit<UseQueryOptions<PaginatedResponse<TemperatureLogResponse>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PaginatedResponse<TemperatureLogResponse>, ApiError>({
|
||||
queryKey: foodSafetyKeys.temperature.list(tenantId, filter),
|
||||
queryFn: () => foodSafetyService.getTemperatureLogs(tenantId, filter),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTemperatureAnalytics = (
|
||||
tenantId: string,
|
||||
location: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<TemperatureAnalytics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TemperatureAnalytics, ApiError>({
|
||||
queryKey: foodSafetyKeys.temperature.analytics(tenantId, location, startDate, endDate),
|
||||
queryFn: () => foodSafetyService.getTemperatureAnalytics(tenantId, location, startDate, endDate),
|
||||
enabled: !!tenantId && !!location,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTemperatureViolations = (
|
||||
tenantId: string,
|
||||
limit: number = 20,
|
||||
options?: Omit<UseQueryOptions<TemperatureLogResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TemperatureLogResponse[], ApiError>({
|
||||
queryKey: foodSafetyKeys.temperature.violations(tenantId, limit),
|
||||
queryFn: () => foodSafetyService.getTemperatureViolations(tenantId, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Alert Queries
|
||||
export const useFoodSafetyAlerts = (
|
||||
tenantId: string,
|
||||
status?: 'open' | 'in_progress' | 'resolved' | 'dismissed',
|
||||
severity?: 'critical' | 'warning' | 'info',
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
options?: Omit<UseQueryOptions<PaginatedResponse<FoodSafetyAlertResponse>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PaginatedResponse<FoodSafetyAlertResponse>, ApiError>({
|
||||
queryKey: foodSafetyKeys.alerts.list(tenantId, status, severity, limit, offset),
|
||||
queryFn: () => foodSafetyService.getFoodSafetyAlerts(tenantId, status, severity, limit, offset),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFoodSafetyAlert = (
|
||||
tenantId: string,
|
||||
alertId: string,
|
||||
options?: Omit<UseQueryOptions<FoodSafetyAlertResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<FoodSafetyAlertResponse, ApiError>({
|
||||
queryKey: foodSafetyKeys.alerts.detail(tenantId, alertId),
|
||||
queryFn: () => foodSafetyService.getFoodSafetyAlert(tenantId, alertId),
|
||||
enabled: !!tenantId && !!alertId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Dashboard and Metrics Queries
|
||||
export const useFoodSafetyDashboard = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<FoodSafetyDashboard, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<FoodSafetyDashboard, ApiError>({
|
||||
queryKey: foodSafetyKeys.dashboard(tenantId),
|
||||
queryFn: () => foodSafetyService.getFoodSafetyDashboard(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFoodSafetyMetrics = (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<FoodSafetyMetrics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<FoodSafetyMetrics, ApiError>({
|
||||
queryKey: foodSafetyKeys.metrics(tenantId, startDate, endDate),
|
||||
queryFn: () => foodSafetyService.getFoodSafetyMetrics(tenantId, startDate, endDate),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useComplianceRate = (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<{
|
||||
overall_rate: number;
|
||||
by_type: Record<string, number>;
|
||||
trend: Array<{ date: string; rate: number }>;
|
||||
}, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<{
|
||||
overall_rate: number;
|
||||
by_type: Record<string, number>;
|
||||
trend: Array<{ date: string; rate: number }>;
|
||||
}, ApiError>({
|
||||
queryKey: foodSafetyKeys.complianceRate(tenantId, startDate, endDate),
|
||||
queryFn: () => foodSafetyService.getComplianceRate(tenantId, startDate, endDate),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Compliance Mutations
|
||||
export const useCreateComplianceRecord = (
|
||||
options?: UseMutationOptions<
|
||||
FoodSafetyComplianceResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; complianceData: FoodSafetyComplianceCreate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
FoodSafetyComplianceResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; complianceData: FoodSafetyComplianceCreate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, complianceData }) =>
|
||||
foodSafetyService.createComplianceRecord(tenantId, complianceData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Add to cache
|
||||
queryClient.setQueryData(foodSafetyKeys.compliance.detail(tenantId, data.id), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.compliance.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.dashboard(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.metrics(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateComplianceRecord = (
|
||||
options?: UseMutationOptions<
|
||||
FoodSafetyComplianceResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; recordId: string; updateData: FoodSafetyComplianceUpdate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
FoodSafetyComplianceResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; recordId: string; updateData: FoodSafetyComplianceUpdate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, recordId, updateData }) =>
|
||||
foodSafetyService.updateComplianceRecord(tenantId, recordId, updateData),
|
||||
onSuccess: (data, { tenantId, recordId }) => {
|
||||
// Update cache
|
||||
queryClient.setQueryData(foodSafetyKeys.compliance.detail(tenantId, recordId), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.compliance.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.dashboard(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Temperature Mutations
|
||||
export const useCreateTemperatureLog = (
|
||||
options?: UseMutationOptions<
|
||||
TemperatureLogResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; logData: TemperatureLogCreate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TemperatureLogResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; logData: TemperatureLogCreate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, logData }) => foodSafetyService.createTemperatureLog(tenantId, logData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate temperature queries
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.temperature.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.dashboard(tenantId) });
|
||||
|
||||
// If alert was triggered, invalidate alerts
|
||||
if (data.alert_triggered) {
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.alerts.lists() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateBulkTemperatureLogs = (
|
||||
options?: UseMutationOptions<
|
||||
{ created_count: number; failed_count: number; errors?: string[] },
|
||||
ApiError,
|
||||
{ tenantId: string; bulkData: BulkTemperatureLogCreate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ created_count: number; failed_count: number; errors?: string[] },
|
||||
ApiError,
|
||||
{ tenantId: string; bulkData: BulkTemperatureLogCreate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, bulkData }) => foodSafetyService.createBulkTemperatureLogs(tenantId, bulkData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate temperature queries
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.temperature.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.dashboard(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Alert Mutations
|
||||
export const useCreateFoodSafetyAlert = (
|
||||
options?: UseMutationOptions<
|
||||
FoodSafetyAlertResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; alertData: FoodSafetyAlertCreate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
FoodSafetyAlertResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; alertData: FoodSafetyAlertCreate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, alertData }) => foodSafetyService.createFoodSafetyAlert(tenantId, alertData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Add to cache
|
||||
queryClient.setQueryData(foodSafetyKeys.alerts.detail(tenantId, data.id), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.alerts.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.dashboard(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFoodSafetyAlert = (
|
||||
options?: UseMutationOptions<
|
||||
FoodSafetyAlertResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; alertId: string; updateData: FoodSafetyAlertUpdate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
FoodSafetyAlertResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; alertId: string; updateData: FoodSafetyAlertUpdate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, alertId, updateData }) =>
|
||||
foodSafetyService.updateFoodSafetyAlert(tenantId, alertId, updateData),
|
||||
onSuccess: (data, { tenantId, alertId }) => {
|
||||
// Update cache
|
||||
queryClient.setQueryData(foodSafetyKeys.alerts.detail(tenantId, alertId), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.alerts.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: foodSafetyKeys.dashboard(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
372
frontend/src/api/hooks/inventory.ts
Normal file
372
frontend/src/api/hooks/inventory.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Inventory React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { inventoryService } from '../services/inventory';
|
||||
import {
|
||||
IngredientCreate,
|
||||
IngredientUpdate,
|
||||
IngredientResponse,
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
StockResponse,
|
||||
StockMovementCreate,
|
||||
StockMovementResponse,
|
||||
InventoryFilter,
|
||||
StockFilter,
|
||||
StockConsumptionRequest,
|
||||
StockConsumptionResponse,
|
||||
PaginatedResponse,
|
||||
} from '../types/inventory';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const inventoryKeys = {
|
||||
all: ['inventory'] as const,
|
||||
ingredients: {
|
||||
all: () => [...inventoryKeys.all, 'ingredients'] as const,
|
||||
lists: () => [...inventoryKeys.ingredients.all(), 'list'] as const,
|
||||
list: (tenantId: string, filters?: InventoryFilter) =>
|
||||
[...inventoryKeys.ingredients.lists(), tenantId, filters] as const,
|
||||
details: () => [...inventoryKeys.ingredients.all(), 'detail'] as const,
|
||||
detail: (tenantId: string, ingredientId: string) =>
|
||||
[...inventoryKeys.ingredients.details(), tenantId, ingredientId] as const,
|
||||
byCategory: (tenantId: string) =>
|
||||
[...inventoryKeys.ingredients.all(), 'by-category', tenantId] as const,
|
||||
lowStock: (tenantId: string) =>
|
||||
[...inventoryKeys.ingredients.all(), 'low-stock', tenantId] as const,
|
||||
},
|
||||
stock: {
|
||||
all: () => [...inventoryKeys.all, 'stock'] as const,
|
||||
lists: () => [...inventoryKeys.stock.all(), 'list'] as const,
|
||||
list: (tenantId: string, filters?: StockFilter) =>
|
||||
[...inventoryKeys.stock.lists(), tenantId, filters] as const,
|
||||
details: () => [...inventoryKeys.stock.all(), 'detail'] as const,
|
||||
detail: (tenantId: string, stockId: string) =>
|
||||
[...inventoryKeys.stock.details(), tenantId, stockId] as const,
|
||||
byIngredient: (tenantId: string, ingredientId: string, includeUnavailable?: boolean) =>
|
||||
[...inventoryKeys.stock.all(), 'by-ingredient', tenantId, ingredientId, includeUnavailable] as const,
|
||||
expiring: (tenantId: string, withinDays?: number) =>
|
||||
[...inventoryKeys.stock.all(), 'expiring', tenantId, withinDays] as const,
|
||||
expired: (tenantId: string) =>
|
||||
[...inventoryKeys.stock.all(), 'expired', tenantId] as const,
|
||||
movements: (tenantId: string, ingredientId?: string) =>
|
||||
[...inventoryKeys.stock.all(), 'movements', tenantId, ingredientId] as const,
|
||||
},
|
||||
analytics: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
[...inventoryKeys.all, 'analytics', tenantId, { startDate, endDate }] as const,
|
||||
} as const;
|
||||
|
||||
// Ingredient Queries
|
||||
export const useIngredients = (
|
||||
tenantId: string,
|
||||
filter?: InventoryFilter,
|
||||
options?: Omit<UseQueryOptions<PaginatedResponse<IngredientResponse>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PaginatedResponse<IngredientResponse>, ApiError>({
|
||||
queryKey: inventoryKeys.ingredients.list(tenantId, filter),
|
||||
queryFn: () => inventoryService.getIngredients(tenantId, filter),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useIngredient = (
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
options?: Omit<UseQueryOptions<IngredientResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<IngredientResponse, ApiError>({
|
||||
queryKey: inventoryKeys.ingredients.detail(tenantId, ingredientId),
|
||||
queryFn: () => inventoryService.getIngredient(tenantId, ingredientId),
|
||||
enabled: !!tenantId && !!ingredientId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useIngredientsByCategory = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<Record<string, IngredientResponse[]>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<Record<string, IngredientResponse[]>, ApiError>({
|
||||
queryKey: inventoryKeys.ingredients.byCategory(tenantId),
|
||||
queryFn: () => inventoryService.getIngredientsByCategory(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useLowStockIngredients = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<IngredientResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<IngredientResponse[], ApiError>({
|
||||
queryKey: inventoryKeys.ingredients.lowStock(tenantId),
|
||||
queryFn: () => inventoryService.getLowStockIngredients(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Stock Queries
|
||||
export const useStock = (
|
||||
tenantId: string,
|
||||
filter?: StockFilter,
|
||||
options?: Omit<UseQueryOptions<PaginatedResponse<StockResponse>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PaginatedResponse<StockResponse>, ApiError>({
|
||||
queryKey: inventoryKeys.stock.list(tenantId, filter),
|
||||
queryFn: () => inventoryService.getAllStock(tenantId, filter),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStockByIngredient = (
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
includeUnavailable: boolean = false,
|
||||
options?: Omit<UseQueryOptions<StockResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<StockResponse[], ApiError>({
|
||||
queryKey: inventoryKeys.stock.byIngredient(tenantId, ingredientId, includeUnavailable),
|
||||
queryFn: () => inventoryService.getStockByIngredient(tenantId, ingredientId, includeUnavailable),
|
||||
enabled: !!tenantId && !!ingredientId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useExpiringStock = (
|
||||
tenantId: string,
|
||||
withinDays: number = 7,
|
||||
options?: Omit<UseQueryOptions<StockResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<StockResponse[], ApiError>({
|
||||
queryKey: inventoryKeys.stock.expiring(tenantId, withinDays),
|
||||
queryFn: () => inventoryService.getExpiringStock(tenantId, withinDays),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useExpiredStock = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<StockResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<StockResponse[], ApiError>({
|
||||
queryKey: inventoryKeys.stock.expired(tenantId),
|
||||
queryFn: () => inventoryService.getExpiredStock(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStockMovements = (
|
||||
tenantId: string,
|
||||
ingredientId?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
options?: Omit<UseQueryOptions<PaginatedResponse<StockMovementResponse>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PaginatedResponse<StockMovementResponse>, ApiError>({
|
||||
queryKey: inventoryKeys.stock.movements(tenantId, ingredientId),
|
||||
queryFn: () => inventoryService.getStockMovements(tenantId, ingredientId, limit, offset),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStockAnalytics = (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<any, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<any, ApiError>({
|
||||
queryKey: inventoryKeys.analytics(tenantId, startDate, endDate),
|
||||
queryFn: () => inventoryService.getStockAnalytics(tenantId, startDate, endDate),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Ingredient Mutations
|
||||
export const useCreateIngredient = (
|
||||
options?: UseMutationOptions<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<IngredientResponse, ApiError, { tenantId: string; ingredientData: IngredientCreate }>({
|
||||
mutationFn: ({ tenantId, ingredientData }) => inventoryService.createIngredient(tenantId, ingredientData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Add to cache
|
||||
queryClient.setQueryData(inventoryKeys.ingredients.detail(tenantId, data.id), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateIngredient = (
|
||||
options?: UseMutationOptions<
|
||||
IngredientResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; ingredientId: string; updateData: IngredientUpdate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
IngredientResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; ingredientId: string; updateData: IngredientUpdate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, ingredientId, updateData }) =>
|
||||
inventoryService.updateIngredient(tenantId, ingredientId, updateData),
|
||||
onSuccess: (data, { tenantId, ingredientId }) => {
|
||||
// Update cache
|
||||
queryClient.setQueryData(inventoryKeys.ingredients.detail(tenantId, ingredientId), data);
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteIngredient = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, { tenantId: string; ingredientId: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, { tenantId: string; ingredientId: string }>({
|
||||
mutationFn: ({ tenantId, ingredientId }) => inventoryService.deleteIngredient(tenantId, ingredientId),
|
||||
onSuccess: (data, { tenantId, ingredientId }) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({ queryKey: inventoryKeys.ingredients.detail(tenantId, ingredientId) });
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Stock Mutations
|
||||
export const useAddStock = (
|
||||
options?: UseMutationOptions<StockResponse, ApiError, { tenantId: string; stockData: StockCreate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<StockResponse, ApiError, { tenantId: string; stockData: StockCreate }>({
|
||||
mutationFn: ({ tenantId, stockData }) => inventoryService.addStock(tenantId, stockData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate stock queries
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.byIngredient(tenantId, data.ingredient_id) });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateStock = (
|
||||
options?: UseMutationOptions<
|
||||
StockResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; stockId: string; updateData: StockUpdate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
StockResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; stockId: string; updateData: StockUpdate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, stockId, updateData }) =>
|
||||
inventoryService.updateStock(tenantId, stockId, updateData),
|
||||
onSuccess: (data, { tenantId, stockId }) => {
|
||||
// Update cache
|
||||
queryClient.setQueryData(inventoryKeys.stock.detail(tenantId, stockId), data);
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.byIngredient(tenantId, data.ingredient_id) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useConsumeStock = (
|
||||
options?: UseMutationOptions<
|
||||
StockConsumptionResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; consumptionData: StockConsumptionRequest }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
StockConsumptionResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; consumptionData: StockConsumptionRequest }
|
||||
>({
|
||||
mutationFn: ({ tenantId, consumptionData }) => inventoryService.consumeStock(tenantId, consumptionData),
|
||||
onSuccess: (data, { tenantId, consumptionData }) => {
|
||||
// Invalidate stock queries for the affected ingredient
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryKeys.stock.byIngredient(tenantId, consumptionData.ingredient_id)
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateStockMovement = (
|
||||
options?: UseMutationOptions<
|
||||
StockMovementResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; movementData: StockMovementCreate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
StockMovementResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; movementData: StockMovementCreate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, movementData }) => inventoryService.createStockMovement(tenantId, movementData),
|
||||
onSuccess: (data, { tenantId, movementData }) => {
|
||||
// Invalidate movement queries
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryKeys.stock.movements(tenantId, movementData.ingredient_id)
|
||||
});
|
||||
// Invalidate stock queries if this affects stock levels
|
||||
if (['in', 'out', 'adjustment'].includes(movementData.movement_type)) {
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryKeys.stock.byIngredient(tenantId, movementData.ingredient_id)
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
183
frontend/src/api/hooks/inventoryDashboard.ts
Normal file
183
frontend/src/api/hooks/inventoryDashboard.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Inventory Dashboard React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { inventoryDashboardService } from '../services/inventoryDashboard';
|
||||
import {
|
||||
InventoryDashboardSummary,
|
||||
InventoryAnalytics,
|
||||
BusinessModelInsights,
|
||||
DashboardFilter,
|
||||
AlertsFilter,
|
||||
RecentActivity,
|
||||
} from '../types/dashboard';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const inventoryDashboardKeys = {
|
||||
all: ['inventory-dashboard'] as const,
|
||||
summary: (tenantId: string, filter?: DashboardFilter) =>
|
||||
[...inventoryDashboardKeys.all, 'summary', tenantId, filter] as const,
|
||||
analytics: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
[...inventoryDashboardKeys.all, 'analytics', tenantId, { startDate, endDate }] as const,
|
||||
insights: (tenantId: string) =>
|
||||
[...inventoryDashboardKeys.all, 'business-insights', tenantId] as const,
|
||||
activity: (tenantId: string, limit?: number) =>
|
||||
[...inventoryDashboardKeys.all, 'recent-activity', tenantId, limit] as const,
|
||||
alerts: (tenantId: string, filter?: AlertsFilter) =>
|
||||
[...inventoryDashboardKeys.all, 'alerts', tenantId, filter] as const,
|
||||
stockSummary: (tenantId: string) =>
|
||||
[...inventoryDashboardKeys.all, 'stock-summary', tenantId] as const,
|
||||
topCategories: (tenantId: string, limit?: number) =>
|
||||
[...inventoryDashboardKeys.all, 'top-categories', tenantId, limit] as const,
|
||||
expiryCalendar: (tenantId: string, daysAhead?: number) =>
|
||||
[...inventoryDashboardKeys.all, 'expiry-calendar', tenantId, daysAhead] as const,
|
||||
} as const;
|
||||
|
||||
// Queries
|
||||
export const useInventoryDashboardSummary = (
|
||||
tenantId: string,
|
||||
filter?: DashboardFilter,
|
||||
options?: Omit<UseQueryOptions<InventoryDashboardSummary, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<InventoryDashboardSummary, ApiError>({
|
||||
queryKey: inventoryDashboardKeys.summary(tenantId, filter),
|
||||
queryFn: () => inventoryDashboardService.getDashboardSummary(tenantId, filter),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInventoryAnalytics = (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<InventoryAnalytics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<InventoryAnalytics, ApiError>({
|
||||
queryKey: inventoryDashboardKeys.analytics(tenantId, startDate, endDate),
|
||||
queryFn: () => inventoryDashboardService.getInventoryAnalytics(tenantId, startDate, endDate),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useBusinessModelInsights = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<BusinessModelInsights, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<BusinessModelInsights, ApiError>({
|
||||
queryKey: inventoryDashboardKeys.insights(tenantId),
|
||||
queryFn: () => inventoryDashboardService.getBusinessModelInsights(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRecentActivity = (
|
||||
tenantId: string,
|
||||
limit: number = 20,
|
||||
options?: Omit<UseQueryOptions<RecentActivity[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<RecentActivity[], ApiError>({
|
||||
queryKey: inventoryDashboardKeys.activity(tenantId, limit),
|
||||
queryFn: () => inventoryDashboardService.getRecentActivity(tenantId, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInventoryAlerts = (
|
||||
tenantId: string,
|
||||
filter?: AlertsFilter,
|
||||
options?: Omit<UseQueryOptions<{ items: any[]; total: number }, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<{ items: any[]; total: number }, ApiError>({
|
||||
queryKey: inventoryDashboardKeys.alerts(tenantId, filter),
|
||||
queryFn: () => inventoryDashboardService.getAlerts(tenantId, filter),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStockSummary = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<{
|
||||
in_stock: number;
|
||||
low_stock: number;
|
||||
out_of_stock: number;
|
||||
overstock: number;
|
||||
total_value: number;
|
||||
}, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<{
|
||||
in_stock: number;
|
||||
low_stock: number;
|
||||
out_of_stock: number;
|
||||
overstock: number;
|
||||
total_value: number;
|
||||
}, ApiError>({
|
||||
queryKey: inventoryDashboardKeys.stockSummary(tenantId),
|
||||
queryFn: () => inventoryDashboardService.getStockSummary(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTopCategories = (
|
||||
tenantId: string,
|
||||
limit: number = 10,
|
||||
options?: Omit<UseQueryOptions<Array<{
|
||||
category: string;
|
||||
ingredient_count: number;
|
||||
total_value: number;
|
||||
low_stock_count: number;
|
||||
}>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<Array<{
|
||||
category: string;
|
||||
ingredient_count: number;
|
||||
total_value: number;
|
||||
low_stock_count: number;
|
||||
}>, ApiError>({
|
||||
queryKey: inventoryDashboardKeys.topCategories(tenantId, limit),
|
||||
queryFn: () => inventoryDashboardService.getTopCategories(tenantId, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useExpiryCalendar = (
|
||||
tenantId: string,
|
||||
daysAhead: number = 30,
|
||||
options?: Omit<UseQueryOptions<Array<{
|
||||
date: string;
|
||||
items: Array<{
|
||||
ingredient_name: string;
|
||||
quantity: number;
|
||||
batch_number?: string;
|
||||
}>;
|
||||
}>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<Array<{
|
||||
date: string;
|
||||
items: Array<{
|
||||
ingredient_name: string;
|
||||
quantity: number;
|
||||
batch_number?: string;
|
||||
}>;
|
||||
}>, ApiError>({
|
||||
queryKey: inventoryDashboardKeys.expiryCalendar(tenantId, daysAhead),
|
||||
queryFn: () => inventoryDashboardService.getExpiryCalendar(tenantId, daysAhead),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
128
frontend/src/api/hooks/onboarding.ts
Normal file
128
frontend/src/api/hooks/onboarding.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Onboarding React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { onboardingService } from '../services/onboarding';
|
||||
import { UserProgress, UpdateStepRequest } from '../types/onboarding';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const onboardingKeys = {
|
||||
all: ['onboarding'] as const,
|
||||
progress: (userId: string) => [...onboardingKeys.all, 'progress', userId] as const,
|
||||
steps: () => [...onboardingKeys.all, 'steps'] as const,
|
||||
stepDetail: (stepName: string) => [...onboardingKeys.steps(), stepName] as const,
|
||||
} as const;
|
||||
|
||||
// Queries
|
||||
export const useUserProgress = (
|
||||
userId: string,
|
||||
options?: Omit<UseQueryOptions<UserProgress, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<UserProgress, ApiError>({
|
||||
queryKey: onboardingKeys.progress(userId),
|
||||
queryFn: () => onboardingService.getUserProgress(userId),
|
||||
enabled: !!userId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAllSteps = (
|
||||
options?: Omit<UseQueryOptions<Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}>, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}>, ApiError>({
|
||||
queryKey: onboardingKeys.steps(),
|
||||
queryFn: () => onboardingService.getAllSteps(),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useStepDetails = (
|
||||
stepName: string,
|
||||
options?: Omit<UseQueryOptions<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}, ApiError>({
|
||||
queryKey: onboardingKeys.stepDetail(stepName),
|
||||
queryFn: () => onboardingService.getStepDetails(stepName),
|
||||
enabled: !!stepName,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutations
|
||||
export const useUpdateStep = (
|
||||
options?: UseMutationOptions<UserProgress, ApiError, { userId: string; stepData: UpdateStepRequest }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<UserProgress, ApiError, { userId: string; stepData: UpdateStepRequest }>({
|
||||
mutationFn: ({ userId, stepData }) => onboardingService.updateStep(userId, stepData),
|
||||
onSuccess: (data, { userId }) => {
|
||||
// Update progress cache
|
||||
queryClient.setQueryData(onboardingKeys.progress(userId), data);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkStepCompleted = (
|
||||
options?: UseMutationOptions<
|
||||
UserProgress,
|
||||
ApiError,
|
||||
{ userId: string; stepName: string; data?: Record<string, any> }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
UserProgress,
|
||||
ApiError,
|
||||
{ userId: string; stepName: string; data?: Record<string, any> }
|
||||
>({
|
||||
mutationFn: ({ userId, stepName, data }) =>
|
||||
onboardingService.markStepCompleted(userId, stepName, data),
|
||||
onSuccess: (data, { userId }) => {
|
||||
// Update progress cache
|
||||
queryClient.setQueryData(onboardingKeys.progress(userId), data);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useResetProgress = (
|
||||
options?: UseMutationOptions<UserProgress, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<UserProgress, ApiError, string>({
|
||||
mutationFn: (userId: string) => onboardingService.resetProgress(userId),
|
||||
onSuccess: (data, userId) => {
|
||||
// Update progress cache
|
||||
queryClient.setQueryData(onboardingKeys.progress(userId), data);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
190
frontend/src/api/hooks/sales.ts
Normal file
190
frontend/src/api/hooks/sales.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Sales React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { salesService } from '../services/sales';
|
||||
import {
|
||||
SalesDataCreate,
|
||||
SalesDataUpdate,
|
||||
SalesDataResponse,
|
||||
SalesDataQuery,
|
||||
SalesAnalytics,
|
||||
} from '../types/sales';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const salesKeys = {
|
||||
all: ['sales'] as const,
|
||||
lists: () => [...salesKeys.all, 'list'] as const,
|
||||
list: (tenantId: string, filters?: SalesDataQuery) => [...salesKeys.lists(), tenantId, filters] as const,
|
||||
details: () => [...salesKeys.all, 'detail'] as const,
|
||||
detail: (tenantId: string, recordId: string) => [...salesKeys.details(), tenantId, recordId] as const,
|
||||
analytics: (tenantId: string, startDate?: string, endDate?: string) =>
|
||||
[...salesKeys.all, 'analytics', tenantId, { startDate, endDate }] as const,
|
||||
productSales: (tenantId: string, productId: string, startDate?: string, endDate?: string) =>
|
||||
[...salesKeys.all, 'product-sales', tenantId, productId, { startDate, endDate }] as const,
|
||||
categories: (tenantId: string) => [...salesKeys.all, 'categories', tenantId] as const,
|
||||
} as const;
|
||||
|
||||
// Queries
|
||||
export const useSalesRecords = (
|
||||
tenantId: string,
|
||||
query?: SalesDataQuery,
|
||||
options?: Omit<UseQueryOptions<SalesDataResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<SalesDataResponse[], ApiError>({
|
||||
queryKey: salesKeys.list(tenantId, query),
|
||||
queryFn: () => salesService.getSalesRecords(tenantId, query),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSalesRecord = (
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
options?: Omit<UseQueryOptions<SalesDataResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<SalesDataResponse, ApiError>({
|
||||
queryKey: salesKeys.detail(tenantId, recordId),
|
||||
queryFn: () => salesService.getSalesRecord(tenantId, recordId),
|
||||
enabled: !!tenantId && !!recordId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSalesAnalytics = (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<SalesAnalytics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<SalesAnalytics, ApiError>({
|
||||
queryKey: salesKeys.analytics(tenantId, startDate, endDate),
|
||||
queryFn: () => salesService.getSalesAnalytics(tenantId, startDate, endDate),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProductSales = (
|
||||
tenantId: string,
|
||||
inventoryProductId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
options?: Omit<UseQueryOptions<SalesDataResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<SalesDataResponse[], ApiError>({
|
||||
queryKey: salesKeys.productSales(tenantId, inventoryProductId, startDate, endDate),
|
||||
queryFn: () => salesService.getProductSales(tenantId, inventoryProductId, startDate, endDate),
|
||||
enabled: !!tenantId && !!inventoryProductId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProductCategories = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<string[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<string[], ApiError>({
|
||||
queryKey: salesKeys.categories(tenantId),
|
||||
queryFn: () => salesService.getProductCategories(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutations
|
||||
export const useCreateSalesRecord = (
|
||||
options?: UseMutationOptions<SalesDataResponse, ApiError, { tenantId: string; salesData: SalesDataCreate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<SalesDataResponse, ApiError, { tenantId: string; salesData: SalesDataCreate }>({
|
||||
mutationFn: ({ tenantId, salesData }) => salesService.createSalesRecord(tenantId, salesData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate sales lists to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: salesKeys.analytics(tenantId) });
|
||||
// Set the new record in cache
|
||||
queryClient.setQueryData(salesKeys.detail(tenantId, data.id), data);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateSalesRecord = (
|
||||
options?: UseMutationOptions<
|
||||
SalesDataResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; recordId: string; updateData: SalesDataUpdate }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
SalesDataResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; recordId: string; updateData: SalesDataUpdate }
|
||||
>({
|
||||
mutationFn: ({ tenantId, recordId, updateData }) =>
|
||||
salesService.updateSalesRecord(tenantId, recordId, updateData),
|
||||
onSuccess: (data, { tenantId, recordId }) => {
|
||||
// Update the record cache
|
||||
queryClient.setQueryData(salesKeys.detail(tenantId, recordId), data);
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: salesKeys.analytics(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSalesRecord = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, { tenantId: string; recordId: string }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, { tenantId: string; recordId: string }>({
|
||||
mutationFn: ({ tenantId, recordId }) => salesService.deleteSalesRecord(tenantId, recordId),
|
||||
onSuccess: (data, { tenantId, recordId }) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({ queryKey: salesKeys.detail(tenantId, recordId) });
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: salesKeys.analytics(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useValidateSalesRecord = (
|
||||
options?: UseMutationOptions<
|
||||
SalesDataResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; recordId: string; validationNotes?: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
SalesDataResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; recordId: string; validationNotes?: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, recordId, validationNotes }) =>
|
||||
salesService.validateSalesRecord(tenantId, recordId, validationNotes),
|
||||
onSuccess: (data, { tenantId, recordId }) => {
|
||||
// Update the record cache
|
||||
queryClient.setQueryData(salesKeys.detail(tenantId, recordId), data);
|
||||
// Invalidate sales lists to reflect validation status
|
||||
queryClient.invalidateQueries({ queryKey: salesKeys.lists() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
316
frontend/src/api/hooks/tenant.ts
Normal file
316
frontend/src/api/hooks/tenant.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Tenant React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { tenantService } from '../services/tenant';
|
||||
import {
|
||||
BakeryRegistration,
|
||||
TenantResponse,
|
||||
TenantAccessResponse,
|
||||
TenantUpdate,
|
||||
TenantMemberResponse,
|
||||
TenantStatistics,
|
||||
TenantSearchParams,
|
||||
TenantNearbyParams,
|
||||
} from '../types/tenant';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const tenantKeys = {
|
||||
all: ['tenant'] as const,
|
||||
lists: () => [...tenantKeys.all, 'list'] as const,
|
||||
list: (filters: string) => [...tenantKeys.lists(), { filters }] as const,
|
||||
details: () => [...tenantKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...tenantKeys.details(), id] as const,
|
||||
subdomain: (subdomain: string) => [...tenantKeys.all, 'subdomain', subdomain] as const,
|
||||
userTenants: (userId: string) => [...tenantKeys.all, 'user', userId] as const,
|
||||
userOwnedTenants: (userId: string) => [...tenantKeys.all, 'user-owned', userId] as const,
|
||||
access: (tenantId: string, userId: string) => [...tenantKeys.all, 'access', tenantId, userId] as const,
|
||||
search: (params: TenantSearchParams) => [...tenantKeys.lists(), 'search', params] as const,
|
||||
nearby: (params: TenantNearbyParams) => [...tenantKeys.lists(), 'nearby', params] as const,
|
||||
members: (tenantId: string) => [...tenantKeys.all, 'members', tenantId] as const,
|
||||
statistics: () => [...tenantKeys.all, 'statistics'] as const,
|
||||
} as const;
|
||||
|
||||
// Queries
|
||||
export const useTenant = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<TenantResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantResponse, ApiError>({
|
||||
queryKey: tenantKeys.detail(tenantId),
|
||||
queryFn: () => tenantService.getTenant(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTenantBySubdomain = (
|
||||
subdomain: string,
|
||||
options?: Omit<UseQueryOptions<TenantResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantResponse, ApiError>({
|
||||
queryKey: tenantKeys.subdomain(subdomain),
|
||||
queryFn: () => tenantService.getTenantBySubdomain(subdomain),
|
||||
enabled: !!subdomain,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserTenants = (
|
||||
userId: string,
|
||||
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantResponse[], ApiError>({
|
||||
queryKey: tenantKeys.userTenants(userId),
|
||||
queryFn: () => tenantService.getUserTenants(userId),
|
||||
enabled: !!userId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserOwnedTenants = (
|
||||
userId: string,
|
||||
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantResponse[], ApiError>({
|
||||
queryKey: tenantKeys.userOwnedTenants(userId),
|
||||
queryFn: () => tenantService.getUserOwnedTenants(userId),
|
||||
enabled: !!userId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTenantAccess = (
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
options?: Omit<UseQueryOptions<TenantAccessResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantAccessResponse, ApiError>({
|
||||
queryKey: tenantKeys.access(tenantId, userId),
|
||||
queryFn: () => tenantService.verifyTenantAccess(tenantId, userId),
|
||||
enabled: !!tenantId && !!userId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSearchTenants = (
|
||||
params: TenantSearchParams,
|
||||
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantResponse[], ApiError>({
|
||||
queryKey: tenantKeys.search(params),
|
||||
queryFn: () => tenantService.searchTenants(params),
|
||||
enabled: !!params.search_term,
|
||||
staleTime: 30 * 1000, // 30 seconds for search results
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useNearbyTenants = (
|
||||
params: TenantNearbyParams,
|
||||
options?: Omit<UseQueryOptions<TenantResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantResponse[], ApiError>({
|
||||
queryKey: tenantKeys.nearby(params),
|
||||
queryFn: () => tenantService.getNearbyTenants(params),
|
||||
enabled: !!(params.latitude && params.longitude),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTeamMembers = (
|
||||
tenantId: string,
|
||||
activeOnly: boolean = true,
|
||||
options?: Omit<UseQueryOptions<TenantMemberResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantMemberResponse[], ApiError>({
|
||||
queryKey: tenantKeys.members(tenantId),
|
||||
queryFn: () => tenantService.getTeamMembers(tenantId, activeOnly),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useTenantStatistics = (
|
||||
options?: Omit<UseQueryOptions<TenantStatistics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<TenantStatistics, ApiError>({
|
||||
queryKey: tenantKeys.statistics(),
|
||||
queryFn: () => tenantService.getTenantStatistics(),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutations
|
||||
export const useRegisterBakery = (
|
||||
options?: UseMutationOptions<TenantResponse, ApiError, BakeryRegistration>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TenantResponse, ApiError, BakeryRegistration>({
|
||||
mutationFn: (bakeryData: BakeryRegistration) => tenantService.registerBakery(bakeryData),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate user tenants to include the new one
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
|
||||
// Set the tenant data in cache
|
||||
queryClient.setQueryData(tenantKeys.detail(data.id), data);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTenant = (
|
||||
options?: UseMutationOptions<TenantResponse, ApiError, { tenantId: string; updateData: TenantUpdate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TenantResponse, ApiError, { tenantId: string; updateData: TenantUpdate }>({
|
||||
mutationFn: ({ tenantId, updateData }) => tenantService.updateTenant(tenantId, updateData),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Update the tenant cache
|
||||
queryClient.setQueryData(tenantKeys.detail(tenantId), data);
|
||||
// Invalidate related queries
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeactivateTenant = (
|
||||
options?: UseMutationOptions<{ success: boolean; message: string }, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ success: boolean; message: string }, ApiError, string>({
|
||||
mutationFn: (tenantId: string) => tenantService.deactivateTenant(tenantId),
|
||||
onSuccess: (data, tenantId) => {
|
||||
// Invalidate tenant-related queries
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.detail(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useActivateTenant = (
|
||||
options?: UseMutationOptions<{ success: boolean; message: string }, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ success: boolean; message: string }, ApiError, string>({
|
||||
mutationFn: (tenantId: string) => tenantService.activateTenant(tenantId),
|
||||
onSuccess: (data, tenantId) => {
|
||||
// Invalidate tenant-related queries
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.detail(tenantId) });
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userTenants('') });
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.userOwnedTenants('') });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateModelStatus = (
|
||||
options?: UseMutationOptions<
|
||||
TenantResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; modelTrained: boolean; lastTrainingDate?: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TenantResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; modelTrained: boolean; lastTrainingDate?: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, modelTrained, lastTrainingDate }) =>
|
||||
tenantService.updateModelStatus(tenantId, modelTrained, lastTrainingDate),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Update the tenant cache
|
||||
queryClient.setQueryData(tenantKeys.detail(tenantId), data);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddTeamMember = (
|
||||
options?: UseMutationOptions<
|
||||
TenantMemberResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; userId: string; role: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TenantMemberResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; userId: string; role: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, userId, role }) => tenantService.addTeamMember(tenantId, userId, role),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate team members query
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateMemberRole = (
|
||||
options?: UseMutationOptions<
|
||||
TenantMemberResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; memberUserId: string; newRole: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
TenantMemberResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; memberUserId: string; newRole: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, memberUserId, newRole }) =>
|
||||
tenantService.updateMemberRole(tenantId, memberUserId, newRole),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate team members query
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveTeamMember = (
|
||||
options?: UseMutationOptions<
|
||||
{ success: boolean; message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; memberUserId: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ success: boolean; message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; memberUserId: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, memberUserId }) => tenantService.removeTeamMember(tenantId, memberUserId),
|
||||
onSuccess: (data, { tenantId }) => {
|
||||
// Invalidate team members query
|
||||
queryClient.invalidateQueries({ queryKey: tenantKeys.members(tenantId) });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
112
frontend/src/api/hooks/user.ts
Normal file
112
frontend/src/api/hooks/user.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* User React Query hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { userService } from '../services/user';
|
||||
import { UserResponse, UserUpdate } from '../types/auth';
|
||||
import { AdminDeleteRequest, AdminDeleteResponse } from '../types/user';
|
||||
import { ApiError } from '../client';
|
||||
|
||||
// Query Keys
|
||||
export const userKeys = {
|
||||
all: ['user'] as const,
|
||||
current: () => [...userKeys.all, 'current'] as const,
|
||||
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
|
||||
admin: {
|
||||
all: () => [...userKeys.all, 'admin'] as const,
|
||||
list: () => [...userKeys.admin.all(), 'list'] as const,
|
||||
detail: (id: string) => [...userKeys.admin.all(), 'detail', id] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Queries
|
||||
export const useCurrentUser = (
|
||||
options?: Omit<UseQueryOptions<UserResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<UserResponse, ApiError>({
|
||||
queryKey: userKeys.current(),
|
||||
queryFn: () => userService.getCurrentUser(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAllUsers = (
|
||||
options?: Omit<UseQueryOptions<UserResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<UserResponse[], ApiError>({
|
||||
queryKey: userKeys.admin.list(),
|
||||
queryFn: () => userService.getAllUsers(),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserById = (
|
||||
userId: string,
|
||||
options?: Omit<UseQueryOptions<UserResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<UserResponse, ApiError>({
|
||||
queryKey: userKeys.admin.detail(userId),
|
||||
queryFn: () => userService.getUserById(userId),
|
||||
enabled: !!userId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Mutations
|
||||
export const useUpdateUser = (
|
||||
options?: UseMutationOptions<UserResponse, ApiError, { userId: string; updateData: UserUpdate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<UserResponse, ApiError, { userId: string; updateData: UserUpdate }>({
|
||||
mutationFn: ({ userId, updateData }) => userService.updateUser(userId, updateData),
|
||||
onSuccess: (data, { userId }) => {
|
||||
// Update user cache
|
||||
queryClient.setQueryData(userKeys.detail(userId), data);
|
||||
queryClient.setQueryData(userKeys.current(), data);
|
||||
queryClient.setQueryData(userKeys.admin.detail(userId), data);
|
||||
// Invalidate user lists
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.admin.list() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteUser = (
|
||||
options?: UseMutationOptions<{ message: string }, ApiError, string>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, ApiError, string>({
|
||||
mutationFn: (userId: string) => userService.deleteUser(userId),
|
||||
onSuccess: (data, userId) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({ queryKey: userKeys.detail(userId) });
|
||||
queryClient.removeQueries({ queryKey: userKeys.admin.detail(userId) });
|
||||
// Invalidate user lists
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.admin.list() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useAdminDeleteUser = (
|
||||
options?: UseMutationOptions<AdminDeleteResponse, ApiError, AdminDeleteRequest>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<AdminDeleteResponse, ApiError, AdminDeleteRequest>({
|
||||
mutationFn: (deleteRequest: AdminDeleteRequest) => userService.adminDeleteUser(deleteRequest),
|
||||
onSuccess: (data, request) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({ queryKey: userKeys.detail(request.user_id) });
|
||||
queryClient.removeQueries({ queryKey: userKeys.admin.detail(request.user_id) });
|
||||
// Invalidate user lists
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.admin.list() });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
303
frontend/src/api/index.ts
Normal file
303
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Main API exports for clean imports
|
||||
* Export all services, types, and hooks
|
||||
*/
|
||||
|
||||
// Client
|
||||
export { apiClient } from './client';
|
||||
export type { ApiError } from './client';
|
||||
|
||||
// Services
|
||||
export { authService } from './services/auth';
|
||||
export { userService } from './services/user';
|
||||
export { onboardingService } from './services/onboarding';
|
||||
export { tenantService } from './services/tenant';
|
||||
export { subscriptionService } from './services/subscription';
|
||||
export { salesService } from './services/sales';
|
||||
export { dataImportService } from './services/dataImport';
|
||||
export { inventoryService } from './services/inventory';
|
||||
export { classificationService } from './services/classification';
|
||||
export { inventoryDashboardService } from './services/inventoryDashboard';
|
||||
export { foodSafetyService } from './services/foodSafety';
|
||||
|
||||
// Types - Auth
|
||||
export type {
|
||||
User,
|
||||
UserRegistration,
|
||||
UserLogin,
|
||||
TokenResponse,
|
||||
RefreshTokenRequest,
|
||||
PasswordChange,
|
||||
PasswordReset,
|
||||
UserResponse,
|
||||
UserUpdate as AuthUserUpdate,
|
||||
TokenVerificationResponse,
|
||||
AuthHealthResponse,
|
||||
} from './types/auth';
|
||||
|
||||
// Types - User
|
||||
export type {
|
||||
UserUpdate,
|
||||
AdminDeleteRequest,
|
||||
AdminDeleteResponse,
|
||||
} from './types/user';
|
||||
|
||||
// Types - Onboarding
|
||||
export type {
|
||||
OnboardingStepStatus,
|
||||
UserProgress,
|
||||
UpdateStepRequest,
|
||||
} from './types/onboarding';
|
||||
|
||||
// Types - Tenant
|
||||
export type {
|
||||
BakeryRegistration,
|
||||
TenantResponse,
|
||||
TenantAccessResponse,
|
||||
TenantUpdate,
|
||||
TenantMemberResponse,
|
||||
TenantStatistics,
|
||||
TenantSearchParams,
|
||||
TenantNearbyParams,
|
||||
} from './types/tenant';
|
||||
|
||||
// Types - Subscription
|
||||
export type {
|
||||
SubscriptionLimits,
|
||||
FeatureCheckResponse,
|
||||
UsageCheckResponse,
|
||||
} from './types/subscription';
|
||||
|
||||
// Types - Sales
|
||||
export type {
|
||||
SalesDataCreate,
|
||||
SalesDataUpdate,
|
||||
SalesDataResponse,
|
||||
SalesDataQuery,
|
||||
SalesAnalytics,
|
||||
SalesValidationRequest,
|
||||
} from './types/sales';
|
||||
|
||||
// Types - Data Import
|
||||
export type {
|
||||
ImportValidationRequest,
|
||||
ImportValidationResponse,
|
||||
ImportProcessRequest,
|
||||
ImportProcessResponse,
|
||||
ImportStatusResponse,
|
||||
} from './types/dataImport';
|
||||
|
||||
// Types - Inventory
|
||||
export type {
|
||||
IngredientCreate,
|
||||
IngredientUpdate,
|
||||
IngredientResponse,
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
StockResponse,
|
||||
StockMovementCreate,
|
||||
StockMovementResponse,
|
||||
InventoryFilter,
|
||||
StockFilter,
|
||||
StockConsumptionRequest,
|
||||
StockConsumptionResponse,
|
||||
PaginatedResponse,
|
||||
} from './types/inventory';
|
||||
|
||||
// Types - Classification
|
||||
export type {
|
||||
ProductClassificationRequest,
|
||||
BatchClassificationRequest,
|
||||
ProductSuggestionResponse,
|
||||
BusinessModelAnalysisResponse,
|
||||
ClassificationApprovalRequest,
|
||||
ClassificationApprovalResponse,
|
||||
} from './types/classification';
|
||||
|
||||
// Types - Dashboard
|
||||
export type {
|
||||
InventoryDashboardSummary,
|
||||
InventoryAnalytics,
|
||||
BusinessModelInsights,
|
||||
DashboardFilter,
|
||||
AlertsFilter,
|
||||
RecentActivity,
|
||||
StockMovementSummary,
|
||||
CategorySummary,
|
||||
AlertSummary,
|
||||
StockStatusSummary,
|
||||
} from './types/dashboard';
|
||||
|
||||
// Types - Food Safety
|
||||
export type {
|
||||
FoodSafetyComplianceCreate,
|
||||
FoodSafetyComplianceUpdate,
|
||||
FoodSafetyComplianceResponse,
|
||||
TemperatureLogCreate,
|
||||
BulkTemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
FoodSafetyAlertCreate,
|
||||
FoodSafetyAlertUpdate,
|
||||
FoodSafetyAlertResponse,
|
||||
FoodSafetyFilter,
|
||||
TemperatureMonitoringFilter,
|
||||
FoodSafetyMetrics,
|
||||
TemperatureAnalytics,
|
||||
FoodSafetyDashboard,
|
||||
} from './types/foodSafety';
|
||||
|
||||
// Hooks - Auth
|
||||
export {
|
||||
useAuthProfile,
|
||||
useAuthHealth,
|
||||
useVerifyToken,
|
||||
useRegister,
|
||||
useLogin,
|
||||
useRefreshToken,
|
||||
useLogout,
|
||||
useChangePassword,
|
||||
useResetPassword,
|
||||
useUpdateProfile,
|
||||
useVerifyEmail,
|
||||
authKeys,
|
||||
} from './hooks/auth';
|
||||
|
||||
// Hooks - User
|
||||
export {
|
||||
useCurrentUser,
|
||||
useAllUsers,
|
||||
useUserById,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
useAdminDeleteUser,
|
||||
userKeys,
|
||||
} from './hooks/user';
|
||||
|
||||
// Hooks - Onboarding
|
||||
export {
|
||||
useUserProgress,
|
||||
useAllSteps,
|
||||
useStepDetails,
|
||||
useUpdateStep,
|
||||
useMarkStepCompleted,
|
||||
useResetProgress,
|
||||
onboardingKeys,
|
||||
} from './hooks/onboarding';
|
||||
|
||||
// Hooks - Tenant
|
||||
export {
|
||||
useTenant,
|
||||
useTenantBySubdomain,
|
||||
useUserTenants,
|
||||
useUserOwnedTenants,
|
||||
useTenantAccess,
|
||||
useSearchTenants,
|
||||
useNearbyTenants,
|
||||
useTeamMembers,
|
||||
useTenantStatistics,
|
||||
useRegisterBakery,
|
||||
useUpdateTenant,
|
||||
useDeactivateTenant,
|
||||
useActivateTenant,
|
||||
useUpdateModelStatus,
|
||||
useAddTeamMember,
|
||||
useUpdateMemberRole,
|
||||
useRemoveTeamMember,
|
||||
tenantKeys,
|
||||
} from './hooks/tenant';
|
||||
|
||||
// Hooks - Sales
|
||||
export {
|
||||
useSalesRecords,
|
||||
useSalesRecord,
|
||||
useSalesAnalytics,
|
||||
useProductSales,
|
||||
useProductCategories,
|
||||
useCreateSalesRecord,
|
||||
useUpdateSalesRecord,
|
||||
useDeleteSalesRecord,
|
||||
useValidateSalesRecord,
|
||||
salesKeys,
|
||||
} from './hooks/sales';
|
||||
|
||||
// Hooks - Inventory
|
||||
export {
|
||||
useIngredients,
|
||||
useIngredient,
|
||||
useIngredientsByCategory,
|
||||
useLowStockIngredients,
|
||||
useStock,
|
||||
useStockByIngredient,
|
||||
useExpiringStock,
|
||||
useExpiredStock,
|
||||
useStockMovements,
|
||||
useStockAnalytics,
|
||||
useCreateIngredient,
|
||||
useUpdateIngredient,
|
||||
useDeleteIngredient,
|
||||
useAddStock,
|
||||
useUpdateStock,
|
||||
useConsumeStock,
|
||||
useCreateStockMovement,
|
||||
inventoryKeys,
|
||||
} from './hooks/inventory';
|
||||
|
||||
// Hooks - Classification
|
||||
export {
|
||||
usePendingSuggestions,
|
||||
useSuggestionHistory,
|
||||
useBusinessModelAnalysis,
|
||||
useClassifyProduct,
|
||||
useClassifyProductsBatch,
|
||||
useApproveClassification,
|
||||
useUpdateSuggestion,
|
||||
useDeleteSuggestion,
|
||||
classificationKeys,
|
||||
} from './hooks/classification';
|
||||
|
||||
// Hooks - Inventory Dashboard
|
||||
export {
|
||||
useInventoryDashboardSummary,
|
||||
useInventoryAnalytics,
|
||||
useBusinessModelInsights,
|
||||
useRecentActivity,
|
||||
useInventoryAlerts,
|
||||
useStockSummary,
|
||||
useTopCategories,
|
||||
useExpiryCalendar,
|
||||
inventoryDashboardKeys,
|
||||
} from './hooks/inventoryDashboard';
|
||||
|
||||
// Hooks - Food Safety
|
||||
export {
|
||||
useComplianceRecords,
|
||||
useComplianceRecord,
|
||||
useTemperatureLogs,
|
||||
useTemperatureAnalytics,
|
||||
useTemperatureViolations,
|
||||
useFoodSafetyAlerts,
|
||||
useFoodSafetyAlert,
|
||||
useFoodSafetyDashboard,
|
||||
useFoodSafetyMetrics,
|
||||
useComplianceRate,
|
||||
useCreateComplianceRecord,
|
||||
useUpdateComplianceRecord,
|
||||
useCreateTemperatureLog,
|
||||
useCreateBulkTemperatureLogs,
|
||||
useCreateFoodSafetyAlert,
|
||||
useUpdateFoodSafetyAlert,
|
||||
foodSafetyKeys,
|
||||
} from './hooks/foodSafety';
|
||||
|
||||
// Query Key Factories (for advanced usage)
|
||||
export {
|
||||
authKeys,
|
||||
userKeys,
|
||||
onboardingKeys,
|
||||
tenantKeys,
|
||||
salesKeys,
|
||||
inventoryKeys,
|
||||
classificationKeys,
|
||||
inventoryDashboardKeys,
|
||||
foodSafetyKeys,
|
||||
};
|
||||
87
frontend/src/api/services/auth.ts
Normal file
87
frontend/src/api/services/auth.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Auth Service - Mirror backend auth endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
UserRegistration,
|
||||
UserLogin,
|
||||
TokenResponse,
|
||||
RefreshTokenRequest,
|
||||
PasswordChange,
|
||||
PasswordReset,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
TokenVerificationResponse,
|
||||
AuthHealthResponse,
|
||||
} from '../types/auth';
|
||||
|
||||
export class AuthService {
|
||||
private readonly baseUrl = '/auth';
|
||||
|
||||
async register(userData: UserRegistration): Promise<TokenResponse> {
|
||||
return apiClient.post<TokenResponse>(`${this.baseUrl}/register`, userData);
|
||||
}
|
||||
|
||||
async login(loginData: UserLogin): Promise<TokenResponse> {
|
||||
return apiClient.post<TokenResponse>(`${this.baseUrl}/login`, loginData);
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<TokenResponse> {
|
||||
const refreshData: RefreshTokenRequest = { refresh_token: refreshToken };
|
||||
return apiClient.post<TokenResponse>(`${this.baseUrl}/refresh`, refreshData);
|
||||
}
|
||||
|
||||
async verifyToken(token?: string): Promise<TokenVerificationResponse> {
|
||||
// If token is provided, temporarily set it; otherwise use current token
|
||||
const currentToken = apiClient.getAuthToken();
|
||||
if (token && token !== currentToken) {
|
||||
apiClient.setAuthToken(token);
|
||||
}
|
||||
|
||||
const response = await apiClient.post<TokenVerificationResponse>(`${this.baseUrl}/verify`);
|
||||
|
||||
// Restore original token if we temporarily changed it
|
||||
if (token && token !== currentToken) {
|
||||
apiClient.setAuthToken(currentToken);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout(refreshToken: string): Promise<{ message: string }> {
|
||||
const refreshData: RefreshTokenRequest = { refresh_token: refreshToken };
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/logout`, refreshData);
|
||||
}
|
||||
|
||||
async changePassword(passwordData: PasswordChange): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/change-password`, passwordData);
|
||||
}
|
||||
|
||||
async resetPassword(resetData: PasswordReset): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/reset-password`, resetData);
|
||||
}
|
||||
|
||||
async getProfile(): Promise<UserResponse> {
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/profile`);
|
||||
}
|
||||
|
||||
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
|
||||
return apiClient.put<UserResponse>(`${this.baseUrl}/profile`, updateData);
|
||||
}
|
||||
|
||||
async verifyEmail(
|
||||
userId: string,
|
||||
verificationToken: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.post<{ message: string }>(`${this.baseUrl}/verify-email`, {
|
||||
user_id: userId,
|
||||
verification_token: verificationToken,
|
||||
});
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<AuthHealthResponse> {
|
||||
return apiClient.get<AuthHealthResponse>(`${this.baseUrl}/health`);
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
94
frontend/src/api/services/classification.ts
Normal file
94
frontend/src/api/services/classification.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Classification Service - Mirror backend classification endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
ProductClassificationRequest,
|
||||
BatchClassificationRequest,
|
||||
ProductSuggestionResponse,
|
||||
BusinessModelAnalysisResponse,
|
||||
ClassificationApprovalRequest,
|
||||
ClassificationApprovalResponse,
|
||||
} from '../types/classification';
|
||||
|
||||
export class ClassificationService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
async classifyProduct(
|
||||
tenantId: string,
|
||||
classificationData: ProductClassificationRequest
|
||||
): Promise<ProductSuggestionResponse> {
|
||||
return apiClient.post<ProductSuggestionResponse>(
|
||||
`${this.baseUrl}/${tenantId}/classification/classify-product`,
|
||||
classificationData
|
||||
);
|
||||
}
|
||||
|
||||
async classifyProductsBatch(
|
||||
tenantId: string,
|
||||
batchData: BatchClassificationRequest
|
||||
): Promise<ProductSuggestionResponse[]> {
|
||||
return apiClient.post<ProductSuggestionResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/classification/classify-batch`,
|
||||
batchData
|
||||
);
|
||||
}
|
||||
|
||||
async getBusinessModelAnalysis(tenantId: string): Promise<BusinessModelAnalysisResponse> {
|
||||
return apiClient.get<BusinessModelAnalysisResponse>(
|
||||
`${this.baseUrl}/${tenantId}/classification/business-model-analysis`
|
||||
);
|
||||
}
|
||||
|
||||
async approveClassification(
|
||||
tenantId: string,
|
||||
approvalData: ClassificationApprovalRequest
|
||||
): Promise<ClassificationApprovalResponse> {
|
||||
return apiClient.post<ClassificationApprovalResponse>(
|
||||
`${this.baseUrl}/${tenantId}/classification/approve-suggestion`,
|
||||
approvalData
|
||||
);
|
||||
}
|
||||
|
||||
async getPendingSuggestions(tenantId: string): Promise<ProductSuggestionResponse[]> {
|
||||
return apiClient.get<ProductSuggestionResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/classification/pending-suggestions`
|
||||
);
|
||||
}
|
||||
|
||||
async getSuggestionHistory(
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<{
|
||||
items: ProductSuggestionResponse[];
|
||||
total: number;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
|
||||
return apiClient.get(
|
||||
`${this.baseUrl}/${tenantId}/classification/suggestion-history?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async deleteSuggestion(tenantId: string, suggestionId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/classification/suggestions/${suggestionId}`
|
||||
);
|
||||
}
|
||||
|
||||
async updateSuggestion(
|
||||
tenantId: string,
|
||||
suggestionId: string,
|
||||
updateData: Partial<ProductSuggestionResponse>
|
||||
): Promise<ProductSuggestionResponse> {
|
||||
return apiClient.put<ProductSuggestionResponse>(
|
||||
`${this.baseUrl}/${tenantId}/classification/suggestions/${suggestionId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const classificationService = new ClassificationService();
|
||||
99
frontend/src/api/services/dataImport.ts
Normal file
99
frontend/src/api/services/dataImport.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Data Import Service - Mirror backend data import endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
ImportValidationRequest,
|
||||
ImportValidationResponse,
|
||||
ImportProcessRequest,
|
||||
ImportProcessResponse,
|
||||
ImportStatusResponse
|
||||
} from '../types/dataImport';
|
||||
|
||||
export class DataImportService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
async validateJsonData(
|
||||
tenantId: string,
|
||||
data: any
|
||||
): Promise<ImportValidationResponse> {
|
||||
return apiClient.post<ImportValidationResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/import/validate-json`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async validateCsvFile(
|
||||
tenantId: string,
|
||||
file: File
|
||||
): Promise<ImportValidationResponse> {
|
||||
return apiClient.uploadFile<ImportValidationResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/import/validate-csv`,
|
||||
file
|
||||
);
|
||||
}
|
||||
|
||||
async importJsonData(
|
||||
tenantId: string,
|
||||
data: any,
|
||||
options?: {
|
||||
skip_validation?: boolean;
|
||||
chunk_size?: number;
|
||||
}
|
||||
): Promise<ImportProcessResponse> {
|
||||
const payload = {
|
||||
...data,
|
||||
options,
|
||||
};
|
||||
return apiClient.post<ImportProcessResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/import/json`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
async importCsvFile(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
options?: {
|
||||
skip_validation?: boolean;
|
||||
chunk_size?: number;
|
||||
}
|
||||
): Promise<ImportProcessResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (options) {
|
||||
formData.append('options', JSON.stringify(options));
|
||||
}
|
||||
|
||||
return apiClient.uploadFile<ImportProcessResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/import/csv`,
|
||||
formData
|
||||
);
|
||||
}
|
||||
|
||||
async getImportStatus(
|
||||
tenantId: string,
|
||||
importId: string
|
||||
): Promise<ImportStatusResponse> {
|
||||
return apiClient.get<ImportStatusResponse>(
|
||||
`${this.baseUrl}/${tenantId}/sales/import/${importId}/status`
|
||||
);
|
||||
}
|
||||
|
||||
async cancelImport(
|
||||
tenantId: string,
|
||||
importId: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/sales/import/${importId}/cancel`
|
||||
);
|
||||
}
|
||||
|
||||
async getImportHistory(tenantId: string): Promise<ImportStatusResponse[]> {
|
||||
return apiClient.get<ImportStatusResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/sales/import/history`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const dataImportService = new DataImportService();
|
||||
273
frontend/src/api/services/foodSafety.ts
Normal file
273
frontend/src/api/services/foodSafety.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Food Safety Service - Mirror backend food safety endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
FoodSafetyComplianceCreate,
|
||||
FoodSafetyComplianceUpdate,
|
||||
FoodSafetyComplianceResponse,
|
||||
TemperatureLogCreate,
|
||||
BulkTemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
FoodSafetyAlertCreate,
|
||||
FoodSafetyAlertUpdate,
|
||||
FoodSafetyAlertResponse,
|
||||
FoodSafetyFilter,
|
||||
TemperatureMonitoringFilter,
|
||||
FoodSafetyMetrics,
|
||||
TemperatureAnalytics,
|
||||
FoodSafetyDashboard,
|
||||
} from '../types/foodSafety';
|
||||
import { PaginatedResponse } from '../types/inventory';
|
||||
|
||||
export class FoodSafetyService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// Compliance Management
|
||||
async createComplianceRecord(
|
||||
tenantId: string,
|
||||
complianceData: FoodSafetyComplianceCreate
|
||||
): Promise<FoodSafetyComplianceResponse> {
|
||||
return apiClient.post<FoodSafetyComplianceResponse>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/compliance`,
|
||||
complianceData
|
||||
);
|
||||
}
|
||||
|
||||
async getComplianceRecord(
|
||||
tenantId: string,
|
||||
recordId: string
|
||||
): Promise<FoodSafetyComplianceResponse> {
|
||||
return apiClient.get<FoodSafetyComplianceResponse>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/compliance/${recordId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getComplianceRecords(
|
||||
tenantId: string,
|
||||
filter?: FoodSafetyFilter
|
||||
): Promise<PaginatedResponse<FoodSafetyComplianceResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.compliance_type) queryParams.append('compliance_type', filter.compliance_type);
|
||||
if (filter?.status) queryParams.append('status', filter.status);
|
||||
if (filter?.ingredient_id) queryParams.append('ingredient_id', filter.ingredient_id);
|
||||
if (filter?.resolved !== undefined) queryParams.append('resolved', filter.resolved.toString());
|
||||
if (filter?.date_range?.start) queryParams.append('start_date', filter.date_range.start);
|
||||
if (filter?.date_range?.end) queryParams.append('end_date', filter.date_range.end);
|
||||
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
|
||||
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
|
||||
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
|
||||
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/food-safety/compliance?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/food-safety/compliance`;
|
||||
|
||||
return apiClient.get<PaginatedResponse<FoodSafetyComplianceResponse>>(url);
|
||||
}
|
||||
|
||||
async updateComplianceRecord(
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
updateData: FoodSafetyComplianceUpdate
|
||||
): Promise<FoodSafetyComplianceResponse> {
|
||||
return apiClient.put<FoodSafetyComplianceResponse>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/compliance/${recordId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteComplianceRecord(
|
||||
tenantId: string,
|
||||
recordId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/compliance/${recordId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Temperature Monitoring
|
||||
async createTemperatureLog(
|
||||
tenantId: string,
|
||||
logData: TemperatureLogCreate
|
||||
): Promise<TemperatureLogResponse> {
|
||||
return apiClient.post<TemperatureLogResponse>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/temperature-logs`,
|
||||
logData
|
||||
);
|
||||
}
|
||||
|
||||
async createBulkTemperatureLogs(
|
||||
tenantId: string,
|
||||
bulkData: BulkTemperatureLogCreate
|
||||
): Promise<{
|
||||
created_count: number;
|
||||
failed_count: number;
|
||||
errors?: string[];
|
||||
}> {
|
||||
return apiClient.post(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/temperature-logs/bulk`,
|
||||
bulkData
|
||||
);
|
||||
}
|
||||
|
||||
async getTemperatureLogs(
|
||||
tenantId: string,
|
||||
filter?: TemperatureMonitoringFilter
|
||||
): Promise<PaginatedResponse<TemperatureLogResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.location) queryParams.append('location', filter.location);
|
||||
if (filter?.equipment_id) queryParams.append('equipment_id', filter.equipment_id);
|
||||
if (filter?.temperature_range?.min !== undefined)
|
||||
queryParams.append('min_temperature', filter.temperature_range.min.toString());
|
||||
if (filter?.temperature_range?.max !== undefined)
|
||||
queryParams.append('max_temperature', filter.temperature_range.max.toString());
|
||||
if (filter?.alert_triggered !== undefined)
|
||||
queryParams.append('alert_triggered', filter.alert_triggered.toString());
|
||||
if (filter?.date_range?.start) queryParams.append('start_date', filter.date_range.start);
|
||||
if (filter?.date_range?.end) queryParams.append('end_date', filter.date_range.end);
|
||||
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
|
||||
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
|
||||
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
|
||||
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/food-safety/temperature-logs?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/food-safety/temperature-logs`;
|
||||
|
||||
return apiClient.get<PaginatedResponse<TemperatureLogResponse>>(url);
|
||||
}
|
||||
|
||||
async getTemperatureAnalytics(
|
||||
tenantId: string,
|
||||
location: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<TemperatureAnalytics> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('location', location);
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
return apiClient.get<TemperatureAnalytics>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/temperature-analytics?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// Alert Management
|
||||
async createFoodSafetyAlert(
|
||||
tenantId: string,
|
||||
alertData: FoodSafetyAlertCreate
|
||||
): Promise<FoodSafetyAlertResponse> {
|
||||
return apiClient.post<FoodSafetyAlertResponse>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/alerts`,
|
||||
alertData
|
||||
);
|
||||
}
|
||||
|
||||
async getFoodSafetyAlert(
|
||||
tenantId: string,
|
||||
alertId: string
|
||||
): Promise<FoodSafetyAlertResponse> {
|
||||
return apiClient.get<FoodSafetyAlertResponse>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/alerts/${alertId}`
|
||||
);
|
||||
}
|
||||
|
||||
async getFoodSafetyAlerts(
|
||||
tenantId: string,
|
||||
status?: 'open' | 'in_progress' | 'resolved' | 'dismissed',
|
||||
severity?: 'critical' | 'warning' | 'info',
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<PaginatedResponse<FoodSafetyAlertResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (status) queryParams.append('status', status);
|
||||
if (severity) queryParams.append('severity', severity);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
|
||||
return apiClient.get<PaginatedResponse<FoodSafetyAlertResponse>>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/alerts?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async updateFoodSafetyAlert(
|
||||
tenantId: string,
|
||||
alertId: string,
|
||||
updateData: FoodSafetyAlertUpdate
|
||||
): Promise<FoodSafetyAlertResponse> {
|
||||
return apiClient.put<FoodSafetyAlertResponse>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/alerts/${alertId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteFoodSafetyAlert(
|
||||
tenantId: string,
|
||||
alertId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/alerts/${alertId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Dashboard and Metrics
|
||||
async getFoodSafetyDashboard(tenantId: string): Promise<FoodSafetyDashboard> {
|
||||
return apiClient.get<FoodSafetyDashboard>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/dashboard`
|
||||
);
|
||||
}
|
||||
|
||||
async getFoodSafetyMetrics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<FoodSafetyMetrics> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/food-safety/metrics?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/food-safety/metrics`;
|
||||
|
||||
return apiClient.get<FoodSafetyMetrics>(url);
|
||||
}
|
||||
|
||||
async getTemperatureViolations(
|
||||
tenantId: string,
|
||||
limit: number = 20
|
||||
): Promise<TemperatureLogResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('limit', limit.toString());
|
||||
|
||||
return apiClient.get<TemperatureLogResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/food-safety/temperature-violations?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getComplianceRate(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<{
|
||||
overall_rate: number;
|
||||
by_type: Record<string, number>;
|
||||
trend: Array<{ date: string; rate: number }>;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/food-safety/compliance-rate?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/food-safety/compliance-rate`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const foodSafetyService = new FoodSafetyService();
|
||||
228
frontend/src/api/services/inventory.ts
Normal file
228
frontend/src/api/services/inventory.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Inventory Service - Mirror backend inventory endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
IngredientCreate,
|
||||
IngredientUpdate,
|
||||
IngredientResponse,
|
||||
StockCreate,
|
||||
StockUpdate,
|
||||
StockResponse,
|
||||
StockMovementCreate,
|
||||
StockMovementResponse,
|
||||
InventoryFilter,
|
||||
StockFilter,
|
||||
StockConsumptionRequest,
|
||||
StockConsumptionResponse,
|
||||
PaginatedResponse,
|
||||
} from '../types/inventory';
|
||||
|
||||
export class InventoryService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// Ingredient Management
|
||||
async createIngredient(
|
||||
tenantId: string,
|
||||
ingredientData: IngredientCreate
|
||||
): Promise<IngredientResponse> {
|
||||
return apiClient.post<IngredientResponse>(`${this.baseUrl}/${tenantId}/ingredients`, ingredientData);
|
||||
}
|
||||
|
||||
async getIngredient(tenantId: string, ingredientId: string): Promise<IngredientResponse> {
|
||||
return apiClient.get<IngredientResponse>(`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}`);
|
||||
}
|
||||
|
||||
async getIngredients(
|
||||
tenantId: string,
|
||||
filter?: InventoryFilter
|
||||
): Promise<PaginatedResponse<IngredientResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.category) queryParams.append('category', filter.category);
|
||||
if (filter?.stock_status) queryParams.append('stock_status', filter.stock_status);
|
||||
if (filter?.requires_refrigeration !== undefined)
|
||||
queryParams.append('requires_refrigeration', filter.requires_refrigeration.toString());
|
||||
if (filter?.requires_freezing !== undefined)
|
||||
queryParams.append('requires_freezing', filter.requires_freezing.toString());
|
||||
if (filter?.is_seasonal !== undefined)
|
||||
queryParams.append('is_seasonal', filter.is_seasonal.toString());
|
||||
if (filter?.supplier_id) queryParams.append('supplier_id', filter.supplier_id);
|
||||
if (filter?.expiring_within_days !== undefined)
|
||||
queryParams.append('expiring_within_days', filter.expiring_within_days.toString());
|
||||
if (filter?.search) queryParams.append('search', filter.search);
|
||||
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
|
||||
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
|
||||
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
|
||||
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/ingredients?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/ingredients`;
|
||||
|
||||
return apiClient.get<PaginatedResponse<IngredientResponse>>(url);
|
||||
}
|
||||
|
||||
async updateIngredient(
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
updateData: IngredientUpdate
|
||||
): Promise<IngredientResponse> {
|
||||
return apiClient.put<IngredientResponse>(
|
||||
`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}`,
|
||||
updateData
|
||||
);
|
||||
}
|
||||
|
||||
async deleteIngredient(tenantId: string, ingredientId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${tenantId}/ingredients/${ingredientId}`);
|
||||
}
|
||||
|
||||
async getIngredientsByCategory(tenantId: string): Promise<Record<string, IngredientResponse[]>> {
|
||||
return apiClient.get<Record<string, IngredientResponse[]>>(`${this.baseUrl}/${tenantId}/ingredients/by-category`);
|
||||
}
|
||||
|
||||
async getLowStockIngredients(tenantId: string): Promise<IngredientResponse[]> {
|
||||
return apiClient.get<IngredientResponse[]>(`${this.baseUrl}/${tenantId}/ingredients/low-stock`);
|
||||
}
|
||||
|
||||
// Stock Management
|
||||
async addStock(tenantId: string, stockData: StockCreate): Promise<StockResponse> {
|
||||
return apiClient.post<StockResponse>(`${this.baseUrl}/${tenantId}/stock`, stockData);
|
||||
}
|
||||
|
||||
async getStock(tenantId: string, stockId: string): Promise<StockResponse> {
|
||||
return apiClient.get<StockResponse>(`${this.baseUrl}/${tenantId}/stock/${stockId}`);
|
||||
}
|
||||
|
||||
async getStockByIngredient(
|
||||
tenantId: string,
|
||||
ingredientId: string,
|
||||
includeUnavailable: boolean = false
|
||||
): Promise<StockResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('include_unavailable', includeUnavailable.toString());
|
||||
|
||||
return apiClient.get<StockResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/stock/ingredient/${ingredientId}?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getAllStock(tenantId: string, filter?: StockFilter): Promise<PaginatedResponse<StockResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.ingredient_id) queryParams.append('ingredient_id', filter.ingredient_id);
|
||||
if (filter?.is_available !== undefined) queryParams.append('is_available', filter.is_available.toString());
|
||||
if (filter?.is_expired !== undefined) queryParams.append('is_expired', filter.is_expired.toString());
|
||||
if (filter?.expiring_within_days !== undefined)
|
||||
queryParams.append('expiring_within_days', filter.expiring_within_days.toString());
|
||||
if (filter?.batch_number) queryParams.append('batch_number', filter.batch_number);
|
||||
if (filter?.supplier_id) queryParams.append('supplier_id', filter.supplier_id);
|
||||
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
|
||||
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
|
||||
if (filter?.order_by) queryParams.append('order_by', filter.order_by);
|
||||
if (filter?.order_direction) queryParams.append('order_direction', filter.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/stock?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/stock`;
|
||||
|
||||
return apiClient.get<PaginatedResponse<StockResponse>>(url);
|
||||
}
|
||||
|
||||
async updateStock(
|
||||
tenantId: string,
|
||||
stockId: string,
|
||||
updateData: StockUpdate
|
||||
): Promise<StockResponse> {
|
||||
return apiClient.put<StockResponse>(`${this.baseUrl}/${tenantId}/stock/${stockId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteStock(tenantId: string, stockId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${tenantId}/stock/${stockId}`);
|
||||
}
|
||||
|
||||
async consumeStock(
|
||||
tenantId: string,
|
||||
consumptionData: StockConsumptionRequest
|
||||
): Promise<StockConsumptionResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('ingredient_id', consumptionData.ingredient_id);
|
||||
queryParams.append('quantity', consumptionData.quantity.toString());
|
||||
if (consumptionData.reference_number)
|
||||
queryParams.append('reference_number', consumptionData.reference_number);
|
||||
if (consumptionData.notes) queryParams.append('notes', consumptionData.notes);
|
||||
if (consumptionData.fifo !== undefined) queryParams.append('fifo', consumptionData.fifo.toString());
|
||||
|
||||
return apiClient.post<StockConsumptionResponse>(
|
||||
`${this.baseUrl}/${tenantId}/stock/consume?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// Stock Movements
|
||||
async createStockMovement(
|
||||
tenantId: string,
|
||||
movementData: StockMovementCreate
|
||||
): Promise<StockMovementResponse> {
|
||||
return apiClient.post<StockMovementResponse>(`${this.baseUrl}/${tenantId}/stock/movements`, movementData);
|
||||
}
|
||||
|
||||
async getStockMovements(
|
||||
tenantId: string,
|
||||
ingredientId?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<PaginatedResponse<StockMovementResponse>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (ingredientId) queryParams.append('ingredient_id', ingredientId);
|
||||
queryParams.append('limit', limit.toString());
|
||||
queryParams.append('offset', offset.toString());
|
||||
|
||||
return apiClient.get<PaginatedResponse<StockMovementResponse>>(
|
||||
`${this.baseUrl}/${tenantId}/stock/movements?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// Expiry Management
|
||||
async getExpiringStock(
|
||||
tenantId: string,
|
||||
withinDays: number = 7
|
||||
): Promise<StockResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('within_days', withinDays.toString());
|
||||
|
||||
return apiClient.get<StockResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/stock/expiring?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getExpiredStock(tenantId: string): Promise<StockResponse[]> {
|
||||
return apiClient.get<StockResponse[]>(`${this.baseUrl}/${tenantId}/stock/expired`);
|
||||
}
|
||||
|
||||
// Analytics
|
||||
async getStockAnalytics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<{
|
||||
total_ingredients: number;
|
||||
total_stock_value: number;
|
||||
low_stock_count: number;
|
||||
out_of_stock_count: number;
|
||||
expiring_soon_count: number;
|
||||
stock_turnover_rate: number;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/inventory/analytics?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/inventory/analytics`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
}
|
||||
|
||||
export const inventoryService = new InventoryService();
|
||||
138
frontend/src/api/services/inventoryDashboard.ts
Normal file
138
frontend/src/api/services/inventoryDashboard.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Inventory Dashboard Service - Mirror backend dashboard endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
InventoryDashboardSummary,
|
||||
InventoryAnalytics,
|
||||
BusinessModelInsights,
|
||||
DashboardFilter,
|
||||
AlertsFilter,
|
||||
RecentActivity,
|
||||
} from '../types/dashboard';
|
||||
|
||||
export class InventoryDashboardService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
async getDashboardSummary(
|
||||
tenantId: string,
|
||||
filter?: DashboardFilter
|
||||
): Promise<InventoryDashboardSummary> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.date_range?.start) queryParams.append('start_date', filter.date_range.start);
|
||||
if (filter?.date_range?.end) queryParams.append('end_date', filter.date_range.end);
|
||||
if (filter?.categories?.length) queryParams.append('categories', filter.categories.join(','));
|
||||
if (filter?.include_expired !== undefined)
|
||||
queryParams.append('include_expired', filter.include_expired.toString());
|
||||
if (filter?.include_unavailable !== undefined)
|
||||
queryParams.append('include_unavailable', filter.include_unavailable.toString());
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/dashboard/summary?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/dashboard/summary`;
|
||||
|
||||
return apiClient.get<InventoryDashboardSummary>(url);
|
||||
}
|
||||
|
||||
async getInventoryAnalytics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<InventoryAnalytics> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/dashboard/analytics?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/dashboard/analytics`;
|
||||
|
||||
return apiClient.get<InventoryAnalytics>(url);
|
||||
}
|
||||
|
||||
async getBusinessModelInsights(tenantId: string): Promise<BusinessModelInsights> {
|
||||
return apiClient.get<BusinessModelInsights>(
|
||||
`${this.baseUrl}/${tenantId}/dashboard/business-insights`
|
||||
);
|
||||
}
|
||||
|
||||
async getRecentActivity(
|
||||
tenantId: string,
|
||||
limit: number = 20
|
||||
): Promise<RecentActivity[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('limit', limit.toString());
|
||||
|
||||
return apiClient.get<RecentActivity[]>(
|
||||
`${this.baseUrl}/${tenantId}/dashboard/recent-activity?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getAlerts(
|
||||
tenantId: string,
|
||||
filter?: AlertsFilter
|
||||
): Promise<{
|
||||
items: any[];
|
||||
total: number;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filter?.severity) queryParams.append('severity', filter.severity);
|
||||
if (filter?.type) queryParams.append('type', filter.type);
|
||||
if (filter?.resolved !== undefined) queryParams.append('resolved', filter.resolved.toString());
|
||||
if (filter?.limit !== undefined) queryParams.append('limit', filter.limit.toString());
|
||||
if (filter?.offset !== undefined) queryParams.append('offset', filter.offset.toString());
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/dashboard/alerts?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/dashboard/alerts`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getStockSummary(tenantId: string): Promise<{
|
||||
in_stock: number;
|
||||
low_stock: number;
|
||||
out_of_stock: number;
|
||||
overstock: number;
|
||||
total_value: number;
|
||||
}> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/dashboard/stock-summary`);
|
||||
}
|
||||
|
||||
async getTopCategories(tenantId: string, limit: number = 10): Promise<Array<{
|
||||
category: string;
|
||||
ingredient_count: number;
|
||||
total_value: number;
|
||||
low_stock_count: number;
|
||||
}>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('limit', limit.toString());
|
||||
|
||||
return apiClient.get(
|
||||
`${this.baseUrl}/${tenantId}/dashboard/top-categories?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getExpiryCalendar(
|
||||
tenantId: string,
|
||||
daysAhead: number = 30
|
||||
): Promise<Array<{
|
||||
date: string;
|
||||
items: Array<{
|
||||
ingredient_name: string;
|
||||
quantity: number;
|
||||
batch_number?: string;
|
||||
}>;
|
||||
}>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('days_ahead', daysAhead.toString());
|
||||
|
||||
return apiClient.get(
|
||||
`${this.baseUrl}/${tenantId}/dashboard/expiry-calendar?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const inventoryDashboardService = new InventoryDashboardService();
|
||||
52
frontend/src/api/services/onboarding.ts
Normal file
52
frontend/src/api/services/onboarding.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Onboarding Service - Mirror backend onboarding endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import { UserProgress, UpdateStepRequest } from '../types/onboarding';
|
||||
|
||||
export class OnboardingService {
|
||||
private readonly baseUrl = '/onboarding';
|
||||
|
||||
async getUserProgress(userId: string): Promise<UserProgress> {
|
||||
return apiClient.get<UserProgress>(`${this.baseUrl}/progress/${userId}`);
|
||||
}
|
||||
|
||||
async updateStep(userId: string, stepData: UpdateStepRequest): Promise<UserProgress> {
|
||||
return apiClient.put<UserProgress>(`${this.baseUrl}/progress/${userId}/step`, stepData);
|
||||
}
|
||||
|
||||
async markStepCompleted(
|
||||
userId: string,
|
||||
stepName: string,
|
||||
data?: Record<string, any>
|
||||
): Promise<UserProgress> {
|
||||
return apiClient.post<UserProgress>(`${this.baseUrl}/progress/${userId}/complete`, {
|
||||
step_name: stepName,
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
async resetProgress(userId: string): Promise<UserProgress> {
|
||||
return apiClient.post<UserProgress>(`${this.baseUrl}/progress/${userId}/reset`);
|
||||
}
|
||||
|
||||
async getStepDetails(stepName: string): Promise<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}> {
|
||||
return apiClient.get(`${this.baseUrl}/steps/${stepName}`);
|
||||
}
|
||||
|
||||
async getAllSteps(): Promise<Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
dependencies: string[];
|
||||
estimated_time_minutes: number;
|
||||
}>> {
|
||||
return apiClient.get(`${this.baseUrl}/steps`);
|
||||
}
|
||||
}
|
||||
|
||||
export const onboardingService = new OnboardingService();
|
||||
126
frontend/src/api/services/sales.ts
Normal file
126
frontend/src/api/services/sales.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Sales Service - Mirror backend sales endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
SalesDataCreate,
|
||||
SalesDataUpdate,
|
||||
SalesDataResponse,
|
||||
SalesDataQuery,
|
||||
SalesAnalytics,
|
||||
} from '../types/sales';
|
||||
|
||||
export class SalesService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// Sales Data CRUD Operations
|
||||
async createSalesRecord(
|
||||
tenantId: string,
|
||||
salesData: SalesDataCreate
|
||||
): Promise<SalesDataResponse> {
|
||||
return apiClient.post<SalesDataResponse>(`${this.baseUrl}/${tenantId}/sales`, salesData);
|
||||
}
|
||||
|
||||
async getSalesRecords(
|
||||
tenantId: string,
|
||||
query?: SalesDataQuery
|
||||
): Promise<SalesDataResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (query?.start_date) queryParams.append('start_date', query.start_date);
|
||||
if (query?.end_date) queryParams.append('end_date', query.end_date);
|
||||
if (query?.product_name) queryParams.append('product_name', query.product_name);
|
||||
if (query?.product_category) queryParams.append('product_category', query.product_category);
|
||||
if (query?.location_id) queryParams.append('location_id', query.location_id);
|
||||
if (query?.sales_channel) queryParams.append('sales_channel', query.sales_channel);
|
||||
if (query?.source) queryParams.append('source', query.source);
|
||||
if (query?.is_validated !== undefined) queryParams.append('is_validated', query.is_validated.toString());
|
||||
if (query?.limit !== undefined) queryParams.append('limit', query.limit.toString());
|
||||
if (query?.offset !== undefined) queryParams.append('offset', query.offset.toString());
|
||||
if (query?.order_by) queryParams.append('order_by', query.order_by);
|
||||
if (query?.order_direction) queryParams.append('order_direction', query.order_direction);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales`;
|
||||
|
||||
return apiClient.get<SalesDataResponse[]>(url);
|
||||
}
|
||||
|
||||
async getSalesRecord(
|
||||
tenantId: string,
|
||||
recordId: string
|
||||
): Promise<SalesDataResponse> {
|
||||
return apiClient.get<SalesDataResponse>(`${this.baseUrl}/${tenantId}/sales/${recordId}`);
|
||||
}
|
||||
|
||||
async updateSalesRecord(
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
updateData: SalesDataUpdate
|
||||
): Promise<SalesDataResponse> {
|
||||
return apiClient.put<SalesDataResponse>(`${this.baseUrl}/${tenantId}/sales/${recordId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteSalesRecord(
|
||||
tenantId: string,
|
||||
recordId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${tenantId}/sales/${recordId}`);
|
||||
}
|
||||
|
||||
async validateSalesRecord(
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
validationNotes?: string
|
||||
): Promise<SalesDataResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (validationNotes) queryParams.append('validation_notes', validationNotes);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/${recordId}/validate?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/${recordId}/validate`;
|
||||
|
||||
return apiClient.post<SalesDataResponse>(url);
|
||||
}
|
||||
|
||||
// Analytics & Reporting
|
||||
async getSalesAnalytics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<SalesAnalytics> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/sales/analytics/summary?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/sales/analytics/summary`;
|
||||
|
||||
return apiClient.get<SalesAnalytics>(url);
|
||||
}
|
||||
|
||||
async getProductSales(
|
||||
tenantId: string,
|
||||
inventoryProductId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<SalesDataResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (startDate) queryParams.append('start_date', startDate);
|
||||
if (endDate) queryParams.append('end_date', endDate);
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/inventory-products/${inventoryProductId}/sales?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/inventory-products/${inventoryProductId}/sales`;
|
||||
|
||||
return apiClient.get<SalesDataResponse[]>(url);
|
||||
}
|
||||
|
||||
async getProductCategories(tenantId: string): Promise<string[]> {
|
||||
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/categories`);
|
||||
}
|
||||
}
|
||||
|
||||
export const salesService = new SalesService();
|
||||
67
frontend/src/api/services/subscription.ts
Normal file
67
frontend/src/api/services/subscription.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Subscription Service - Mirror backend subscription endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
SubscriptionLimits,
|
||||
FeatureCheckRequest,
|
||||
FeatureCheckResponse,
|
||||
UsageCheckRequest,
|
||||
UsageCheckResponse
|
||||
} from '../types/subscription';
|
||||
|
||||
export class SubscriptionService {
|
||||
private readonly baseUrl = '/subscriptions';
|
||||
|
||||
async getSubscriptionLimits(tenantId: string): Promise<SubscriptionLimits> {
|
||||
return apiClient.get<SubscriptionLimits>(`${this.baseUrl}/${tenantId}/limits`);
|
||||
}
|
||||
|
||||
async checkFeatureAccess(
|
||||
tenantId: string,
|
||||
featureName: string
|
||||
): Promise<FeatureCheckResponse> {
|
||||
return apiClient.get<FeatureCheckResponse>(
|
||||
`${this.baseUrl}/${tenantId}/features/${featureName}/check`
|
||||
);
|
||||
}
|
||||
|
||||
async checkUsageLimit(
|
||||
tenantId: string,
|
||||
resourceType: 'users' | 'sales_records' | 'inventory_items' | 'api_requests',
|
||||
requestedAmount?: number
|
||||
): Promise<UsageCheckResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (requestedAmount !== undefined) {
|
||||
queryParams.append('requested_amount', requestedAmount.toString());
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/${tenantId}/usage/${resourceType}/check?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/${tenantId}/usage/${resourceType}/check`;
|
||||
|
||||
return apiClient.get<UsageCheckResponse>(url);
|
||||
}
|
||||
|
||||
async recordUsage(
|
||||
tenantId: string,
|
||||
resourceType: 'users' | 'sales_records' | 'inventory_items' | 'api_requests',
|
||||
amount: number = 1
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string }>(
|
||||
`${this.baseUrl}/${tenantId}/usage/${resourceType}/record`,
|
||||
{ amount }
|
||||
);
|
||||
}
|
||||
|
||||
async getCurrentUsage(tenantId: string): Promise<{
|
||||
users: number;
|
||||
sales_records: number;
|
||||
inventory_items: number;
|
||||
api_requests_this_hour: number;
|
||||
}> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/usage/current`);
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
145
frontend/src/api/services/tenant.ts
Normal file
145
frontend/src/api/services/tenant.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tenant Service - Mirror backend tenant endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
BakeryRegistration,
|
||||
TenantResponse,
|
||||
TenantAccessResponse,
|
||||
TenantUpdate,
|
||||
TenantMemberResponse,
|
||||
TenantStatistics,
|
||||
TenantSearchParams,
|
||||
TenantNearbyParams,
|
||||
} from '../types/tenant';
|
||||
|
||||
export class TenantService {
|
||||
private readonly baseUrl = '/tenants';
|
||||
|
||||
// Tenant CRUD Operations
|
||||
async registerBakery(bakeryData: BakeryRegistration): Promise<TenantResponse> {
|
||||
return apiClient.post<TenantResponse>(`${this.baseUrl}/register`, bakeryData);
|
||||
}
|
||||
|
||||
async getTenant(tenantId: string): Promise<TenantResponse> {
|
||||
return apiClient.get<TenantResponse>(`${this.baseUrl}/${tenantId}`);
|
||||
}
|
||||
|
||||
async getTenantBySubdomain(subdomain: string): Promise<TenantResponse> {
|
||||
return apiClient.get<TenantResponse>(`${this.baseUrl}/subdomain/${subdomain}`);
|
||||
}
|
||||
|
||||
async getUserTenants(userId: string): Promise<TenantResponse[]> {
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/users/${userId}`);
|
||||
}
|
||||
|
||||
async getUserOwnedTenants(userId: string): Promise<TenantResponse[]> {
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/user/${userId}/owned`);
|
||||
}
|
||||
|
||||
async updateTenant(tenantId: string, updateData: TenantUpdate): Promise<TenantResponse> {
|
||||
return apiClient.put<TenantResponse>(`${this.baseUrl}/${tenantId}`, updateData);
|
||||
}
|
||||
|
||||
async deactivateTenant(tenantId: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/deactivate`);
|
||||
}
|
||||
|
||||
async activateTenant(tenantId: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/activate`);
|
||||
}
|
||||
|
||||
// Access Control
|
||||
async verifyTenantAccess(tenantId: string, userId: string): Promise<TenantAccessResponse> {
|
||||
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/access/${userId}`);
|
||||
}
|
||||
|
||||
// Search & Discovery
|
||||
async searchTenants(params: TenantSearchParams): Promise<TenantResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.search_term) queryParams.append('search_term', params.search_term);
|
||||
if (params.business_type) queryParams.append('business_type', params.business_type);
|
||||
if (params.city) queryParams.append('city', params.city);
|
||||
if (params.skip !== undefined) queryParams.append('skip', params.skip.toString());
|
||||
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/search?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
async getNearbyTenants(params: TenantNearbyParams): Promise<TenantResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
queryParams.append('latitude', params.latitude.toString());
|
||||
queryParams.append('longitude', params.longitude.toString());
|
||||
if (params.radius_km !== undefined) queryParams.append('radius_km', params.radius_km.toString());
|
||||
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/nearby?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// Model Management
|
||||
async updateModelStatus(
|
||||
tenantId: string,
|
||||
modelTrained: boolean,
|
||||
lastTrainingDate?: string
|
||||
): Promise<TenantResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('model_trained', modelTrained.toString());
|
||||
if (lastTrainingDate) queryParams.append('last_training_date', lastTrainingDate);
|
||||
|
||||
return apiClient.put<TenantResponse>(`${this.baseUrl}/${tenantId}/model-status?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// Team Management
|
||||
async addTeamMember(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
role: string
|
||||
): Promise<TenantMemberResponse> {
|
||||
return apiClient.post<TenantMemberResponse>(`${this.baseUrl}/${tenantId}/members`, {
|
||||
user_id: userId,
|
||||
role: role,
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamMembers(tenantId: string, activeOnly: boolean = true): Promise<TenantMemberResponse[]> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('active_only', activeOnly.toString());
|
||||
|
||||
return apiClient.get<TenantMemberResponse[]>(`${this.baseUrl}/${tenantId}/members?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
async updateMemberRole(
|
||||
tenantId: string,
|
||||
memberUserId: string,
|
||||
newRole: string
|
||||
): Promise<TenantMemberResponse> {
|
||||
return apiClient.put<TenantMemberResponse>(
|
||||
`${this.baseUrl}/${tenantId}/members/${memberUserId}/role`,
|
||||
{ new_role: newRole }
|
||||
);
|
||||
}
|
||||
|
||||
async removeTeamMember(tenantId: string, memberUserId: string): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.delete<{ success: boolean; message: string }>(`${this.baseUrl}/${tenantId}/members/${memberUserId}`);
|
||||
}
|
||||
|
||||
// Admin Operations
|
||||
async getTenantStatistics(): Promise<TenantStatistics> {
|
||||
return apiClient.get<TenantStatistics>(`${this.baseUrl}/statistics`);
|
||||
}
|
||||
|
||||
// Context Management (Frontend-only operations)
|
||||
setCurrentTenant(tenant: TenantResponse): void {
|
||||
// Set tenant context in API client
|
||||
apiClient.setTenantId(tenant.id);
|
||||
}
|
||||
|
||||
clearCurrentTenant(): void {
|
||||
// Clear tenant context from API client
|
||||
apiClient.setTenantId(null);
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantService = new TenantService();
|
||||
37
frontend/src/api/services/user.ts
Normal file
37
frontend/src/api/services/user.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* User Service - Mirror backend user endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import { UserResponse, UserUpdate } from '../types/auth';
|
||||
import { AdminDeleteRequest, AdminDeleteResponse } from '../types/user';
|
||||
|
||||
export class UserService {
|
||||
private readonly baseUrl = '/users';
|
||||
|
||||
async getCurrentUser(): Promise<UserResponse> {
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/me`);
|
||||
}
|
||||
|
||||
async updateUser(userId: string, updateData: UserUpdate): Promise<UserResponse> {
|
||||
return apiClient.put<UserResponse>(`${this.baseUrl}/${userId}`, updateData);
|
||||
}
|
||||
|
||||
async deleteUser(userId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(`${this.baseUrl}/${userId}`);
|
||||
}
|
||||
|
||||
// Admin operations
|
||||
async adminDeleteUser(deleteRequest: AdminDeleteRequest): Promise<AdminDeleteResponse> {
|
||||
return apiClient.post<AdminDeleteResponse>(`${this.baseUrl}/admin/delete`, deleteRequest);
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<UserResponse[]> {
|
||||
return apiClient.get<UserResponse[]>(`${this.baseUrl}/admin/all`);
|
||||
}
|
||||
|
||||
async getUserById(userId: string): Promise<UserResponse> {
|
||||
return apiClient.get<UserResponse>(`${this.baseUrl}/admin/${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
92
frontend/src/api/types/auth.ts
Normal file
92
frontend/src/api/types/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Auth API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
is_verified: boolean;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
tenant_id?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UserRegistration {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
tenant_name?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface UserLogin {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface PasswordChange {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface PasswordReset {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
is_verified: boolean;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
tenant_id?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
full_name?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface TokenVerificationResponse {
|
||||
valid: boolean;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
exp?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AuthHealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
version: string;
|
||||
features: string[];
|
||||
}
|
||||
65
frontend/src/api/types/classification.ts
Normal file
65
frontend/src/api/types/classification.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Product Classification API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface ProductClassificationRequest {
|
||||
product_name: string;
|
||||
sales_volume?: number;
|
||||
sales_data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BatchClassificationRequest {
|
||||
products: ProductClassificationRequest[];
|
||||
}
|
||||
|
||||
export interface ProductSuggestionResponse {
|
||||
suggestion_id: string;
|
||||
original_name: string;
|
||||
suggested_name: string;
|
||||
product_type: string;
|
||||
category: string;
|
||||
unit_of_measure: string;
|
||||
confidence_score: number;
|
||||
estimated_shelf_life_days?: number;
|
||||
requires_refrigeration: boolean;
|
||||
requires_freezing: boolean;
|
||||
is_seasonal: boolean;
|
||||
suggested_supplier?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface BusinessModelAnalysisResponse {
|
||||
tenant_id: string;
|
||||
analysis_date: string;
|
||||
business_type: string;
|
||||
primary_products: string[];
|
||||
seasonality_patterns: Record<string, any>;
|
||||
supplier_recommendations: Array<{
|
||||
category: string;
|
||||
suppliers: string[];
|
||||
estimated_cost_savings: number;
|
||||
}>;
|
||||
inventory_optimization_suggestions: Array<{
|
||||
product_name: string;
|
||||
current_stock_level: number;
|
||||
suggested_stock_level: number;
|
||||
reason: string;
|
||||
}>;
|
||||
confidence_score: number;
|
||||
}
|
||||
|
||||
export interface ClassificationApprovalRequest {
|
||||
suggestion_id: string;
|
||||
approved: boolean;
|
||||
modifications?: Partial<ProductSuggestionResponse>;
|
||||
}
|
||||
|
||||
export interface ClassificationApprovalResponse {
|
||||
suggestion_id: string;
|
||||
approved: boolean;
|
||||
created_ingredient?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
message: string;
|
||||
}
|
||||
111
frontend/src/api/types/dashboard.ts
Normal file
111
frontend/src/api/types/dashboard.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Dashboard API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface InventoryDashboardSummary {
|
||||
tenant_id: string;
|
||||
total_ingredients: number;
|
||||
total_stock_value: number;
|
||||
low_stock_count: number;
|
||||
out_of_stock_count: number;
|
||||
overstock_count: number;
|
||||
expiring_soon_count: number;
|
||||
expired_count: number;
|
||||
recent_movements: StockMovementSummary[];
|
||||
top_categories: CategorySummary[];
|
||||
alerts_summary: AlertSummary;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface StockMovementSummary {
|
||||
id: string;
|
||||
ingredient_name: string;
|
||||
movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste';
|
||||
quantity: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CategorySummary {
|
||||
category: string;
|
||||
ingredient_count: number;
|
||||
total_value: number;
|
||||
low_stock_count: number;
|
||||
}
|
||||
|
||||
export interface AlertSummary {
|
||||
total_alerts: number;
|
||||
critical_alerts: number;
|
||||
warning_alerts: number;
|
||||
info_alerts: number;
|
||||
}
|
||||
|
||||
export interface StockStatusSummary {
|
||||
in_stock: number;
|
||||
low_stock: number;
|
||||
out_of_stock: number;
|
||||
overstock: number;
|
||||
}
|
||||
|
||||
export interface InventoryAnalytics {
|
||||
tenant_id: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
stock_turnover_rate: number;
|
||||
average_days_to_consume: number;
|
||||
waste_percentage: number;
|
||||
cost_of_goods_sold: number;
|
||||
inventory_value_trend: Array<{
|
||||
date: string;
|
||||
value: number;
|
||||
}>;
|
||||
top_consuming_ingredients: Array<{
|
||||
ingredient_name: string;
|
||||
quantity_consumed: number;
|
||||
value_consumed: number;
|
||||
}>;
|
||||
seasonal_patterns: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BusinessModelInsights {
|
||||
tenant_id: string;
|
||||
business_type: string;
|
||||
primary_categories: string[];
|
||||
seasonality_score: number;
|
||||
optimization_opportunities: Array<{
|
||||
type: 'stock_level' | 'supplier' | 'storage' | 'ordering';
|
||||
description: string;
|
||||
potential_savings: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
benchmarking: {
|
||||
inventory_turnover_vs_industry: number;
|
||||
waste_percentage_vs_industry: number;
|
||||
storage_efficiency_score: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecentActivity {
|
||||
id: string;
|
||||
type: 'stock_in' | 'stock_out' | 'ingredient_created' | 'alert_created';
|
||||
description: string;
|
||||
timestamp: string;
|
||||
user_name?: string;
|
||||
}
|
||||
|
||||
export interface DashboardFilter {
|
||||
date_range?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
categories?: string[];
|
||||
include_expired?: boolean;
|
||||
include_unavailable?: boolean;
|
||||
}
|
||||
|
||||
export interface AlertsFilter {
|
||||
severity?: 'critical' | 'warning' | 'info';
|
||||
type?: 'expiry' | 'low_stock' | 'out_of_stock' | 'food_safety';
|
||||
resolved?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
47
frontend/src/api/types/dataImport.ts
Normal file
47
frontend/src/api/types/dataImport.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Data Import API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface ImportValidationRequest {
|
||||
tenant_id: string;
|
||||
data?: string;
|
||||
data_format?: 'json' | 'csv';
|
||||
}
|
||||
|
||||
export interface ImportValidationResponse {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
record_count?: number;
|
||||
sample_records?: any[];
|
||||
}
|
||||
|
||||
export interface ImportProcessRequest {
|
||||
tenant_id: string;
|
||||
data?: string;
|
||||
data_format?: 'json' | 'csv';
|
||||
options?: {
|
||||
skip_validation?: boolean;
|
||||
chunk_size?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportProcessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
records_processed: number;
|
||||
records_failed: number;
|
||||
import_id?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface ImportStatusResponse {
|
||||
import_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress_percentage: number;
|
||||
records_processed: number;
|
||||
records_failed: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
226
frontend/src/api/types/foodSafety.ts
Normal file
226
frontend/src/api/types/foodSafety.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Food Safety API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface FoodSafetyComplianceCreate {
|
||||
ingredient_id: string;
|
||||
compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
|
||||
status: 'pass' | 'fail' | 'warning';
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
ph_level?: number;
|
||||
visual_inspection_notes?: string;
|
||||
corrective_actions?: string;
|
||||
inspector_name?: string;
|
||||
certification_reference?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface FoodSafetyComplianceUpdate {
|
||||
compliance_type?: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
|
||||
status?: 'pass' | 'fail' | 'warning';
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
ph_level?: number;
|
||||
visual_inspection_notes?: string;
|
||||
corrective_actions?: string;
|
||||
inspector_name?: string;
|
||||
certification_reference?: string;
|
||||
notes?: string;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export interface FoodSafetyComplianceResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
|
||||
status: 'pass' | 'fail' | 'warning';
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
ph_level?: number;
|
||||
visual_inspection_notes?: string;
|
||||
corrective_actions?: string;
|
||||
inspector_name?: string;
|
||||
certification_reference?: string;
|
||||
notes?: string;
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface TemperatureLogCreate {
|
||||
location: string;
|
||||
temperature: number;
|
||||
humidity?: number;
|
||||
equipment_id?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface BulkTemperatureLogCreate {
|
||||
logs: TemperatureLogCreate[];
|
||||
}
|
||||
|
||||
export interface TemperatureLogResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
location: string;
|
||||
temperature: number;
|
||||
humidity?: number;
|
||||
equipment_id?: string;
|
||||
notes?: string;
|
||||
is_within_range: boolean;
|
||||
alert_triggered: boolean;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface FoodSafetyAlertCreate {
|
||||
alert_type: 'temperature_violation' | 'expiry_warning' | 'quality_issue' | 'compliance_failure';
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
title: string;
|
||||
description: string;
|
||||
ingredient_id?: string;
|
||||
temperature_log_id?: string;
|
||||
compliance_record_id?: string;
|
||||
requires_action: boolean;
|
||||
assigned_to?: string;
|
||||
}
|
||||
|
||||
export interface FoodSafetyAlertUpdate {
|
||||
status?: 'open' | 'in_progress' | 'resolved' | 'dismissed';
|
||||
assigned_to?: string;
|
||||
resolution_notes?: string;
|
||||
corrective_actions?: string;
|
||||
}
|
||||
|
||||
export interface FoodSafetyAlertResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
alert_type: 'temperature_violation' | 'expiry_warning' | 'quality_issue' | 'compliance_failure';
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'open' | 'in_progress' | 'resolved' | 'dismissed';
|
||||
ingredient_id?: string;
|
||||
ingredient_name?: string;
|
||||
temperature_log_id?: string;
|
||||
compliance_record_id?: string;
|
||||
requires_action: boolean;
|
||||
assigned_to?: string;
|
||||
assigned_to_name?: string;
|
||||
resolution_notes?: string;
|
||||
corrective_actions?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
resolved_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface FoodSafetyFilter {
|
||||
compliance_type?: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
|
||||
status?: 'pass' | 'fail' | 'warning';
|
||||
ingredient_id?: string;
|
||||
resolved?: boolean;
|
||||
date_range?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: string;
|
||||
order_direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface TemperatureMonitoringFilter {
|
||||
location?: string;
|
||||
equipment_id?: string;
|
||||
temperature_range?: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
alert_triggered?: boolean;
|
||||
date_range?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: string;
|
||||
order_direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface FoodSafetyMetrics {
|
||||
tenant_id: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
total_compliance_checks: number;
|
||||
passed_checks: number;
|
||||
failed_checks: number;
|
||||
warning_checks: number;
|
||||
compliance_rate: number;
|
||||
total_temperature_logs: number;
|
||||
temperature_violations: number;
|
||||
critical_alerts: number;
|
||||
resolved_alerts: number;
|
||||
average_resolution_time_hours: number;
|
||||
top_risk_ingredients: Array<{
|
||||
ingredient_name: string;
|
||||
risk_score: number;
|
||||
incident_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface TemperatureAnalytics {
|
||||
tenant_id: string;
|
||||
location: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
average_temperature: number;
|
||||
min_temperature: number;
|
||||
max_temperature: number;
|
||||
temperature_trend: Array<{
|
||||
timestamp: string;
|
||||
temperature: number;
|
||||
humidity?: number;
|
||||
}>;
|
||||
violations_count: number;
|
||||
uptime_percentage: number;
|
||||
}
|
||||
|
||||
export interface FoodSafetyDashboard {
|
||||
tenant_id: string;
|
||||
compliance_summary: {
|
||||
total_checks: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
warnings: number;
|
||||
compliance_rate: number;
|
||||
};
|
||||
temperature_monitoring: {
|
||||
total_logs: number;
|
||||
violations: number;
|
||||
locations_monitored: number;
|
||||
latest_readings: TemperatureLogResponse[];
|
||||
};
|
||||
active_alerts: {
|
||||
critical: number;
|
||||
warning: number;
|
||||
info: number;
|
||||
overdue: number;
|
||||
};
|
||||
recent_activities: Array<{
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
severity?: string;
|
||||
}>;
|
||||
upcoming_expirations: Array<{
|
||||
ingredient_name: string;
|
||||
expiration_date: string;
|
||||
days_until_expiry: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
}
|
||||
192
frontend/src/api/types/inventory.ts
Normal file
192
frontend/src/api/types/inventory.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Inventory API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
// Base Inventory Types
|
||||
export interface IngredientCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
unit_of_measure: string;
|
||||
minimum_stock_level: number;
|
||||
maximum_stock_level: number;
|
||||
reorder_point: number;
|
||||
shelf_life_days?: number;
|
||||
requires_refrigeration?: boolean;
|
||||
requires_freezing?: boolean;
|
||||
is_seasonal?: boolean;
|
||||
supplier_id?: string;
|
||||
cost_per_unit?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface IngredientUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
unit_of_measure?: string;
|
||||
minimum_stock_level?: number;
|
||||
maximum_stock_level?: number;
|
||||
reorder_point?: number;
|
||||
shelf_life_days?: number;
|
||||
requires_refrigeration?: boolean;
|
||||
requires_freezing?: boolean;
|
||||
is_seasonal?: boolean;
|
||||
supplier_id?: string;
|
||||
cost_per_unit?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface IngredientResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
unit_of_measure: string;
|
||||
minimum_stock_level: number;
|
||||
maximum_stock_level: number;
|
||||
reorder_point: number;
|
||||
shelf_life_days?: number;
|
||||
requires_refrigeration: boolean;
|
||||
requires_freezing: boolean;
|
||||
is_seasonal: boolean;
|
||||
supplier_id?: string;
|
||||
cost_per_unit?: number;
|
||||
notes?: string;
|
||||
current_stock_level: number;
|
||||
available_stock: number;
|
||||
reserved_stock: number;
|
||||
stock_status: 'in_stock' | 'low_stock' | 'out_of_stock' | 'overstock';
|
||||
last_restocked?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// Stock Management Types
|
||||
export interface StockCreate {
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
expiration_date?: string;
|
||||
batch_number?: string;
|
||||
supplier_id?: string;
|
||||
purchase_order_reference?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface StockUpdate {
|
||||
quantity?: number;
|
||||
unit_price?: number;
|
||||
expiration_date?: string;
|
||||
batch_number?: string;
|
||||
notes?: string;
|
||||
is_available?: boolean;
|
||||
}
|
||||
|
||||
export interface StockResponse {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
tenant_id: string;
|
||||
quantity: number;
|
||||
available_quantity: number;
|
||||
reserved_quantity: number;
|
||||
unit_price: number;
|
||||
total_value: number;
|
||||
expiration_date?: string;
|
||||
batch_number?: string;
|
||||
supplier_id?: string;
|
||||
purchase_order_reference?: string;
|
||||
notes?: string;
|
||||
is_available: boolean;
|
||||
is_expired: boolean;
|
||||
days_until_expiry?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface StockMovementCreate {
|
||||
ingredient_id: string;
|
||||
movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste';
|
||||
quantity: number;
|
||||
unit_price?: number;
|
||||
reference_number?: string;
|
||||
notes?: string;
|
||||
related_stock_id?: string;
|
||||
}
|
||||
|
||||
export interface StockMovementResponse {
|
||||
id: string;
|
||||
ingredient_id: string;
|
||||
tenant_id: string;
|
||||
movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste';
|
||||
quantity: number;
|
||||
unit_price?: number;
|
||||
total_value?: number;
|
||||
reference_number?: string;
|
||||
notes?: string;
|
||||
related_stock_id?: string;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// Filter and Query Types
|
||||
export interface InventoryFilter {
|
||||
category?: string;
|
||||
stock_status?: 'in_stock' | 'low_stock' | 'out_of_stock' | 'overstock';
|
||||
requires_refrigeration?: boolean;
|
||||
requires_freezing?: boolean;
|
||||
is_seasonal?: boolean;
|
||||
supplier_id?: string;
|
||||
expiring_within_days?: number;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: string;
|
||||
order_direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface StockFilter {
|
||||
ingredient_id?: string;
|
||||
is_available?: boolean;
|
||||
is_expired?: boolean;
|
||||
expiring_within_days?: number;
|
||||
batch_number?: string;
|
||||
supplier_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: string;
|
||||
order_direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Stock Consumption Types
|
||||
export interface StockConsumptionRequest {
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
reference_number?: string;
|
||||
notes?: string;
|
||||
fifo?: boolean;
|
||||
}
|
||||
|
||||
export interface StockConsumptionResponse {
|
||||
ingredient_id: string;
|
||||
total_quantity_consumed: number;
|
||||
consumed_items: Array<{
|
||||
stock_id: string;
|
||||
quantity_consumed: number;
|
||||
batch_number?: string;
|
||||
expiration_date?: string;
|
||||
}>;
|
||||
method: 'FIFO' | 'LIFO';
|
||||
}
|
||||
|
||||
// Pagination Response
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
26
frontend/src/api/types/onboarding.ts
Normal file
26
frontend/src/api/types/onboarding.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Onboarding API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface OnboardingStepStatus {
|
||||
step_name: string;
|
||||
completed: boolean;
|
||||
completed_at?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UserProgress {
|
||||
user_id: string;
|
||||
steps: OnboardingStepStatus[];
|
||||
current_step: string;
|
||||
next_step?: string;
|
||||
completion_percentage: number;
|
||||
fully_completed: boolean;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface UpdateStepRequest {
|
||||
step_name: string;
|
||||
completed: boolean;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
137
frontend/src/api/types/sales.ts
Normal file
137
frontend/src/api/types/sales.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Sales API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface SalesDataCreate {
|
||||
date: string;
|
||||
product_name: string;
|
||||
product_category?: string;
|
||||
quantity_sold: number;
|
||||
unit_price: number;
|
||||
total_revenue: number;
|
||||
location_id?: string;
|
||||
sales_channel?: string;
|
||||
discount_applied?: number;
|
||||
promotion_used?: string;
|
||||
customer_id?: string;
|
||||
inventory_product_id?: string;
|
||||
cost_of_goods_sold?: number;
|
||||
profit_margin?: number;
|
||||
weather_condition?: string;
|
||||
temperature?: number;
|
||||
precipitation?: number;
|
||||
is_holiday?: boolean;
|
||||
day_of_week?: string;
|
||||
hour_of_day?: number;
|
||||
season?: string;
|
||||
local_event?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SalesDataUpdate {
|
||||
date?: string;
|
||||
product_name?: string;
|
||||
product_category?: string;
|
||||
quantity_sold?: number;
|
||||
unit_price?: number;
|
||||
total_revenue?: number;
|
||||
location_id?: string;
|
||||
sales_channel?: string;
|
||||
discount_applied?: number;
|
||||
promotion_used?: string;
|
||||
customer_id?: string;
|
||||
inventory_product_id?: string;
|
||||
cost_of_goods_sold?: number;
|
||||
profit_margin?: number;
|
||||
weather_condition?: string;
|
||||
temperature?: number;
|
||||
precipitation?: number;
|
||||
is_holiday?: boolean;
|
||||
day_of_week?: string;
|
||||
hour_of_day?: number;
|
||||
season?: string;
|
||||
local_event?: string;
|
||||
validation_notes?: string;
|
||||
}
|
||||
|
||||
export interface SalesDataResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
date: string;
|
||||
product_name: string;
|
||||
product_category?: string;
|
||||
quantity_sold: number;
|
||||
unit_price: number;
|
||||
total_revenue: number;
|
||||
location_id?: string;
|
||||
sales_channel?: string;
|
||||
discount_applied?: number;
|
||||
promotion_used?: string;
|
||||
customer_id?: string;
|
||||
inventory_product_id?: string;
|
||||
cost_of_goods_sold?: number;
|
||||
profit_margin?: number;
|
||||
weather_condition?: string;
|
||||
temperature?: number;
|
||||
precipitation?: number;
|
||||
is_holiday?: boolean;
|
||||
day_of_week?: string;
|
||||
hour_of_day?: number;
|
||||
season?: string;
|
||||
local_event?: string;
|
||||
source?: string;
|
||||
is_validated?: boolean;
|
||||
validation_notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface SalesDataQuery {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
product_name?: string;
|
||||
product_category?: string;
|
||||
location_id?: string;
|
||||
sales_channel?: string;
|
||||
source?: string;
|
||||
is_validated?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: string;
|
||||
order_direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface SalesAnalytics {
|
||||
total_revenue: number;
|
||||
total_quantity: number;
|
||||
average_unit_price: number;
|
||||
total_transactions: number;
|
||||
top_products: Array<{
|
||||
product_name: string;
|
||||
total_revenue: number;
|
||||
total_quantity: number;
|
||||
transaction_count: number;
|
||||
}>;
|
||||
revenue_by_date: Array<{
|
||||
date: string;
|
||||
revenue: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
revenue_by_category: Array<{
|
||||
category: string;
|
||||
revenue: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
revenue_by_channel: Array<{
|
||||
channel: string;
|
||||
revenue: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SalesValidationRequest {
|
||||
record_id: string;
|
||||
tenant_id: string;
|
||||
validation_notes?: string;
|
||||
}
|
||||
43
frontend/src/api/types/subscription.ts
Normal file
43
frontend/src/api/types/subscription.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Subscription API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface SubscriptionLimits {
|
||||
max_users: number;
|
||||
max_sales_records: number;
|
||||
max_inventory_items: number;
|
||||
max_api_requests_per_hour: number;
|
||||
features_enabled: string[];
|
||||
current_usage: {
|
||||
users: number;
|
||||
sales_records: number;
|
||||
inventory_items: number;
|
||||
api_requests_this_hour: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FeatureCheckRequest {
|
||||
feature_name: string;
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
export interface FeatureCheckResponse {
|
||||
enabled: boolean;
|
||||
limit?: number;
|
||||
current_usage?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UsageCheckRequest {
|
||||
resource_type: 'users' | 'sales_records' | 'inventory_items' | 'api_requests';
|
||||
tenant_id: string;
|
||||
requested_amount?: number;
|
||||
}
|
||||
|
||||
export interface UsageCheckResponse {
|
||||
allowed: boolean;
|
||||
limit: number;
|
||||
current_usage: number;
|
||||
remaining: number;
|
||||
message?: string;
|
||||
}
|
||||
109
frontend/src/api/types/tenant.ts
Normal file
109
frontend/src/api/types/tenant.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Tenant API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface BakeryRegistration {
|
||||
name: string;
|
||||
business_type?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
postal_code?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
subdomain?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export interface TenantResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
business_type?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
postal_code?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
subdomain?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
owner_id: string;
|
||||
model_trained?: boolean;
|
||||
last_training_date?: string;
|
||||
}
|
||||
|
||||
export interface TenantAccessResponse {
|
||||
has_access: boolean;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface TenantUpdate {
|
||||
name?: string;
|
||||
business_type?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
postal_code?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export interface TenantMemberResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
joined_at: string;
|
||||
user_email?: string;
|
||||
user_full_name?: string;
|
||||
}
|
||||
|
||||
export interface TenantSearchRequest {
|
||||
search_term: string;
|
||||
business_type?: string;
|
||||
city?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TenantStatistics {
|
||||
total_tenants: number;
|
||||
active_tenants: number;
|
||||
inactive_tenants: number;
|
||||
tenants_by_business_type: Record<string, number>;
|
||||
tenants_by_city: Record<string, number>;
|
||||
recent_registrations: TenantResponse[];
|
||||
}
|
||||
|
||||
export interface TenantSearchParams {
|
||||
search_term?: string;
|
||||
business_type?: string;
|
||||
city?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface TenantNearbyParams {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
radius_km?: number;
|
||||
limit?: number;
|
||||
}
|
||||
24
frontend/src/api/types/user.ts
Normal file
24
frontend/src/api/types/user.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* User API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
export interface UserUpdate {
|
||||
full_name?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminDeleteRequest {
|
||||
user_id: string;
|
||||
reason?: string;
|
||||
hard_delete?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminDeleteResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
deleted_user_id: string;
|
||||
deletion_type: 'soft' | 'hard';
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Card } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Modal } from '../../ui';
|
||||
import { IngredientFormData, UnitOfMeasure, ProductType, IngredientResponse } from '../../../types/inventory.types';
|
||||
import { inventoryService } from '../../../services/api/inventory.service';
|
||||
import { inventoryService } from '../../../api/services/inventory.service';
|
||||
|
||||
export interface InventoryFormProps {
|
||||
item?: IngredientResponse;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Select } from '../../ui';
|
||||
import { Modal } from '../../ui';
|
||||
import { ConfirmDialog } from '../../shared';
|
||||
import { InventoryFilters, IngredientResponse, UnitOfMeasure, SortOrder } from '../../../types/inventory.types';
|
||||
import { inventoryService } from '../../../services/api/inventory.service';
|
||||
import { inventoryService } from '../../../api/services/inventory.service';
|
||||
import { StockLevelIndicator } from './StockLevelIndicator';
|
||||
|
||||
export interface InventoryTableProps {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Input } from '../../ui';
|
||||
import { EmptyState } from '../../shared';
|
||||
import { LoadingSpinner } from '../../shared';
|
||||
import { StockAlert, IngredientResponse, AlertSeverity } from '../../../types/inventory.types';
|
||||
import { inventoryService } from '../../../services/api/inventory.service';
|
||||
import { inventoryService } from '../../../api/services/inventory.service';
|
||||
import { StockLevelIndicator } from './StockLevelIndicator';
|
||||
|
||||
export interface LowStockAlertProps {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, Button, Input, Select, Badge } from '../../ui';
|
||||
import { tenantService } from '../../../services/api/tenant.service';
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
import { useTenantActions } from '../../../stores/tenant.store';
|
||||
import { Card, Button } from '../../ui';
|
||||
|
||||
|
||||
export interface OnboardingStep {
|
||||
@@ -26,22 +23,31 @@ export interface OnboardingStepProps {
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
steps: OnboardingStep[];
|
||||
currentStep: number;
|
||||
data: any;
|
||||
onStepChange: (stepIndex: number, stepData: any) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
onComplete: (data: any) => void;
|
||||
onGoToStep: (stepIndex: number) => void;
|
||||
onExit?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
steps,
|
||||
currentStep: currentStepIndex,
|
||||
data: stepData,
|
||||
onStepChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onComplete,
|
||||
onGoToStep,
|
||||
onExit,
|
||||
className = '',
|
||||
}) => {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [stepData, setStepData] = useState<Record<string, any>>({});
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const { setCurrentTenant } = useTenantActions();
|
||||
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
@@ -64,7 +70,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
|
||||
const validateCurrentStep = useCallback(() => {
|
||||
const step = currentStep;
|
||||
const data = stepData[step.id] || {};
|
||||
const data = stepData || {};
|
||||
|
||||
if (step.validation) {
|
||||
const error = step.validation(data);
|
||||
@@ -82,100 +88,19 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
return true;
|
||||
}, [currentStep, stepData]);
|
||||
|
||||
const goToNextStep = useCallback(async () => {
|
||||
// Special handling for setup step - create tenant if needed
|
||||
if (currentStep.id === 'setup') {
|
||||
const data = stepData[currentStep.id] || {};
|
||||
|
||||
if (!data.bakery?.tenant_id) {
|
||||
// Create tenant inline using real backend API
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: { ...data.bakery, isCreating: true }
|
||||
});
|
||||
|
||||
try {
|
||||
// Use the backend-compatible data directly from BakerySetupStep
|
||||
const bakeryRegistration = {
|
||||
name: data.bakery.name,
|
||||
address: data.bakery.address,
|
||||
city: data.bakery.city,
|
||||
postal_code: data.bakery.postal_code,
|
||||
phone: data.bakery.phone,
|
||||
business_type: data.bakery.business_type,
|
||||
business_model: data.bakery.business_model
|
||||
};
|
||||
|
||||
const response = await tenantService.createTenant(bakeryRegistration);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const tenantData = response.data;
|
||||
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: {
|
||||
...data.bakery,
|
||||
tenant_id: tenantData.id,
|
||||
created_at: tenantData.created_at,
|
||||
isCreating: false
|
||||
}
|
||||
});
|
||||
|
||||
// Update the tenant store with the new tenant
|
||||
setCurrentTenant(tenantData);
|
||||
|
||||
} else {
|
||||
throw new Error(response.error || 'Failed to create tenant');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating tenant:', error);
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: {
|
||||
...data.bakery,
|
||||
isCreating: false,
|
||||
creationError: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for suppliers step - trigger ML training
|
||||
if (currentStep.id === 'suppliers') {
|
||||
const nextStepIndex = currentStepIndex + 1;
|
||||
const nextStep = steps[nextStepIndex];
|
||||
|
||||
if (nextStep && nextStep.id === 'ml-training') {
|
||||
// Set autoStartTraining flag for the ML training step
|
||||
updateStepData(nextStep.id, {
|
||||
...stepData[nextStep.id],
|
||||
autoStartTraining: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const goToNextStep = useCallback(() => {
|
||||
if (validateCurrentStep()) {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
} else {
|
||||
// All steps completed, call onComplete with all data
|
||||
onComplete(stepData);
|
||||
}
|
||||
onNext();
|
||||
}
|
||||
}, [currentStep.id, currentStepIndex, steps, validateCurrentStep, onComplete, stepData, updateStepData]);
|
||||
}, [validateCurrentStep, onNext]);
|
||||
|
||||
const goToPreviousStep = useCallback(() => {
|
||||
if (currentStepIndex > 0) {
|
||||
setCurrentStepIndex(currentStepIndex - 1);
|
||||
}
|
||||
}, [currentStepIndex]);
|
||||
onPrevious();
|
||||
}, [onPrevious]);
|
||||
|
||||
const goToStep = useCallback((stepIndex: number) => {
|
||||
setCurrentStepIndex(stepIndex);
|
||||
}, []);
|
||||
onGoToStep(stepIndex);
|
||||
}, [onGoToStep]);
|
||||
|
||||
const calculateProgress = () => {
|
||||
return (completedSteps.size / steps.length) * 100;
|
||||
@@ -412,11 +337,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
{/* Step content */}
|
||||
<div className="px-8 py-8">
|
||||
<StepComponent
|
||||
data={{
|
||||
...stepData[currentStep.id],
|
||||
// Pass all step data to allow access to previous steps
|
||||
allStepData: stepData
|
||||
}}
|
||||
data={stepData}
|
||||
onDataChange={(data) => {
|
||||
updateStepData(currentStep.id, data);
|
||||
}}
|
||||
@@ -443,21 +364,11 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
variant="primary"
|
||||
onClick={goToNextStep}
|
||||
disabled={
|
||||
(currentStep.validation && currentStep.validation(stepData[currentStep.id] || {})) ||
|
||||
(currentStep.id === 'setup' && stepData[currentStep.id]?.bakery?.isCreating)
|
||||
(currentStep.validation && currentStep.validation(stepData || {}))
|
||||
}
|
||||
className="px-8"
|
||||
>
|
||||
{currentStep.id === 'setup' && stepData[currentStep.id]?.bakery?.isCreating ? (
|
||||
<>
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2" />
|
||||
Creando espacio...
|
||||
</>
|
||||
) : currentStep.id === 'suppliers' ? (
|
||||
'Siguiente →'
|
||||
) : (
|
||||
currentStepIndex === steps.length - 1 ? '🎉 Finalizar' : 'Siguiente →'
|
||||
)}
|
||||
{currentStepIndex === steps.length - 1 ? '🎉 Finalizar' : 'Siguiente →'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,10 @@ import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
import { useInventory } from '../../../../hooks/api/useInventory';
|
||||
import { useIngredients } from '../../../../api';
|
||||
import { useModal } from '../../../../hooks/ui/useModal';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
|
||||
interface CompletionStats {
|
||||
totalProducts: number;
|
||||
@@ -28,9 +27,12 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
isLastStep
|
||||
}) => {
|
||||
const user = useAuthUser();
|
||||
const { createAlert } = useAlertActions();
|
||||
const createAlert = (alert: any) => {
|
||||
console.log('Alert:', alert);
|
||||
};
|
||||
const { showToast } = useToast();
|
||||
const { createInventoryFromSuggestions, isLoading: inventoryLoading } = useInventory();
|
||||
// TODO: Replace with proper inventory creation logic when needed
|
||||
const inventoryLoading = false;
|
||||
const certificateModal = useModal();
|
||||
const demoModal = useModal();
|
||||
const shareModal = useModal();
|
||||
@@ -57,9 +59,8 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
try {
|
||||
// Sales data should already be imported during DataProcessingStep
|
||||
// Just create inventory items from approved suggestions
|
||||
const result = await createInventoryFromSuggestions(
|
||||
data.approvedSuggestions || []
|
||||
);
|
||||
// TODO: Implement inventory creation from suggestions
|
||||
const result = { success: true, successful_imports: 0 };
|
||||
|
||||
createAlert({
|
||||
type: 'success',
|
||||
@@ -347,7 +348,7 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
<p className="text-xs text-[var(--text-secondary)]">{badge.description}</p>
|
||||
{badge.earned && (
|
||||
<div className="mt-2">
|
||||
<Badge variant="green" className="text-xs">Conseguido</Badge>
|
||||
<Badge variant="success" className="text-xs">Conseguido</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,11 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
import { useInventory } from '../../../../hooks/api/useInventory';
|
||||
import { useSales } from '../../../../hooks/api/useSales';
|
||||
import { useModal } from '../../../../hooks/ui/useModal';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { salesService } from '../../../../services/api/sales.service';
|
||||
import { inventoryService } from '../../../../services/api/inventory.service';
|
||||
import { salesService } from '../../../../api';
|
||||
import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
|
||||
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error';
|
||||
|
||||
@@ -134,7 +130,9 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
||||
const authLoading = useAuthLoading();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantLoading = useTenantLoading();
|
||||
const { createAlert } = useAlertActions();
|
||||
const createAlert = (alert: any) => {
|
||||
console.log('Alert:', alert);
|
||||
};
|
||||
|
||||
// Use hooks for UI and direct service calls for now (until we extend hooks)
|
||||
const { isLoading: inventoryLoading } = useInventory();
|
||||
|
||||
@@ -2,13 +2,11 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react';
|
||||
import { Button, Card, Input, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
import { useInventory } from '../../../../hooks/api/useInventory';
|
||||
import { useSales } from '../../../../hooks/api/useSales';
|
||||
import { useCreateIngredient, useCreateSalesRecord } from '../../../../api';
|
||||
import { useModal } from '../../../../hooks/ui/useModal';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
@@ -62,22 +60,14 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
}) => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { createAlert } = useAlertActions();
|
||||
const createAlert = (alert: any) => {
|
||||
console.log('Alert:', alert);
|
||||
};
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Use inventory hook for API operations
|
||||
const {
|
||||
createIngredient,
|
||||
isLoading: inventoryLoading,
|
||||
error: inventoryError,
|
||||
clearError: clearInventoryError
|
||||
} = useInventory();
|
||||
|
||||
// Use sales hook for importing sales data
|
||||
const {
|
||||
importSalesData,
|
||||
isLoading: salesLoading
|
||||
} = useSales();
|
||||
// Use proper API hooks that are already available
|
||||
const createIngredientMutation = useCreateIngredient();
|
||||
const createSalesRecordMutation = useCreateSalesRecord();
|
||||
|
||||
// Use modal for confirmations and editing
|
||||
const editModal = useModal();
|
||||
@@ -174,11 +164,18 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
shelf_life_days: product.estimated_shelf_life_days || 30,
|
||||
requires_refrigeration: product.requires_refrigeration || false,
|
||||
requires_freezing: product.requires_freezing || false,
|
||||
is_seasonal: product.is_seasonal || false
|
||||
is_seasonal: product.is_seasonal || false,
|
||||
minimum_stock_level: 0,
|
||||
maximum_stock_level: 1000,
|
||||
reorder_point: 10
|
||||
};
|
||||
|
||||
try {
|
||||
const success = await createIngredient(ingredientData);
|
||||
const response = await createIngredientMutation.mutateAsync({
|
||||
tenantId: currentTenant!.id,
|
||||
ingredientData
|
||||
});
|
||||
const success = !!response;
|
||||
if (success) {
|
||||
successCount++;
|
||||
// Mock created item data since hook doesn't return it
|
||||
@@ -236,7 +233,9 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
source: 'onboarding'
|
||||
});
|
||||
|
||||
const importSuccess = await importSalesData(salesDataFile, 'csv');
|
||||
// TODO: Implement bulk sales record creation from file
|
||||
// For now, simulate success
|
||||
const importSuccess = true;
|
||||
|
||||
if (importSuccess) {
|
||||
salesImportResult = {
|
||||
|
||||
@@ -4,14 +4,25 @@ import { Button, Card, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
import {
|
||||
WebSocketService,
|
||||
TrainingProgressMessage,
|
||||
TrainingCompletedMessage,
|
||||
TrainingErrorMessage
|
||||
} from '../../../../services/realtime/websocket.service';
|
||||
import { trainingService } from '../../../../services/api/training.service';
|
||||
// TODO: Implement WebSocket training progress updates when realtime API is available
|
||||
|
||||
// Type definitions for training messages (will be moved to API types later)
|
||||
interface TrainingProgressMessage {
|
||||
type: 'training_progress';
|
||||
progress: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface TrainingCompletedMessage {
|
||||
type: 'training_completed';
|
||||
metrics: TrainingMetrics;
|
||||
}
|
||||
|
||||
interface TrainingErrorMessage {
|
||||
type: 'training_error';
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface TrainingMetrics {
|
||||
accuracy: number;
|
||||
@@ -48,7 +59,9 @@ export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
|
||||
}) => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { createAlert } = useAlertActions();
|
||||
const createAlert = (alert: any) => {
|
||||
console.log('Alert:', alert);
|
||||
};
|
||||
|
||||
const [trainingStatus, setTrainingStatus] = useState<'idle' | 'validating' | 'training' | 'completed' | 'failed'>(
|
||||
data.trainingStatus || 'idle'
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Eye, CheckCircle, AlertCircle, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
@@ -72,7 +71,9 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const { createAlert } = useAlertActions();
|
||||
const createAlert = (alert: any) => {
|
||||
console.log('Alert:', alert);
|
||||
};
|
||||
|
||||
// Generate products from AI suggestions in processing results
|
||||
const generateProductsFromResults = (results: any) => {
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useModal } from '../../../../hooks/ui/useModal';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||
import { procurementService } from '../../../../services/api/procurement.service';
|
||||
// TODO: Import procurement service from new API when available
|
||||
|
||||
// Frontend supplier interface that matches the form needs
|
||||
interface SupplierFormData {
|
||||
@@ -47,7 +46,9 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
}) => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { createAlert } = useAlertActions();
|
||||
const createAlert = (alert: any) => {
|
||||
console.log('Alert:', alert);
|
||||
};
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Use modals for confirmations and editing
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
||||
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../services/api/production.service';
|
||||
import { productionService, type ProductionBatchResponse, ProductionBatchStatus, ProductionPriority } from '../../../api/services/production.service';
|
||||
import type { ProductionBatch, QualityCheck } from '../../../types/production.types';
|
||||
|
||||
interface BatchTrackerProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Card, Button, Badge, Select, DatePicker, Input, Modal, Table } from '../../ui';
|
||||
import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../services/api/production.service';
|
||||
import { productionService, type ProductionScheduleEntry, ProductionBatchStatus } from '../../../api/services/production.service';
|
||||
import type { ProductionSchedule as ProductionScheduleType, ProductionBatch, EquipmentReservation, StaffAssignment } from '../../../types/production.types';
|
||||
|
||||
interface ProductionScheduleProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Card, Button, Badge, Input, Modal, Table, Select, DatePicker } from '../../ui';
|
||||
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../services/api/production.service';
|
||||
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api/services/production.service';
|
||||
import type { QualityCheck, QualityCheckCriteria, ProductionBatch, QualityCheckType } from '../../../types/production.types';
|
||||
|
||||
interface QualityControlProps {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SalesChannel,
|
||||
PaymentMethod
|
||||
} from '../../../types/sales.types';
|
||||
import { salesService } from '../../../services/api/sales.service';
|
||||
import { salesService } from '../../../api/services/sales.service';
|
||||
import { useSales } from '../../../hooks/api/useSales';
|
||||
|
||||
// Customer interfaces
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SalesChannel,
|
||||
PaymentMethod
|
||||
} from '../../../types/sales.types';
|
||||
import { salesService } from '../../../services/api/sales.service';
|
||||
import { salesService } from '../../../api/services/sales.service';
|
||||
|
||||
// Order form interfaces
|
||||
interface Product {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SalesSortField,
|
||||
SortOrder
|
||||
} from '../../../types/sales.types';
|
||||
import { salesService } from '../../../services/api/sales.service';
|
||||
import { salesService } from '../../../api/services/sales.service';
|
||||
import { useSales } from '../../../hooks/api/useSales';
|
||||
|
||||
// Extended interface for orders
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ProductPerformance,
|
||||
PeriodType
|
||||
} from '../../../types/sales.types';
|
||||
import { salesService } from '../../../services/api/sales.service';
|
||||
import { salesService } from '../../../api/services/sales.service';
|
||||
import { useSales } from '../../../hooks/api/useSales';
|
||||
|
||||
interface SalesChartProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useCallback, forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useTenantInitializer } from '../../../hooks/useTenantInitializer';
|
||||
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
|
||||
import { Header } from '../Header';
|
||||
import { Sidebar } from '../Sidebar';
|
||||
import { Footer } from '../Footer';
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Mock configuration for testing the complete onboarding flow
|
||||
* Set MOCK_MODE to false for production
|
||||
*/
|
||||
|
||||
export const MOCK_CONFIG = {
|
||||
// Global mock mode toggle
|
||||
MOCK_MODE: true,
|
||||
|
||||
// Component-specific toggles
|
||||
MOCK_REGISTRATION: false, // Now using real backend
|
||||
MOCK_AUTHENTICATION: false, // Now using real backend
|
||||
MOCK_ONBOARDING_FLOW: true, // Keep onboarding mock for now
|
||||
|
||||
// Mock user data
|
||||
MOCK_USER: {
|
||||
full_name: 'María García López',
|
||||
email: 'maria.garcia@elbuenpan.com',
|
||||
role: 'admin',
|
||||
tenant_name: 'Panadería Artesanal El Buen Pan'
|
||||
},
|
||||
|
||||
// Mock admin user data for testing
|
||||
MOCK_ADMIN_USER: {
|
||||
full_name: 'Admin User',
|
||||
email: 'admin@bakery.com',
|
||||
role: 'admin',
|
||||
tenant_name: 'Bakery Admin Demo'
|
||||
},
|
||||
|
||||
// Mock bakery data
|
||||
MOCK_BAKERY: {
|
||||
name: 'Panadería Artesanal El Buen Pan',
|
||||
type: 'artisan',
|
||||
location: 'Av. Principal 123, Centro Histórico',
|
||||
phone: '+1 234 567 8900',
|
||||
email: 'info@elbuenpan.com'
|
||||
},
|
||||
|
||||
// Mock subscription data for admin@bakery.com
|
||||
MOCK_SUBSCRIPTION: {
|
||||
id: 'sub_admin_demo_001',
|
||||
tenant_id: 'tenant_admin_demo',
|
||||
plan: 'professional',
|
||||
status: 'active',
|
||||
monthly_price: 129.0,
|
||||
currency: 'EUR',
|
||||
billing_cycle: 'monthly',
|
||||
current_period_start: '2024-08-01T00:00:00Z',
|
||||
current_period_end: '2024-09-01T00:00:00Z',
|
||||
next_billing_date: '2024-09-01T00:00:00Z',
|
||||
trial_ends_at: null,
|
||||
canceled_at: null,
|
||||
created_at: '2024-01-15T10:30:00Z',
|
||||
updated_at: '2024-08-01T00:00:00Z',
|
||||
max_users: 15,
|
||||
max_locations: 2,
|
||||
max_products: -1,
|
||||
features: {
|
||||
inventory_management: 'advanced',
|
||||
demand_prediction: 'ai_92_percent',
|
||||
production_management: 'complete',
|
||||
pos_integrated: true,
|
||||
logistics: 'basic',
|
||||
analytics: 'advanced',
|
||||
support: 'priority_24_7',
|
||||
trial_days: 14,
|
||||
locations: '1_2_locations'
|
||||
},
|
||||
usage: {
|
||||
users: 8,
|
||||
locations: 1,
|
||||
products: 145,
|
||||
storage_gb: 2.4,
|
||||
api_calls_month: 1250,
|
||||
reports_generated: 23
|
||||
},
|
||||
billing_history: [
|
||||
{
|
||||
id: 'inv_001',
|
||||
date: '2024-08-01T00:00:00Z',
|
||||
amount: 129.0,
|
||||
status: 'paid' as 'paid',
|
||||
description: 'Plan Professional - Agosto 2024'
|
||||
},
|
||||
{
|
||||
id: 'inv_002',
|
||||
date: '2024-07-01T00:00:00Z',
|
||||
amount: 129.0,
|
||||
status: 'paid' as 'paid',
|
||||
description: 'Plan Professional - Julio 2024'
|
||||
},
|
||||
{
|
||||
id: 'inv_003',
|
||||
date: '2024-06-01T00:00:00Z',
|
||||
amount: 129.0,
|
||||
status: 'paid' as 'paid',
|
||||
description: 'Plan Professional - Junio 2024'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if mock mode is enabled
|
||||
export const isMockMode = () => MOCK_CONFIG.MOCK_MODE;
|
||||
export const isMockRegistration = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_REGISTRATION;
|
||||
export const isMockAuthentication = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_AUTHENTICATION;
|
||||
export const isMockOnboardingFlow = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_ONBOARDING_FLOW;
|
||||
|
||||
// Helper functions to get mock data
|
||||
export const getMockUser = (isAdmin = false) => isAdmin ? MOCK_CONFIG.MOCK_ADMIN_USER : MOCK_CONFIG.MOCK_USER;
|
||||
export const getMockSubscription = () => MOCK_CONFIG.MOCK_SUBSCRIPTION;
|
||||
@@ -52,7 +52,7 @@ export const SSEProvider: React.FC<SSEProviderProps> = ({ children }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const eventSource = new EventSource(`/api/events?token=${token}`, {
|
||||
const eventSource = new EventSource(`http://localhost:8000/api/events?token=${token}`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Authentication hook for managing user authentication state
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { authService } from '../../services/api/auth.service';
|
||||
import { storageService } from '../../services/utils/storage.service';
|
||||
import { User, UserLogin, UserRegistration, TokenResponse } from '../../types/auth.types';
|
||||
import { ApiResponse } from '../../types/api.types';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
tenant_id: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
login: (credentials: UserLogin) => Promise<boolean>;
|
||||
register: (data: UserRegistration) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
refreshToken: () => Promise<boolean>;
|
||||
requestPasswordReset: (email: string) => Promise<boolean>;
|
||||
resetPassword: (token: string, password: string) => Promise<boolean>;
|
||||
verifyEmail: (token: string) => Promise<boolean>;
|
||||
updateProfile: (data: Partial<User>) => Promise<boolean>;
|
||||
switchTenant: (tenantId: string) => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuth = (): AuthState & AuthActions => {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
tenant_id: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
|
||||
// Initialize authentication state
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const access_token = storageService.getItem<string>('access_token');
|
||||
const user_data = storageService.getItem('user_data');
|
||||
const tenant_id = storageService.getItem<string>('tenant_id');
|
||||
|
||||
if (access_token) {
|
||||
// Try to get current user profile
|
||||
const profileResponse = await authService.getCurrentUser();
|
||||
if (profileResponse.success && profileResponse.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: profileResponse.data,
|
||||
tenant_id: tenant_id || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No valid authentication found
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al inicializar la autenticación',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (credentials: UserLogin): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authService.login(credentials);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const profileResponse = await authService.getCurrentUser();
|
||||
|
||||
if (profileResponse.success && profileResponse.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: profileResponse.data,
|
||||
tenant_id: response.data.user?.tenant_id || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al iniciar sesión',
|
||||
}));
|
||||
return false;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: UserRegistration): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authService.register(data);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.success ? null : (response.error || 'Error al registrar usuario'),
|
||||
}));
|
||||
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
setState({
|
||||
user: null,
|
||||
tenant_id: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshToken = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authService.refreshToken();
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
console.error('Token refresh error:', error);
|
||||
await logout();
|
||||
return false;
|
||||
}
|
||||
}, [logout]);
|
||||
|
||||
const requestPasswordReset = useCallback(async (email: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authService.resetPassword(email);
|
||||
|
||||
if (!response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al solicitar restablecimiento de contraseña',
|
||||
}));
|
||||
}
|
||||
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetPassword = useCallback(async (token: string, password: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authService.confirmPasswordReset({ token, new_password: password });
|
||||
|
||||
if (!response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al restablecer contraseña',
|
||||
}));
|
||||
}
|
||||
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const verifyEmail = useCallback(async (token: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authService.confirmEmailVerification(token);
|
||||
|
||||
if (!response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al verificar email',
|
||||
}));
|
||||
}
|
||||
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateProfile = useCallback(async (data: Partial<User>): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authService.updateProfile(data);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: response.data,
|
||||
isLoading: false,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al actualizar perfil',
|
||||
}));
|
||||
return false;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const switchTenant = useCallback(async (tenantId: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// switchTenant method doesn't exist in AuthService, implement tenant switching logic here
|
||||
// For now, just update the local state
|
||||
storageService.setItem('tenant_id', tenantId);
|
||||
const response = { success: true };
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tenant_id: tenantId,
|
||||
isLoading: false,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cambiar organización',
|
||||
}));
|
||||
return false;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
updateProfile,
|
||||
switchTenant,
|
||||
clearError,
|
||||
};
|
||||
};
|
||||
@@ -1,568 +0,0 @@
|
||||
/**
|
||||
* Forecasting hook for managing demand forecasting and ML models
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ForecastingService } from '../../services/api/forecasting.service';
|
||||
import {
|
||||
ForecastModel,
|
||||
ForecastModelCreate,
|
||||
ForecastModelUpdate,
|
||||
ForecastPrediction,
|
||||
ForecastPredictionCreate,
|
||||
ForecastBatch,
|
||||
ModelTraining,
|
||||
ModelEvaluation
|
||||
} from '../../types/forecasting.types';
|
||||
import { ApiResponse, PaginatedResponse, QueryParams } from '../../types/api.types';
|
||||
|
||||
interface ForecastingState {
|
||||
models: ForecastModel[];
|
||||
predictions: ForecastPrediction[];
|
||||
batches: ForecastBatch[];
|
||||
trainings: ModelTraining[];
|
||||
evaluations: ModelEvaluation[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ForecastingActions {
|
||||
// Models
|
||||
fetchModels: (params?: QueryParams) => Promise<void>;
|
||||
createModel: (data: ForecastModelCreate) => Promise<boolean>;
|
||||
updateModel: (id: string, data: ForecastModelUpdate) => Promise<boolean>;
|
||||
deleteModel: (id: string) => Promise<boolean>;
|
||||
getModel: (id: string) => Promise<ForecastModel | null>;
|
||||
trainModel: (id: string, parameters?: any) => Promise<boolean>;
|
||||
deployModel: (id: string) => Promise<boolean>;
|
||||
|
||||
// Predictions
|
||||
fetchPredictions: (params?: QueryParams) => Promise<void>;
|
||||
createPrediction: (data: ForecastPredictionCreate) => Promise<boolean>;
|
||||
getPrediction: (id: string) => Promise<ForecastPrediction | null>;
|
||||
generateDemandForecast: (modelId: string, horizon: number, parameters?: any) => Promise<any>;
|
||||
|
||||
// Batch Predictions
|
||||
fetchBatches: (params?: QueryParams) => Promise<void>;
|
||||
createBatch: (modelId: string, data: any) => Promise<boolean>;
|
||||
getBatch: (id: string) => Promise<ForecastBatch | null>;
|
||||
downloadBatchResults: (id: string) => Promise<boolean>;
|
||||
|
||||
// Model Training
|
||||
fetchTrainings: (modelId?: string) => Promise<void>;
|
||||
getTraining: (id: string) => Promise<ModelTraining | null>;
|
||||
cancelTraining: (id: string) => Promise<boolean>;
|
||||
|
||||
// Model Evaluation
|
||||
fetchEvaluations: (modelId?: string) => Promise<void>;
|
||||
createEvaluation: (modelId: string, testData: any) => Promise<boolean>;
|
||||
getEvaluation: (id: string) => Promise<ModelEvaluation | null>;
|
||||
|
||||
// Analytics
|
||||
getModelPerformance: (modelId: string, period?: string) => Promise<any>;
|
||||
getAccuracyReport: (modelId: string, startDate?: string, endDate?: string) => Promise<any>;
|
||||
getFeatureImportance: (modelId: string) => Promise<any>;
|
||||
|
||||
// Utilities
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useForecasting = (): ForecastingState & ForecastingActions => {
|
||||
const [state, setState] = useState<ForecastingState>({
|
||||
models: [],
|
||||
predictions: [],
|
||||
batches: [],
|
||||
trainings: [],
|
||||
evaluations: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
pagination: {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const forecastingService = new ForecastingService();
|
||||
|
||||
// Fetch forecast models
|
||||
const fetchModels = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.getModels(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
models: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
pagination: response.data.pagination || prev.pagination,
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar modelos de predicción',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Create forecast model
|
||||
const createModel = useCallback(async (data: ForecastModelCreate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.createModel(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchModels();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear modelo de predicción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchModels]);
|
||||
|
||||
// Update forecast model
|
||||
const updateModel = useCallback(async (id: string, data: ForecastModelUpdate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.updateModel(id, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchModels();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al actualizar modelo de predicción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchModels]);
|
||||
|
||||
// Delete forecast model
|
||||
const deleteModel = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.deleteModel(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
models: prev.models.filter(model => model.id !== id),
|
||||
}));
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al eliminar modelo de predicción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Get single forecast model
|
||||
const getModel = useCallback(async (id: string): Promise<ForecastModel | null> => {
|
||||
try {
|
||||
const response = await forecastingService.getModel(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching model:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Train forecast model
|
||||
const trainModel = useCallback(async (id: string, parameters?: any): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.trainModel(id, parameters);
|
||||
|
||||
if (response.success) {
|
||||
await fetchModels();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al entrenar modelo',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchModels]);
|
||||
|
||||
// Deploy forecast model
|
||||
const deployModel = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.deployModel(id);
|
||||
|
||||
if (response.success) {
|
||||
await fetchModels();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al desplegar modelo',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchModels]);
|
||||
|
||||
// Fetch predictions
|
||||
const fetchPredictions = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.getPredictions(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
predictions: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar predicciones',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Create prediction
|
||||
const createPrediction = useCallback(async (data: ForecastPredictionCreate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await forecastingService.createPrediction(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchPredictions();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear predicción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchPredictions]);
|
||||
|
||||
// Get single prediction
|
||||
const getPrediction = useCallback(async (id: string): Promise<ForecastPrediction | null> => {
|
||||
try {
|
||||
const response = await forecastingService.getPrediction(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching prediction:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Generate demand forecast
|
||||
const generateDemandForecast = useCallback(async (modelId: string, horizon: number, parameters?: any) => {
|
||||
try {
|
||||
const response = await forecastingService.generateDemandForecast(modelId, horizon, parameters);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error generating demand forecast:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Fetch batch predictions
|
||||
const fetchBatches = useCallback(async (params?: QueryParams) => {
|
||||
try {
|
||||
const response = await forecastingService.getBatches(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
batches: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching batches:', error);
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Create batch prediction
|
||||
const createBatch = useCallback(async (modelId: string, data: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await forecastingService.createBatch(modelId, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchBatches();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error creating batch:', error);
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchBatches]);
|
||||
|
||||
// Get single batch
|
||||
const getBatch = useCallback(async (id: string): Promise<ForecastBatch | null> => {
|
||||
try {
|
||||
const response = await forecastingService.getBatch(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching batch:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Download batch results
|
||||
const downloadBatchResults = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await forecastingService.downloadBatchResults(id);
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
console.error('Error downloading batch results:', error);
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Fetch model trainings
|
||||
const fetchTrainings = useCallback(async (modelId?: string) => {
|
||||
try {
|
||||
const response = await forecastingService.getTrainings(modelId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
trainings: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching trainings:', error);
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Get single training
|
||||
const getTraining = useCallback(async (id: string): Promise<ModelTraining | null> => {
|
||||
try {
|
||||
const response = await forecastingService.getTraining(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching training:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Cancel model training
|
||||
const cancelTraining = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await forecastingService.cancelTraining(id);
|
||||
|
||||
if (response.success) {
|
||||
await fetchTrainings();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error canceling training:', error);
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchTrainings]);
|
||||
|
||||
// Fetch model evaluations
|
||||
const fetchEvaluations = useCallback(async (modelId?: string) => {
|
||||
try {
|
||||
const response = await forecastingService.getEvaluations(modelId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
evaluations: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching evaluations:', error);
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Create model evaluation
|
||||
const createEvaluation = useCallback(async (modelId: string, testData: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await forecastingService.createEvaluation(modelId, testData);
|
||||
|
||||
if (response.success) {
|
||||
await fetchEvaluations(modelId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error creating evaluation:', error);
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, fetchEvaluations]);
|
||||
|
||||
// Get single evaluation
|
||||
const getEvaluation = useCallback(async (id: string): Promise<ModelEvaluation | null> => {
|
||||
try {
|
||||
const response = await forecastingService.getEvaluation(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching evaluation:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Get model performance
|
||||
const getModelPerformance = useCallback(async (modelId: string, period?: string) => {
|
||||
try {
|
||||
const response = await forecastingService.getModelPerformance(modelId, period);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching model performance:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Get accuracy report
|
||||
const getAccuracyReport = useCallback(async (modelId: string, startDate?: string, endDate?: string) => {
|
||||
try {
|
||||
const response = await forecastingService.getAccuracyReport(modelId, startDate, endDate);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching accuracy report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Get feature importance
|
||||
const getFeatureImportance = useCallback(async (modelId: string) => {
|
||||
try {
|
||||
const response = await forecastingService.getFeatureImportance(modelId);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching feature importance:', error);
|
||||
return null;
|
||||
}
|
||||
}, [forecastingService]);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Refresh all data
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
fetchModels(),
|
||||
fetchPredictions(),
|
||||
fetchBatches(),
|
||||
]);
|
||||
}, [fetchModels, fetchPredictions, fetchBatches]);
|
||||
|
||||
// Initialize data on mount
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchModels,
|
||||
createModel,
|
||||
updateModel,
|
||||
deleteModel,
|
||||
getModel,
|
||||
trainModel,
|
||||
deployModel,
|
||||
fetchPredictions,
|
||||
createPrediction,
|
||||
getPrediction,
|
||||
generateDemandForecast,
|
||||
fetchBatches,
|
||||
createBatch,
|
||||
getBatch,
|
||||
downloadBatchResults,
|
||||
fetchTrainings,
|
||||
getTraining,
|
||||
cancelTraining,
|
||||
fetchEvaluations,
|
||||
createEvaluation,
|
||||
getEvaluation,
|
||||
getModelPerformance,
|
||||
getAccuracyReport,
|
||||
getFeatureImportance,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -1,475 +0,0 @@
|
||||
/**
|
||||
* Inventory hook for managing inventory state and operations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { InventoryService } from '../../services/api/inventory.service';
|
||||
import {
|
||||
Ingredient,
|
||||
IngredientCreate,
|
||||
IngredientUpdate,
|
||||
StockLevel,
|
||||
StockMovement,
|
||||
StockMovementCreate,
|
||||
InventoryAlert,
|
||||
QualityCheckCreate,
|
||||
QualityCheck
|
||||
} from '../../types/inventory.types';
|
||||
import { ApiResponse, PaginatedResponse, QueryParams } from '../../types/api.types';
|
||||
|
||||
interface InventoryState {
|
||||
ingredients: Ingredient[];
|
||||
stockLevels: StockLevel[];
|
||||
stockMovements: StockMovement[];
|
||||
alerts: InventoryAlert[];
|
||||
qualityChecks: QualityCheck[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface InventoryActions {
|
||||
// Ingredients
|
||||
fetchIngredients: (params?: QueryParams) => Promise<void>;
|
||||
createIngredient: (data: IngredientCreate) => Promise<boolean>;
|
||||
updateIngredient: (id: string, data: IngredientUpdate) => Promise<boolean>;
|
||||
deleteIngredient: (id: string) => Promise<boolean>;
|
||||
getIngredient: (id: string) => Promise<Ingredient | null>;
|
||||
|
||||
// Stock Levels
|
||||
fetchStockLevels: (params?: QueryParams) => Promise<void>;
|
||||
updateStockLevel: (ingredientId: string, quantity: number, reason?: string) => Promise<boolean>;
|
||||
|
||||
// Stock Movements
|
||||
fetchStockMovements: (params?: QueryParams) => Promise<void>;
|
||||
createStockMovement: (data: StockMovementCreate) => Promise<boolean>;
|
||||
|
||||
// Alerts
|
||||
fetchAlerts: (params?: QueryParams) => Promise<void>;
|
||||
markAlertAsRead: (id: string) => Promise<boolean>;
|
||||
dismissAlert: (id: string) => Promise<boolean>;
|
||||
|
||||
// Quality Checks
|
||||
fetchQualityChecks: (params?: QueryParams) => Promise<void>;
|
||||
createQualityCheck: (data: QualityCheckCreate) => Promise<boolean>;
|
||||
|
||||
// Analytics
|
||||
getInventoryAnalytics: (startDate?: string, endDate?: string) => Promise<any>;
|
||||
getExpirationReport: () => Promise<any>;
|
||||
|
||||
// Utilities
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useInventory = (): InventoryState & InventoryActions => {
|
||||
const [state, setState] = useState<InventoryState>({
|
||||
ingredients: [],
|
||||
stockLevels: [],
|
||||
stockMovements: [],
|
||||
alerts: [],
|
||||
qualityChecks: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
pagination: {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const inventoryService = new InventoryService();
|
||||
|
||||
// Fetch ingredients
|
||||
const fetchIngredients = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.getIngredients(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
ingredients: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
pagination: response.data.pagination || prev.pagination,
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar ingredientes',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Create ingredient
|
||||
const createIngredient = useCallback(async (data: IngredientCreate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.createIngredient(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchIngredients();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear ingrediente',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService, fetchIngredients]);
|
||||
|
||||
// Update ingredient
|
||||
const updateIngredient = useCallback(async (id: string, data: IngredientUpdate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.updateIngredient(id, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchIngredients();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al actualizar ingrediente',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService, fetchIngredients]);
|
||||
|
||||
// Delete ingredient
|
||||
const deleteIngredient = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.deleteIngredient(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
ingredients: prev.ingredients.filter(ingredient => ingredient.id !== id),
|
||||
}));
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al eliminar ingrediente',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Get single ingredient
|
||||
const getIngredient = useCallback(async (id: string): Promise<Ingredient | null> => {
|
||||
try {
|
||||
const response = await inventoryService.getIngredient(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching ingredient:', error);
|
||||
return null;
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Fetch stock levels
|
||||
const fetchStockLevels = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.getStockLevels(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stockLevels: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar niveles de stock',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Update stock level
|
||||
const updateStockLevel = useCallback(async (ingredientId: string, quantity: number, reason?: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.updateStockLevel(ingredientId, {
|
||||
quantity,
|
||||
reason: reason || 'Manual adjustment'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
await fetchStockLevels();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al actualizar stock',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService, fetchStockLevels]);
|
||||
|
||||
// Fetch stock movements
|
||||
const fetchStockMovements = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.getStockMovements(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stockMovements: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar movimientos de stock',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Create stock movement
|
||||
const createStockMovement = useCallback(async (data: StockMovementCreate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.createStockMovement(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchStockMovements();
|
||||
await fetchStockLevels();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear movimiento de stock',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService, fetchStockMovements, fetchStockLevels]);
|
||||
|
||||
// Fetch alerts
|
||||
const fetchAlerts = useCallback(async (params?: QueryParams) => {
|
||||
try {
|
||||
const response = await inventoryService.getAlerts(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching alerts:', error);
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Mark alert as read
|
||||
const markAlertAsRead = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await inventoryService.markAlertAsRead(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: prev.alerts.map(alert =>
|
||||
alert.id === id ? { ...alert, is_read: true } : alert
|
||||
),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error marking alert as read:', error);
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Dismiss alert
|
||||
const dismissAlert = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await inventoryService.dismissAlert(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: prev.alerts.filter(alert => alert.id !== id),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error dismissing alert:', error);
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Fetch quality checks
|
||||
const fetchQualityChecks = useCallback(async (params?: QueryParams) => {
|
||||
try {
|
||||
const response = await inventoryService.getQualityChecks(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
qualityChecks: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching quality checks:', error);
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Create quality check
|
||||
const createQualityCheck = useCallback(async (data: QualityCheckCreate): Promise<boolean> => {
|
||||
try {
|
||||
const response = await inventoryService.createQualityCheck(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchQualityChecks();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error creating quality check:', error);
|
||||
return false;
|
||||
}
|
||||
}, [inventoryService, fetchQualityChecks]);
|
||||
|
||||
// Get inventory analytics
|
||||
const getInventoryAnalytics = useCallback(async (startDate?: string, endDate?: string) => {
|
||||
try {
|
||||
const response = await inventoryService.getAnalytics(startDate, endDate);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory analytics:', error);
|
||||
return null;
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Get expiration report
|
||||
const getExpirationReport = useCallback(async () => {
|
||||
try {
|
||||
const response = await inventoryService.getExpirationReport();
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching expiration report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Refresh all data
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
fetchIngredients(),
|
||||
fetchStockLevels(),
|
||||
fetchAlerts(),
|
||||
]);
|
||||
}, [fetchIngredients, fetchStockLevels, fetchAlerts]);
|
||||
|
||||
// Initialize data on mount
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchIngredients,
|
||||
createIngredient,
|
||||
updateIngredient,
|
||||
deleteIngredient,
|
||||
getIngredient,
|
||||
fetchStockLevels,
|
||||
updateStockLevel,
|
||||
fetchStockMovements,
|
||||
createStockMovement,
|
||||
fetchAlerts,
|
||||
markAlertAsRead,
|
||||
dismissAlert,
|
||||
fetchQualityChecks,
|
||||
createQualityCheck,
|
||||
getInventoryAnalytics,
|
||||
getExpirationReport,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -1,650 +0,0 @@
|
||||
/**
|
||||
* Production hook for managing production batches, recipes, and scheduling
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ProductionService } from '../../services/api/production.service';
|
||||
import {
|
||||
ProductionBatch,
|
||||
ProductionBatchCreate,
|
||||
ProductionBatchUpdate,
|
||||
Recipe,
|
||||
RecipeCreate,
|
||||
RecipeUpdate,
|
||||
ProductionSchedule,
|
||||
ProductionScheduleCreate,
|
||||
QualityControl,
|
||||
QualityControlCreate
|
||||
} from '../../types/production.types';
|
||||
import { ApiResponse, PaginatedResponse, QueryParams } from '../../types/api.types';
|
||||
|
||||
interface ProductionState {
|
||||
batches: ProductionBatch[];
|
||||
recipes: Recipe[];
|
||||
schedules: ProductionSchedule[];
|
||||
qualityControls: QualityControl[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductionActions {
|
||||
// Production Batches
|
||||
fetchBatches: (params?: QueryParams) => Promise<void>;
|
||||
createBatch: (data: ProductionBatchCreate) => Promise<boolean>;
|
||||
updateBatch: (id: string, data: ProductionBatchUpdate) => Promise<boolean>;
|
||||
deleteBatch: (id: string) => Promise<boolean>;
|
||||
getBatch: (id: string) => Promise<ProductionBatch | null>;
|
||||
startBatch: (id: string) => Promise<boolean>;
|
||||
completeBatch: (id: string) => Promise<boolean>;
|
||||
cancelBatch: (id: string, reason: string) => Promise<boolean>;
|
||||
|
||||
// Recipes
|
||||
fetchRecipes: (params?: QueryParams) => Promise<void>;
|
||||
createRecipe: (data: RecipeCreate) => Promise<boolean>;
|
||||
updateRecipe: (id: string, data: RecipeUpdate) => Promise<boolean>;
|
||||
deleteRecipe: (id: string) => Promise<boolean>;
|
||||
getRecipe: (id: string) => Promise<Recipe | null>;
|
||||
duplicateRecipe: (id: string, name: string) => Promise<boolean>;
|
||||
|
||||
// Production Scheduling
|
||||
fetchSchedules: (params?: QueryParams) => Promise<void>;
|
||||
createSchedule: (data: ProductionScheduleCreate) => Promise<boolean>;
|
||||
updateSchedule: (id: string, data: Partial<ProductionScheduleCreate>) => Promise<boolean>;
|
||||
deleteSchedule: (id: string) => Promise<boolean>;
|
||||
getCapacityAnalysis: (date: string) => Promise<any>;
|
||||
|
||||
// Quality Control
|
||||
fetchQualityControls: (params?: QueryParams) => Promise<void>;
|
||||
createQualityControl: (data: QualityControlCreate) => Promise<boolean>;
|
||||
updateQualityControl: (id: string, data: Partial<QualityControlCreate>) => Promise<boolean>;
|
||||
|
||||
// Analytics
|
||||
getProductionAnalytics: (startDate?: string, endDate?: string) => Promise<any>;
|
||||
getEfficiencyReport: (period: string) => Promise<any>;
|
||||
getRecipePerformance: (recipeId?: string) => Promise<any>;
|
||||
|
||||
// Utilities
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useProduction = (): ProductionState & ProductionActions => {
|
||||
const [state, setState] = useState<ProductionState>({
|
||||
batches: [],
|
||||
recipes: [],
|
||||
schedules: [],
|
||||
qualityControls: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
pagination: {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const productionService = new ProductionService();
|
||||
|
||||
// Fetch production batches
|
||||
const fetchBatches = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.getBatches(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
batches: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
pagination: response.data.pagination || prev.pagination,
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar lotes de producción',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Create production batch
|
||||
const createBatch = useCallback(async (data: ProductionBatchCreate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.createBatch(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchBatches();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear lote de producción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchBatches]);
|
||||
|
||||
// Update production batch
|
||||
const updateBatch = useCallback(async (id: string, data: ProductionBatchUpdate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.updateBatch(id, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchBatches();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al actualizar lote de producción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchBatches]);
|
||||
|
||||
// Delete production batch
|
||||
const deleteBatch = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.deleteBatch(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
batches: prev.batches.filter(batch => batch.id !== id),
|
||||
}));
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al eliminar lote de producción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Get single production batch
|
||||
const getBatch = useCallback(async (id: string): Promise<ProductionBatch | null> => {
|
||||
try {
|
||||
const response = await productionService.getBatch(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching batch:', error);
|
||||
return null;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Start production batch
|
||||
const startBatch = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.startBatch(id);
|
||||
|
||||
if (response.success) {
|
||||
await fetchBatches();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al iniciar lote de producción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchBatches]);
|
||||
|
||||
// Complete production batch
|
||||
const completeBatch = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.completeBatch(id);
|
||||
|
||||
if (response.success) {
|
||||
await fetchBatches();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al completar lote de producción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchBatches]);
|
||||
|
||||
// Cancel production batch
|
||||
const cancelBatch = useCallback(async (id: string, reason: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.cancelBatch(id, reason);
|
||||
|
||||
if (response.success) {
|
||||
await fetchBatches();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al cancelar lote de producción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchBatches]);
|
||||
|
||||
// Fetch recipes
|
||||
const fetchRecipes = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.getRecipes(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
recipes: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar recetas',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Create recipe
|
||||
const createRecipe = useCallback(async (data: RecipeCreate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.createRecipe(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchRecipes();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear receta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchRecipes]);
|
||||
|
||||
// Update recipe
|
||||
const updateRecipe = useCallback(async (id: string, data: RecipeUpdate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.updateRecipe(id, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchRecipes();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al actualizar receta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchRecipes]);
|
||||
|
||||
// Delete recipe
|
||||
const deleteRecipe = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.deleteRecipe(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
recipes: prev.recipes.filter(recipe => recipe.id !== id),
|
||||
}));
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al eliminar receta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Get single recipe
|
||||
const getRecipe = useCallback(async (id: string): Promise<Recipe | null> => {
|
||||
try {
|
||||
const response = await productionService.getRecipe(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipe:', error);
|
||||
return null;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Duplicate recipe
|
||||
const duplicateRecipe = useCallback(async (id: string, name: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await productionService.duplicateRecipe(id, name);
|
||||
|
||||
if (response.success) {
|
||||
await fetchRecipes();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al duplicar receta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchRecipes]);
|
||||
|
||||
// Fetch production schedules
|
||||
const fetchSchedules = useCallback(async (params?: QueryParams) => {
|
||||
try {
|
||||
const response = await productionService.getSchedules(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
schedules: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching schedules:', error);
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Create production schedule
|
||||
const createSchedule = useCallback(async (data: ProductionScheduleCreate): Promise<boolean> => {
|
||||
try {
|
||||
const response = await productionService.createSchedule(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchSchedules();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error creating schedule:', error);
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchSchedules]);
|
||||
|
||||
// Update production schedule
|
||||
const updateSchedule = useCallback(async (id: string, data: Partial<ProductionScheduleCreate>): Promise<boolean> => {
|
||||
try {
|
||||
const response = await productionService.updateSchedule(id, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchSchedules();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error updating schedule:', error);
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchSchedules]);
|
||||
|
||||
// Delete production schedule
|
||||
const deleteSchedule = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await productionService.deleteSchedule(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
schedules: prev.schedules.filter(schedule => schedule.id !== id),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error deleting schedule:', error);
|
||||
return false;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Get capacity analysis
|
||||
const getCapacityAnalysis = useCallback(async (date: string) => {
|
||||
try {
|
||||
const response = await productionService.getCapacityAnalysis(date);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching capacity analysis:', error);
|
||||
return null;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Fetch quality controls
|
||||
const fetchQualityControls = useCallback(async (params?: QueryParams) => {
|
||||
try {
|
||||
const response = await productionService.getQualityControls(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
qualityControls: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching quality controls:', error);
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Create quality control
|
||||
const createQualityControl = useCallback(async (data: QualityControlCreate): Promise<boolean> => {
|
||||
try {
|
||||
const response = await productionService.createQualityControl(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchQualityControls();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error creating quality control:', error);
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchQualityControls]);
|
||||
|
||||
// Update quality control
|
||||
const updateQualityControl = useCallback(async (id: string, data: Partial<QualityControlCreate>): Promise<boolean> => {
|
||||
try {
|
||||
const response = await productionService.updateQualityControl(id, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchQualityControls();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error updating quality control:', error);
|
||||
return false;
|
||||
}
|
||||
}, [productionService, fetchQualityControls]);
|
||||
|
||||
// Get production analytics
|
||||
const getProductionAnalytics = useCallback(async (startDate?: string, endDate?: string) => {
|
||||
try {
|
||||
const response = await productionService.getAnalytics(startDate, endDate);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching production analytics:', error);
|
||||
return null;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Get efficiency report
|
||||
const getEfficiencyReport = useCallback(async (period: string) => {
|
||||
try {
|
||||
const response = await productionService.getEfficiencyReport(period);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching efficiency report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Get recipe performance
|
||||
const getRecipePerformance = useCallback(async (recipeId?: string) => {
|
||||
try {
|
||||
const response = await productionService.getRecipePerformance(recipeId);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipe performance:', error);
|
||||
return null;
|
||||
}
|
||||
}, [productionService]);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Refresh all data
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
fetchBatches(),
|
||||
fetchRecipes(),
|
||||
fetchSchedules(),
|
||||
]);
|
||||
}, [fetchBatches, fetchRecipes, fetchSchedules]);
|
||||
|
||||
// Initialize data on mount
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchBatches,
|
||||
createBatch,
|
||||
updateBatch,
|
||||
deleteBatch,
|
||||
getBatch,
|
||||
startBatch,
|
||||
completeBatch,
|
||||
cancelBatch,
|
||||
fetchRecipes,
|
||||
createRecipe,
|
||||
updateRecipe,
|
||||
deleteRecipe,
|
||||
getRecipe,
|
||||
duplicateRecipe,
|
||||
fetchSchedules,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
getCapacityAnalysis,
|
||||
fetchQualityControls,
|
||||
createQualityControl,
|
||||
updateQualityControl,
|
||||
getProductionAnalytics,
|
||||
getEfficiencyReport,
|
||||
getRecipePerformance,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -1,329 +0,0 @@
|
||||
/**
|
||||
* Server-Sent Events (SSE) hook for real-time notifications and updates
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { StorageService } from '../../services/api/utils/storage.service';
|
||||
|
||||
export interface SSEMessage {
|
||||
id?: string;
|
||||
event?: string;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface SSEState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
error: string | null;
|
||||
messages: SSEMessage[];
|
||||
lastEventId: string | null;
|
||||
}
|
||||
|
||||
interface SSEOptions {
|
||||
withCredentials?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
bufferSize?: number;
|
||||
autoConnect?: boolean;
|
||||
eventFilters?: string[];
|
||||
}
|
||||
|
||||
interface SSEActions {
|
||||
connect: (url: string) => void;
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
clearMessages: () => void;
|
||||
clearError: () => void;
|
||||
addEventListener: (eventType: string, handler: (data: any) => void) => () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<SSEOptions> = {
|
||||
withCredentials: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
bufferSize: 100,
|
||||
autoConnect: true,
|
||||
eventFilters: [],
|
||||
};
|
||||
|
||||
export const useSSE = (
|
||||
initialUrl?: string,
|
||||
options: SSEOptions = {}
|
||||
): SSEState & SSEActions => {
|
||||
const [state, setState] = useState<SSEState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
messages: [],
|
||||
lastEventId: null,
|
||||
});
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const urlRef = useRef<string | null>(initialUrl || null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const reconnectAttemptsRef = useRef<number>(0);
|
||||
const eventHandlersRef = useRef<Map<string, Set<(data: any) => void>>>(new Map());
|
||||
|
||||
const config = { ...DEFAULT_OPTIONS, ...options };
|
||||
const storageService = new StorageService();
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = useCallback(() => {
|
||||
const authData = storageService.getAuthData();
|
||||
if (authData?.access_token) {
|
||||
return `Bearer ${authData.access_token}`;
|
||||
}
|
||||
return null;
|
||||
}, [storageService]);
|
||||
|
||||
// Helper function to build URL with auth token
|
||||
const buildUrlWithAuth = useCallback((baseUrl: string) => {
|
||||
const url = new URL(baseUrl);
|
||||
const authToken = getAuthHeaders();
|
||||
|
||||
if (authToken) {
|
||||
url.searchParams.set('Authorization', authToken);
|
||||
}
|
||||
|
||||
if (state.lastEventId) {
|
||||
url.searchParams.set('Last-Event-ID', state.lastEventId);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}, [getAuthHeaders, state.lastEventId]);
|
||||
|
||||
// Add event listener for specific event types
|
||||
const addEventListener = useCallback((eventType: string, handler: (data: any) => void) => {
|
||||
if (!eventHandlersRef.current.has(eventType)) {
|
||||
eventHandlersRef.current.set(eventType, new Set());
|
||||
}
|
||||
eventHandlersRef.current.get(eventType)!.add(handler);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
const handlers = eventHandlersRef.current.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
eventHandlersRef.current.delete(eventType);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Process incoming message
|
||||
const processMessage = useCallback((event: MessageEvent, eventType: string = 'message') => {
|
||||
try {
|
||||
let data: any;
|
||||
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
data = event.data;
|
||||
}
|
||||
|
||||
const message: SSEMessage = {
|
||||
id: event.lastEventId || undefined,
|
||||
event: eventType,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Filter messages if eventFilters is specified
|
||||
if (config.eventFilters.length > 0 && !config.eventFilters.includes(eventType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages.slice(-(config.bufferSize - 1)), message],
|
||||
lastEventId: event.lastEventId || prev.lastEventId,
|
||||
}));
|
||||
|
||||
// Call registered event handlers
|
||||
const handlers = eventHandlersRef.current.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error('Error in SSE event handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Call generic message handlers
|
||||
const messageHandlers = eventHandlersRef.current.get('message');
|
||||
if (messageHandlers && eventType !== 'message') {
|
||||
messageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
console.error('Error in SSE message handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing SSE message:', error);
|
||||
}
|
||||
}, [config.eventFilters, config.bufferSize]);
|
||||
|
||||
// Connect to SSE endpoint
|
||||
const connect = useCallback((url: string) => {
|
||||
if (eventSourceRef.current) {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
urlRef.current = url;
|
||||
setState(prev => ({ ...prev, isConnecting: true, error: null }));
|
||||
|
||||
try {
|
||||
const fullUrl = buildUrlWithAuth(url);
|
||||
const eventSource = new EventSource(fullUrl, {
|
||||
withCredentials: config.withCredentials,
|
||||
});
|
||||
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
}));
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
// Clear reconnect timeout if it exists
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
processMessage(event, 'message');
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error: 'Error de conexión con el servidor',
|
||||
}));
|
||||
|
||||
// Attempt reconnection if within limits
|
||||
if (reconnectAttemptsRef.current < config.maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (urlRef.current) {
|
||||
connect(urlRef.current);
|
||||
}
|
||||
}, config.reconnectInterval);
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: `Máximo número de intentos de reconexión alcanzado (${config.maxReconnectAttempts})`,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Add listeners for custom event types
|
||||
const commonEventTypes = ['notification', 'alert', 'update', 'heartbeat'];
|
||||
commonEventTypes.forEach(eventType => {
|
||||
eventSource.addEventListener(eventType, (event) => {
|
||||
processMessage(event as MessageEvent, eventType);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnecting: false,
|
||||
error: 'Error al establecer conexión SSE',
|
||||
}));
|
||||
}
|
||||
}, [buildUrlWithAuth, config.withCredentials, config.maxReconnectAttempts, config.reconnectInterval, processMessage]);
|
||||
|
||||
// Disconnect from SSE
|
||||
const disconnect = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
}));
|
||||
|
||||
reconnectAttemptsRef.current = 0;
|
||||
}, []);
|
||||
|
||||
// Reconnect to SSE
|
||||
const reconnect = useCallback(() => {
|
||||
if (urlRef.current) {
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect(urlRef.current!);
|
||||
}, 100);
|
||||
}
|
||||
}, [connect, disconnect]);
|
||||
|
||||
// Clear messages buffer
|
||||
const clearMessages = useCallback(() => {
|
||||
setState(prev => ({ ...prev, messages: [] }));
|
||||
}, []);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Auto-connect on mount if URL provided
|
||||
useEffect(() => {
|
||||
if (initialUrl && config.autoConnect) {
|
||||
connect(initialUrl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [initialUrl, config.autoConnect]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect();
|
||||
eventHandlersRef.current.clear();
|
||||
};
|
||||
}, [disconnect]);
|
||||
|
||||
// Auto-reconnect when auth token changes
|
||||
useEffect(() => {
|
||||
if (state.isConnected && urlRef.current) {
|
||||
reconnect();
|
||||
}
|
||||
}, [storageService.getAuthData()?.access_token]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect,
|
||||
clearMessages,
|
||||
clearError,
|
||||
addEventListener,
|
||||
};
|
||||
};
|
||||
@@ -1,507 +0,0 @@
|
||||
/**
|
||||
* Sales hook for managing sales data, analytics, and reporting
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { salesService } from '../../services/api/sales.service';
|
||||
import {
|
||||
SalesData,
|
||||
SalesDataCreate,
|
||||
SalesDataUpdate,
|
||||
SalesAnalytics,
|
||||
OnboardingAnalysis,
|
||||
WeatherCorrelation
|
||||
} from '../../types/sales.types';
|
||||
import { ApiResponse, PaginatedResponse, QueryParams } from '../../types/api.types';
|
||||
|
||||
interface SalesState {
|
||||
salesData: SalesData[];
|
||||
analytics: SalesAnalytics | null;
|
||||
onboardingAnalysis: OnboardingAnalysis | null;
|
||||
weatherCorrelation: WeatherCorrelation | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
pagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SalesActions {
|
||||
// Sales Data
|
||||
fetchSalesData: (params?: QueryParams) => Promise<void>;
|
||||
createSalesData: (data: SalesDataCreate) => Promise<boolean>;
|
||||
updateSalesData: (id: string, data: SalesDataUpdate) => Promise<boolean>;
|
||||
deleteSalesData: (id: string) => Promise<boolean>;
|
||||
getSalesData: (id: string) => Promise<SalesData | null>;
|
||||
bulkCreateSalesData: (data: SalesDataCreate[]) => Promise<boolean>;
|
||||
|
||||
// Analytics
|
||||
fetchAnalytics: (startDate?: string, endDate?: string, filters?: any) => Promise<void>;
|
||||
getRevenueAnalytics: (period: string, groupBy?: string) => Promise<any>;
|
||||
getProductAnalytics: (startDate?: string, endDate?: string) => Promise<any>;
|
||||
getCustomerAnalytics: (startDate?: string, endDate?: string) => Promise<any>;
|
||||
getTimeAnalytics: (period: string) => Promise<any>;
|
||||
|
||||
// Reports
|
||||
getDailyReport: (date: string) => Promise<any>;
|
||||
getWeeklyReport: (startDate: string) => Promise<any>;
|
||||
getMonthlyReport: (year: number, month: number) => Promise<any>;
|
||||
getPerformanceReport: (startDate: string, endDate: string) => Promise<any>;
|
||||
|
||||
// Weather Correlation
|
||||
fetchWeatherCorrelation: (startDate?: string, endDate?: string) => Promise<void>;
|
||||
updateWeatherData: (startDate: string, endDate: string) => Promise<boolean>;
|
||||
|
||||
// Onboarding Analysis
|
||||
fetchOnboardingAnalysis: () => Promise<void>;
|
||||
generateOnboardingReport: () => Promise<any>;
|
||||
|
||||
// Import/Export
|
||||
importSalesData: (file: File, format: string) => Promise<boolean>;
|
||||
exportSalesData: (startDate: string, endDate: string, format: string) => Promise<boolean>;
|
||||
getImportTemplates: () => Promise<any>;
|
||||
|
||||
// Utilities
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSales = (): SalesState & SalesActions => {
|
||||
const [state, setState] = useState<SalesState>({
|
||||
salesData: [],
|
||||
analytics: null,
|
||||
onboardingAnalysis: null,
|
||||
weatherCorrelation: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
pagination: {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Fetch sales data
|
||||
const fetchSalesData = useCallback(async (params?: QueryParams) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await salesService.getSalesData(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
salesData: Array.isArray(response.data) ? response.data : response.data.items || [],
|
||||
pagination: response.data.pagination || prev.pagination,
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar datos de ventas',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Create sales data
|
||||
const createSalesData = useCallback(async (data: SalesDataCreate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await salesService.createSalesData(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchSalesData();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear dato de venta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [salesService, fetchSalesData]);
|
||||
|
||||
// Update sales data
|
||||
const updateSalesData = useCallback(async (id: string, data: SalesDataUpdate): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await salesService.updateSalesData(id, data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchSalesData();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al actualizar dato de venta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [salesService, fetchSalesData]);
|
||||
|
||||
// Delete sales data
|
||||
const deleteSalesData = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await salesService.deleteSalesData(id);
|
||||
|
||||
if (response.success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
salesData: prev.salesData.filter(data => data.id !== id),
|
||||
}));
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al eliminar dato de venta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get single sales data
|
||||
const getSalesData = useCallback(async (id: string): Promise<SalesData | null> => {
|
||||
try {
|
||||
const response = await salesService.getSalesDataById(id);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching sales data:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Bulk create sales data
|
||||
const bulkCreateSalesData = useCallback(async (data: SalesDataCreate[]): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await salesService.bulkCreateSalesData(data);
|
||||
|
||||
if (response.success) {
|
||||
await fetchSalesData();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al crear datos de venta en lote',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [salesService, fetchSalesData]);
|
||||
|
||||
// Fetch analytics
|
||||
const fetchAnalytics = useCallback(async (startDate?: string, endDate?: string, filters?: any) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await salesService.getAnalytics(startDate, endDate, filters);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
analytics: response.data,
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: response.error || 'Error al cargar analytics de ventas',
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get revenue analytics
|
||||
const getRevenueAnalytics = useCallback(async (period: string, groupBy?: string) => {
|
||||
try {
|
||||
const response = await salesService.getRevenueAnalytics(period, groupBy);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching revenue analytics:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get product analytics
|
||||
const getProductAnalytics = useCallback(async (startDate?: string, endDate?: string) => {
|
||||
try {
|
||||
const response = await salesService.getProductAnalytics(startDate, endDate);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching product analytics:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get customer analytics
|
||||
const getCustomerAnalytics = useCallback(async (startDate?: string, endDate?: string) => {
|
||||
try {
|
||||
const response = await salesService.getCustomerAnalytics(startDate, endDate);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching customer analytics:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get time analytics
|
||||
const getTimeAnalytics = useCallback(async (period: string) => {
|
||||
try {
|
||||
const response = await salesService.getTimeAnalytics(period);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching time analytics:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get daily report
|
||||
const getDailyReport = useCallback(async (date: string) => {
|
||||
try {
|
||||
const response = await salesService.getDailyReport(date);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching daily report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get weekly report
|
||||
const getWeeklyReport = useCallback(async (startDate: string) => {
|
||||
try {
|
||||
const response = await salesService.getWeeklyReport(startDate);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching weekly report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get monthly report
|
||||
const getMonthlyReport = useCallback(async (year: number, month: number) => {
|
||||
try {
|
||||
const response = await salesService.getMonthlyReport(year, month);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching monthly report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get performance report
|
||||
const getPerformanceReport = useCallback(async (startDate: string, endDate: string) => {
|
||||
try {
|
||||
const response = await salesService.getPerformanceReport(startDate, endDate);
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching performance report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Fetch weather correlation
|
||||
const fetchWeatherCorrelation = useCallback(async (startDate?: string, endDate?: string) => {
|
||||
try {
|
||||
const response = await salesService.getWeatherCorrelation(startDate, endDate);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
weatherCorrelation: response.data,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching weather correlation:', error);
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Update weather data
|
||||
const updateWeatherData = useCallback(async (startDate: string, endDate: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await salesService.updateWeatherData(startDate, endDate);
|
||||
|
||||
if (response.success) {
|
||||
await fetchWeatherCorrelation(startDate, endDate);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error updating weather data:', error);
|
||||
return false;
|
||||
}
|
||||
}, [salesService, fetchWeatherCorrelation]);
|
||||
|
||||
// Fetch onboarding analysis
|
||||
const fetchOnboardingAnalysis = useCallback(async () => {
|
||||
try {
|
||||
const response = await salesService.getOnboardingAnalysis();
|
||||
|
||||
if (response.success && response.data) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
onboardingAnalysis: response.data,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching onboarding analysis:', error);
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Generate onboarding report
|
||||
const generateOnboardingReport = useCallback(async () => {
|
||||
try {
|
||||
const response = await salesService.generateOnboardingReport();
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error generating onboarding report:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Import sales data
|
||||
const importSalesData = useCallback(async (file: File, format: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const response = await salesService.importSalesData(file, format);
|
||||
|
||||
if (response.success) {
|
||||
await fetchSalesData();
|
||||
return true;
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: response.error || 'Error al importar datos de venta',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión al servidor',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [salesService, fetchSalesData]);
|
||||
|
||||
// Export sales data
|
||||
const exportSalesData = useCallback(async (startDate: string, endDate: string, format: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await salesService.exportSalesData(startDate, endDate, format);
|
||||
return response.success;
|
||||
} catch (error) {
|
||||
console.error('Error exporting sales data:', error);
|
||||
return false;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Get import templates
|
||||
const getImportTemplates = useCallback(async () => {
|
||||
try {
|
||||
const response = await salesService.getImportTemplates();
|
||||
return response.success ? response.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching import templates:', error);
|
||||
return null;
|
||||
}
|
||||
}, [salesService]);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Refresh all data
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
fetchSalesData(),
|
||||
fetchAnalytics(),
|
||||
fetchOnboardingAnalysis(),
|
||||
]);
|
||||
}, [fetchSalesData, fetchAnalytics, fetchOnboardingAnalysis]);
|
||||
|
||||
// Initialize data on mount
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchSalesData,
|
||||
createSalesData,
|
||||
updateSalesData,
|
||||
deleteSalesData,
|
||||
getSalesData,
|
||||
bulkCreateSalesData,
|
||||
fetchAnalytics,
|
||||
getRevenueAnalytics,
|
||||
getProductAnalytics,
|
||||
getCustomerAnalytics,
|
||||
getTimeAnalytics,
|
||||
getDailyReport,
|
||||
getWeeklyReport,
|
||||
getMonthlyReport,
|
||||
getPerformanceReport,
|
||||
fetchWeatherCorrelation,
|
||||
updateWeatherData,
|
||||
fetchOnboardingAnalysis,
|
||||
generateOnboardingReport,
|
||||
importSalesData,
|
||||
exportSalesData,
|
||||
getImportTemplates,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -1,423 +0,0 @@
|
||||
/**
|
||||
* WebSocket hook for real-time bidirectional communication
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { StorageService } from '../../services/api/utils/storage.service';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
id?: string;
|
||||
type: string;
|
||||
data: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface WebSocketState {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
error: string | null;
|
||||
messages: WebSocketMessage[];
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
interface WebSocketOptions {
|
||||
protocols?: string | string[];
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
bufferSize?: number;
|
||||
autoConnect?: boolean;
|
||||
heartbeatInterval?: number;
|
||||
messageFilters?: string[];
|
||||
}
|
||||
|
||||
interface WebSocketActions {
|
||||
connect: (url: string) => void;
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
send: (data: any, type?: string) => boolean;
|
||||
sendMessage: (message: WebSocketMessage) => boolean;
|
||||
clearMessages: () => void;
|
||||
clearError: () => void;
|
||||
addEventListener: (messageType: string, handler: (data: any) => void) => () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<WebSocketOptions> = {
|
||||
protocols: [],
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10,
|
||||
bufferSize: 100,
|
||||
autoConnect: true,
|
||||
heartbeatInterval: 30000,
|
||||
messageFilters: [],
|
||||
};
|
||||
|
||||
export const useWebSocket = (
|
||||
initialUrl?: string,
|
||||
options: WebSocketOptions = {}
|
||||
): WebSocketState & WebSocketActions => {
|
||||
const [state, setState] = useState<WebSocketState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
messages: [],
|
||||
readyState: WebSocket.CLOSED,
|
||||
});
|
||||
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const urlRef = useRef<string | null>(initialUrl || null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const heartbeatTimeoutRef = useRef<number | null>(null);
|
||||
const reconnectAttemptsRef = useRef<number>(0);
|
||||
const messageHandlersRef = useRef<Map<string, Set<(data: any) => void>>>(new Map());
|
||||
const messageQueueRef = useRef<WebSocketMessage[]>([]);
|
||||
|
||||
const config = { ...DEFAULT_OPTIONS, ...options };
|
||||
const storageService = new StorageService();
|
||||
|
||||
// Helper function to get auth token
|
||||
const getAuthToken = useCallback(() => {
|
||||
const authData = storageService.getAuthData();
|
||||
return authData?.access_token || null;
|
||||
}, [storageService]);
|
||||
|
||||
// Add event listener for specific message types
|
||||
const addEventListener = useCallback((messageType: string, handler: (data: any) => void) => {
|
||||
if (!messageHandlersRef.current.has(messageType)) {
|
||||
messageHandlersRef.current.set(messageType, new Set());
|
||||
}
|
||||
messageHandlersRef.current.get(messageType)!.add(handler);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
const handlers = messageHandlersRef.current.get(messageType);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
messageHandlersRef.current.delete(messageType);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Process incoming message
|
||||
const processMessage = useCallback((event: MessageEvent) => {
|
||||
try {
|
||||
let messageData: WebSocketMessage;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
messageData = {
|
||||
id: parsed.id,
|
||||
type: parsed.type || 'message',
|
||||
data: parsed.data || parsed,
|
||||
timestamp: parsed.timestamp || Date.now(),
|
||||
};
|
||||
} catch {
|
||||
messageData = {
|
||||
type: 'message',
|
||||
data: event.data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// Filter messages if messageFilters is specified
|
||||
if (config.messageFilters.length > 0 && !config.messageFilters.includes(messageData.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages.slice(-(config.bufferSize - 1)), messageData],
|
||||
}));
|
||||
|
||||
// Call registered message handlers
|
||||
const handlers = messageHandlersRef.current.get(messageData.type);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(messageData.data);
|
||||
} catch (error) {
|
||||
console.error('Error in WebSocket message handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Call generic message handlers
|
||||
const messageHandlers = messageHandlersRef.current.get('message');
|
||||
if (messageHandlers && messageData.type !== 'message') {
|
||||
messageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(messageData);
|
||||
} catch (error) {
|
||||
console.error('Error in WebSocket message handler:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing WebSocket message:', error);
|
||||
}
|
||||
}, [config.messageFilters, config.bufferSize]);
|
||||
|
||||
// Send heartbeat/ping message
|
||||
const sendHeartbeat = useCallback(() => {
|
||||
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
const heartbeatMessage: WebSocketMessage = {
|
||||
type: 'ping',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
webSocketRef.current.send(JSON.stringify(heartbeatMessage));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Setup heartbeat interval
|
||||
const setupHeartbeat = useCallback(() => {
|
||||
if (config.heartbeatInterval > 0) {
|
||||
heartbeatTimeoutRef.current = window.setInterval(sendHeartbeat, config.heartbeatInterval);
|
||||
}
|
||||
}, [config.heartbeatInterval, sendHeartbeat]);
|
||||
|
||||
// Clear heartbeat interval
|
||||
const clearHeartbeat = useCallback(() => {
|
||||
if (heartbeatTimeoutRef.current) {
|
||||
window.clearInterval(heartbeatTimeoutRef.current);
|
||||
heartbeatTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Process queued messages
|
||||
const processMessageQueue = useCallback(() => {
|
||||
while (messageQueueRef.current.length > 0) {
|
||||
const message = messageQueueRef.current.shift();
|
||||
if (message && webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Connect to WebSocket endpoint
|
||||
const connect = useCallback((url: string) => {
|
||||
if (webSocketRef.current) {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
urlRef.current = url;
|
||||
setState(prev => ({ ...prev, isConnecting: true, error: null }));
|
||||
|
||||
try {
|
||||
// Build URL with auth token if available
|
||||
const wsUrl = new URL(url);
|
||||
const authToken = getAuthToken();
|
||||
if (authToken) {
|
||||
wsUrl.searchParams.set('token', authToken);
|
||||
}
|
||||
|
||||
const webSocket = new WebSocket(
|
||||
wsUrl.toString(),
|
||||
Array.isArray(config.protocols) ? config.protocols : [config.protocols].filter(Boolean)
|
||||
);
|
||||
|
||||
webSocketRef.current = webSocket;
|
||||
|
||||
webSocket.onopen = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
readyState: WebSocket.OPEN,
|
||||
}));
|
||||
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
// Clear reconnect timeout if it exists
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Setup heartbeat
|
||||
setupHeartbeat();
|
||||
|
||||
// Process any queued messages
|
||||
processMessageQueue();
|
||||
};
|
||||
|
||||
webSocket.onmessage = processMessage;
|
||||
|
||||
webSocket.onclose = (event) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
readyState: WebSocket.CLOSED,
|
||||
}));
|
||||
|
||||
clearHeartbeat();
|
||||
|
||||
// Attempt reconnection if not a clean close and within limits
|
||||
if (!event.wasClean && reconnectAttemptsRef.current < config.maxReconnectAttempts) {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: `Conexión perdida. Reintentando... (${reconnectAttemptsRef.current}/${config.maxReconnectAttempts})`,
|
||||
}));
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
if (urlRef.current) {
|
||||
connect(urlRef.current);
|
||||
}
|
||||
}, config.reconnectInterval);
|
||||
} else if (reconnectAttemptsRef.current >= config.maxReconnectAttempts) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: `Máximo número de intentos de reconexión alcanzado (${config.maxReconnectAttempts})`,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
webSocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: 'Error de conexión WebSocket',
|
||||
readyState: webSocket.readyState,
|
||||
}));
|
||||
};
|
||||
|
||||
// Update ready state periodically
|
||||
const stateInterval = setInterval(() => {
|
||||
if (webSocketRef.current) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
readyState: webSocketRef.current!.readyState,
|
||||
}));
|
||||
} else {
|
||||
clearInterval(stateInterval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnecting: false,
|
||||
error: 'Error al establecer conexión WebSocket',
|
||||
readyState: WebSocket.CLOSED,
|
||||
}));
|
||||
}
|
||||
}, [getAuthToken, config.protocols, config.maxReconnectAttempts, config.reconnectInterval, processMessage, setupHeartbeat, clearHeartbeat, processMessageQueue]);
|
||||
|
||||
// Disconnect from WebSocket
|
||||
const disconnect = useCallback(() => {
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close(1000, 'Manual disconnect');
|
||||
webSocketRef.current = null;
|
||||
}
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
window.clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
clearHeartbeat();
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
readyState: WebSocket.CLOSED,
|
||||
}));
|
||||
|
||||
reconnectAttemptsRef.current = 0;
|
||||
}, [clearHeartbeat]);
|
||||
|
||||
// Reconnect to WebSocket
|
||||
const reconnect = useCallback(() => {
|
||||
if (urlRef.current) {
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect(urlRef.current!);
|
||||
}, 100);
|
||||
}
|
||||
}, [connect, disconnect]);
|
||||
|
||||
// Send raw data
|
||||
const send = useCallback((data: any, type: string = 'message'): boolean => {
|
||||
const message: WebSocketMessage = {
|
||||
type,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return sendMessage(message);
|
||||
}, []);
|
||||
|
||||
// Send structured message
|
||||
const sendMessage = useCallback((message: WebSocketMessage): boolean => {
|
||||
if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
webSocketRef.current.send(JSON.stringify(message));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error sending WebSocket message:', error);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Queue message for later if not connected
|
||||
messageQueueRef.current.push(message);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear messages buffer
|
||||
const clearMessages = useCallback(() => {
|
||||
setState(prev => ({ ...prev, messages: [] }));
|
||||
}, []);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Auto-connect on mount if URL provided
|
||||
useEffect(() => {
|
||||
if (initialUrl && config.autoConnect) {
|
||||
connect(initialUrl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, [initialUrl, config.autoConnect]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
disconnect();
|
||||
messageHandlersRef.current.clear();
|
||||
messageQueueRef.current = [];
|
||||
};
|
||||
}, [disconnect]);
|
||||
|
||||
// Auto-reconnect when auth token changes
|
||||
useEffect(() => {
|
||||
if (state.isConnected && urlRef.current) {
|
||||
reconnect();
|
||||
}
|
||||
}, [getAuthToken()]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
connect,
|
||||
disconnect,
|
||||
reconnect,
|
||||
send,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
clearError,
|
||||
addEventListener,
|
||||
};
|
||||
};
|
||||
@@ -1,621 +0,0 @@
|
||||
/**
|
||||
* Alerts hook for managing bakery alerts and notifications
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { InventoryService } from '../../services/api/inventory.service';
|
||||
import { ProductionService } from '../../services/api/production.service';
|
||||
import { NotificationService } from '../../services/api/notification.service';
|
||||
|
||||
export type AlertType = 'inventory' | 'production' | 'quality' | 'maintenance' | 'safety' | 'system' | 'custom';
|
||||
export type AlertPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type AlertStatus = 'active' | 'acknowledged' | 'resolved' | 'dismissed';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
type: AlertType;
|
||||
priority: AlertPriority;
|
||||
status: AlertStatus;
|
||||
title: string;
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
source: string;
|
||||
sourceId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
acknowledgedAt?: Date;
|
||||
acknowledgedBy?: string;
|
||||
resolvedAt?: Date;
|
||||
resolvedBy?: string;
|
||||
dismissedAt?: Date;
|
||||
dismissedBy?: string;
|
||||
expiresAt?: Date;
|
||||
actions?: {
|
||||
id: string;
|
||||
label: string;
|
||||
action: string;
|
||||
parameters?: Record<string, any>;
|
||||
}[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AlertRule {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AlertType;
|
||||
priority: AlertPriority;
|
||||
condition: {
|
||||
field: string;
|
||||
operator: 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'greater_or_equal' | 'less_or_equal' | 'contains' | 'not_contains';
|
||||
value: any;
|
||||
}[];
|
||||
actions: string[];
|
||||
enabled: boolean;
|
||||
cooldownPeriod?: number; // in minutes
|
||||
lastTriggered?: Date;
|
||||
}
|
||||
|
||||
interface AlertsState {
|
||||
alerts: Alert[];
|
||||
rules: AlertRule[];
|
||||
unreadCount: number;
|
||||
criticalCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
filters: {
|
||||
types: AlertType[];
|
||||
priorities: AlertPriority[];
|
||||
statuses: AlertStatus[];
|
||||
sources: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface AlertsActions {
|
||||
// Alert Management
|
||||
fetchAlerts: (filters?: Partial<AlertsState['filters']>) => Promise<void>;
|
||||
acknowledgeAlert: (id: string) => Promise<boolean>;
|
||||
resolveAlert: (id: string, resolution?: string) => Promise<boolean>;
|
||||
dismissAlert: (id: string, reason?: string) => Promise<boolean>;
|
||||
bulkAcknowledge: (ids: string[]) => Promise<boolean>;
|
||||
bulkResolve: (ids: string[], resolution?: string) => Promise<boolean>;
|
||||
bulkDismiss: (ids: string[], reason?: string) => Promise<boolean>;
|
||||
|
||||
// Alert Creation
|
||||
createAlert: (alert: Omit<Alert, 'id' | 'createdAt' | 'status'>) => Promise<boolean>;
|
||||
createCustomAlert: (title: string, message: string, priority?: AlertPriority, details?: Record<string, any>) => Promise<boolean>;
|
||||
|
||||
// Alert Rules
|
||||
fetchAlertRules: () => Promise<void>;
|
||||
createAlertRule: (rule: Omit<AlertRule, 'id'>) => Promise<boolean>;
|
||||
updateAlertRule: (id: string, rule: Partial<AlertRule>) => Promise<boolean>;
|
||||
deleteAlertRule: (id: string) => Promise<boolean>;
|
||||
testAlertRule: (rule: AlertRule) => Promise<boolean>;
|
||||
|
||||
// Monitoring and Checks
|
||||
checkInventoryAlerts: () => Promise<void>;
|
||||
checkProductionAlerts: () => Promise<void>;
|
||||
checkQualityAlerts: () => Promise<void>;
|
||||
checkMaintenanceAlerts: () => Promise<void>;
|
||||
checkSystemAlerts: () => Promise<void>;
|
||||
|
||||
// Analytics
|
||||
getAlertAnalytics: (period: string) => Promise<any>;
|
||||
getAlertTrends: (days: number) => Promise<any>;
|
||||
getMostFrequentAlerts: (period: string) => Promise<any>;
|
||||
|
||||
// Filters and Search
|
||||
setFilters: (filters: Partial<AlertsState['filters']>) => void;
|
||||
searchAlerts: (query: string) => Promise<Alert[]>;
|
||||
|
||||
// Real-time Updates
|
||||
subscribeToAlerts: (callback: (alert: Alert) => void) => () => void;
|
||||
|
||||
// Utilities
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAlerts = (): AlertsState & AlertsActions => {
|
||||
const [state, setState] = useState<AlertsState>({
|
||||
alerts: [],
|
||||
rules: [],
|
||||
unreadCount: 0,
|
||||
criticalCount: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
types: [],
|
||||
priorities: [],
|
||||
statuses: [],
|
||||
sources: [],
|
||||
},
|
||||
});
|
||||
|
||||
const inventoryService = new InventoryService();
|
||||
const productionService = new ProductionService();
|
||||
const notificationService = new NotificationService();
|
||||
|
||||
// Fetch alerts
|
||||
const fetchAlerts = useCallback(async (filters?: Partial<AlertsState['filters']>) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Combine filters
|
||||
const activeFilters = { ...state.filters, ...filters };
|
||||
|
||||
// Fetch alerts from different sources
|
||||
const [inventoryAlerts, productionAlerts, systemAlerts] = await Promise.all([
|
||||
getInventoryAlerts(activeFilters),
|
||||
getProductionAlerts(activeFilters),
|
||||
getSystemAlerts(activeFilters),
|
||||
]);
|
||||
|
||||
const allAlerts = [...inventoryAlerts, ...productionAlerts, ...systemAlerts]
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
// Calculate counts
|
||||
const unreadCount = allAlerts.filter(alert => alert.status === 'active').length;
|
||||
const criticalCount = allAlerts.filter(alert => alert.priority === 'critical' && alert.status === 'active').length;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: allAlerts,
|
||||
unreadCount,
|
||||
criticalCount,
|
||||
isLoading: false,
|
||||
filters: activeFilters,
|
||||
}));
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al cargar alertas',
|
||||
}));
|
||||
}
|
||||
}, [state.filters]);
|
||||
|
||||
// Get inventory alerts
|
||||
const getInventoryAlerts = async (filters: Partial<AlertsState['filters']>): Promise<Alert[]> => {
|
||||
try {
|
||||
const response = await inventoryService.getAlerts();
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data.map((alert: any) => convertToAlert(alert, 'inventory'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching inventory alerts:', error);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// Get production alerts
|
||||
const getProductionAlerts = async (filters: Partial<AlertsState['filters']>): Promise<Alert[]> => {
|
||||
try {
|
||||
const response = await productionService.getAlerts?.();
|
||||
|
||||
if (response?.success && response.data) {
|
||||
return response.data.map((alert: any) => convertToAlert(alert, 'production'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching production alerts:', error);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// Get system alerts
|
||||
const getSystemAlerts = async (filters: Partial<AlertsState['filters']>): Promise<Alert[]> => {
|
||||
try {
|
||||
const response = await notificationService.getNotifications();
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data
|
||||
.filter((notif: any) => notif.type === 'alert')
|
||||
.map((alert: any) => convertToAlert(alert, 'system'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching system alerts:', error);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// Convert API response to Alert format
|
||||
const convertToAlert = (apiAlert: any, source: string): Alert => {
|
||||
return {
|
||||
id: apiAlert.id,
|
||||
type: apiAlert.type || (source as AlertType),
|
||||
priority: mapPriority(apiAlert.priority || apiAlert.severity),
|
||||
status: mapStatus(apiAlert.status),
|
||||
title: apiAlert.title || apiAlert.message?.substring(0, 50) || 'Alert',
|
||||
message: apiAlert.message || apiAlert.description || '',
|
||||
details: apiAlert.details || apiAlert.data,
|
||||
source,
|
||||
sourceId: apiAlert.source_id,
|
||||
createdAt: new Date(apiAlert.created_at || Date.now()),
|
||||
updatedAt: apiAlert.updated_at ? new Date(apiAlert.updated_at) : undefined,
|
||||
acknowledgedAt: apiAlert.acknowledged_at ? new Date(apiAlert.acknowledged_at) : undefined,
|
||||
acknowledgedBy: apiAlert.acknowledged_by,
|
||||
resolvedAt: apiAlert.resolved_at ? new Date(apiAlert.resolved_at) : undefined,
|
||||
resolvedBy: apiAlert.resolved_by,
|
||||
dismissedAt: apiAlert.dismissed_at ? new Date(apiAlert.dismissed_at) : undefined,
|
||||
dismissedBy: apiAlert.dismissed_by,
|
||||
expiresAt: apiAlert.expires_at ? new Date(apiAlert.expires_at) : undefined,
|
||||
actions: apiAlert.actions || [],
|
||||
metadata: apiAlert.metadata || {},
|
||||
};
|
||||
};
|
||||
|
||||
// Map priority from different sources
|
||||
const mapPriority = (priority: string): AlertPriority => {
|
||||
const priorityMap: Record<string, AlertPriority> = {
|
||||
'low': 'low',
|
||||
'medium': 'medium',
|
||||
'high': 'high',
|
||||
'critical': 'critical',
|
||||
'urgent': 'critical',
|
||||
'warning': 'medium',
|
||||
'error': 'high',
|
||||
'info': 'low',
|
||||
};
|
||||
|
||||
return priorityMap[priority?.toLowerCase()] || 'medium';
|
||||
};
|
||||
|
||||
// Map status from different sources
|
||||
const mapStatus = (status: string): AlertStatus => {
|
||||
const statusMap: Record<string, AlertStatus> = {
|
||||
'active': 'active',
|
||||
'new': 'active',
|
||||
'open': 'active',
|
||||
'acknowledged': 'acknowledged',
|
||||
'ack': 'acknowledged',
|
||||
'resolved': 'resolved',
|
||||
'closed': 'resolved',
|
||||
'dismissed': 'dismissed',
|
||||
'ignored': 'dismissed',
|
||||
};
|
||||
|
||||
return statusMap[status?.toLowerCase()] || 'active';
|
||||
};
|
||||
|
||||
// Acknowledge alert
|
||||
const acknowledgeAlert = useCallback(async (id: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const alert = state.alerts.find(a => a.id === id);
|
||||
if (!alert) return false;
|
||||
|
||||
// Call appropriate service based on source
|
||||
let success = false;
|
||||
if (alert.source === 'inventory') {
|
||||
const response = await inventoryService.markAlertAsRead(id);
|
||||
success = response.success;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: prev.alerts.map(a =>
|
||||
a.id === id
|
||||
? { ...a, status: 'acknowledged', acknowledgedAt: new Date() }
|
||||
: a
|
||||
),
|
||||
unreadCount: Math.max(0, prev.unreadCount - 1),
|
||||
}));
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al confirmar alerta' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.alerts, inventoryService]);
|
||||
|
||||
// Resolve alert
|
||||
const resolveAlert = useCallback(async (id: string, resolution?: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const alert = state.alerts.find(a => a.id === id);
|
||||
if (!alert) return false;
|
||||
|
||||
// Call appropriate service based on source
|
||||
let success = false;
|
||||
if (alert.source === 'inventory') {
|
||||
const response = await inventoryService.dismissAlert(id);
|
||||
success = response.success;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: prev.alerts.map(a =>
|
||||
a.id === id
|
||||
? { ...a, status: 'resolved', resolvedAt: new Date(), metadata: { ...a.metadata, resolution } }
|
||||
: a
|
||||
),
|
||||
unreadCount: a.status === 'active' ? Math.max(0, prev.unreadCount - 1) : prev.unreadCount,
|
||||
criticalCount: a.priority === 'critical' && a.status === 'active' ? Math.max(0, prev.criticalCount - 1) : prev.criticalCount,
|
||||
}));
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al resolver alerta' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.alerts, inventoryService]);
|
||||
|
||||
// Dismiss alert
|
||||
const dismissAlert = useCallback(async (id: string, reason?: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const alert = state.alerts.find(a => a.id === id);
|
||||
if (!alert) return false;
|
||||
|
||||
// Call appropriate service based on source
|
||||
let success = false;
|
||||
if (alert.source === 'inventory') {
|
||||
const response = await inventoryService.dismissAlert(id);
|
||||
success = response.success;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: prev.alerts.map(a =>
|
||||
a.id === id
|
||||
? { ...a, status: 'dismissed', dismissedAt: new Date(), metadata: { ...a.metadata, dismissReason: reason } }
|
||||
: a
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al descartar alerta' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.alerts, inventoryService]);
|
||||
|
||||
// Bulk acknowledge
|
||||
const bulkAcknowledge = useCallback(async (ids: string[]): Promise<boolean> => {
|
||||
const results = await Promise.all(ids.map(id => acknowledgeAlert(id)));
|
||||
return results.every(result => result);
|
||||
}, [acknowledgeAlert]);
|
||||
|
||||
// Bulk resolve
|
||||
const bulkResolve = useCallback(async (ids: string[], resolution?: string): Promise<boolean> => {
|
||||
const results = await Promise.all(ids.map(id => resolveAlert(id, resolution)));
|
||||
return results.every(result => result);
|
||||
}, [resolveAlert]);
|
||||
|
||||
// Bulk dismiss
|
||||
const bulkDismiss = useCallback(async (ids: string[], reason?: string): Promise<boolean> => {
|
||||
const results = await Promise.all(ids.map(id => dismissAlert(id, reason)));
|
||||
return results.every(result => result);
|
||||
}, [dismissAlert]);
|
||||
|
||||
// Create alert
|
||||
const createAlert = useCallback(async (alert: Omit<Alert, 'id' | 'createdAt' | 'status'>): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
const newAlert: Alert = {
|
||||
...alert,
|
||||
id: generateAlertId(),
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
alerts: [newAlert, ...prev.alerts],
|
||||
unreadCount: prev.unreadCount + 1,
|
||||
criticalCount: newAlert.priority === 'critical' ? prev.criticalCount + 1 : prev.criticalCount,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al crear alerta' }));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create custom alert
|
||||
const createCustomAlert = useCallback(async (
|
||||
title: string,
|
||||
message: string,
|
||||
priority: AlertPriority = 'medium',
|
||||
details?: Record<string, any>
|
||||
): Promise<boolean> => {
|
||||
return createAlert({
|
||||
type: 'custom',
|
||||
priority,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
source: 'user',
|
||||
});
|
||||
}, [createAlert]);
|
||||
|
||||
// Check inventory alerts
|
||||
const checkInventoryAlerts = useCallback(async () => {
|
||||
try {
|
||||
// Check for low stock
|
||||
const stockResponse = await inventoryService.getStockLevels();
|
||||
if (stockResponse.success && stockResponse.data) {
|
||||
const lowStockItems = stockResponse.data.filter((item: any) =>
|
||||
item.current_quantity <= item.reorder_point
|
||||
);
|
||||
|
||||
for (const item of lowStockItems) {
|
||||
await createAlert({
|
||||
type: 'inventory',
|
||||
priority: item.current_quantity === 0 ? 'critical' : 'high',
|
||||
title: 'Stock bajo',
|
||||
message: `Stock bajo para ${item.ingredient?.name}: ${item.current_quantity} ${item.unit}`,
|
||||
source: 'inventory',
|
||||
sourceId: item.id,
|
||||
details: { item },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for expiring items
|
||||
const expirationResponse = await inventoryService.getExpirationReport();
|
||||
if (expirationResponse?.success && expirationResponse.data) {
|
||||
const expiringItems = expirationResponse.data.expiring_soon || [];
|
||||
|
||||
for (const item of expiringItems) {
|
||||
await createAlert({
|
||||
type: 'inventory',
|
||||
priority: 'medium',
|
||||
title: 'Producto próximo a expirar',
|
||||
message: `${item.name} expira el ${item.expiration_date}`,
|
||||
source: 'inventory',
|
||||
sourceId: item.id,
|
||||
details: { item },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking inventory alerts:', error);
|
||||
}
|
||||
}, [inventoryService, createAlert]);
|
||||
|
||||
// Generate unique ID
|
||||
const generateAlertId = (): string => {
|
||||
return `alert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
// Set filters
|
||||
const setFilters = useCallback((filters: Partial<AlertsState['filters']>) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
filters: { ...prev.filters, ...filters },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
// Refresh alerts
|
||||
const refresh = useCallback(async () => {
|
||||
await fetchAlerts(state.filters);
|
||||
}, [fetchAlerts, state.filters]);
|
||||
|
||||
// Placeholder implementations for remaining functions
|
||||
const fetchAlertRules = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, rules: [] }));
|
||||
}, []);
|
||||
|
||||
const createAlertRule = useCallback(async (rule: Omit<AlertRule, 'id'>): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const updateAlertRule = useCallback(async (id: string, rule: Partial<AlertRule>): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const deleteAlertRule = useCallback(async (id: string): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const testAlertRule = useCallback(async (rule: AlertRule): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const checkProductionAlerts = useCallback(async () => {
|
||||
// Implementation for production alerts
|
||||
}, []);
|
||||
|
||||
const checkQualityAlerts = useCallback(async () => {
|
||||
// Implementation for quality alerts
|
||||
}, []);
|
||||
|
||||
const checkMaintenanceAlerts = useCallback(async () => {
|
||||
// Implementation for maintenance alerts
|
||||
}, []);
|
||||
|
||||
const checkSystemAlerts = useCallback(async () => {
|
||||
// Implementation for system alerts
|
||||
}, []);
|
||||
|
||||
const getAlertAnalytics = useCallback(async (period: string): Promise<any> => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const getAlertTrends = useCallback(async (days: number): Promise<any> => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const getMostFrequentAlerts = useCallback(async (period: string): Promise<any> => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const searchAlerts = useCallback(async (query: string): Promise<Alert[]> => {
|
||||
return state.alerts.filter(alert =>
|
||||
alert.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
alert.message.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}, [state.alerts]);
|
||||
|
||||
const subscribeToAlerts = useCallback((callback: (alert: Alert) => void): (() => void) => {
|
||||
// Implementation would set up real-time subscription
|
||||
return () => {
|
||||
// Cleanup subscription
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize alerts on mount
|
||||
useEffect(() => {
|
||||
fetchAlerts();
|
||||
}, []);
|
||||
|
||||
// Set up periodic checks
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
checkInventoryAlerts();
|
||||
}, 5 * 60 * 1000); // Check every 5 minutes
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [checkInventoryAlerts]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchAlerts,
|
||||
acknowledgeAlert,
|
||||
resolveAlert,
|
||||
dismissAlert,
|
||||
bulkAcknowledge,
|
||||
bulkResolve,
|
||||
bulkDismiss,
|
||||
createAlert,
|
||||
createCustomAlert,
|
||||
fetchAlertRules,
|
||||
createAlertRule,
|
||||
updateAlertRule,
|
||||
deleteAlertRule,
|
||||
testAlertRule,
|
||||
checkInventoryAlerts,
|
||||
checkProductionAlerts,
|
||||
checkQualityAlerts,
|
||||
checkMaintenanceAlerts,
|
||||
checkSystemAlerts,
|
||||
getAlertAnalytics,
|
||||
getAlertTrends,
|
||||
getMostFrequentAlerts,
|
||||
setFilters,
|
||||
searchAlerts,
|
||||
subscribeToAlerts,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -1,520 +0,0 @@
|
||||
/**
|
||||
* Bakery workflow hook for managing daily bakery operations and processes
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ProductionService } from '../../services/api/production.service';
|
||||
import { InventoryService } from '../../services/api/inventory.service';
|
||||
import { SalesService } from '../../services/api/sales.service';
|
||||
import { OrderService } from '../../services/api/order.service';
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
estimatedDuration: number; // in minutes
|
||||
actualDuration?: number;
|
||||
dependencies?: string[];
|
||||
assignedTo?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DailyWorkflow {
|
||||
date: string;
|
||||
steps: WorkflowStep[];
|
||||
totalEstimatedTime: number;
|
||||
totalActualTime: number;
|
||||
completionRate: number;
|
||||
status: 'not_started' | 'in_progress' | 'completed' | 'behind_schedule';
|
||||
}
|
||||
|
||||
interface BakeryWorkflowState {
|
||||
currentWorkflow: DailyWorkflow | null;
|
||||
workflowHistory: DailyWorkflow[];
|
||||
activeStep: WorkflowStep | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface BakeryWorkflowActions {
|
||||
// Workflow Management
|
||||
initializeDailyWorkflow: (date: string) => Promise<void>;
|
||||
generateWorkflowFromForecast: (date: string) => Promise<void>;
|
||||
updateWorkflow: (workflow: Partial<DailyWorkflow>) => Promise<boolean>;
|
||||
|
||||
// Step Management
|
||||
startStep: (stepId: string) => Promise<boolean>;
|
||||
completeStep: (stepId: string, notes?: string) => Promise<boolean>;
|
||||
failStep: (stepId: string, reason: string) => Promise<boolean>;
|
||||
skipStep: (stepId: string, reason: string) => Promise<boolean>;
|
||||
updateStepProgress: (stepId: string, progress: Partial<WorkflowStep>) => Promise<boolean>;
|
||||
|
||||
// Workflow Templates
|
||||
createWorkflowTemplate: (name: string, steps: Omit<WorkflowStep, 'id' | 'status'>[]) => Promise<boolean>;
|
||||
loadWorkflowTemplate: (templateId: string, date: string) => Promise<boolean>;
|
||||
getWorkflowTemplates: () => Promise<any[]>;
|
||||
|
||||
// Analytics and Optimization
|
||||
getWorkflowAnalytics: (startDate: string, endDate: string) => Promise<any>;
|
||||
getBottleneckAnalysis: (period: string) => Promise<any>;
|
||||
getEfficiencyReport: (date: string) => Promise<any>;
|
||||
|
||||
// Real-time Updates
|
||||
subscribeToWorkflowUpdates: (callback: (workflow: DailyWorkflow) => void) => () => void;
|
||||
|
||||
// Utilities
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useBakeryWorkflow = (): BakeryWorkflowState & BakeryWorkflowActions => {
|
||||
const [state, setState] = useState<BakeryWorkflowState>({
|
||||
currentWorkflow: null,
|
||||
workflowHistory: [],
|
||||
activeStep: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const productionService = new ProductionService();
|
||||
const inventoryService = new InventoryService();
|
||||
const salesService = new SalesService();
|
||||
const orderService = new OrderService();
|
||||
|
||||
// Initialize daily workflow
|
||||
const initializeDailyWorkflow = useCallback(async (date: string) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Get existing workflow or create new one
|
||||
const existingWorkflow = await getWorkflowForDate(date);
|
||||
|
||||
if (existingWorkflow) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: existingWorkflow,
|
||||
activeStep: findActiveStep(existingWorkflow.steps),
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
await generateWorkflowFromForecast(date);
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al inicializar flujo de trabajo diario',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Generate workflow from forecast and orders
|
||||
const generateWorkflowFromForecast = useCallback(async (date: string) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Get daily forecast and orders
|
||||
const [forecastData, ordersData, inventoryData] = await Promise.all([
|
||||
productionService.getDailyForecast?.(date),
|
||||
orderService.getOrdersByDate?.(date),
|
||||
inventoryService.getStockLevels(),
|
||||
]);
|
||||
|
||||
const workflow = generateWorkflowSteps(forecastData, ordersData, inventoryData);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: workflow,
|
||||
activeStep: workflow.steps.find(step => step.status === 'pending') || null,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al generar flujo de trabajo desde predicción',
|
||||
}));
|
||||
}
|
||||
}, [productionService, orderService, inventoryService]);
|
||||
|
||||
// Generate workflow steps based on data
|
||||
const generateWorkflowSteps = (forecastData: any, ordersData: any, inventoryData: any): DailyWorkflow => {
|
||||
const steps: WorkflowStep[] = [
|
||||
// Morning prep
|
||||
{
|
||||
id: 'morning_prep',
|
||||
name: 'Preparación matutina',
|
||||
description: 'Verificar equipos, ingredientes y planificación del día',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
estimatedDuration: 30,
|
||||
},
|
||||
|
||||
// Inventory check
|
||||
{
|
||||
id: 'inventory_check',
|
||||
name: 'Control de inventario',
|
||||
description: 'Verificar niveles de stock y calidad de ingredientes',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
estimatedDuration: 20,
|
||||
dependencies: ['morning_prep'],
|
||||
},
|
||||
|
||||
// Production preparation
|
||||
{
|
||||
id: 'production_prep',
|
||||
name: 'Preparación de producción',
|
||||
description: 'Preparar ingredientes y configurar equipos',
|
||||
status: 'pending',
|
||||
priority: 'high',
|
||||
estimatedDuration: 45,
|
||||
dependencies: ['inventory_check'],
|
||||
},
|
||||
|
||||
// Production batches (generated from forecast)
|
||||
...generateProductionSteps(forecastData),
|
||||
|
||||
// Quality control
|
||||
{
|
||||
id: 'quality_control',
|
||||
name: 'Control de calidad',
|
||||
description: 'Verificar calidad de productos terminados',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
estimatedDuration: 30,
|
||||
dependencies: ['production_prep'],
|
||||
},
|
||||
|
||||
// Order fulfillment
|
||||
...generateOrderSteps(ordersData),
|
||||
|
||||
// End of day cleanup
|
||||
{
|
||||
id: 'cleanup',
|
||||
name: 'Limpieza final',
|
||||
description: 'Limpieza de equipos y área de trabajo',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
estimatedDuration: 45,
|
||||
},
|
||||
|
||||
// Daily reporting
|
||||
{
|
||||
id: 'daily_report',
|
||||
name: 'Reporte diario',
|
||||
description: 'Completar reportes de producción y ventas',
|
||||
status: 'pending',
|
||||
priority: 'low',
|
||||
estimatedDuration: 15,
|
||||
dependencies: ['cleanup'],
|
||||
},
|
||||
];
|
||||
|
||||
const totalEstimatedTime = steps.reduce((total, step) => total + step.estimatedDuration, 0);
|
||||
|
||||
return {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
steps,
|
||||
totalEstimatedTime,
|
||||
totalActualTime: 0,
|
||||
completionRate: 0,
|
||||
status: 'not_started',
|
||||
};
|
||||
};
|
||||
|
||||
// Generate production steps from forecast
|
||||
const generateProductionSteps = (forecastData: any): WorkflowStep[] => {
|
||||
if (!forecastData || !forecastData.products) return [];
|
||||
|
||||
return forecastData.products.map((product: any, index: number) => ({
|
||||
id: `production_${product.id}`,
|
||||
name: `Producir ${product.name}`,
|
||||
description: `Producir ${product.estimated_quantity} unidades de ${product.name}`,
|
||||
status: 'pending' as const,
|
||||
priority: product.priority || 'medium' as const,
|
||||
estimatedDuration: product.production_time || 60,
|
||||
dependencies: ['production_prep'],
|
||||
}));
|
||||
};
|
||||
|
||||
// Generate order fulfillment steps
|
||||
const generateOrderSteps = (ordersData: any): WorkflowStep[] => {
|
||||
if (!ordersData || !ordersData.length) return [];
|
||||
|
||||
const specialOrders = ordersData.filter((order: any) => order.type === 'special' || order.priority === 'high');
|
||||
|
||||
return specialOrders.map((order: any) => ({
|
||||
id: `order_${order.id}`,
|
||||
name: `Preparar pedido especial`,
|
||||
description: `Preparar pedido #${order.id} - ${order.items?.length || 0} items`,
|
||||
status: 'pending' as const,
|
||||
priority: order.priority || 'medium' as const,
|
||||
estimatedDuration: order.preparation_time || 30,
|
||||
dependencies: ['production_prep'],
|
||||
}));
|
||||
};
|
||||
|
||||
// Find active step
|
||||
const findActiveStep = (steps: WorkflowStep[]): WorkflowStep | null => {
|
||||
return steps.find(step => step.status === 'in_progress') ||
|
||||
steps.find(step => step.status === 'pending') ||
|
||||
null;
|
||||
};
|
||||
|
||||
// Get workflow for specific date
|
||||
const getWorkflowForDate = async (date: string): Promise<DailyWorkflow | null> => {
|
||||
// This would typically fetch from an API
|
||||
// For now, return null to force generation
|
||||
return null;
|
||||
};
|
||||
|
||||
// Start a workflow step
|
||||
const startStep = useCallback(async (stepId: string): Promise<boolean> => {
|
||||
if (!state.currentWorkflow) return false;
|
||||
|
||||
try {
|
||||
const updatedWorkflow = {
|
||||
...state.currentWorkflow,
|
||||
steps: state.currentWorkflow.steps.map(step =>
|
||||
step.id === stepId
|
||||
? { ...step, status: 'in_progress' as const, startTime: new Date() }
|
||||
: step
|
||||
),
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: updatedWorkflow,
|
||||
activeStep: updatedWorkflow.steps.find(step => step.id === stepId) || null,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al iniciar paso del flujo de trabajo' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentWorkflow]);
|
||||
|
||||
// Complete a workflow step
|
||||
const completeStep = useCallback(async (stepId: string, notes?: string): Promise<boolean> => {
|
||||
if (!state.currentWorkflow) return false;
|
||||
|
||||
try {
|
||||
const step = state.currentWorkflow.steps.find(s => s.id === stepId);
|
||||
if (!step || !step.startTime) return false;
|
||||
|
||||
const endTime = new Date();
|
||||
const actualDuration = Math.round((endTime.getTime() - step.startTime.getTime()) / 60000);
|
||||
|
||||
const updatedWorkflow = {
|
||||
...state.currentWorkflow,
|
||||
steps: state.currentWorkflow.steps.map(s =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
status: 'completed' as const,
|
||||
endTime,
|
||||
actualDuration,
|
||||
notes: notes || s.notes,
|
||||
}
|
||||
: s
|
||||
),
|
||||
};
|
||||
|
||||
// Update completion rate
|
||||
const completedSteps = updatedWorkflow.steps.filter(s => s.status === 'completed');
|
||||
updatedWorkflow.completionRate = (completedSteps.length / updatedWorkflow.steps.length) * 100;
|
||||
updatedWorkflow.totalActualTime = updatedWorkflow.steps
|
||||
.filter(s => s.actualDuration)
|
||||
.reduce((total, s) => total + (s.actualDuration || 0), 0);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: updatedWorkflow,
|
||||
activeStep: findActiveStep(updatedWorkflow.steps),
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al completar paso del flujo de trabajo' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentWorkflow]);
|
||||
|
||||
// Fail a workflow step
|
||||
const failStep = useCallback(async (stepId: string, reason: string): Promise<boolean> => {
|
||||
if (!state.currentWorkflow) return false;
|
||||
|
||||
try {
|
||||
const updatedWorkflow = {
|
||||
...state.currentWorkflow,
|
||||
steps: state.currentWorkflow.steps.map(step =>
|
||||
step.id === stepId
|
||||
? { ...step, status: 'failed' as const, notes: reason }
|
||||
: step
|
||||
),
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: updatedWorkflow,
|
||||
activeStep: findActiveStep(updatedWorkflow.steps),
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al marcar paso como fallido' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentWorkflow]);
|
||||
|
||||
// Skip a workflow step
|
||||
const skipStep = useCallback(async (stepId: string, reason: string): Promise<boolean> => {
|
||||
if (!state.currentWorkflow) return false;
|
||||
|
||||
try {
|
||||
const updatedWorkflow = {
|
||||
...state.currentWorkflow,
|
||||
steps: state.currentWorkflow.steps.map(step =>
|
||||
step.id === stepId
|
||||
? { ...step, status: 'completed' as const, notes: `Omitido: ${reason}` }
|
||||
: step
|
||||
),
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: updatedWorkflow,
|
||||
activeStep: findActiveStep(updatedWorkflow.steps),
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al omitir paso del flujo de trabajo' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentWorkflow]);
|
||||
|
||||
// Update workflow
|
||||
const updateWorkflow = useCallback(async (workflow: Partial<DailyWorkflow>): Promise<boolean> => {
|
||||
if (!state.currentWorkflow) return false;
|
||||
|
||||
try {
|
||||
const updatedWorkflow = { ...state.currentWorkflow, ...workflow };
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: updatedWorkflow,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al actualizar flujo de trabajo' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentWorkflow]);
|
||||
|
||||
// Update step progress
|
||||
const updateStepProgress = useCallback(async (stepId: string, progress: Partial<WorkflowStep>): Promise<boolean> => {
|
||||
if (!state.currentWorkflow) return false;
|
||||
|
||||
try {
|
||||
const updatedWorkflow = {
|
||||
...state.currentWorkflow,
|
||||
steps: state.currentWorkflow.steps.map(step =>
|
||||
step.id === stepId ? { ...step, ...progress } : step
|
||||
),
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentWorkflow: updatedWorkflow,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al actualizar progreso del paso' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentWorkflow]);
|
||||
|
||||
// Placeholder implementations for template and analytics functions
|
||||
const createWorkflowTemplate = useCallback(async (name: string, steps: Omit<WorkflowStep, 'id' | 'status'>[]): Promise<boolean> => {
|
||||
// Implementation would save template to backend
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const loadWorkflowTemplate = useCallback(async (templateId: string, date: string): Promise<boolean> => {
|
||||
// Implementation would load template from backend
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const getWorkflowTemplates = useCallback(async (): Promise<any[]> => {
|
||||
// Implementation would fetch templates from backend
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
const getWorkflowAnalytics = useCallback(async (startDate: string, endDate: string): Promise<any> => {
|
||||
// Implementation would fetch analytics from backend
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const getBottleneckAnalysis = useCallback(async (period: string): Promise<any> => {
|
||||
// Implementation would analyze bottlenecks
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const getEfficiencyReport = useCallback(async (date: string): Promise<any> => {
|
||||
// Implementation would generate efficiency report
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const subscribeToWorkflowUpdates = useCallback((callback: (workflow: DailyWorkflow) => void): (() => void) => {
|
||||
// Implementation would set up real-time subscription
|
||||
return () => {
|
||||
// Cleanup subscription
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (state.currentWorkflow) {
|
||||
await initializeDailyWorkflow(state.currentWorkflow.date);
|
||||
}
|
||||
}, [state.currentWorkflow, initializeDailyWorkflow]);
|
||||
|
||||
// Initialize workflow for today on mount
|
||||
useEffect(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
initializeDailyWorkflow(today);
|
||||
}, [initializeDailyWorkflow]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
initializeDailyWorkflow,
|
||||
generateWorkflowFromForecast,
|
||||
updateWorkflow,
|
||||
startStep,
|
||||
completeStep,
|
||||
failStep,
|
||||
skipStep,
|
||||
updateStepProgress,
|
||||
createWorkflowTemplate,
|
||||
loadWorkflowTemplate,
|
||||
getWorkflowTemplates,
|
||||
getWorkflowAnalytics,
|
||||
getBottleneckAnalysis,
|
||||
getEfficiencyReport,
|
||||
subscribeToWorkflowUpdates,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -4,23 +4,26 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { inventoryService } from '../../services/api/inventory.service';
|
||||
import { salesService } from '../../services/api/sales.service';
|
||||
import { authService } from '../../services/api/auth.service';
|
||||
import { tenantService } from '../../services/api/tenant.service';
|
||||
import { useAuthUser } from '../../stores/auth.store';
|
||||
import { useAlertActions } from '../../stores/alerts.store';
|
||||
import {
|
||||
ProductSuggestion,
|
||||
ProductSuggestionsResponse,
|
||||
InventoryCreationResponse
|
||||
} from '../../types/inventory.types';
|
||||
import {
|
||||
BusinessModelGuide,
|
||||
BusinessModelType,
|
||||
TemplateData
|
||||
} from '../../types/sales.types';
|
||||
import { OnboardingStatus } from '../../types/auth.types';
|
||||
import {
|
||||
// Auth hooks
|
||||
useAuthProfile,
|
||||
// Tenant hooks
|
||||
useRegisterBakery,
|
||||
// Sales hooks
|
||||
useValidateSalesRecord,
|
||||
// Inventory hooks
|
||||
useClassifyProductsBatch,
|
||||
useCreateIngredient,
|
||||
// Classification hooks
|
||||
useBusinessModelAnalysis,
|
||||
// Types
|
||||
type User,
|
||||
type BakeryRegistration,
|
||||
type ProductSuggestionResponse,
|
||||
type BusinessModelAnalysisResponse,
|
||||
type ProductClassificationRequest,
|
||||
} from '../../api';
|
||||
|
||||
export interface OnboardingStep {
|
||||
id: string;
|
||||
@@ -33,16 +36,7 @@ export interface OnboardingStep {
|
||||
|
||||
export interface OnboardingData {
|
||||
// Step 1: Setup
|
||||
bakery?: {
|
||||
name: string;
|
||||
business_model: BusinessModelType;
|
||||
address: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
};
|
||||
bakery?: BakeryRegistration;
|
||||
|
||||
// Step 2: Data Processing
|
||||
files?: {
|
||||
@@ -64,8 +58,8 @@ export interface OnboardingData {
|
||||
};
|
||||
|
||||
// Step 3: Review
|
||||
suggestions?: ProductSuggestion[];
|
||||
approvedSuggestions?: ProductSuggestion[];
|
||||
suggestions?: ProductSuggestionResponse[];
|
||||
approvedSuggestions?: ProductSuggestionResponse[];
|
||||
reviewCompleted?: boolean;
|
||||
|
||||
// Step 4: Inventory
|
||||
@@ -99,7 +93,7 @@ interface OnboardingState {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isInitialized: boolean;
|
||||
onboardingStatus: OnboardingStatus | null;
|
||||
onboardingStatus: User['onboarding_status'] | null;
|
||||
}
|
||||
|
||||
interface OnboardingActions {
|
||||
@@ -113,12 +107,12 @@ interface OnboardingActions {
|
||||
validateCurrentStep: () => string | null;
|
||||
|
||||
// Step-specific Actions
|
||||
createTenant: (bakeryData: OnboardingData['bakery']) => Promise<boolean>;
|
||||
createTenant: (bakeryData: BakeryRegistration) => Promise<boolean>;
|
||||
processSalesFile: (file: File, onProgress: (progress: number, stage: string, message: string) => void) => Promise<boolean>;
|
||||
generateInventorySuggestions: (productList: string[]) => Promise<ProductSuggestionsResponse | null>;
|
||||
createInventoryFromSuggestions: (suggestions: ProductSuggestion[]) => Promise<InventoryCreationResponse | null>;
|
||||
getBusinessModelGuide: (model: BusinessModelType) => Promise<BusinessModelGuide | null>;
|
||||
downloadTemplate: (templateData: TemplateData, filename: string, format?: 'csv' | 'json') => void;
|
||||
generateInventorySuggestions: (productList: string[]) => Promise<ProductSuggestionResponse[] | null>;
|
||||
createInventoryFromSuggestions: (suggestions: ProductSuggestionResponse[]) => Promise<any | null>;
|
||||
getBusinessModelGuide: (model: string) => Promise<BusinessModelAnalysisResponse | null>;
|
||||
downloadTemplate: (templateData: any, filename: string, format?: 'csv' | 'json') => void;
|
||||
|
||||
// Completion
|
||||
completeOnboarding: () => Promise<boolean>;
|
||||
@@ -138,7 +132,7 @@ const DEFAULT_STEPS: OnboardingStep[] = [
|
||||
isCompleted: false,
|
||||
validation: (data: OnboardingData) => {
|
||||
if (!data.bakery?.name) return 'El nombre de la panadería es requerido';
|
||||
if (!data.bakery?.business_model) return 'El modelo de negocio es requerido';
|
||||
if (!data.bakery?.business_type) return 'El tipo de negocio es requerido';
|
||||
if (!data.bakery?.address) return 'La dirección es requerida';
|
||||
if (!data.bakery?.city) return 'La ciudad es requerida';
|
||||
if (!data.bakery?.postal_code) return 'El código postal es requerido';
|
||||
@@ -221,7 +215,13 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const { createAlert } = useAlertActions();
|
||||
|
||||
// React Query hooks
|
||||
const { data: profile } = useAuthProfile();
|
||||
const registerBakeryMutation = useRegisterBakery();
|
||||
const validateSalesMutation = useValidateSalesRecord();
|
||||
const classifyProductsMutation = useClassifyProductsBatch();
|
||||
const businessModelMutation = useBusinessModelAnalysis();
|
||||
|
||||
// Initialize onboarding status
|
||||
useEffect(() => {
|
||||
@@ -236,14 +236,7 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
const validation = validateCurrentStep();
|
||||
|
||||
if (validation) {
|
||||
createAlert({
|
||||
type: 'error',
|
||||
category: 'validation',
|
||||
priority: 'high',
|
||||
title: 'Validación fallida',
|
||||
message: validation,
|
||||
source: 'onboarding'
|
||||
});
|
||||
setState(prev => ({ ...prev, error: validation }));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -302,54 +295,25 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
}, [state.currentStep, state.steps, state.data]);
|
||||
|
||||
// Step-specific Actions
|
||||
const createTenant = useCallback(async (bakeryData: OnboardingData['bakery']): Promise<boolean> => {
|
||||
const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise<boolean> => {
|
||||
if (!bakeryData) return false;
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await tenantService.createTenant({
|
||||
name: bakeryData.name,
|
||||
description: bakeryData.description || '',
|
||||
business_type: bakeryData.business_model,
|
||||
settings: {
|
||||
address: bakeryData.address,
|
||||
city: bakeryData.city,
|
||||
postal_code: bakeryData.postal_code,
|
||||
phone: bakeryData.phone,
|
||||
email: bakeryData.email,
|
||||
}
|
||||
await registerBakeryMutation.mutateAsync({
|
||||
bakeryData
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
updateStepData('setup', { bakery: bakeryData });
|
||||
createAlert({
|
||||
type: 'success',
|
||||
category: 'system',
|
||||
priority: 'medium',
|
||||
title: 'Tenant creado',
|
||||
message: 'Tu panadería ha sido configurada exitosamente',
|
||||
source: 'onboarding'
|
||||
});
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.error || 'Error creating tenant');
|
||||
}
|
||||
|
||||
updateStepData('setup', { bakery: bakeryData });
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error desconocido';
|
||||
setState(prev => ({ ...prev, isLoading: false, error: errorMessage }));
|
||||
createAlert({
|
||||
type: 'error',
|
||||
category: 'system',
|
||||
priority: 'high',
|
||||
title: 'Error al crear tenant',
|
||||
message: errorMessage,
|
||||
source: 'onboarding'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}, [updateStepData, createAlert]);
|
||||
}, [updateStepData, registerBakeryMutation]);
|
||||
|
||||
const processSalesFile = useCallback(async (
|
||||
file: File,
|
||||
@@ -360,16 +324,19 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
try {
|
||||
// Stage 1: Validate file
|
||||
onProgress(20, 'validating', 'Validando estructura del archivo...');
|
||||
const validationResult = await salesService.validateSalesData(file);
|
||||
|
||||
const validationResult = await validateSalesMutation.mutateAsync({
|
||||
// Convert file to the expected format for validation
|
||||
data: {
|
||||
// This would need to be adapted based on the actual API structure
|
||||
file_data: file
|
||||
}
|
||||
});
|
||||
|
||||
onProgress(40, 'validating', 'Verificando integridad de datos...');
|
||||
|
||||
if (!validationResult.is_valid) {
|
||||
throw new Error('Archivo de datos inválido');
|
||||
}
|
||||
|
||||
if (!validationResult.product_list || validationResult.product_list.length === 0) {
|
||||
throw new Error('No se encontraron productos en el archivo');
|
||||
if (!validationResult || !validationResult.product_list || validationResult.product_list.length === 0) {
|
||||
throw new Error('No se encontraron productos válidos en el archivo');
|
||||
}
|
||||
|
||||
// Stage 2: Generate AI suggestions
|
||||
@@ -384,7 +351,7 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
files: { salesData: file },
|
||||
processingStage: 'completed',
|
||||
processingResults: validationResult,
|
||||
suggestions: suggestions?.suggestions || []
|
||||
suggestions: suggestions || []
|
||||
});
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
@@ -402,36 +369,48 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [updateStepData]);
|
||||
}, [updateStepData, validateSalesMutation]);
|
||||
|
||||
const generateInventorySuggestions = useCallback(async (productList: string[]): Promise<ProductSuggestionsResponse | null> => {
|
||||
const generateInventorySuggestions = useCallback(async (productList: string[]): Promise<ProductSuggestionResponse[] | null> => {
|
||||
try {
|
||||
const response = await inventoryService.generateInventorySuggestions(productList);
|
||||
return response.success ? response.data : null;
|
||||
const response = await classifyProductsMutation.mutateAsync({
|
||||
products: productList.map(name => ({ name, description: '' }))
|
||||
});
|
||||
return response.suggestions || [];
|
||||
} catch (error) {
|
||||
console.error('Error generating inventory suggestions:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
}, [classifyProductsMutation]);
|
||||
|
||||
const createInventoryFromSuggestions = useCallback(async (suggestions: ProductSuggestion[]): Promise<InventoryCreationResponse | null> => {
|
||||
const createInventoryFromSuggestions = useCallback(async (suggestions: ProductSuggestionResponse[]): Promise<any | null> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await inventoryService.createInventoryFromSuggestions(suggestions);
|
||||
// Create ingredients from approved suggestions
|
||||
const createdItems = [];
|
||||
const inventoryMapping: { [key: string]: string } = {};
|
||||
|
||||
if (response.success) {
|
||||
updateStepData('inventory', {
|
||||
inventoryItems: response.data.created_items,
|
||||
inventoryMapping: response.data.inventory_mapping,
|
||||
inventoryConfigured: true
|
||||
for (const suggestion of suggestions) {
|
||||
// This would need to be adapted based on actual API structure
|
||||
const createdItem = await useCreateIngredient().mutateAsync({
|
||||
name: suggestion.name,
|
||||
category: suggestion.category,
|
||||
// Map other suggestion properties to ingredient properties
|
||||
});
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(response.error || 'Error creating inventory');
|
||||
createdItems.push(createdItem);
|
||||
inventoryMapping[suggestion.name] = createdItem.id;
|
||||
}
|
||||
|
||||
updateStepData('inventory', {
|
||||
inventoryItems: createdItems,
|
||||
inventoryMapping,
|
||||
inventoryConfigured: true
|
||||
});
|
||||
|
||||
setState(prev => ({ ...prev, isLoading: false }));
|
||||
return { created_items: createdItems, inventory_mapping: inventoryMapping };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error creating inventory';
|
||||
setState(prev => ({ ...prev, isLoading: false, error: errorMessage }));
|
||||
@@ -439,28 +418,49 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
}
|
||||
}, [updateStepData]);
|
||||
|
||||
const getBusinessModelGuide = useCallback(async (model: BusinessModelType): Promise<BusinessModelGuide | null> => {
|
||||
const getBusinessModelGuide = useCallback(async (model: string): Promise<BusinessModelAnalysisResponse | null> => {
|
||||
try {
|
||||
const response = await salesService.getBusinessModelGuide(model);
|
||||
return response.success ? response.data : null;
|
||||
const response = await businessModelMutation.mutateAsync({
|
||||
business_model: model,
|
||||
// Include any other required parameters
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error getting business model guide:', error);
|
||||
return null;
|
||||
}
|
||||
}, [businessModelMutation]);
|
||||
|
||||
const downloadTemplate = useCallback((templateData: any, filename: string, format: 'csv' | 'json' = 'csv') => {
|
||||
// Create and download template file
|
||||
const content = format === 'json' ? JSON.stringify(templateData, null, 2) : convertToCSV(templateData);
|
||||
const blob = new Blob([content], { type: format === 'json' ? 'application/json' : 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${filename}.${format}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
const downloadTemplate = useCallback((templateData: TemplateData, filename: string, format: 'csv' | 'json' = 'csv') => {
|
||||
salesService.downloadTemplate(templateData, filename, format);
|
||||
}, []);
|
||||
const convertToCSV = (data: any): string => {
|
||||
// Simple CSV conversion - this should be adapted based on the actual data structure
|
||||
if (Array.isArray(data)) {
|
||||
const headers = Object.keys(data[0] || {}).join(',');
|
||||
const rows = data.map(item => Object.values(item).join(',')).join('\n');
|
||||
return `${headers}\n${rows}`;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
};
|
||||
|
||||
const checkOnboardingStatus = useCallback(async () => {
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await authService.checkOnboardingStatus();
|
||||
// Use the profile data to get onboarding status
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
onboardingStatus: response.success ? response.data : null,
|
||||
onboardingStatus: profile?.onboarding_status || null,
|
||||
isInitialized: true,
|
||||
isLoading: false
|
||||
}));
|
||||
@@ -471,58 +471,33 @@ export const useOnboarding = (): OnboardingState & OnboardingActions => {
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await authService.completeOnboarding({
|
||||
completedAt: new Date().toISOString(),
|
||||
data: state.data
|
||||
});
|
||||
// Mark onboarding as completed - this would typically involve an API call
|
||||
// For now, we'll simulate success and navigate to dashboard
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
steps: prev.steps.map(step => ({ ...step, isCompleted: true }))
|
||||
}));
|
||||
|
||||
if (response.success) {
|
||||
createAlert({
|
||||
type: 'success',
|
||||
category: 'system',
|
||||
priority: 'high',
|
||||
title: '¡Onboarding completado!',
|
||||
message: 'Has completado exitosamente la configuración inicial',
|
||||
source: 'onboarding'
|
||||
});
|
||||
// Navigate to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/app/dashboard');
|
||||
}, 2000);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
steps: prev.steps.map(step => ({ ...step, isCompleted: true }))
|
||||
}));
|
||||
|
||||
// Navigate to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/app/dashboard');
|
||||
}, 2000);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.error || 'Error completing onboarding');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error completing onboarding';
|
||||
setState(prev => ({ ...prev, isLoading: false, error: errorMessage }));
|
||||
|
||||
createAlert({
|
||||
type: 'error',
|
||||
category: 'system',
|
||||
priority: 'high',
|
||||
title: 'Error al completar onboarding',
|
||||
message: errorMessage,
|
||||
source: 'onboarding'
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [state.data, createAlert, navigate]);
|
||||
}, [state.data, navigate]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
@@ -1,655 +0,0 @@
|
||||
/**
|
||||
* Production schedule hook for managing bakery production scheduling and capacity planning
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ProductionService } from '../../services/api/production.service';
|
||||
import { InventoryService } from '../../services/api/inventory.service';
|
||||
import { ForecastingService } from '../../services/api/forecasting.service';
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: string;
|
||||
recipeId: string;
|
||||
recipeName: string;
|
||||
quantity: number;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
estimatedStartTime: Date;
|
||||
estimatedEndTime: Date;
|
||||
actualStartTime?: Date;
|
||||
actualEndTime?: Date;
|
||||
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'delayed';
|
||||
assignedEquipment?: string[];
|
||||
assignedStaff?: string[];
|
||||
requiredIngredients: {
|
||||
ingredientId: string;
|
||||
ingredientName: string;
|
||||
requiredQuantity: number;
|
||||
availableQuantity: number;
|
||||
unit: string;
|
||||
}[];
|
||||
notes?: string;
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
export interface ProductionSlot {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAvailable: boolean;
|
||||
assignedItems: ScheduleItem[];
|
||||
capacity: number;
|
||||
utilizationRate: number;
|
||||
}
|
||||
|
||||
export interface DailySchedule {
|
||||
date: string;
|
||||
items: ScheduleItem[];
|
||||
slots: ProductionSlot[];
|
||||
totalCapacity: number;
|
||||
totalUtilization: number;
|
||||
efficiency: number;
|
||||
bottlenecks: string[];
|
||||
}
|
||||
|
||||
interface ProductionScheduleState {
|
||||
currentSchedule: DailySchedule | null;
|
||||
scheduleHistory: DailySchedule[];
|
||||
availableRecipes: any[];
|
||||
equipmentStatus: any[];
|
||||
staffAvailability: any[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
constraints: {
|
||||
maxDailyCapacity: number;
|
||||
workingHours: { start: string; end: string };
|
||||
equipmentLimitations: Record<string, number>;
|
||||
staffLimitations: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductionScheduleActions {
|
||||
// Schedule Management
|
||||
loadSchedule: (date: string) => Promise<void>;
|
||||
createSchedule: (date: string) => Promise<void>;
|
||||
updateSchedule: (schedule: Partial<DailySchedule>) => Promise<boolean>;
|
||||
|
||||
// Schedule Items
|
||||
addScheduleItem: (item: Omit<ScheduleItem, 'id'>) => Promise<boolean>;
|
||||
updateScheduleItem: (id: string, item: Partial<ScheduleItem>) => Promise<boolean>;
|
||||
removeScheduleItem: (id: string) => Promise<boolean>;
|
||||
moveScheduleItem: (id: string, newStartTime: Date) => Promise<boolean>;
|
||||
|
||||
// Automatic Scheduling
|
||||
autoSchedule: (date: string, items: Omit<ScheduleItem, 'id'>[]) => Promise<boolean>;
|
||||
optimizeSchedule: (date: string) => Promise<boolean>;
|
||||
generateFromForecast: (date: string) => Promise<boolean>;
|
||||
|
||||
// Capacity Management
|
||||
checkCapacity: (date: string, newItem: Omit<ScheduleItem, 'id'>) => Promise<{ canSchedule: boolean; suggestedTime?: Date; conflicts?: string[] }>;
|
||||
getAvailableSlots: (date: string, duration: number) => Promise<ProductionSlot[]>;
|
||||
calculateUtilization: (date: string) => Promise<number>;
|
||||
|
||||
// Resource Management
|
||||
checkIngredientAvailability: (items: ScheduleItem[]) => Promise<{ available: boolean; shortages: any[] }>;
|
||||
checkEquipmentAvailability: (date: string, equipment: string[], timeSlot: { start: Date; end: Date }) => Promise<boolean>;
|
||||
checkStaffAvailability: (date: string, staff: string[], timeSlot: { start: Date; end: Date }) => Promise<boolean>;
|
||||
|
||||
// Analytics and Optimization
|
||||
getScheduleAnalytics: (startDate: string, endDate: string) => Promise<any>;
|
||||
getBottleneckAnalysis: (date: string) => Promise<any>;
|
||||
getEfficiencyReport: (period: string) => Promise<any>;
|
||||
predictDelays: (date: string) => Promise<any>;
|
||||
|
||||
// Templates and Presets
|
||||
saveScheduleTemplate: (name: string, template: Omit<ScheduleItem, 'id'>[]) => Promise<boolean>;
|
||||
loadScheduleTemplate: (templateId: string, date: string) => Promise<boolean>;
|
||||
getScheduleTemplates: () => Promise<any[]>;
|
||||
|
||||
// Utilities
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useProductionSchedule = (): ProductionScheduleState & ProductionScheduleActions => {
|
||||
const [state, setState] = useState<ProductionScheduleState>({
|
||||
currentSchedule: null,
|
||||
scheduleHistory: [],
|
||||
availableRecipes: [],
|
||||
equipmentStatus: [],
|
||||
staffAvailability: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
constraints: {
|
||||
maxDailyCapacity: 8 * 60, // 8 hours in minutes
|
||||
workingHours: { start: '06:00', end: '20:00' },
|
||||
equipmentLimitations: {},
|
||||
staffLimitations: {},
|
||||
},
|
||||
});
|
||||
|
||||
const productionService = new ProductionService();
|
||||
const inventoryService = new InventoryService();
|
||||
const forecastingService = new ForecastingService();
|
||||
|
||||
// Load schedule for specific date
|
||||
const loadSchedule = useCallback(async (date: string) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Get schedule data from API
|
||||
const scheduleData = await getScheduleFromAPI(date);
|
||||
|
||||
if (scheduleData) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentSchedule: scheduleData,
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
// Create new schedule if none exists
|
||||
await createSchedule(date);
|
||||
}
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al cargar programación de producción',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create new schedule
|
||||
const createSchedule = useCallback(async (date: string) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const workingHours = generateWorkingHours(date, state.constraints.workingHours);
|
||||
const slots = generateTimeSlots(workingHours, 30); // 30-minute slots
|
||||
|
||||
const newSchedule: DailySchedule = {
|
||||
date,
|
||||
items: [],
|
||||
slots,
|
||||
totalCapacity: state.constraints.maxDailyCapacity,
|
||||
totalUtilization: 0,
|
||||
efficiency: 0,
|
||||
bottlenecks: [],
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentSchedule: newSchedule,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al crear nueva programación',
|
||||
}));
|
||||
}
|
||||
}, [state.constraints]);
|
||||
|
||||
// Generate working hours for a date
|
||||
const generateWorkingHours = (date: string, workingHours: { start: string; end: string }) => {
|
||||
const startTime = new Date(`${date}T${workingHours.start}`);
|
||||
const endTime = new Date(`${date}T${workingHours.end}`);
|
||||
return { startTime, endTime };
|
||||
};
|
||||
|
||||
// Generate time slots
|
||||
const generateTimeSlots = (workingHours: { startTime: Date; endTime: Date }, slotDuration: number): ProductionSlot[] => {
|
||||
const slots: ProductionSlot[] = [];
|
||||
const current = new Date(workingHours.startTime);
|
||||
|
||||
while (current < workingHours.endTime) {
|
||||
const slotEnd = new Date(current.getTime() + slotDuration * 60000);
|
||||
|
||||
slots.push({
|
||||
startTime: new Date(current),
|
||||
endTime: slotEnd,
|
||||
isAvailable: true,
|
||||
assignedItems: [],
|
||||
capacity: 1,
|
||||
utilizationRate: 0,
|
||||
});
|
||||
|
||||
current.setTime(slotEnd.getTime());
|
||||
}
|
||||
|
||||
return slots;
|
||||
};
|
||||
|
||||
// Add schedule item
|
||||
const addScheduleItem = useCallback(async (item: Omit<ScheduleItem, 'id'>): Promise<boolean> => {
|
||||
if (!state.currentSchedule) return false;
|
||||
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
|
||||
try {
|
||||
// Check capacity and resources
|
||||
const capacityCheck = await checkCapacity(state.currentSchedule.date, item);
|
||||
|
||||
if (!capacityCheck.canSchedule) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: `No se puede programar: ${capacityCheck.conflicts?.join(', ')}`,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
const newItem: ScheduleItem = {
|
||||
...item,
|
||||
id: generateScheduleItemId(),
|
||||
};
|
||||
|
||||
const updatedSchedule = {
|
||||
...state.currentSchedule,
|
||||
items: [...state.currentSchedule.items, newItem],
|
||||
};
|
||||
|
||||
// Recalculate utilization and efficiency
|
||||
recalculateScheduleMetrics(updatedSchedule);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentSchedule: updatedSchedule,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al agregar item a la programación' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentSchedule]);
|
||||
|
||||
// Update schedule item
|
||||
const updateScheduleItem = useCallback(async (id: string, item: Partial<ScheduleItem>): Promise<boolean> => {
|
||||
if (!state.currentSchedule) return false;
|
||||
|
||||
try {
|
||||
const updatedSchedule = {
|
||||
...state.currentSchedule,
|
||||
items: state.currentSchedule.items.map(scheduleItem =>
|
||||
scheduleItem.id === id ? { ...scheduleItem, ...item } : scheduleItem
|
||||
),
|
||||
};
|
||||
|
||||
recalculateScheduleMetrics(updatedSchedule);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentSchedule: updatedSchedule,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al actualizar item de programación' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentSchedule]);
|
||||
|
||||
// Remove schedule item
|
||||
const removeScheduleItem = useCallback(async (id: string): Promise<boolean> => {
|
||||
if (!state.currentSchedule) return false;
|
||||
|
||||
try {
|
||||
const updatedSchedule = {
|
||||
...state.currentSchedule,
|
||||
items: state.currentSchedule.items.filter(item => item.id !== id),
|
||||
};
|
||||
|
||||
recalculateScheduleMetrics(updatedSchedule);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentSchedule: updatedSchedule,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({ ...prev, error: 'Error al eliminar item de programación' }));
|
||||
return false;
|
||||
}
|
||||
}, [state.currentSchedule]);
|
||||
|
||||
// Move schedule item to new time
|
||||
const moveScheduleItem = useCallback(async (id: string, newStartTime: Date): Promise<boolean> => {
|
||||
if (!state.currentSchedule) return false;
|
||||
|
||||
const item = state.currentSchedule.items.find(item => item.id === id);
|
||||
if (!item) return false;
|
||||
|
||||
const duration = item.estimatedEndTime.getTime() - item.estimatedStartTime.getTime();
|
||||
const newEndTime = new Date(newStartTime.getTime() + duration);
|
||||
|
||||
return updateScheduleItem(id, {
|
||||
estimatedStartTime: newStartTime,
|
||||
estimatedEndTime: newEndTime,
|
||||
});
|
||||
}, [state.currentSchedule, updateScheduleItem]);
|
||||
|
||||
// Auto-schedule items
|
||||
const autoSchedule = useCallback(async (date: string, items: Omit<ScheduleItem, 'id'>[]): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Sort items by priority and estimated duration
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
const priorityOrder = { urgent: 4, high: 3, medium: 2, low: 1 };
|
||||
return priorityOrder[b.priority] - priorityOrder[a.priority];
|
||||
});
|
||||
|
||||
const schedule = await createOptimalSchedule(date, sortedItems);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentSchedule: schedule,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al programar automáticamente',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create optimal schedule
|
||||
const createOptimalSchedule = async (date: string, items: Omit<ScheduleItem, 'id'>[]): Promise<DailySchedule> => {
|
||||
const workingHours = generateWorkingHours(date, state.constraints.workingHours);
|
||||
const slots = generateTimeSlots(workingHours, 30);
|
||||
|
||||
const scheduledItems: ScheduleItem[] = [];
|
||||
let currentTime = new Date(workingHours.startTime);
|
||||
|
||||
for (const item of items) {
|
||||
const duration = item.estimatedEndTime.getTime() - item.estimatedStartTime.getTime();
|
||||
const endTime = new Date(currentTime.getTime() + duration);
|
||||
|
||||
// Check if item fits in remaining time
|
||||
if (endTime <= workingHours.endTime) {
|
||||
scheduledItems.push({
|
||||
...item,
|
||||
id: generateScheduleItemId(),
|
||||
estimatedStartTime: new Date(currentTime),
|
||||
estimatedEndTime: endTime,
|
||||
});
|
||||
|
||||
currentTime = endTime;
|
||||
}
|
||||
}
|
||||
|
||||
const schedule: DailySchedule = {
|
||||
date,
|
||||
items: scheduledItems,
|
||||
slots,
|
||||
totalCapacity: state.constraints.maxDailyCapacity,
|
||||
totalUtilization: 0,
|
||||
efficiency: 0,
|
||||
bottlenecks: [],
|
||||
};
|
||||
|
||||
recalculateScheduleMetrics(schedule);
|
||||
return schedule;
|
||||
};
|
||||
|
||||
// Check capacity for new item
|
||||
const checkCapacity = useCallback(async (date: string, newItem: Omit<ScheduleItem, 'id'>) => {
|
||||
const conflicts: string[] = [];
|
||||
let canSchedule = true;
|
||||
|
||||
// Check time conflicts
|
||||
if (state.currentSchedule) {
|
||||
const hasTimeConflict = state.currentSchedule.items.some(item => {
|
||||
return (newItem.estimatedStartTime < item.estimatedEndTime &&
|
||||
newItem.estimatedEndTime > item.estimatedStartTime);
|
||||
});
|
||||
|
||||
if (hasTimeConflict) {
|
||||
conflicts.push('Conflicto de horario');
|
||||
canSchedule = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check ingredient availability
|
||||
const ingredientCheck = await checkIngredientAvailability([newItem as ScheduleItem]);
|
||||
if (!ingredientCheck.available) {
|
||||
conflicts.push('Ingredientes insuficientes');
|
||||
canSchedule = false;
|
||||
}
|
||||
|
||||
return { canSchedule, conflicts };
|
||||
}, [state.currentSchedule]);
|
||||
|
||||
// Get available slots
|
||||
const getAvailableSlots = useCallback(async (date: string, duration: number): Promise<ProductionSlot[]> => {
|
||||
if (!state.currentSchedule) return [];
|
||||
|
||||
return state.currentSchedule.slots.filter(slot => {
|
||||
const slotDuration = slot.endTime.getTime() - slot.startTime.getTime();
|
||||
return slot.isAvailable && slotDuration >= duration * 60000;
|
||||
});
|
||||
}, [state.currentSchedule]);
|
||||
|
||||
// Check ingredient availability
|
||||
const checkIngredientAvailability = useCallback(async (items: ScheduleItem[]) => {
|
||||
try {
|
||||
const stockLevels = await inventoryService.getStockLevels();
|
||||
const shortages: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
for (const ingredient of item.requiredIngredients) {
|
||||
const stock = stockLevels.data?.find((s: any) => s.ingredient_id === ingredient.ingredientId);
|
||||
if (!stock || stock.current_quantity < ingredient.requiredQuantity) {
|
||||
shortages.push({
|
||||
ingredientName: ingredient.ingredientName,
|
||||
required: ingredient.requiredQuantity,
|
||||
available: stock?.current_quantity || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { available: shortages.length === 0, shortages };
|
||||
} catch (error) {
|
||||
return { available: false, shortages: [] };
|
||||
}
|
||||
}, [inventoryService]);
|
||||
|
||||
// Generate from forecast
|
||||
const generateFromForecast = useCallback(async (date: string): Promise<boolean> => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
// Get forecast data
|
||||
const forecast = await forecastingService.generateDemandForecast('default', 1);
|
||||
|
||||
if (!forecast) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'No se pudo obtener predicción de demanda',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert forecast to schedule items
|
||||
const items = convertForecastToScheduleItems(forecast);
|
||||
|
||||
// Auto-schedule the items
|
||||
return await autoSchedule(date, items);
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Error al generar programación desde predicción',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
}, [forecastingService, autoSchedule]);
|
||||
|
||||
// Convert forecast to schedule items
|
||||
const convertForecastToScheduleItems = (forecast: any): Omit<ScheduleItem, 'id'>[] => {
|
||||
if (!forecast.products) return [];
|
||||
|
||||
return forecast.products.map((product: any) => ({
|
||||
recipeId: product.recipe_id || `recipe_${product.id}`,
|
||||
recipeName: product.name,
|
||||
quantity: product.estimated_quantity || 1,
|
||||
priority: 'medium' as const,
|
||||
estimatedStartTime: new Date(),
|
||||
estimatedEndTime: new Date(Date.now() + (product.production_time || 60) * 60000),
|
||||
status: 'scheduled' as const,
|
||||
requiredIngredients: product.ingredients || [],
|
||||
}));
|
||||
};
|
||||
|
||||
// Recalculate schedule metrics
|
||||
const recalculateScheduleMetrics = (schedule: DailySchedule) => {
|
||||
const totalScheduledTime = schedule.items.reduce((total, item) => {
|
||||
return total + (item.estimatedEndTime.getTime() - item.estimatedStartTime.getTime());
|
||||
}, 0);
|
||||
|
||||
schedule.totalUtilization = (totalScheduledTime / (schedule.totalCapacity * 60000)) * 100;
|
||||
schedule.efficiency = calculateEfficiency(schedule.items);
|
||||
schedule.bottlenecks = identifyBottlenecks(schedule.items);
|
||||
};
|
||||
|
||||
// Calculate efficiency
|
||||
const calculateEfficiency = (items: ScheduleItem[]): number => {
|
||||
if (items.length === 0) return 0;
|
||||
|
||||
const completedItems = items.filter(item => item.status === 'completed');
|
||||
return (completedItems.length / items.length) * 100;
|
||||
};
|
||||
|
||||
// Identify bottlenecks
|
||||
const identifyBottlenecks = (items: ScheduleItem[]): string[] => {
|
||||
const bottlenecks: string[] = [];
|
||||
|
||||
// Check for equipment conflicts
|
||||
const equipmentUsage: Record<string, number> = {};
|
||||
items.forEach(item => {
|
||||
item.assignedEquipment?.forEach(equipment => {
|
||||
equipmentUsage[equipment] = (equipmentUsage[equipment] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(equipmentUsage).forEach(([equipment, usage]) => {
|
||||
if (usage > 1) {
|
||||
bottlenecks.push(`Conflicto de equipamiento: ${equipment}`);
|
||||
}
|
||||
});
|
||||
|
||||
return bottlenecks;
|
||||
};
|
||||
|
||||
// Generate unique ID
|
||||
const generateScheduleItemId = (): string => {
|
||||
return `schedule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
// Get schedule from API (placeholder)
|
||||
const getScheduleFromAPI = async (date: string): Promise<DailySchedule | null> => {
|
||||
// This would fetch from actual API
|
||||
return null;
|
||||
};
|
||||
|
||||
// Placeholder implementations for remaining functions
|
||||
const updateSchedule = useCallback(async (schedule: Partial<DailySchedule>): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const optimizeSchedule = useCallback(async (date: string): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const calculateUtilization = useCallback(async (date: string): Promise<number> => {
|
||||
return state.currentSchedule?.totalUtilization || 0;
|
||||
}, [state.currentSchedule]);
|
||||
|
||||
const checkEquipmentAvailability = useCallback(async (date: string, equipment: string[], timeSlot: { start: Date; end: Date }): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const checkStaffAvailability = useCallback(async (date: string, staff: string[], timeSlot: { start: Date; end: Date }): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const getScheduleAnalytics = useCallback(async (startDate: string, endDate: string): Promise<any> => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const getBottleneckAnalysis = useCallback(async (date: string): Promise<any> => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const getEfficiencyReport = useCallback(async (period: string): Promise<any> => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const predictDelays = useCallback(async (date: string): Promise<any> => {
|
||||
return {};
|
||||
}, []);
|
||||
|
||||
const saveScheduleTemplate = useCallback(async (name: string, template: Omit<ScheduleItem, 'id'>[]): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const loadScheduleTemplate = useCallback(async (templateId: string, date: string): Promise<boolean> => {
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const getScheduleTemplates = useCallback(async (): Promise<any[]> => {
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (state.currentSchedule) {
|
||||
await loadSchedule(state.currentSchedule.date);
|
||||
}
|
||||
}, [state.currentSchedule, loadSchedule]);
|
||||
|
||||
// Load today's schedule on mount
|
||||
useEffect(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
loadSchedule(today);
|
||||
}, [loadSchedule]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
loadSchedule,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
addScheduleItem,
|
||||
updateScheduleItem,
|
||||
removeScheduleItem,
|
||||
moveScheduleItem,
|
||||
autoSchedule,
|
||||
optimizeSchedule,
|
||||
generateFromForecast,
|
||||
checkCapacity,
|
||||
getAvailableSlots,
|
||||
calculateUtilization,
|
||||
checkIngredientAvailability,
|
||||
checkEquipmentAvailability,
|
||||
checkStaffAvailability,
|
||||
getScheduleAnalytics,
|
||||
getBottleneckAnalysis,
|
||||
getEfficiencyReport,
|
||||
predictDelays,
|
||||
saveScheduleTemplate,
|
||||
loadScheduleTemplate,
|
||||
getScheduleTemplates,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
// Mock auth hook for testing
|
||||
export const useAuth = () => {
|
||||
return {
|
||||
user: {
|
||||
id: 'user_123',
|
||||
tenant_id: 'tenant_456',
|
||||
email: 'user@example.com',
|
||||
name: 'Usuario Demo'
|
||||
},
|
||||
isAuthenticated: true,
|
||||
login: async (credentials: any) => {
|
||||
console.log('Mock login:', credentials);
|
||||
},
|
||||
logout: () => {
|
||||
console.log('Mock logout');
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,313 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Brain, TrendingUp, AlertTriangle, Lightbulb, Target, Zap, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const insights = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'optimization',
|
||||
priority: 'high',
|
||||
title: 'Optimización de Producción de Croissants',
|
||||
description: 'La demanda de croissants aumenta un 23% los viernes. Recomendamos incrementar la producción en 15 unidades.',
|
||||
impact: 'Aumento estimado de ingresos: €180/semana',
|
||||
confidence: 87,
|
||||
category: 'production',
|
||||
timestamp: '2024-01-26 09:30',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
currentProduction: 45,
|
||||
recommendedProduction: 60,
|
||||
expectedIncrease: '+23%'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'alert',
|
||||
priority: 'medium',
|
||||
title: 'Patrón de Compra en Tardes',
|
||||
description: 'Los clientes compran más productos salados después de las 16:00. Considera promocionar empanadas durante estas horas.',
|
||||
impact: 'Potencial aumento de ventas: 12%',
|
||||
confidence: 92,
|
||||
category: 'sales',
|
||||
timestamp: '2024-01-26 08:45',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
afternoonSales: '+15%',
|
||||
savoryProducts: '68%',
|
||||
conversionRate: '12.3%'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'prediction',
|
||||
priority: 'high',
|
||||
title: 'Predicción de Demanda de San Valentín',
|
||||
description: 'Se espera un incremento del 40% en la demanda de productos de repostería especiales entre el 10-14 de febrero.',
|
||||
impact: 'Preparar stock adicional de ingredientes premium',
|
||||
confidence: 94,
|
||||
category: 'forecasting',
|
||||
timestamp: '2024-01-26 07:15',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
expectedIncrease: '+40%',
|
||||
daysAhead: 18,
|
||||
recommendedPrep: '3 días'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'recommendation',
|
||||
priority: 'low',
|
||||
title: 'Optimización de Inventario de Harina',
|
||||
description: 'El consumo de harina integral ha disminuido 8% este mes. Considera ajustar las órdenes de compra.',
|
||||
impact: 'Reducción de desperdicios: €45/mes',
|
||||
confidence: 78,
|
||||
category: 'inventory',
|
||||
timestamp: '2024-01-25 16:20',
|
||||
actionable: false,
|
||||
metrics: {
|
||||
consumption: '-8%',
|
||||
currentStock: '45kg',
|
||||
recommendedOrder: '25kg'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'insight',
|
||||
priority: 'medium',
|
||||
title: 'Análisis de Satisfacción del Cliente',
|
||||
description: 'Los clientes valoran más la frescura (95%) que el precio (67%). Enfoque en destacar la calidad artesanal.',
|
||||
impact: 'Mejorar estrategia de marketing',
|
||||
confidence: 89,
|
||||
category: 'customer',
|
||||
timestamp: '2024-01-25 14:30',
|
||||
actionable: true,
|
||||
metrics: {
|
||||
freshnessScore: '95%',
|
||||
priceScore: '67%',
|
||||
qualityScore: '91%'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas las Categorías', count: insights.length },
|
||||
{ value: 'production', label: 'Producción', count: insights.filter(i => i.category === 'production').length },
|
||||
{ value: 'sales', label: 'Ventas', count: insights.filter(i => i.category === 'sales').length },
|
||||
{ value: 'forecasting', label: 'Pronósticos', count: insights.filter(i => i.category === 'forecasting').length },
|
||||
{ value: 'inventory', label: 'Inventario', count: insights.filter(i => i.category === 'inventory').length },
|
||||
{ value: 'customer', label: 'Clientes', count: insights.filter(i => i.category === 'customer').length },
|
||||
];
|
||||
|
||||
const aiMetrics = {
|
||||
totalInsights: insights.length,
|
||||
actionableInsights: insights.filter(i => i.actionable).length,
|
||||
averageConfidence: Math.round(insights.reduce((sum, i) => sum + i.confidence, 0) / insights.length),
|
||||
highPriorityInsights: insights.filter(i => i.priority === 'high').length,
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
const iconProps = { className: "w-5 h-5" };
|
||||
switch (type) {
|
||||
case 'optimization': return <Target {...iconProps} />;
|
||||
case 'alert': return <AlertTriangle {...iconProps} />;
|
||||
case 'prediction': return <TrendingUp {...iconProps} />;
|
||||
case 'recommendation': return <Lightbulb {...iconProps} />;
|
||||
case 'insight': return <Brain {...iconProps} />;
|
||||
default: return <Brain {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'red';
|
||||
case 'medium': return 'yellow';
|
||||
case 'low': return 'green';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'optimization': return 'bg-blue-100 text-blue-800';
|
||||
case 'alert': return 'bg-red-100 text-red-800';
|
||||
case 'prediction': return 'bg-purple-100 text-purple-800';
|
||||
case 'recommendation': return 'bg-green-100 text-green-800';
|
||||
case 'insight': return 'bg-orange-100 text-orange-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredInsights = selectedCategory === 'all'
|
||||
? insights
|
||||
: insights.filter(insight => insight.category === selectedCategory);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Inteligencia Artificial"
|
||||
description="Insights inteligentes y recomendaciones automáticas para optimizar tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* AI Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Insights</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{aiMetrics.totalInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Brain className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Accionables</p>
|
||||
<p className="text-3xl font-bold text-green-600">{aiMetrics.actionableInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Zap className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Confianza Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{aiMetrics.averageConfidence}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Target className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Alta Prioridad</p>
|
||||
<p className="text-3xl font-bold text-red-600">{aiMetrics.highPriorityInsights}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="space-y-4">
|
||||
{filteredInsights.map((insight) => (
|
||||
<Card key={insight.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`p-2 rounded-lg ${getTypeColor(insight.type)}`}>
|
||||
{getTypeIcon(insight.type)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant={getPriorityColor(insight.priority)}>
|
||||
{insight.priority === 'high' ? 'Alta' : insight.priority === 'medium' ? 'Media' : 'Baja'} Prioridad
|
||||
</Badge>
|
||||
<Badge variant="gray">{insight.confidence}% confianza</Badge>
|
||||
{insight.actionable && (
|
||||
<Badge variant="blue">Accionable</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{insight.title}</h3>
|
||||
<p className="text-gray-700 mb-3">{insight.description}</p>
|
||||
<p className="text-sm font-medium text-green-600 mb-4">{insight.impact}</p>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
{Object.entries(insight.metrics).map(([key, value]) => (
|
||||
<div key={key} className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500">{insight.timestamp}</p>
|
||||
{insight.actionable && (
|
||||
<Button size="sm">
|
||||
Aplicar Recomendación
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredInsights.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Brain className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No hay insights disponibles</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
No se encontraron insights para la categoría seleccionada.
|
||||
</p>
|
||||
<Button onClick={handleRefresh}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Generar Nuevos Insights
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPage;
|
||||
@@ -1,385 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings } from 'lucide-react';
|
||||
import { Button, Card, Badge, Select } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting';
|
||||
|
||||
const ForecastingPage: React.FC = () => {
|
||||
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||
const [forecastPeriod, setForecastPeriod] = useState('7');
|
||||
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
|
||||
|
||||
const forecastData = {
|
||||
accuracy: 92,
|
||||
totalDemand: 1247,
|
||||
growthTrend: 8.5,
|
||||
seasonalityFactor: 1.15,
|
||||
};
|
||||
|
||||
const products = [
|
||||
{ id: 'all', name: 'Todos los productos' },
|
||||
{ id: 'bread', name: 'Panes' },
|
||||
{ id: 'pastry', name: 'Bollería' },
|
||||
{ id: 'cake', name: 'Tartas' },
|
||||
];
|
||||
|
||||
const periods = [
|
||||
{ value: '7', label: '7 días' },
|
||||
{ value: '14', label: '14 días' },
|
||||
{ value: '30', label: '30 días' },
|
||||
{ value: '90', label: '3 meses' },
|
||||
];
|
||||
|
||||
const mockForecasts = [
|
||||
{
|
||||
id: '1',
|
||||
product: 'Pan de Molde Integral',
|
||||
currentStock: 25,
|
||||
forecastDemand: 45,
|
||||
recommendedProduction: 50,
|
||||
confidence: 95,
|
||||
trend: 'up',
|
||||
stockoutRisk: 'low',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
product: 'Croissants de Mantequilla',
|
||||
currentStock: 18,
|
||||
forecastDemand: 32,
|
||||
recommendedProduction: 35,
|
||||
confidence: 88,
|
||||
trend: 'stable',
|
||||
stockoutRisk: 'medium',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
product: 'Baguettes Francesas',
|
||||
currentStock: 12,
|
||||
forecastDemand: 28,
|
||||
recommendedProduction: 30,
|
||||
confidence: 91,
|
||||
trend: 'down',
|
||||
stockoutRisk: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
const alerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'stockout',
|
||||
product: 'Baguettes Francesas',
|
||||
message: 'Alto riesgo de agotamiento en las próximas 24h',
|
||||
severity: 'high',
|
||||
recommendation: 'Incrementar producción en 15 unidades',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'overstock',
|
||||
product: 'Magdalenas',
|
||||
message: 'Probable exceso de stock para mañana',
|
||||
severity: 'medium',
|
||||
recommendation: 'Reducir producción en 20%',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'weather',
|
||||
product: 'Todos',
|
||||
message: 'Lluvia prevista - incremento esperado en demanda de bollería',
|
||||
severity: 'info',
|
||||
recommendation: 'Aumentar producción de productos de interior en 10%',
|
||||
},
|
||||
];
|
||||
|
||||
const weatherImpact = {
|
||||
today: 'sunny',
|
||||
temperature: 22,
|
||||
demandFactor: 0.95,
|
||||
affectedCategories: ['helados', 'bebidas frías'],
|
||||
};
|
||||
|
||||
const seasonalInsights = [
|
||||
{ period: 'Mañana', factor: 1.2, products: ['Pan', 'Bollería'] },
|
||||
{ period: 'Tarde', factor: 0.8, products: ['Tartas', 'Dulces'] },
|
||||
{ period: 'Fin de semana', factor: 1.4, products: ['Tartas especiales'] },
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="h-4 w-4 text-green-600" />;
|
||||
case 'down':
|
||||
return <TrendingUp className="h-4 w-4 text-red-600 rotate-180" />;
|
||||
default:
|
||||
return <div className="h-4 w-4 bg-gray-400 rounded-full" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getRiskBadge = (risk: string) => {
|
||||
const riskConfig = {
|
||||
low: { color: 'green', text: 'Bajo' },
|
||||
medium: { color: 'yellow', text: 'Medio' },
|
||||
high: { color: 'red', text: 'Alto' },
|
||||
};
|
||||
|
||||
const config = riskConfig[risk as keyof typeof riskConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Predicción de Demanda"
|
||||
description="Predicciones inteligentes basadas en IA para optimizar tu producción"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configurar
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Precisión del Modelo</p>
|
||||
<p className="text-3xl font-bold text-green-600">{forecastData.accuracy}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Demanda Prevista</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{forecastData.totalDemand}</p>
|
||||
<p className="text-xs text-gray-500">próximos {forecastPeriod} días</p>
|
||||
</div>
|
||||
<Calendar className="h-12 w-12 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Tendencia</p>
|
||||
<p className="text-3xl font-bold text-purple-600">+{forecastData.growthTrend}%</p>
|
||||
<p className="text-xs text-gray-500">vs período anterior</p>
|
||||
</div>
|
||||
<TrendingUp className="h-12 w-12 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Factor Estacional</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{forecastData.seasonalityFactor}x</p>
|
||||
<p className="text-xs text-gray-500">multiplicador actual</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Producto</label>
|
||||
<select
|
||||
value={selectedProduct}
|
||||
onChange={(e) => setSelectedProduct(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={forecastPeriod}
|
||||
onChange={(e) => setForecastPeriod(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{periods.map(period => (
|
||||
<option key={period.value} value={period.value}>{period.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Vista</label>
|
||||
<div className="flex rounded-md border border-gray-300">
|
||||
<button
|
||||
onClick={() => setViewMode('chart')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'chart' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-l-md`}
|
||||
>
|
||||
Gráfico
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('table')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'table' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-r-md border-l`}
|
||||
>
|
||||
Tabla
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Forecast Display */}
|
||||
<div className="lg:col-span-2">
|
||||
{viewMode === 'chart' ? (
|
||||
<DemandChart
|
||||
product={selectedProduct}
|
||||
period={forecastPeriod}
|
||||
/>
|
||||
) : (
|
||||
<ForecastTable forecasts={mockForecasts} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts Panel */}
|
||||
<div className="space-y-6">
|
||||
<AlertsPanel alerts={alerts} />
|
||||
|
||||
{/* Weather Impact */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Impacto Meteorológico</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Hoy:</span>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span>
|
||||
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Factor de demanda:</span>
|
||||
<span className="text-sm font-medium text-blue-600">{weatherImpact.demandFactor}x</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-gray-500 mb-2">Categorías afectadas:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{weatherImpact.affectedCategories.map((category, index) => (
|
||||
<Badge key={index} variant="blue">{category}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Seasonal Insights */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Patrones Estacionales</h3>
|
||||
<div className="space-y-3">
|
||||
{seasonalInsights.map((insight, index) => (
|
||||
<div key={index} className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{insight.period}</span>
|
||||
<span className="text-sm text-purple-600 font-medium">{insight.factor}x</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{insight.products.map((product, idx) => (
|
||||
<Badge key={idx} variant="purple">{product}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Forecasts Table */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Predicciones Detalladas</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Producto
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Stock Actual
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Demanda Prevista
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Producción Recomendada
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Confianza
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tendencia
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Riesgo Agotamiento
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockForecasts.map((forecast) => (
|
||||
<tr key={forecast.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{forecast.product}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{forecast.currentStock}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-blue-600">
|
||||
{forecast.forecastDemand}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-green-600">
|
||||
{forecast.recommendedProduction}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{forecast.confidence}%
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(forecast.trend)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getRiskBadge(forecast.stockoutRisk)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastingPage;
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './forecasting';
|
||||
export * from './sales-analytics';
|
||||
export * from './sales-analytics';
|
||||
export * from './performance';
|
||||
export * from './ai-insights';
|
||||
@@ -1,403 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Activity, Clock, Users, TrendingUp, Target, AlertCircle, Download, Calendar } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PerformanceAnalyticsPage: React.FC = () => {
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState('month');
|
||||
const [selectedMetric, setSelectedMetric] = useState('efficiency');
|
||||
|
||||
const performanceMetrics = {
|
||||
overallEfficiency: 87.5,
|
||||
productionTime: 4.2,
|
||||
qualityScore: 92.1,
|
||||
employeeProductivity: 89.3,
|
||||
customerSatisfaction: 94.7,
|
||||
resourceUtilization: 78.9,
|
||||
};
|
||||
|
||||
const timeframes = [
|
||||
{ value: 'day', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Esta Semana' },
|
||||
{ value: 'month', label: 'Este Mes' },
|
||||
{ value: 'quarter', label: 'Trimestre' },
|
||||
{ value: 'year', label: 'Año' },
|
||||
];
|
||||
|
||||
const departmentPerformance = [
|
||||
{
|
||||
department: 'Producción',
|
||||
efficiency: 91.2,
|
||||
trend: 5.3,
|
||||
issues: 2,
|
||||
employees: 8,
|
||||
metrics: {
|
||||
avgBatchTime: '2.3h',
|
||||
qualityRate: '94%',
|
||||
wastePercentage: '3.1%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Ventas',
|
||||
efficiency: 88.7,
|
||||
trend: -1.2,
|
||||
issues: 1,
|
||||
employees: 4,
|
||||
metrics: {
|
||||
avgServiceTime: '3.2min',
|
||||
customerWaitTime: '2.1min',
|
||||
salesPerHour: '€127'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Inventario',
|
||||
efficiency: 82.4,
|
||||
trend: 2.8,
|
||||
issues: 3,
|
||||
employees: 2,
|
||||
metrics: {
|
||||
stockAccuracy: '96.7%',
|
||||
turnoverRate: '12.3',
|
||||
wastageRate: '4.2%'
|
||||
}
|
||||
},
|
||||
{
|
||||
department: 'Administración',
|
||||
efficiency: 94.1,
|
||||
trend: 8.1,
|
||||
issues: 0,
|
||||
employees: 3,
|
||||
metrics: {
|
||||
responseTime: '1.2h',
|
||||
taskCompletion: '98%',
|
||||
documentAccuracy: '99.1%'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const kpiTrends = [
|
||||
{
|
||||
name: 'Eficiencia General',
|
||||
current: 87.5,
|
||||
target: 90.0,
|
||||
previous: 84.2,
|
||||
unit: '%',
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
name: 'Tiempo de Producción',
|
||||
current: 4.2,
|
||||
target: 4.0,
|
||||
previous: 4.5,
|
||||
unit: 'h',
|
||||
color: 'green',
|
||||
inverse: true
|
||||
},
|
||||
{
|
||||
name: 'Satisfacción Cliente',
|
||||
current: 94.7,
|
||||
target: 95.0,
|
||||
previous: 93.1,
|
||||
unit: '%',
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
name: 'Utilización de Recursos',
|
||||
current: 78.9,
|
||||
target: 85.0,
|
||||
previous: 76.3,
|
||||
unit: '%',
|
||||
color: 'orange'
|
||||
}
|
||||
];
|
||||
|
||||
const performanceAlerts = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'warning',
|
||||
title: 'Eficiencia de Inventario Baja',
|
||||
description: 'El departamento de inventario está por debajo del objetivo del 85%',
|
||||
value: '82.4%',
|
||||
target: '85%',
|
||||
department: 'Inventario'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'info',
|
||||
title: 'Tiempo de Producción Mejorado',
|
||||
description: 'El tiempo promedio de producción ha mejorado este mes',
|
||||
value: '4.2h',
|
||||
target: '4.0h',
|
||||
department: 'Producción'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'success',
|
||||
title: 'Administración Supera Objetivos',
|
||||
description: 'El departamento administrativo está funcionando por encima del objetivo',
|
||||
value: '94.1%',
|
||||
target: '90%',
|
||||
department: 'Administración'
|
||||
}
|
||||
];
|
||||
|
||||
const productivityData = [
|
||||
{ hour: '07:00', efficiency: 75, transactions: 12, employees: 3 },
|
||||
{ hour: '08:00', efficiency: 82, transactions: 18, employees: 5 },
|
||||
{ hour: '09:00', efficiency: 89, transactions: 28, employees: 6 },
|
||||
{ hour: '10:00', efficiency: 91, transactions: 32, employees: 7 },
|
||||
{ hour: '11:00', efficiency: 94, transactions: 38, employees: 8 },
|
||||
{ hour: '12:00', efficiency: 96, transactions: 45, employees: 8 },
|
||||
{ hour: '13:00', efficiency: 95, transactions: 42, employees: 8 },
|
||||
{ hour: '14:00', efficiency: 88, transactions: 35, employees: 7 },
|
||||
{ hour: '15:00', efficiency: 85, transactions: 28, employees: 6 },
|
||||
{ hour: '16:00', efficiency: 83, transactions: 25, employees: 5 },
|
||||
{ hour: '17:00', efficiency: 87, transactions: 31, employees: 6 },
|
||||
{ hour: '18:00', efficiency: 90, transactions: 38, employees: 7 },
|
||||
{ hour: '19:00', efficiency: 86, transactions: 29, employees: 5 },
|
||||
{ hour: '20:00', efficiency: 78, transactions: 18, employees: 3 },
|
||||
];
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
if (trend > 0) {
|
||||
return <TrendingUp className="w-4 h-4 text-green-600" />;
|
||||
} else {
|
||||
return <TrendingUp className="w-4 h-4 text-red-600 transform rotate-180" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
const getPerformanceColor = (value: number, target: number, inverse = false) => {
|
||||
const comparison = inverse ? value < target : value >= target;
|
||||
return comparison ? 'text-green-600' : value >= target * 0.9 ? 'text-yellow-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-600" />;
|
||||
case 'success':
|
||||
return <TrendingUp className="w-5 h-5 text-green-600" />;
|
||||
default:
|
||||
return <Activity className="w-5 h-5 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 border-yellow-200';
|
||||
case 'success':
|
||||
return 'bg-green-50 border-green-200';
|
||||
default:
|
||||
return 'bg-blue-50 border-blue-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Rendimiento"
|
||||
description="Monitorea la eficiencia operativa y el rendimiento de todos los departamentos"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Configurar Alertas
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Reporte
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedTimeframe}
|
||||
onChange={(e) => setSelectedTimeframe(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{timeframes.map(timeframe => (
|
||||
<option key={timeframe.value} value={timeframe.value}>{timeframe.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Métrica Principal</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="efficiency">Eficiencia</option>
|
||||
<option value="productivity">Productividad</option>
|
||||
<option value="quality">Calidad</option>
|
||||
<option value="satisfaction">Satisfacción</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* KPI Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpiTrends.map((kpi) => (
|
||||
<Card key={kpi.name} className="p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">{kpi.name}</h3>
|
||||
<div className={`w-3 h-3 rounded-full bg-${kpi.color}-500`}></div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className={`text-2xl font-bold ${getPerformanceColor(kpi.current, kpi.target, kpi.inverse)}`}>
|
||||
{kpi.current}{kpi.unit}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Objetivo: {kpi.target}{kpi.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(kpi.current - kpi.previous)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(kpi.current - kpi.previous)}`}>
|
||||
{Math.abs(kpi.current - kpi.previous).toFixed(1)}{kpi.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`bg-${kpi.color}-500 h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min((kpi.current / kpi.target) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Performance Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alertas de Rendimiento</h3>
|
||||
<div className="space-y-3">
|
||||
{performanceAlerts.map((alert) => (
|
||||
<div key={alert.id} className={`p-4 rounded-lg border ${getAlertColor(alert.type)}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">{alert.title}</h4>
|
||||
<Badge variant="gray">{alert.department}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{alert.description}</p>
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-sm">
|
||||
<strong>Actual:</strong> {alert.value}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<strong>Objetivo:</strong> {alert.target}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Department Performance */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Rendimiento por Departamento</h3>
|
||||
<div className="space-y-4">
|
||||
{departmentPerformance.map((dept) => (
|
||||
<div key={dept.department} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="font-medium text-gray-900">{dept.department}</h4>
|
||||
<Badge variant="gray">{dept.employees} empleados</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{dept.efficiency}%
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
{getTrendIcon(dept.trend)}
|
||||
<span className={`text-sm ml-1 ${getTrendColor(dept.trend)}`}>
|
||||
{Math.abs(dept.trend).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
{Object.entries(dept.metrics).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="font-medium">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dept.issues > 0 && (
|
||||
<div className="mt-3 flex items-center text-sm text-amber-600">
|
||||
<AlertCircle className="w-4 h-4 mr-1" />
|
||||
{dept.issues} problema{dept.issues > 1 ? 's' : ''} detectado{dept.issues > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Hourly Productivity */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Eficiencia por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{productivityData.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-gray-600 mb-1">{data.efficiency}%</div>
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.efficiency / 100) * 200}px`,
|
||||
minHeight: '8px',
|
||||
backgroundColor: data.efficiency >= 90 ? '#10B981' : data.efficiency >= 80 ? '#F59E0B' : '#EF4444'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-gray-500 mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center space-x-6 text-xs">
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-green-500 rounded mr-1"></div>
|
||||
<span>≥90% Excelente</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded mr-1"></div>
|
||||
<span>80-89% Bueno</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-3 h-3 bg-red-500 rounded mr-1"></div>
|
||||
<span><80% Bajo</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceAnalyticsPage;
|
||||
@@ -1,379 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { AnalyticsDashboard, ChartWidget, ReportsTable } from '../../../../components/domain/analytics';
|
||||
|
||||
const SalesAnalyticsPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
||||
const [selectedMetric, setSelectedMetric] = useState('revenue');
|
||||
const [viewMode, setViewMode] = useState<'overview' | 'detailed'>('overview');
|
||||
|
||||
const salesMetrics = {
|
||||
totalRevenue: 45678.90,
|
||||
totalOrders: 1234,
|
||||
averageOrderValue: 37.02,
|
||||
customerCount: 856,
|
||||
growthRate: 12.5,
|
||||
conversionRate: 68.4,
|
||||
};
|
||||
|
||||
const periods = [
|
||||
{ value: 'day', label: 'Hoy' },
|
||||
{ value: 'week', label: 'Esta Semana' },
|
||||
{ value: 'month', label: 'Este Mes' },
|
||||
{ value: 'quarter', label: 'Este Trimestre' },
|
||||
{ value: 'year', label: 'Este Año' },
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
{ value: 'revenue', label: 'Ingresos' },
|
||||
{ value: 'orders', label: 'Pedidos' },
|
||||
{ value: 'customers', label: 'Clientes' },
|
||||
{ value: 'products', label: 'Productos' },
|
||||
];
|
||||
|
||||
const topProducts = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
revenue: 2250.50,
|
||||
units: 245,
|
||||
growth: 8.2,
|
||||
category: 'Panes'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
revenue: 1890.75,
|
||||
units: 412,
|
||||
growth: 15.4,
|
||||
category: 'Bollería'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Chocolate',
|
||||
revenue: 1675.00,
|
||||
units: 67,
|
||||
growth: -2.1,
|
||||
category: 'Tartas'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Empanadas Variadas',
|
||||
revenue: 1425.25,
|
||||
units: 285,
|
||||
growth: 22.8,
|
||||
category: 'Salados'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Magdalenas',
|
||||
revenue: 1180.50,
|
||||
units: 394,
|
||||
growth: 5.7,
|
||||
category: 'Bollería'
|
||||
},
|
||||
];
|
||||
|
||||
const salesByHour = [
|
||||
{ hour: '07:00', sales: 145, orders: 12 },
|
||||
{ hour: '08:00', sales: 289, orders: 18 },
|
||||
{ hour: '09:00', sales: 425, orders: 28 },
|
||||
{ hour: '10:00', sales: 380, orders: 24 },
|
||||
{ hour: '11:00', sales: 520, orders: 31 },
|
||||
{ hour: '12:00', sales: 675, orders: 42 },
|
||||
{ hour: '13:00', sales: 720, orders: 45 },
|
||||
{ hour: '14:00', sales: 580, orders: 35 },
|
||||
{ hour: '15:00', sales: 420, orders: 28 },
|
||||
{ hour: '16:00', sales: 350, orders: 22 },
|
||||
{ hour: '17:00', sales: 480, orders: 31 },
|
||||
{ hour: '18:00', sales: 620, orders: 38 },
|
||||
{ hour: '19:00', sales: 450, orders: 29 },
|
||||
{ hour: '20:00', sales: 280, orders: 18 },
|
||||
];
|
||||
|
||||
const customerSegments = [
|
||||
{ segment: 'Clientes Frecuentes', count: 123, revenue: 15678, percentage: 34.3 },
|
||||
{ segment: 'Clientes Regulares', count: 245, revenue: 18950, percentage: 41.5 },
|
||||
{ segment: 'Clientes Ocasionales', count: 356, revenue: 8760, percentage: 19.2 },
|
||||
{ segment: 'Clientes Nuevos', count: 132, revenue: 2290, percentage: 5.0 },
|
||||
];
|
||||
|
||||
const paymentMethods = [
|
||||
{ method: 'Tarjeta', count: 567, revenue: 28450, percentage: 62.3 },
|
||||
{ method: 'Efectivo', count: 445, revenue: 13890, percentage: 30.4 },
|
||||
{ method: 'Transferencia', count: 178, revenue: 2890, percentage: 6.3 },
|
||||
{ method: 'Otros', count: 44, revenue: 448, percentage: 1.0 },
|
||||
];
|
||||
|
||||
const getGrowthBadge = (growth: number) => {
|
||||
if (growth > 0) {
|
||||
return <Badge variant="green">+{growth.toFixed(1)}%</Badge>;
|
||||
} else if (growth < 0) {
|
||||
return <Badge variant="red">{growth.toFixed(1)}%</Badge>;
|
||||
} else {
|
||||
return <Badge variant="gray">0%</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getGrowthColor = (growth: number) => {
|
||||
return growth >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Ventas"
|
||||
description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{periods.map(period => (
|
||||
<option key={period.value} value={period.value}>{period.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Métrica Principal</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{metrics.map(metric => (
|
||||
<option key={metric.value} value={metric.value}>{metric.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Vista</label>
|
||||
<div className="flex rounded-md border border-gray-300">
|
||||
<button
|
||||
onClick={() => setViewMode('overview')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'overview' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-l-md`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('detailed')}
|
||||
className={`px-3 py-2 text-sm ${viewMode === 'detailed' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'} rounded-r-md border-l`}
|
||||
>
|
||||
Detallado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
|
||||
<p className="text-2xl font-bold text-green-600">€{salesMetrics.totalRevenue.toLocaleString()}</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{getGrowthBadge(salesMetrics.growthRate)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{salesMetrics.totalOrders.toLocaleString()}</p>
|
||||
</div>
|
||||
<ShoppingCart className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Valor Promedio</p>
|
||||
<p className="text-2xl font-bold text-purple-600">€{salesMetrics.averageOrderValue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Clientes</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{salesMetrics.customerCount}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Tasa Crecimiento</p>
|
||||
<p className={`text-2xl font-bold ${getGrowthColor(salesMetrics.growthRate)}`}>
|
||||
{salesMetrics.growthRate > 0 ? '+' : ''}{salesMetrics.growthRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className={`h-8 w-8 ${getGrowthColor(salesMetrics.growthRate)}`} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Conversión</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{salesMetrics.conversionRate}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{viewMode === 'overview' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Sales by Hour Chart */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Ventas por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{salesByHour.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.sales / Math.max(...salesByHour.map(d => d.sales))) * 200}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-gray-500 mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Top Products */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Productos Más Vendidos</h3>
|
||||
<div className="space-y-3">
|
||||
{topProducts.slice(0, 5).map((product, index) => (
|
||||
<div key={product.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-500 w-6">{index + 1}.</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{product.name}</p>
|
||||
<p className="text-xs text-gray-500">{product.category} • {product.units} unidades</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">€{product.revenue.toLocaleString()}</p>
|
||||
{getGrowthBadge(product.growth)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Segmentos de Clientes</h3>
|
||||
<div className="space-y-4">
|
||||
{customerSegments.map((segment, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-900">{segment.segment}</span>
|
||||
<span className="text-sm text-gray-600">{segment.percentage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${segment.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{segment.count} clientes</span>
|
||||
<span>€{segment.revenue.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Métodos de Pago</h3>
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.map((method, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{method.method}</p>
|
||||
<p className="text-xs text-gray-500">{method.count} transacciones</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">€{method.revenue.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500">{method.percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Detailed Analytics Dashboard */}
|
||||
<AnalyticsDashboard />
|
||||
|
||||
{/* Detailed Reports Table */}
|
||||
<ReportsTable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesAnalyticsPage;
|
||||
@@ -1,388 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PreferencesPage: React.FC = () => {
|
||||
const [preferences, setPreferences] = useState({
|
||||
notifications: {
|
||||
inventory: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
sales: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'hourly'
|
||||
},
|
||||
production: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
system: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'daily'
|
||||
},
|
||||
marketing: {
|
||||
app: false,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'weekly'
|
||||
}
|
||||
},
|
||||
global: {
|
||||
doNotDisturb: false,
|
||||
quietHours: {
|
||||
enabled: false,
|
||||
start: '22:00',
|
||||
end: '07:00'
|
||||
},
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
soundEnabled: true,
|
||||
vibrationEnabled: true
|
||||
},
|
||||
channels: {
|
||||
email: 'panaderia@example.com',
|
||||
phone: '+34 600 123 456',
|
||||
slack: false,
|
||||
webhook: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'inventory',
|
||||
name: 'Inventario',
|
||||
description: 'Alertas de stock, reposiciones y vencimientos',
|
||||
icon: '📦'
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
name: 'Ventas',
|
||||
description: 'Pedidos, transacciones y reportes de ventas',
|
||||
icon: '💰'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
name: 'Producción',
|
||||
description: 'Hornadas, calidad y tiempos de producción',
|
||||
icon: '🍞'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
name: 'Sistema',
|
||||
description: 'Actualizaciones, mantenimiento y errores',
|
||||
icon: '⚙️'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing',
|
||||
description: 'Campañas, promociones y análisis',
|
||||
icon: '📢'
|
||||
}
|
||||
];
|
||||
|
||||
const frequencies = [
|
||||
{ value: 'immediate', label: 'Inmediato' },
|
||||
{ value: 'hourly', label: 'Cada hora' },
|
||||
{ value: 'daily', label: 'Diario' },
|
||||
{ value: 'weekly', label: 'Semanal' }
|
||||
];
|
||||
|
||||
const handleNotificationChange = (category: string, channel: string, value: boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
[channel]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (category: string, frequency: string) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
frequency
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalChange = (setting: string, value: any) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
global: {
|
||||
...prev.global,
|
||||
[setting]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleChannelChange = (channel: string, value: string | boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving preferences:', preferences);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const getChannelIcon = (channel: string) => {
|
||||
switch (channel) {
|
||||
case 'app':
|
||||
return <Bell className="w-4 h-4" />;
|
||||
case 'email':
|
||||
return <Mail className="w-4 h-4" />;
|
||||
case 'sms':
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Preferencias de Comunicación"
|
||||
description="Configura cómo y cuándo recibir notificaciones"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configuración General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.doNotDisturb}
|
||||
onChange={(e) => handleGlobalChange('doNotDisturb', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">No molestar</span>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">Silencia todas las notificaciones</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.soundEnabled}
|
||||
onChange={(e) => handleGlobalChange('soundEnabled', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Sonidos</span>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">Reproducir sonidos de notificación</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.quietHours.enabled}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
enabled: e.target.checked
|
||||
})}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">Horas silenciosas</span>
|
||||
</label>
|
||||
{preferences.global.quietHours.enabled && (
|
||||
<div className="flex space-x-4 ml-6">
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Desde</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.start}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
start: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Hasta</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.end}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
end: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Channel Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Canales de Comunicación</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={preferences.channels.email}
|
||||
onChange={(e) => handleChannelChange('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="tu-email@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Teléfono (SMS)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={preferences.channels.phone}
|
||||
onChange={(e) => handleChannelChange('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="+34 600 123 456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={preferences.channels.webhook}
|
||||
onChange={(e) => handleChannelChange('webhook', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
|
||||
placeholder="https://tu-webhook.com/notifications"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">URL para recibir notificaciones JSON</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Category Preferences */}
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const categoryPrefs = preferences.notifications[category.id as keyof typeof preferences.notifications];
|
||||
|
||||
return (
|
||||
<Card key={category.id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-2xl">{category.icon}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-1">{category.name}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">{category.description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Channel toggles */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Canales</h4>
|
||||
<div className="flex space-x-6">
|
||||
{['app', 'email', 'sms'].map((channel) => (
|
||||
<label key={channel} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={categoryPrefs[channel as keyof typeof categoryPrefs] as boolean}
|
||||
onChange={(e) => handleNotificationChange(category.id, channel, e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)]"
|
||||
/>
|
||||
<div className="flex items-center space-x-1">
|
||||
{getChannelIcon(channel)}
|
||||
<span className="text-sm text-[var(--text-secondary)] capitalize">{channel}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-2">Frecuencia</h4>
|
||||
<select
|
||||
value={categoryPrefs.frequency}
|
||||
onChange={(e) => handleFrequencyChange(category.id, e.target.value)}
|
||||
className="px-3 py-2 border border-[var(--border-secondary)] rounded-md text-sm"
|
||||
>
|
||||
{frequencies.map((freq) => (
|
||||
<option key={freq.value} value={freq.value}>
|
||||
{freq.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-[var(--color-info)] bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesPage;
|
||||
@@ -1,388 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PreferencesPage: React.FC = () => {
|
||||
const [preferences, setPreferences] = useState({
|
||||
notifications: {
|
||||
inventory: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
sales: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'hourly'
|
||||
},
|
||||
production: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
system: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'daily'
|
||||
},
|
||||
marketing: {
|
||||
app: false,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'weekly'
|
||||
}
|
||||
},
|
||||
global: {
|
||||
doNotDisturb: false,
|
||||
quietHours: {
|
||||
enabled: false,
|
||||
start: '22:00',
|
||||
end: '07:00'
|
||||
},
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
soundEnabled: true,
|
||||
vibrationEnabled: true
|
||||
},
|
||||
channels: {
|
||||
email: 'panaderia@example.com',
|
||||
phone: '+34 600 123 456',
|
||||
slack: false,
|
||||
webhook: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'inventory',
|
||||
name: 'Inventario',
|
||||
description: 'Alertas de stock, reposiciones y vencimientos',
|
||||
icon: '📦'
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
name: 'Ventas',
|
||||
description: 'Pedidos, transacciones y reportes de ventas',
|
||||
icon: '💰'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
name: 'Producción',
|
||||
description: 'Hornadas, calidad y tiempos de producción',
|
||||
icon: '🍞'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
name: 'Sistema',
|
||||
description: 'Actualizaciones, mantenimiento y errores',
|
||||
icon: '⚙️'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing',
|
||||
description: 'Campañas, promociones y análisis',
|
||||
icon: '📢'
|
||||
}
|
||||
];
|
||||
|
||||
const frequencies = [
|
||||
{ value: 'immediate', label: 'Inmediato' },
|
||||
{ value: 'hourly', label: 'Cada hora' },
|
||||
{ value: 'daily', label: 'Diario' },
|
||||
{ value: 'weekly', label: 'Semanal' }
|
||||
];
|
||||
|
||||
const handleNotificationChange = (category: string, channel: string, value: boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
[channel]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (category: string, frequency: string) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
frequency
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalChange = (setting: string, value: any) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
global: {
|
||||
...prev.global,
|
||||
[setting]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleChannelChange = (channel: string, value: string | boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving preferences:', preferences);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const getChannelIcon = (channel: string) => {
|
||||
switch (channel) {
|
||||
case 'app':
|
||||
return <Bell className="w-4 h-4" />;
|
||||
case 'email':
|
||||
return <Mail className="w-4 h-4" />;
|
||||
case 'sms':
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Preferencias de Comunicación"
|
||||
description="Configura cómo y cuándo recibir notificaciones"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Configuración General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.doNotDisturb}
|
||||
onChange={(e) => handleGlobalChange('doNotDisturb', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">No molestar</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Silencia todas las notificaciones</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.soundEnabled}
|
||||
onChange={(e) => handleGlobalChange('soundEnabled', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Sonidos</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Reproducir sonidos de notificación</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.quietHours.enabled}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
enabled: e.target.checked
|
||||
})}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Horas silenciosas</span>
|
||||
</label>
|
||||
{preferences.global.quietHours.enabled && (
|
||||
<div className="flex space-x-4 ml-6">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Desde</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.start}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
start: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Hasta</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.end}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
end: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Channel Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Canales de Comunicación</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={preferences.channels.email}
|
||||
onChange={(e) => handleChannelChange('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="tu-email@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Teléfono (SMS)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={preferences.channels.phone}
|
||||
onChange={(e) => handleChannelChange('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="+34 600 123 456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={preferences.channels.webhook}
|
||||
onChange={(e) => handleChannelChange('webhook', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="https://tu-webhook.com/notifications"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">URL para recibir notificaciones JSON</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Category Preferences */}
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const categoryPrefs = preferences.notifications[category.id as keyof typeof preferences.notifications];
|
||||
|
||||
return (
|
||||
<Card key={category.id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-2xl">{category.icon}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{category.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{category.description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Channel toggles */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Canales</h4>
|
||||
<div className="flex space-x-6">
|
||||
{['app', 'email', 'sms'].map((channel) => (
|
||||
<label key={channel} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={categoryPrefs[channel as keyof typeof categoryPrefs] as boolean}
|
||||
onChange={(e) => handleNotificationChange(category.id, channel, e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<div className="flex items-center space-x-1">
|
||||
{getChannelIcon(channel)}
|
||||
<span className="text-sm text-gray-700 capitalize">{channel}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Frecuencia</h4>
|
||||
<select
|
||||
value={categoryPrefs.frequency}
|
||||
onChange={(e) => handleFrequencyChange(category.id, e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
{frequencies.map((freq) => (
|
||||
<option key={freq.value} value={freq.value}>
|
||||
{freq.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-blue-600 bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesPage;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as PreferencesPage } from './PreferencesPage';
|
||||
@@ -1,312 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Activity, Filter, Download, Eye, BarChart3 } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const EventsPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
|
||||
const events = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2024-01-26 10:30:00',
|
||||
category: 'sales',
|
||||
type: 'order_completed',
|
||||
title: 'Pedido Completado',
|
||||
description: 'Pedido #ORD-456 completado por €127.50',
|
||||
metadata: {
|
||||
orderId: 'ORD-456',
|
||||
amount: 127.50,
|
||||
customer: 'María González',
|
||||
items: 8
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2024-01-26 09:15:00',
|
||||
category: 'production',
|
||||
type: 'batch_started',
|
||||
title: 'Lote Iniciado',
|
||||
description: 'Iniciado lote de croissants CR-024',
|
||||
metadata: {
|
||||
batchId: 'CR-024',
|
||||
product: 'Croissants',
|
||||
quantity: 48,
|
||||
expectedDuration: '2.5h'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2024-01-26 08:45:00',
|
||||
category: 'inventory',
|
||||
type: 'stock_updated',
|
||||
title: 'Stock Actualizado',
|
||||
description: 'Repuesto stock de harina - Nivel: 50kg',
|
||||
metadata: {
|
||||
item: 'Harina de Trigo',
|
||||
previousLevel: '5kg',
|
||||
newLevel: '50kg',
|
||||
supplier: 'Molinos del Sur'
|
||||
},
|
||||
severity: 'success'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2024-01-26 07:30:00',
|
||||
category: 'system',
|
||||
type: 'user_login',
|
||||
title: 'Inicio de Sesión',
|
||||
description: 'Usuario admin ha iniciado sesión',
|
||||
metadata: {
|
||||
userId: 'admin',
|
||||
ipAddress: '192.168.1.100',
|
||||
userAgent: 'Chrome/120.0',
|
||||
location: 'Madrid, ES'
|
||||
},
|
||||
severity: 'info'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
timestamp: '2024-01-25 19:20:00',
|
||||
category: 'sales',
|
||||
type: 'payment_processed',
|
||||
title: 'Pago Procesado',
|
||||
description: 'Pago de €45.80 procesado exitosamente',
|
||||
metadata: {
|
||||
amount: 45.80,
|
||||
method: 'Tarjeta',
|
||||
reference: 'PAY-789',
|
||||
customer: 'Juan Pérez'
|
||||
},
|
||||
severity: 'success'
|
||||
}
|
||||
];
|
||||
|
||||
const eventStats = {
|
||||
total: events.length,
|
||||
today: events.filter(e =>
|
||||
new Date(e.timestamp).toDateString() === new Date().toDateString()
|
||||
).length,
|
||||
sales: events.filter(e => e.category === 'sales').length,
|
||||
production: events.filter(e => e.category === 'production').length,
|
||||
system: events.filter(e => e.category === 'system').length
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todos', count: events.length },
|
||||
{ value: 'sales', label: 'Ventas', count: eventStats.sales },
|
||||
{ value: 'production', label: 'Producción', count: eventStats.production },
|
||||
{ value: 'inventory', label: 'Inventario', count: events.filter(e => e.category === 'inventory').length },
|
||||
{ value: 'system', label: 'Sistema', count: eventStats.system }
|
||||
];
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'success': return 'green';
|
||||
case 'warning': return 'yellow';
|
||||
case 'error': return 'red';
|
||||
default: return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const iconProps = { className: "w-4 h-4" };
|
||||
switch (category) {
|
||||
case 'sales': return <BarChart3 {...iconProps} />;
|
||||
case 'production': return <Activity {...iconProps} />;
|
||||
case 'inventory': return <Calendar {...iconProps} />;
|
||||
default: return <Activity {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = selectedCategory === 'all'
|
||||
? events
|
||||
: events.filter(event => event.category === selectedCategory);
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const eventTime = new Date(timestamp);
|
||||
const diffInMs = now.getTime() - eventTime.getTime();
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
|
||||
if (diffInDays > 0) {
|
||||
return `hace ${diffInDays}d`;
|
||||
} else if (diffInHours > 0) {
|
||||
return `hace ${diffInHours}h`;
|
||||
} else {
|
||||
return `hace ${Math.floor(diffInMs / (1000 * 60))}m`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Registro de Eventos"
|
||||
description="Seguimiento de todas las actividades y eventos del sistema"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros Avanzados
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Event Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Eventos</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{eventStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Hoy</p>
|
||||
<p className="text-3xl font-bold text-green-600">{eventStats.today}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{eventStats.sales}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Producción</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{eventStats.production}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="all">Todos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.value}
|
||||
onClick={() => setSelectedCategory(category.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === category.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.label} ({category.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="space-y-4">
|
||||
{filteredEvents.map((event) => (
|
||||
<Card key={event.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className={`p-2 rounded-lg bg-${getSeverityColor(event.severity)}-100`}>
|
||||
{getCategoryIcon(event.category)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{event.title}</h3>
|
||||
<Badge variant={getSeverityColor(event.severity)}>
|
||||
{event.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-3">{event.description}</p>
|
||||
|
||||
{/* Event Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{Object.entries(event.metadata).map(([key, value]) => (
|
||||
<div key={key} className="bg-gray-50 p-3 rounded-lg">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase())}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<span>{formatTimeAgo(event.timestamp)}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{new Date(event.timestamp).toLocaleString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Activity className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No hay eventos</h3>
|
||||
<p className="text-gray-600">
|
||||
No se encontraron eventos para el período y categoría seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventsPage;
|
||||
@@ -1,336 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Clock, TrendingUp, MapPin, Calendar, BarChart3, Download, Filter } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TrafficPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [selectedMetric, setSelectedMetric] = useState('visitors');
|
||||
|
||||
const trafficData = {
|
||||
totalVisitors: 2847,
|
||||
peakHour: '12:00',
|
||||
averageVisitDuration: '23min',
|
||||
busyDays: ['Viernes', 'Sábado'],
|
||||
conversionRate: 68.4
|
||||
};
|
||||
|
||||
const hourlyTraffic = [
|
||||
{ hour: '07:00', visitors: 15, sales: 12, duration: '18min' },
|
||||
{ hour: '08:00', visitors: 32, sales: 24, duration: '22min' },
|
||||
{ hour: '09:00', visitors: 45, sales: 28, duration: '25min' },
|
||||
{ hour: '10:00', visitors: 38, sales: 25, duration: '24min' },
|
||||
{ hour: '11:00', visitors: 52, sales: 35, duration: '26min' },
|
||||
{ hour: '12:00', visitors: 78, sales: 54, duration: '28min' },
|
||||
{ hour: '13:00', visitors: 85, sales: 58, duration: '30min' },
|
||||
{ hour: '14:00', visitors: 62, sales: 42, duration: '27min' },
|
||||
{ hour: '15:00', visitors: 48, sales: 32, duration: '25min' },
|
||||
{ hour: '16:00', visitors: 55, sales: 38, duration: '26min' },
|
||||
{ hour: '17:00', visitors: 68, sales: 46, duration: '29min' },
|
||||
{ hour: '18:00', visitors: 74, sales: 52, duration: '31min' },
|
||||
{ hour: '19:00', visitors: 56, sales: 39, duration: '28min' },
|
||||
{ hour: '20:00', visitors: 28, sales: 18, duration: '22min' }
|
||||
];
|
||||
|
||||
const dailyTraffic = [
|
||||
{ day: 'Lun', visitors: 245, sales: 168, conversion: 68.6, avgDuration: '22min' },
|
||||
{ day: 'Mar', visitors: 289, sales: 195, conversion: 67.5, avgDuration: '24min' },
|
||||
{ day: 'Mié', visitors: 321, sales: 218, conversion: 67.9, avgDuration: '25min' },
|
||||
{ day: 'Jue', visitors: 356, sales: 242, conversion: 68.0, avgDuration: '26min' },
|
||||
{ day: 'Vie', visitors: 445, sales: 312, conversion: 70.1, avgDuration: '28min' },
|
||||
{ day: 'Sáb', visitors: 498, sales: 348, conversion: 69.9, avgDuration: '30min' },
|
||||
{ day: 'Dom', visitors: 398, sales: 265, conversion: 66.6, avgDuration: '27min' }
|
||||
];
|
||||
|
||||
const trafficSources = [
|
||||
{ source: 'Pie', visitors: 1245, percentage: 43.7, trend: 5.2 },
|
||||
{ source: 'Búsqueda Local', visitors: 687, percentage: 24.1, trend: 12.3 },
|
||||
{ source: 'Recomendaciones', visitors: 423, percentage: 14.9, trend: -2.1 },
|
||||
{ source: 'Redes Sociales', visitors: 298, percentage: 10.5, trend: 8.7 },
|
||||
{ source: 'Publicidad', visitors: 194, percentage: 6.8, trend: 15.4 }
|
||||
];
|
||||
|
||||
const customerSegments = [
|
||||
{
|
||||
segment: 'Regulares Matutinos',
|
||||
count: 145,
|
||||
percentage: 24.2,
|
||||
peakHours: ['07:00-09:00'],
|
||||
avgSpend: 12.50,
|
||||
frequency: 'Diaria'
|
||||
},
|
||||
{
|
||||
segment: 'Familia Fin de Semana',
|
||||
count: 198,
|
||||
percentage: 33.1,
|
||||
peakHours: ['10:00-13:00'],
|
||||
avgSpend: 28.90,
|
||||
frequency: 'Semanal'
|
||||
},
|
||||
{
|
||||
segment: 'Oficinistas Almuerzo',
|
||||
count: 112,
|
||||
percentage: 18.7,
|
||||
peakHours: ['12:00-14:00'],
|
||||
avgSpend: 8.75,
|
||||
frequency: '2-3x semana'
|
||||
},
|
||||
{
|
||||
segment: 'Clientes Ocasionales',
|
||||
count: 143,
|
||||
percentage: 23.9,
|
||||
peakHours: ['16:00-19:00'],
|
||||
avgSpend: 15.20,
|
||||
frequency: 'Mensual'
|
||||
}
|
||||
];
|
||||
|
||||
const getTrendColor = (trend: number) => {
|
||||
return trend >= 0 ? 'text-green-600' : 'text-red-600';
|
||||
};
|
||||
|
||||
const getTrendIcon = (trend: number) => {
|
||||
return trend >= 0 ? '↗' : '↘';
|
||||
};
|
||||
|
||||
const maxVisitors = Math.max(...hourlyTraffic.map(h => h.visitors));
|
||||
const maxDailyVisitors = Math.max(...dailyTraffic.map(d => d.visitors));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Análisis de Tráfico"
|
||||
description="Monitorea los patrones de visitas y flujo de clientes"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Traffic Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Visitantes Totales</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{trafficData.totalVisitors.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Hora Pico</p>
|
||||
<p className="text-3xl font-bold text-green-600">{trafficData.peakHour}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Duración Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{trafficData.averageVisitDuration}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Conversión</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{trafficData.conversionRate}%</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<TrendingUp className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Días Ocupados</p>
|
||||
<p className="text-sm font-bold text-red-600">{trafficData.busyDays.join(', ')}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<Calendar className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Período</label>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="day">Hoy</option>
|
||||
<option value="week">Esta Semana</option>
|
||||
<option value="month">Este Mes</option>
|
||||
<option value="year">Este Año</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Métrica</label>
|
||||
<select
|
||||
value={selectedMetric}
|
||||
onChange={(e) => setSelectedMetric(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="visitors">Visitantes</option>
|
||||
<option value="sales">Ventas</option>
|
||||
<option value="duration">Duración</option>
|
||||
<option value="conversion">Conversión</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Hourly Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tráfico por Hora</h3>
|
||||
<div className="h-64 flex items-end space-x-1 justify-between">
|
||||
{hourlyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-gray-600 mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxVisitors) * 200}px`,
|
||||
minHeight: '4px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-xs text-gray-500 mt-2 transform -rotate-45 origin-center">
|
||||
{data.hour}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Daily Traffic */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tráfico Semanal</h3>
|
||||
<div className="h-64 flex items-end space-x-2 justify-between">
|
||||
{dailyTraffic.map((data, index) => (
|
||||
<div key={index} className="flex flex-col items-center flex-1">
|
||||
<div className="text-xs text-gray-600 mb-1">{data.visitors}</div>
|
||||
<div
|
||||
className="w-full bg-green-500 rounded-t"
|
||||
style={{
|
||||
height: `${(data.visitors / maxDailyVisitors) * 200}px`,
|
||||
minHeight: '8px'
|
||||
}}
|
||||
></div>
|
||||
<span className="text-sm text-gray-700 mt-2 font-medium">
|
||||
{data.day}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.conversion}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Traffic Sources */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Fuentes de Tráfico</h3>
|
||||
<div className="space-y-3">
|
||||
{trafficSources.map((source, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{source.source}</p>
|
||||
<p className="text-xs text-gray-500">{source.visitors} visitantes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">{source.percentage}%</p>
|
||||
<div className={`text-xs flex items-center ${getTrendColor(source.trend)}`}>
|
||||
<span>{getTrendIcon(source.trend)} {Math.abs(source.trend).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Customer Segments */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Segmentos de Clientes</h3>
|
||||
<div className="space-y-4">
|
||||
{customerSegments.map((segment, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-900">{segment.segment}</h4>
|
||||
<Badge variant="blue">{segment.percentage}%</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Clientes</p>
|
||||
<p className="font-medium">{segment.count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Gasto Promedio</p>
|
||||
<p className="font-medium">€{segment.avgSpend}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Horario Pico</p>
|
||||
<p className="font-medium">{segment.peakHours.join(', ')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Frecuencia</p>
|
||||
<p className="font-medium">{segment.frequency}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Traffic Heat Map placeholder */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Mapa de Calor - Zonas de la Panadería</h3>
|
||||
<div className="h-64 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Visualización de zonas de mayor tráfico</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Entrada: 45% • Mostrador: 32% • Zona sentada: 23%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrafficPage;
|
||||
@@ -1,423 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Cloud, Sun, CloudRain, Thermometer, Wind, Droplets, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const WeatherPage: React.FC = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
|
||||
const currentWeather = {
|
||||
temperature: 18,
|
||||
condition: 'partly-cloudy',
|
||||
humidity: 65,
|
||||
windSpeed: 12,
|
||||
pressure: 1013,
|
||||
uvIndex: 4,
|
||||
visibility: 10,
|
||||
description: 'Parcialmente nublado'
|
||||
};
|
||||
|
||||
const forecast = [
|
||||
{
|
||||
date: '2024-01-27',
|
||||
day: 'Sábado',
|
||||
condition: 'sunny',
|
||||
tempMax: 22,
|
||||
tempMin: 12,
|
||||
humidity: 45,
|
||||
precipitation: 0,
|
||||
wind: 8,
|
||||
impact: 'high-demand',
|
||||
recommendation: 'Incrementar producción de helados y bebidas frías'
|
||||
},
|
||||
{
|
||||
date: '2024-01-28',
|
||||
day: 'Domingo',
|
||||
condition: 'partly-cloudy',
|
||||
tempMax: 19,
|
||||
tempMin: 11,
|
||||
humidity: 55,
|
||||
precipitation: 20,
|
||||
wind: 15,
|
||||
impact: 'normal',
|
||||
recommendation: 'Producción estándar'
|
||||
},
|
||||
{
|
||||
date: '2024-01-29',
|
||||
day: 'Lunes',
|
||||
condition: 'rainy',
|
||||
tempMax: 15,
|
||||
tempMin: 8,
|
||||
humidity: 85,
|
||||
precipitation: 80,
|
||||
wind: 22,
|
||||
impact: 'comfort-food',
|
||||
recommendation: 'Aumentar sopas, chocolates calientes y pan recién horneado'
|
||||
},
|
||||
{
|
||||
date: '2024-01-30',
|
||||
day: 'Martes',
|
||||
condition: 'cloudy',
|
||||
tempMax: 16,
|
||||
tempMin: 9,
|
||||
humidity: 70,
|
||||
precipitation: 40,
|
||||
wind: 18,
|
||||
impact: 'moderate',
|
||||
recommendation: 'Enfoque en productos de interior'
|
||||
},
|
||||
{
|
||||
date: '2024-01-31',
|
||||
day: 'Miércoles',
|
||||
condition: 'sunny',
|
||||
tempMax: 24,
|
||||
tempMin: 14,
|
||||
humidity: 40,
|
||||
precipitation: 0,
|
||||
wind: 10,
|
||||
impact: 'high-demand',
|
||||
recommendation: 'Incrementar productos frescos y ensaladas'
|
||||
}
|
||||
];
|
||||
|
||||
const weatherImpacts = [
|
||||
{
|
||||
condition: 'Día Soleado',
|
||||
icon: Sun,
|
||||
impact: 'Aumento del 25% en bebidas frías',
|
||||
recommendations: [
|
||||
'Incrementar producción de helados',
|
||||
'Más bebidas refrescantes',
|
||||
'Ensaladas y productos frescos',
|
||||
'Horario extendido de terraza'
|
||||
],
|
||||
color: 'yellow'
|
||||
},
|
||||
{
|
||||
condition: 'Día Lluvioso',
|
||||
icon: CloudRain,
|
||||
impact: 'Aumento del 40% en productos calientes',
|
||||
recommendations: [
|
||||
'Más sopas y caldos',
|
||||
'Chocolates calientes',
|
||||
'Pan recién horneado',
|
||||
'Productos de repostería'
|
||||
],
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
condition: 'Frío Intenso',
|
||||
icon: Thermometer,
|
||||
impact: 'Preferencia por comida reconfortante',
|
||||
recommendations: [
|
||||
'Aumentar productos horneados',
|
||||
'Bebidas calientes especiales',
|
||||
'Productos energéticos',
|
||||
'Promociones de interior'
|
||||
],
|
||||
color: 'purple'
|
||||
}
|
||||
];
|
||||
|
||||
const seasonalTrends = [
|
||||
{
|
||||
season: 'Primavera',
|
||||
period: 'Mar - May',
|
||||
trends: [
|
||||
'Aumento en productos frescos (+30%)',
|
||||
'Mayor demanda de ensaladas',
|
||||
'Bebidas naturales populares',
|
||||
'Horarios extendidos efectivos'
|
||||
],
|
||||
avgTemp: '15-20°C',
|
||||
impact: 'positive'
|
||||
},
|
||||
{
|
||||
season: 'Verano',
|
||||
period: 'Jun - Ago',
|
||||
trends: [
|
||||
'Pico de helados y granizados (+60%)',
|
||||
'Productos ligeros preferidos',
|
||||
'Horario matutino crítico',
|
||||
'Mayor tráfico de turistas'
|
||||
],
|
||||
avgTemp: '25-35°C',
|
||||
impact: 'high'
|
||||
},
|
||||
{
|
||||
season: 'Otoño',
|
||||
period: 'Sep - Nov',
|
||||
trends: [
|
||||
'Regreso a productos tradicionales',
|
||||
'Aumento en bollería (+20%)',
|
||||
'Bebidas calientes populares',
|
||||
'Horarios regulares'
|
||||
],
|
||||
avgTemp: '10-18°C',
|
||||
impact: 'stable'
|
||||
},
|
||||
{
|
||||
season: 'Invierno',
|
||||
period: 'Dec - Feb',
|
||||
trends: [
|
||||
'Máximo de productos calientes (+50%)',
|
||||
'Pan recién horneado crítico',
|
||||
'Chocolates y dulces festivos',
|
||||
'Menor tráfico general (-15%)'
|
||||
],
|
||||
avgTemp: '5-12°C',
|
||||
impact: 'comfort'
|
||||
}
|
||||
];
|
||||
|
||||
const getWeatherIcon = (condition: string) => {
|
||||
const iconProps = { className: "w-8 h-8" };
|
||||
switch (condition) {
|
||||
case 'sunny': return <Sun {...iconProps} className="w-8 h-8 text-yellow-500" />;
|
||||
case 'partly-cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-gray-400" />;
|
||||
case 'cloudy': return <Cloud {...iconProps} className="w-8 h-8 text-gray-600" />;
|
||||
case 'rainy': return <CloudRain {...iconProps} className="w-8 h-8 text-blue-500" />;
|
||||
default: return <Cloud {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConditionLabel = (condition: string) => {
|
||||
switch (condition) {
|
||||
case 'sunny': return 'Soleado';
|
||||
case 'partly-cloudy': return 'Parcialmente nublado';
|
||||
case 'cloudy': return 'Nublado';
|
||||
case 'rainy': return 'Lluvioso';
|
||||
default: return condition;
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactColor = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return 'green';
|
||||
case 'comfort-food': return 'orange';
|
||||
case 'moderate': return 'blue';
|
||||
case 'normal': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getImpactLabel = (impact: string) => {
|
||||
switch (impact) {
|
||||
case 'high-demand': return 'Alta Demanda';
|
||||
case 'comfort-food': return 'Comida Reconfortante';
|
||||
case 'moderate': return 'Demanda Moderada';
|
||||
case 'normal': return 'Demanda Normal';
|
||||
default: return impact;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Datos Meteorológicos"
|
||||
description="Integra información del clima para optimizar la producción y ventas"
|
||||
/>
|
||||
|
||||
{/* Current Weather */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Condiciones Actuales</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
{getWeatherIcon(currentWeather.condition)}
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-gray-900">{currentWeather.temperature}°C</p>
|
||||
<p className="text-sm text-gray-600">{currentWeather.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Droplets className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm text-gray-600">Humedad: {currentWeather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wind className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-600">Viento: {currentWeather.windSpeed} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Presión:</span> {currentWeather.pressure} hPa
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">UV:</span> {currentWeather.uvIndex}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">Visibilidad:</span> {currentWeather.visibility} km
|
||||
</div>
|
||||
<Badge variant="blue">Condiciones favorables</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Forecast */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Pronóstico Extendido</h3>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="week">Próxima Semana</option>
|
||||
<option value="month">Próximo Mes</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{forecast.map((day, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="text-center mb-3">
|
||||
<p className="font-medium text-gray-900">{day.day}</p>
|
||||
<p className="text-xs text-gray-500">{new Date(day.date).toLocaleDateString('es-ES')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-3">
|
||||
{getWeatherIcon(day.condition)}
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-gray-600">{getConditionLabel(day.condition)}</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{day.tempMax}° <span className="text-sm text-gray-500">/ {day.tempMin}°</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Humedad:</span>
|
||||
<span>{day.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Lluvia:</span>
|
||||
<span>{day.precipitation}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Viento:</span>
|
||||
<span>{day.wind} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Badge variant={getImpactColor(day.impact)} className="text-xs">
|
||||
{getImpactLabel(day.impact)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-600">{day.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Weather Impact Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Impacto del Clima</h3>
|
||||
<div className="space-y-4">
|
||||
{weatherImpacts.map((impact, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className={`p-2 rounded-lg bg-${impact.color}-100`}>
|
||||
<impact.icon className={`w-5 h-5 text-${impact.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{impact.condition}</h4>
|
||||
<p className="text-sm text-gray-600">{impact.impact}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-10">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Recomendaciones:</p>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{impact.recommendations.map((rec, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<span className="w-1 h-1 bg-gray-400 rounded-full mr-2"></span>
|
||||
{rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Seasonal Trends */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tendencias Estacionales</h3>
|
||||
<div className="space-y-4">
|
||||
{seasonalTrends.map((season, index) => (
|
||||
<div key={index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{season.season}</h4>
|
||||
<p className="text-sm text-gray-500">{season.period}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-700">{season.avgTemp}</p>
|
||||
<Badge variant={
|
||||
season.impact === 'high' ? 'green' :
|
||||
season.impact === 'positive' ? 'blue' :
|
||||
season.impact === 'comfort' ? 'orange' : 'gray'
|
||||
}>
|
||||
{season.impact === 'high' ? 'Alto' :
|
||||
season.impact === 'positive' ? 'Positivo' :
|
||||
season.impact === 'comfort' ? 'Confort' : 'Estable'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
{season.trends.map((trend, idx) => (
|
||||
<li key={idx} className="flex items-center">
|
||||
<TrendingUp className="w-3 h-3 mr-2 text-green-500" />
|
||||
{trend}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Weather Alerts */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Alertas Meteorológicas</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Sun className="w-5 h-5 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800">Ola de calor prevista</p>
|
||||
<p className="text-sm text-yellow-700">Se esperan temperaturas superiores a 30°C los próximos 3 días</p>
|
||||
<p className="text-xs text-yellow-600 mt-1">Recomendación: Incrementar stock de bebidas frías y helados</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<CloudRain className="w-5 h-5 text-blue-600 mr-3" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Lluvia intensa el lunes</p>
|
||||
<p className="text-sm text-blue-700">80% probabilidad de precipitación con vientos fuertes</p>
|
||||
<p className="text-xs text-blue-600 mt-1">Recomendación: Preparar más productos calientes y de refugio</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeatherPage;
|
||||
@@ -1,232 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { InventoryTable, InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
||||
|
||||
const InventoryPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [filterCategory, setFilterCategory] = useState('all');
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
|
||||
const mockInventoryItems = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Harina de Trigo',
|
||||
category: 'Harinas',
|
||||
currentStock: 45,
|
||||
minStock: 20,
|
||||
maxStock: 100,
|
||||
unit: 'kg',
|
||||
cost: 1.20,
|
||||
supplier: 'Molinos del Sur',
|
||||
lastRestocked: '2024-01-20',
|
||||
expirationDate: '2024-06-30',
|
||||
status: 'normal',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levadura Fresca',
|
||||
category: 'Levaduras',
|
||||
currentStock: 8,
|
||||
minStock: 10,
|
||||
maxStock: 25,
|
||||
unit: 'kg',
|
||||
cost: 8.50,
|
||||
supplier: 'Levaduras SA',
|
||||
lastRestocked: '2024-01-25',
|
||||
expirationDate: '2024-02-15',
|
||||
status: 'low',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Mantequilla',
|
||||
category: 'Lácteos',
|
||||
currentStock: 15,
|
||||
minStock: 5,
|
||||
maxStock: 30,
|
||||
unit: 'kg',
|
||||
cost: 5.80,
|
||||
supplier: 'Lácteos Frescos',
|
||||
lastRestocked: '2024-01-24',
|
||||
expirationDate: '2024-02-10',
|
||||
status: 'normal',
|
||||
},
|
||||
];
|
||||
|
||||
const lowStockItems = mockInventoryItems.filter(item => item.status === 'low');
|
||||
|
||||
const stats = {
|
||||
totalItems: mockInventoryItems.length,
|
||||
lowStockItems: lowStockItems.length,
|
||||
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
||||
needsReorder: lowStockItems.length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Inventario"
|
||||
description="Controla el stock de ingredientes y materias primas"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Artículo
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Artículos</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.totalItems}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4-8-4m16 0v10l-8 4-8-4V7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Stock Bajo</p>
|
||||
<p className="text-3xl font-bold text-red-600">{stats.lowStockItems}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Valor Total</p>
|
||||
<p className="text-3xl font-bold text-green-600">€{stats.totalValue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Necesita Reorden</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{stats.needsReorder}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Low Stock Alert */}
|
||||
{lowStockItems.length > 0 && (
|
||||
<LowStockAlert items={lowStockItems} />
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar artículos..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="all">Todas las categorías</option>
|
||||
<option value="Harinas">Harinas</option>
|
||||
<option value="Levaduras">Levaduras</option>
|
||||
<option value="Lácteos">Lácteos</option>
|
||||
<option value="Grasas">Grasas</option>
|
||||
<option value="Azúcares">Azúcares</option>
|
||||
<option value="Especias">Especias</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value="normal">Stock normal</option>
|
||||
<option value="low">Stock bajo</option>
|
||||
<option value="out">Sin stock</option>
|
||||
<option value="expired">Caducado</option>
|
||||
</select>
|
||||
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Más filtros
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Table */}
|
||||
<Card>
|
||||
<InventoryTable
|
||||
items={mockInventoryItems}
|
||||
searchTerm={searchTerm}
|
||||
filterCategory={filterCategory}
|
||||
filterStatus={filterStatus}
|
||||
onEdit={(item) => {
|
||||
setSelectedItem(item);
|
||||
setShowForm(true);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Inventory Form Modal */}
|
||||
{showForm && (
|
||||
<InventoryForm
|
||||
item={selectedItem}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
onSave={(item) => {
|
||||
// Handle save logic
|
||||
console.log('Saving item:', item);
|
||||
setShowForm(false);
|
||||
setSelectedItem(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryPage;
|
||||
@@ -1,405 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, Calendar, Clock, User, Package } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { OrdersTable, OrderForm } from '../../../../components/domain/sales';
|
||||
|
||||
const OrdersPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
|
||||
const mockOrders = [
|
||||
{
|
||||
id: 'ORD-2024-001',
|
||||
customerName: 'María García',
|
||||
customerEmail: 'maria@email.com',
|
||||
customerPhone: '+34 600 123 456',
|
||||
status: 'pending',
|
||||
orderDate: '2024-01-26T09:30:00Z',
|
||||
deliveryDate: '2024-01-26T16:00:00Z',
|
||||
items: [
|
||||
{ id: '1', name: 'Pan de Molde Integral', quantity: 2, price: 4.50, total: 9.00 },
|
||||
{ id: '2', name: 'Croissants de Mantequilla', quantity: 6, price: 1.50, total: 9.00 },
|
||||
],
|
||||
subtotal: 18.00,
|
||||
tax: 1.89,
|
||||
discount: 0,
|
||||
total: 19.89,
|
||||
paymentMethod: 'card',
|
||||
paymentStatus: 'pending',
|
||||
deliveryMethod: 'pickup',
|
||||
notes: 'Sin gluten por favor en el pan',
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-002',
|
||||
customerName: 'Juan Pérez',
|
||||
customerEmail: 'juan@email.com',
|
||||
customerPhone: '+34 600 654 321',
|
||||
status: 'completed',
|
||||
orderDate: '2024-01-25T14:15:00Z',
|
||||
deliveryDate: '2024-01-25T18:30:00Z',
|
||||
items: [
|
||||
{ id: '3', name: 'Tarta de Chocolate', quantity: 1, price: 25.00, total: 25.00 },
|
||||
{ id: '4', name: 'Magdalenas', quantity: 12, price: 0.75, total: 9.00 },
|
||||
],
|
||||
subtotal: 34.00,
|
||||
tax: 3.57,
|
||||
discount: 2.00,
|
||||
total: 35.57,
|
||||
paymentMethod: 'cash',
|
||||
paymentStatus: 'paid',
|
||||
deliveryMethod: 'delivery',
|
||||
notes: 'Cumpleaños - decoración especial',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'ORD-2024-003',
|
||||
customerName: 'Ana Martínez',
|
||||
customerEmail: 'ana@email.com',
|
||||
customerPhone: '+34 600 987 654',
|
||||
status: 'in_progress',
|
||||
orderDate: '2024-01-26T07:45:00Z',
|
||||
deliveryDate: '2024-01-26T12:00:00Z',
|
||||
items: [
|
||||
{ id: '5', name: 'Baguettes Francesas', quantity: 4, price: 2.80, total: 11.20 },
|
||||
{ id: '6', name: 'Empanadas', quantity: 8, price: 2.50, total: 20.00 },
|
||||
],
|
||||
subtotal: 31.20,
|
||||
tax: 3.28,
|
||||
discount: 0,
|
||||
total: 34.48,
|
||||
paymentMethod: 'transfer',
|
||||
paymentStatus: 'paid',
|
||||
deliveryMethod: 'pickup',
|
||||
notes: '',
|
||||
priority: 'normal',
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
||||
ready: { color: 'green', text: 'Listo' },
|
||||
completed: { color: 'green', text: 'Completado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const priorityConfig = {
|
||||
low: { color: 'gray', text: 'Baja' },
|
||||
normal: { color: 'blue', text: 'Normal' },
|
||||
high: { color: 'orange', text: 'Alta' },
|
||||
urgent: { color: 'red', text: 'Urgente' },
|
||||
};
|
||||
|
||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || priority}</Badge>;
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
paid: { color: 'green', text: 'Pagado' },
|
||||
failed: { color: 'red', text: 'Fallido' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const filteredOrders = mockOrders.filter(order => {
|
||||
const matchesSearch = order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.customerEmail.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTab = activeTab === 'all' || order.status === activeTab;
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: mockOrders.length,
|
||||
pending: mockOrders.filter(o => o.status === 'pending').length,
|
||||
inProgress: mockOrders.filter(o => o.status === 'in_progress').length,
|
||||
completed: mockOrders.filter(o => o.status === 'completed').length,
|
||||
totalRevenue: mockOrders.reduce((sum, order) => sum + order.total, 0),
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: 'Todos', count: stats.total },
|
||||
{ id: 'pending', label: 'Pendientes', count: stats.pending },
|
||||
{ id: 'in_progress', label: 'En Proceso', count: stats.inProgress },
|
||||
{ id: 'ready', label: 'Listos', count: 0 },
|
||||
{ id: 'completed', label: 'Completados', count: stats.completed },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Pedidos"
|
||||
description="Administra y controla todos los pedidos de tu panadería"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Pedido
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.total}</p>
|
||||
</div>
|
||||
<Package className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pendientes</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{stats.pending}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">En Proceso</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.inProgress}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Completados</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.completed}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos</p>
|
||||
<p className="text-2xl font-bold text-purple-600">€{stats.totalRevenue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center ${
|
||||
activeTab === tab.id
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 && (
|
||||
<span className="ml-2 bg-gray-100 text-gray-900 py-0.5 px-2.5 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar pedidos por cliente, ID o email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Fecha
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Orders Table */}
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Cliente
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Prioridad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fecha Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Entrega
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Pago
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{order.id}</div>
|
||||
<div className="text-xs text-gray-500">{order.deliveryMethod === 'delivery' ? 'Entrega' : 'Recogida'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<User className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{order.customerName}</div>
|
||||
<div className="text-xs text-gray-500">{order.customerEmail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPriorityBadge(order.priority)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.orderDate).toLocaleDateString('es-ES')}
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(order.orderDate).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(order.deliveryDate).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">€{order.total.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">{order.items.length} artículos</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(order.paymentStatus)}
|
||||
<div className="text-xs text-gray-500 capitalize">{order.paymentMethod}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedOrder(order);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Order Form Modal */}
|
||||
{showForm && (
|
||||
<OrderForm
|
||||
order={selectedOrder}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
onSave={(order) => {
|
||||
// Handle save logic
|
||||
console.log('Saving order:', order);
|
||||
setShowForm(false);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersPage;
|
||||
@@ -1,368 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const POSPage: React.FC = () => {
|
||||
const [cart, setCart] = useState<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
category: string;
|
||||
}>>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
|
||||
const [cashReceived, setCashReceived] = useState('');
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
price: 4.50,
|
||||
category: 'bread',
|
||||
stock: 25,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
price: 1.50,
|
||||
category: 'pastry',
|
||||
stock: 32,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Baguette Francesa',
|
||||
price: 2.80,
|
||||
category: 'bread',
|
||||
stock: 18,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Tarta de Chocolate',
|
||||
price: 25.00,
|
||||
category: 'cake',
|
||||
stock: 8,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Magdalenas',
|
||||
price: 0.75,
|
||||
category: 'pastry',
|
||||
stock: 48,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Empanadas',
|
||||
price: 2.50,
|
||||
category: 'other',
|
||||
stock: 24,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todos' },
|
||||
{ id: 'bread', name: 'Panes' },
|
||||
{ id: 'pastry', name: 'Bollería' },
|
||||
{ id: 'cake', name: 'Tartas' },
|
||||
{ id: 'other', name: 'Otros' },
|
||||
];
|
||||
|
||||
const filteredProducts = products.filter(product =>
|
||||
selectedCategory === 'all' || product.category === selectedCategory
|
||||
);
|
||||
|
||||
const addToCart = (product: typeof products[0]) => {
|
||||
setCart(prevCart => {
|
||||
const existingItem = prevCart.find(item => item.id === product.id);
|
||||
if (existingItem) {
|
||||
return prevCart.map(item =>
|
||||
item.id === product.id
|
||||
? { ...item, quantity: item.quantity + 1 }
|
||||
: item
|
||||
);
|
||||
} else {
|
||||
return [...prevCart, {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
category: product.category,
|
||||
}];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateQuantity = (id: string, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
||||
} else {
|
||||
setCart(prevCart =>
|
||||
prevCart.map(item =>
|
||||
item.id === id ? { ...item, quantity } : item
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCart = () => {
|
||||
setCart([]);
|
||||
};
|
||||
|
||||
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
const taxRate = 0.21; // 21% IVA
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
const change = cashReceived ? Math.max(0, parseFloat(cashReceived) - total) : 0;
|
||||
|
||||
const processPayment = () => {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
// Process payment logic here
|
||||
console.log('Processing payment:', {
|
||||
cart,
|
||||
customerInfo,
|
||||
paymentMethod,
|
||||
total,
|
||||
cashReceived: paymentMethod === 'cash' ? parseFloat(cashReceived) : undefined,
|
||||
change: paymentMethod === 'cash' ? change : undefined,
|
||||
});
|
||||
|
||||
// Clear cart after successful payment
|
||||
setCart([]);
|
||||
setCustomerInfo({ name: '', email: '', phone: '' });
|
||||
setCashReceived('');
|
||||
|
||||
alert('Venta procesada exitosamente');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 h-screen flex flex-col">
|
||||
<PageHeader
|
||||
title="Punto de Venta"
|
||||
description="Sistema de ventas integrado"
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
||||
{/* Products Section */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Categories */}
|
||||
<div className="flex space-x-2 overflow-x-auto">
|
||||
{categories.map(category => (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategory === category.id ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredProducts.map(product => (
|
||||
<Card
|
||||
key={product.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => addToCart(product)}
|
||||
>
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full h-20 object-cover rounded mb-3"
|
||||
/>
|
||||
<h3 className="font-medium text-sm mb-1 line-clamp-2">{product.name}</h3>
|
||||
<p className="text-lg font-bold text-green-600">€{product.price.toFixed(2)}</p>
|
||||
<p className="text-xs text-gray-500">Stock: {product.stock}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cart and Checkout Section */}
|
||||
<div className="space-y-6">
|
||||
{/* Cart */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<ShoppingCart className="w-5 h-5 mr-2" />
|
||||
Carrito ({cart.length})
|
||||
</h3>
|
||||
{cart.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={clearCart}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{cart.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">Carrito vacío</p>
|
||||
) : (
|
||||
cart.map(item => (
|
||||
<div key={item.id} className="flex items-center justify-between p-3 bg-gray-50 rounded">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">{item.name}</h4>
|
||||
<p className="text-xs text-gray-500">€{item.price.toFixed(2)} c/u</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuantity(item.id, item.quantity - 1);
|
||||
}}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm">{item.quantity}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuantity(item.id, item.quantity + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-4 text-right">
|
||||
<p className="text-sm font-medium">€{(item.price * item.quantity).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cart.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal:</span>
|
||||
<span>€{subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>IVA (21%):</span>
|
||||
<span>€{tax.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2">
|
||||
<span>Total:</span>
|
||||
<span>€{total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Customer Info */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<User className="w-5 h-5 mr-2" />
|
||||
Cliente (Opcional)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Nombre"
|
||||
value={customerInfo.name}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={customerInfo.email}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Teléfono"
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Payment */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Calculator className="w-5 h-5 mr-2" />
|
||||
Método de Pago
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={paymentMethod === 'cash' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('cash')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Banknote className="w-4 h-4 mr-1" />
|
||||
Efectivo
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'card' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-1" />
|
||||
Tarjeta
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'transfer' ? 'default' : 'outline'}
|
||||
onClick={() => setPaymentMethod('transfer')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
Transferencia
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{paymentMethod === 'cash' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Efectivo recibido"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={cashReceived}
|
||||
onChange={(e) => setCashReceived(e.target.value)}
|
||||
/>
|
||||
{cashReceived && parseFloat(cashReceived) >= total && (
|
||||
<div className="p-2 bg-green-50 rounded text-center">
|
||||
<p className="text-sm text-green-600">
|
||||
Cambio: <span className="font-bold">€{change.toFixed(2)}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={processPayment}
|
||||
disabled={cart.length === 0 || (paymentMethod === 'cash' && (!cashReceived || parseFloat(cashReceived) < total))}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Receipt className="w-5 h-5 mr-2" />
|
||||
Procesar Venta - €{total.toFixed(2)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default POSPage;
|
||||
@@ -1,449 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Download, ShoppingCart, Truck, DollarSign, Calendar } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const ProcurementPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('orders');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const mockPurchaseOrders = [
|
||||
{
|
||||
id: 'PO-2024-001',
|
||||
supplier: 'Molinos del Sur',
|
||||
status: 'pending',
|
||||
orderDate: '2024-01-25',
|
||||
deliveryDate: '2024-01-28',
|
||||
totalAmount: 1250.00,
|
||||
items: [
|
||||
{ name: 'Harina de Trigo', quantity: 50, unit: 'kg', price: 1.20, total: 60.00 },
|
||||
{ name: 'Harina Integral', quantity: 100, unit: 'kg', price: 1.30, total: 130.00 },
|
||||
],
|
||||
paymentStatus: 'pending',
|
||||
notes: 'Entrega en horario de mañana',
|
||||
},
|
||||
{
|
||||
id: 'PO-2024-002',
|
||||
supplier: 'Levaduras SA',
|
||||
status: 'delivered',
|
||||
orderDate: '2024-01-20',
|
||||
deliveryDate: '2024-01-23',
|
||||
totalAmount: 425.50,
|
||||
items: [
|
||||
{ name: 'Levadura Fresca', quantity: 5, unit: 'kg', price: 8.50, total: 42.50 },
|
||||
{ name: 'Mejorante', quantity: 10, unit: 'kg', price: 12.30, total: 123.00 },
|
||||
],
|
||||
paymentStatus: 'paid',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
id: 'PO-2024-003',
|
||||
supplier: 'Lácteos Frescos',
|
||||
status: 'in_transit',
|
||||
orderDate: '2024-01-24',
|
||||
deliveryDate: '2024-01-26',
|
||||
totalAmount: 320.75,
|
||||
items: [
|
||||
{ name: 'Mantequilla', quantity: 20, unit: 'kg', price: 5.80, total: 116.00 },
|
||||
{ name: 'Nata', quantity: 15, unit: 'L', price: 3.25, total: 48.75 },
|
||||
],
|
||||
paymentStatus: 'pending',
|
||||
notes: 'Producto refrigerado',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSuppliers = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Molinos del Sur',
|
||||
contact: 'Juan Pérez',
|
||||
email: 'juan@molinosdelsur.com',
|
||||
phone: '+34 91 234 5678',
|
||||
category: 'Harinas',
|
||||
rating: 4.8,
|
||||
totalOrders: 24,
|
||||
totalSpent: 15600.00,
|
||||
paymentTerms: '30 días',
|
||||
leadTime: '2-3 días',
|
||||
location: 'Sevilla',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levaduras SA',
|
||||
contact: 'María González',
|
||||
email: 'maria@levaduras.com',
|
||||
phone: '+34 93 456 7890',
|
||||
category: 'Levaduras',
|
||||
rating: 4.6,
|
||||
totalOrders: 18,
|
||||
totalSpent: 8450.00,
|
||||
paymentTerms: '15 días',
|
||||
leadTime: '1-2 días',
|
||||
location: 'Barcelona',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Lácteos Frescos',
|
||||
contact: 'Carlos Ruiz',
|
||||
email: 'carlos@lacteosfrescos.com',
|
||||
phone: '+34 96 789 0123',
|
||||
category: 'Lácteos',
|
||||
rating: 4.4,
|
||||
totalOrders: 32,
|
||||
totalSpent: 12300.00,
|
||||
paymentTerms: '20 días',
|
||||
leadTime: '1 día',
|
||||
location: 'Valencia',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
approved: { color: 'blue', text: 'Aprobado' },
|
||||
in_transit: { color: 'purple', text: 'En Tránsito' },
|
||||
delivered: { color: 'green', text: 'Entregado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const getPaymentStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
paid: { color: 'green', text: 'Pagado' },
|
||||
overdue: { color: 'red', text: 'Vencido' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config?.color as any}>{config?.text || status}</Badge>;
|
||||
};
|
||||
|
||||
const stats = {
|
||||
totalOrders: mockPurchaseOrders.length,
|
||||
pendingOrders: mockPurchaseOrders.filter(o => o.status === 'pending').length,
|
||||
totalSpent: mockPurchaseOrders.reduce((sum, order) => sum + order.totalAmount, 0),
|
||||
activeSuppliers: mockSuppliers.filter(s => s.status === 'active').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Compras"
|
||||
description="Administra órdenes de compra, proveedores y seguimiento de entregas"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Compra
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Órdenes Totales</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.totalOrders}</p>
|
||||
</div>
|
||||
<ShoppingCart className="h-12 w-12 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Órdenes Pendientes</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{stats.pendingOrders}</p>
|
||||
</div>
|
||||
<Calendar className="h-12 w-12 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Gasto Total</p>
|
||||
<p className="text-3xl font-bold text-green-600">€{stats.totalSpent.toLocaleString()}</p>
|
||||
</div>
|
||||
<DollarSign className="h-12 w-12 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Proveedores Activos</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{stats.activeSuppliers}</p>
|
||||
</div>
|
||||
<Truck className="h-12 w-12 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'orders'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Órdenes de Compra
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suppliers')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'suppliers'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Proveedores
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'analytics'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Análisis
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder={`Buscar ${activeTab === 'orders' ? 'órdenes' : 'proveedores'}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'orders' && (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Orden
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Proveedor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fecha Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fecha Entrega
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Monto Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Pago
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockPurchaseOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{order.id}</div>
|
||||
{order.notes && (
|
||||
<div className="text-xs text-gray-500">{order.notes}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{order.supplier}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.orderDate).toLocaleDateString('es-ES')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.deliveryDate).toLocaleDateString('es-ES')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
€{order.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPaymentStatusBadge(order.paymentStatus)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">Ver</Button>
|
||||
<Button variant="outline" size="sm">Editar</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'suppliers' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{mockSuppliers.map((supplier) => (
|
||||
<Card key={supplier.id} className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{supplier.name}</h3>
|
||||
<p className="text-sm text-gray-600">{supplier.category}</p>
|
||||
</div>
|
||||
<Badge variant="green">Activo</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Contacto:</span>
|
||||
<span className="font-medium">{supplier.contact}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Email:</span>
|
||||
<span className="font-medium">{supplier.email}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Teléfono:</span>
|
||||
<span className="font-medium">{supplier.phone}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Ubicación:</span>
|
||||
<span className="font-medium">{supplier.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Valoración</p>
|
||||
<p className="text-sm font-medium flex items-center">
|
||||
<span className="text-yellow-500">★</span>
|
||||
<span className="ml-1">{supplier.rating}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Pedidos</p>
|
||||
<p className="text-sm font-medium">{supplier.totalOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Total Gastado</p>
|
||||
<p className="text-sm font-medium">€{supplier.totalSpent.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">Tiempo Entrega</p>
|
||||
<p className="text-sm font-medium">{supplier.leadTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-600">Condiciones de Pago</p>
|
||||
<p className="text-sm font-medium">{supplier.paymentTerms}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
Ver Detalles
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1">
|
||||
Nuevo Pedido
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gastos por Mes</h3>
|
||||
<div className="h-64 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">Gráfico de gastos mensuales</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Proveedores</h3>
|
||||
<div className="space-y-3">
|
||||
{mockSuppliers
|
||||
.sort((a, b) => b.totalSpent - a.totalSpent)
|
||||
.slice(0, 5)
|
||||
.map((supplier, index) => (
|
||||
<div key={supplier.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-gray-500 w-4">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="ml-3 text-sm text-gray-900">{supplier.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
€{supplier.totalSpent.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gastos por Categoría</h3>
|
||||
<div className="h-64 flex items-center justify-center bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">Gráfico de gastos por categoría</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcurementPage;
|
||||
@@ -1,315 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Calendar, Clock, Users, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, BatchTracker, QualityControl } from '../../../../components/domain/production';
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState('schedule');
|
||||
|
||||
const mockProductionStats = {
|
||||
dailyTarget: 150,
|
||||
completed: 85,
|
||||
inProgress: 12,
|
||||
pending: 53,
|
||||
efficiency: 78,
|
||||
quality: 94,
|
||||
};
|
||||
|
||||
const mockProductionOrders = [
|
||||
{
|
||||
id: '1',
|
||||
recipeName: 'Pan de Molde Integral',
|
||||
quantity: 20,
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
assignedTo: 'Juan Panadero',
|
||||
startTime: '2024-01-26T06:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T10:00:00Z',
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
recipeName: 'Croissants de Mantequilla',
|
||||
quantity: 50,
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
assignedTo: 'María González',
|
||||
startTime: '2024-01-26T08:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T12:00:00Z',
|
||||
progress: 0,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
recipeName: 'Baguettes Francesas',
|
||||
quantity: 30,
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
assignedTo: 'Carlos Ruiz',
|
||||
startTime: '2024-01-26T04:00:00Z',
|
||||
estimatedCompletion: '2024-01-26T08:00:00Z',
|
||||
progress: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig = {
|
||||
pending: { color: 'yellow', text: 'Pendiente' },
|
||||
in_progress: { color: 'blue', text: 'En Proceso' },
|
||||
completed: { color: 'green', text: 'Completado' },
|
||||
cancelled: { color: 'red', text: 'Cancelado' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const priorityConfig = {
|
||||
low: { color: 'gray', text: 'Baja' },
|
||||
medium: { color: 'yellow', text: 'Media' },
|
||||
high: { color: 'orange', text: 'Alta' },
|
||||
urgent: { color: 'red', text: 'Urgente' },
|
||||
};
|
||||
|
||||
const config = priorityConfig[priority as keyof typeof priorityConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Producción"
|
||||
description="Planifica y controla la producción diaria de tu panadería"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Production Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Meta Diaria</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{mockProductionStats.dailyTarget}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Completado</p>
|
||||
<p className="text-2xl font-bold text-green-600">{mockProductionStats.completed}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">En Proceso</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{mockProductionStats.inProgress}</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Pendiente</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{mockProductionStats.pending}</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Eficiencia</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{mockProductionStats.efficiency}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Calidad</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{mockProductionStats.quality}%</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'schedule'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Programación
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('batches')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'batches'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Lotes de Producción
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('quality')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'quality'
|
||||
? 'border-orange-500 text-orange-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Control de Calidad
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'schedule' && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">Órdenes de Producción</h3>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Filtros
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Vista Calendario
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Receta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Cantidad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Prioridad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Asignado a
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Progreso
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tiempo Estimado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{mockProductionOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{order.recipeName}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{order.quantity} unidades
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getPriorityBadge(order.priority)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<span className="text-sm text-gray-900">{order.assignedTo}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${order.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-900">{order.progress}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(order.estimatedCompletion).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Button variant="outline" size="sm" className="mr-2">
|
||||
Ver
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Editar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'batches' && (
|
||||
<BatchTracker />
|
||||
)}
|
||||
|
||||
{activeTab === 'quality' && (
|
||||
<QualityControl />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPage;
|
||||
@@ -1,412 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Search, Filter, Star, Clock, Users, DollarSign } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const RecipesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [selectedDifficulty, setSelectedDifficulty] = useState('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
category: 'bread',
|
||||
difficulty: 'medium',
|
||||
prepTime: 120,
|
||||
bakingTime: 35,
|
||||
yield: 1,
|
||||
rating: 4.8,
|
||||
cost: 2.50,
|
||||
price: 4.50,
|
||||
profit: 2.00,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['integral', 'saludable', 'artesanal'],
|
||||
description: 'Pan integral artesanal con semillas, perfecto para desayunos saludables.',
|
||||
ingredients: [
|
||||
{ name: 'Harina integral', quantity: 500, unit: 'g' },
|
||||
{ name: 'Agua', quantity: 300, unit: 'ml' },
|
||||
{ name: 'Levadura', quantity: 10, unit: 'g' },
|
||||
{ name: 'Sal', quantity: 8, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
category: 'pastry',
|
||||
difficulty: 'hard',
|
||||
prepTime: 480,
|
||||
bakingTime: 20,
|
||||
yield: 12,
|
||||
rating: 4.9,
|
||||
cost: 8.50,
|
||||
price: 18.00,
|
||||
profit: 9.50,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['francés', 'mantequilla', 'hojaldrado'],
|
||||
description: 'Croissants franceses tradicionales con laminado de mantequilla.',
|
||||
ingredients: [
|
||||
{ name: 'Harina de fuerza', quantity: 500, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 250, unit: 'g' },
|
||||
{ name: 'Leche', quantity: 150, unit: 'ml' },
|
||||
{ name: 'Azúcar', quantity: 50, unit: 'g' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Tarta de Manzana',
|
||||
category: 'cake',
|
||||
difficulty: 'easy',
|
||||
prepTime: 45,
|
||||
bakingTime: 40,
|
||||
yield: 8,
|
||||
rating: 4.6,
|
||||
cost: 4.20,
|
||||
price: 12.00,
|
||||
profit: 7.80,
|
||||
image: '/api/placeholder/300/200',
|
||||
tags: ['frutal', 'casera', 'temporada'],
|
||||
description: 'Tarta casera de manzana con canela y masa quebrada.',
|
||||
ingredients: [
|
||||
{ name: 'Manzanas', quantity: 1000, unit: 'g' },
|
||||
{ name: 'Harina', quantity: 250, unit: 'g' },
|
||||
{ name: 'Mantequilla', quantity: 125, unit: 'g' },
|
||||
{ name: 'Azúcar', quantity: 100, unit: 'g' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ value: 'all', label: 'Todas las categorías' },
|
||||
{ value: 'bread', label: 'Panes' },
|
||||
{ value: 'pastry', label: 'Bollería' },
|
||||
{ value: 'cake', label: 'Tartas' },
|
||||
{ value: 'cookie', label: 'Galletas' },
|
||||
{ value: 'other', label: 'Otros' },
|
||||
];
|
||||
|
||||
const difficulties = [
|
||||
{ value: 'all', label: 'Todas las dificultades' },
|
||||
{ value: 'easy', label: 'Fácil' },
|
||||
{ value: 'medium', label: 'Medio' },
|
||||
{ value: 'hard', label: 'Difícil' },
|
||||
];
|
||||
|
||||
const getCategoryBadge = (category: string) => {
|
||||
const categoryConfig = {
|
||||
bread: { color: 'brown', text: 'Pan' },
|
||||
pastry: { color: 'yellow', text: 'Bollería' },
|
||||
cake: { color: 'pink', text: 'Tarta' },
|
||||
cookie: { color: 'orange', text: 'Galleta' },
|
||||
other: { color: 'gray', text: 'Otro' },
|
||||
};
|
||||
|
||||
const config = categoryConfig[category as keyof typeof categoryConfig] || categoryConfig.other;
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const getDifficultyBadge = (difficulty: string) => {
|
||||
const difficultyConfig = {
|
||||
easy: { color: 'green', text: 'Fácil' },
|
||||
medium: { color: 'yellow', text: 'Medio' },
|
||||
hard: { color: 'red', text: 'Difícil' },
|
||||
};
|
||||
|
||||
const config = difficultyConfig[difficulty as keyof typeof difficultyConfig];
|
||||
return <Badge variant={config.color as any}>{config.text}</Badge>;
|
||||
};
|
||||
|
||||
const formatTime = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const filteredRecipes = mockRecipes.filter(recipe => {
|
||||
const matchesSearch = recipe.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
recipe.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesCategory = selectedCategory === 'all' || recipe.category === selectedCategory;
|
||||
const matchesDifficulty = selectedDifficulty === 'all' || recipe.difficulty === selectedDifficulty;
|
||||
|
||||
return matchesSearch && matchesCategory && matchesDifficulty;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Recetas"
|
||||
description="Administra y organiza todas las recetas de tu panadería"
|
||||
action={
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nueva Receta
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Recetas</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{mockRecipes.length}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Más Populares</p>
|
||||
<p className="text-3xl font-bold text-yellow-600">
|
||||
{mockRecipes.filter(r => r.rating > 4.7).length}
|
||||
</p>
|
||||
</div>
|
||||
<Star className="h-12 w-12 text-yellow-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Costo Promedio</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
€{(mockRecipes.reduce((sum, r) => sum + r.cost, 0) / mockRecipes.length).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-12 w-12 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Margen Promedio</p>
|
||||
<p className="text-3xl font-bold text-purple-600">
|
||||
€{(mockRecipes.reduce((sum, r) => sum + r.profit, 0) / mockRecipes.length).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar recetas por nombre, ingredientes o etiquetas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedDifficulty}
|
||||
onChange={(e) => setSelectedDifficulty(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
{difficulties.map(diff => (
|
||||
<option key={diff.value} value={diff.value}>{diff.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? 'Vista Lista' : 'Vista Cuadrícula'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recipes Grid/List */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<Card key={recipe.id} className="overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.name}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
|
||||
{recipe.name}
|
||||
</h3>
|
||||
<div className="flex items-center ml-2">
|
||||
<Star className="h-4 w-4 text-yellow-400 fill-current" />
|
||||
<span className="text-sm text-gray-600 ml-1">{recipe.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||
{recipe.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{getCategoryBadge(recipe.category)}
|
||||
{getDifficultyBadge(recipe.difficulty)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4 text-sm text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
<span>{formatTime(recipe.prepTime + recipe.bakingTime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
<span>{recipe.yield} porciones</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Costo: </span>
|
||||
<span className="font-medium">€{recipe.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">Precio: </span>
|
||||
<span className="font-medium text-green-600">€{recipe.price.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="flex-1">
|
||||
Ver Receta
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1">
|
||||
Producir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Receta
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Categoría
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Dificultad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tiempo Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Rendimiento
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Costo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Precio
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Margen
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredRecipes.map((recipe) => (
|
||||
<tr key={recipe.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={recipe.image}
|
||||
alt={recipe.name}
|
||||
className="h-10 w-10 rounded-full mr-4"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{recipe.name}</div>
|
||||
<div className="flex items-center">
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
<span className="text-xs text-gray-500 ml-1">{recipe.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getCategoryBadge(recipe.category)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getDifficultyBadge(recipe.difficulty)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatTime(recipe.prepTime + recipe.bakingTime)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recipe.yield} porciones
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
€{recipe.cost.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 font-medium">
|
||||
€{recipe.price.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-purple-600 font-medium">
|
||||
€{recipe.profit.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" size="sm">Ver</Button>
|
||||
<Button size="sm">Producir</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipesPage;
|
||||
@@ -3,7 +3,7 @@ import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, Edit3, Zap, Plus, Se
|
||||
import { Button, Card, Input, Select, Modal, Badge } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { posService, POSConfiguration } from '../../../../services/api/pos.service';
|
||||
import { posService, POSConfiguration } from '../../../../api/services/pos.service';
|
||||
|
||||
interface BakeryConfig {
|
||||
// General Info
|
||||
|
||||
@@ -1,481 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Store, MapPin, Clock, Phone, Mail, Globe, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const BakeryConfigPage: React.FC = () => {
|
||||
const [config, setConfig] = useState({
|
||||
general: {
|
||||
name: 'Panadería Artesanal San Miguel',
|
||||
description: 'Panadería tradicional con más de 30 años de experiencia',
|
||||
logo: '',
|
||||
website: 'https://panaderiasanmiguel.com',
|
||||
email: 'info@panaderiasanmiguel.com',
|
||||
phone: '+34 912 345 678'
|
||||
},
|
||||
location: {
|
||||
address: 'Calle Mayor 123',
|
||||
city: 'Madrid',
|
||||
postalCode: '28001',
|
||||
country: 'España',
|
||||
coordinates: {
|
||||
lat: 40.4168,
|
||||
lng: -3.7038
|
||||
}
|
||||
},
|
||||
schedule: {
|
||||
monday: { open: '07:00', close: '20:00', closed: false },
|
||||
tuesday: { open: '07:00', close: '20:00', closed: false },
|
||||
wednesday: { open: '07:00', close: '20:00', closed: false },
|
||||
thursday: { open: '07:00', close: '20:00', closed: false },
|
||||
friday: { open: '07:00', close: '20:00', closed: false },
|
||||
saturday: { open: '08:00', close: '14:00', closed: false },
|
||||
sunday: { open: '09:00', close: '13:00', closed: false }
|
||||
},
|
||||
business: {
|
||||
taxId: 'B12345678',
|
||||
registrationNumber: 'REG-2024-001',
|
||||
licenseNumber: 'LIC-FOOD-2024',
|
||||
currency: 'EUR',
|
||||
timezone: 'Europe/Madrid',
|
||||
language: 'es'
|
||||
},
|
||||
preferences: {
|
||||
enableOnlineOrders: true,
|
||||
enableReservations: false,
|
||||
enableDelivery: true,
|
||||
deliveryRadius: 5,
|
||||
minimumOrderAmount: 15.00,
|
||||
enableLoyaltyProgram: true,
|
||||
autoBackup: true,
|
||||
emailNotifications: true,
|
||||
smsNotifications: false
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General', icon: Store },
|
||||
{ id: 'location', label: 'Ubicación', icon: MapPin },
|
||||
{ id: 'schedule', label: 'Horarios', icon: Clock },
|
||||
{ id: 'business', label: 'Empresa', icon: Globe }
|
||||
];
|
||||
|
||||
const daysOfWeek = [
|
||||
{ key: 'monday', label: 'Lunes' },
|
||||
{ key: 'tuesday', label: 'Martes' },
|
||||
{ key: 'wednesday', label: 'Miércoles' },
|
||||
{ key: 'thursday', label: 'Jueves' },
|
||||
{ key: 'friday', label: 'Viernes' },
|
||||
{ key: 'saturday', label: 'Sábado' },
|
||||
{ key: 'sunday', label: 'Domingo' }
|
||||
];
|
||||
|
||||
const handleInputChange = (section: string, field: string, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section as keyof typeof prev],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleScheduleChange = (day: string, field: string, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
schedule: {
|
||||
...prev.schedule,
|
||||
[day]: {
|
||||
...prev.schedule[day as keyof typeof prev.schedule],
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving bakery config:', config);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Configuración de Panadería"
|
||||
description="Configura los datos básicos y preferencias de tu panadería"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="w-full lg:w-64">
|
||||
<Card className="p-4">
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center space-x-3 px-3 py-2 text-left rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
{activeTab === 'general' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre de la Panadería
|
||||
</label>
|
||||
<Input
|
||||
value={config.general.name}
|
||||
onChange={(e) => handleInputChange('general', 'name', e.target.value)}
|
||||
placeholder="Nombre de tu panadería"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sitio Web
|
||||
</label>
|
||||
<Input
|
||||
value={config.general.website}
|
||||
onChange={(e) => handleInputChange('general', 'website', e.target.value)}
|
||||
placeholder="https://tu-panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={config.general.description}
|
||||
onChange={(e) => handleInputChange('general', 'description', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Describe tu panadería..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email de Contacto
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
value={config.general.email}
|
||||
onChange={(e) => handleInputChange('general', 'email', e.target.value)}
|
||||
className="pl-10"
|
||||
type="email"
|
||||
placeholder="contacto@panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
value={config.general.phone}
|
||||
onChange={(e) => handleInputChange('general', 'phone', e.target.value)}
|
||||
className="pl-10"
|
||||
type="tel"
|
||||
placeholder="+34 912 345 678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'location' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Ubicación</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dirección
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.address}
|
||||
onChange={(e) => handleInputChange('location', 'address', e.target.value)}
|
||||
placeholder="Calle, número, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Ciudad
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.city}
|
||||
onChange={(e) => handleInputChange('location', 'city', e.target.value)}
|
||||
placeholder="Ciudad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Código Postal
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.postalCode}
|
||||
onChange={(e) => handleInputChange('location', 'postalCode', e.target.value)}
|
||||
placeholder="28001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
País
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.country}
|
||||
onChange={(e) => handleInputChange('location', 'country', e.target.value)}
|
||||
placeholder="España"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Latitud
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.coordinates.lat}
|
||||
onChange={(e) => handleInputChange('location', 'coordinates', {
|
||||
...config.location.coordinates,
|
||||
lat: parseFloat(e.target.value) || 0
|
||||
})}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
placeholder="40.4168"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Longitud
|
||||
</label>
|
||||
<Input
|
||||
value={config.location.coordinates.lng}
|
||||
onChange={(e) => handleInputChange('location', 'coordinates', {
|
||||
...config.location.coordinates,
|
||||
lng: parseFloat(e.target.value) || 0
|
||||
})}
|
||||
type="number"
|
||||
step="0.000001"
|
||||
placeholder="-3.7038"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'schedule' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Horarios de Apertura</h3>
|
||||
<div className="space-y-4">
|
||||
{daysOfWeek.map((day) => {
|
||||
const schedule = config.schedule[day.key as keyof typeof config.schedule];
|
||||
return (
|
||||
<div key={day.key} className="flex items-center space-x-4 p-4 border rounded-lg">
|
||||
<div className="w-20">
|
||||
<span className="text-sm font-medium text-gray-700">{day.label}</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={schedule.closed}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'closed', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">Cerrado</span>
|
||||
</label>
|
||||
|
||||
{!schedule.closed && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Apertura</label>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.open}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'open', e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Cierre</label>
|
||||
<input
|
||||
type="time"
|
||||
value={schedule.close}
|
||||
onChange={(e) => handleScheduleChange(day.key, 'close', e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'business' && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Datos de Empresa</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
NIF/CIF
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.taxId}
|
||||
onChange={(e) => handleInputChange('business', 'taxId', e.target.value)}
|
||||
placeholder="B12345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Número de Registro
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.registrationNumber}
|
||||
onChange={(e) => handleInputChange('business', 'registrationNumber', e.target.value)}
|
||||
placeholder="REG-2024-001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Licencia Sanitaria
|
||||
</label>
|
||||
<Input
|
||||
value={config.business.licenseNumber}
|
||||
onChange={(e) => handleInputChange('business', 'licenseNumber', e.target.value)}
|
||||
placeholder="LIC-FOOD-2024"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Moneda
|
||||
</label>
|
||||
<select
|
||||
value={config.business.currency}
|
||||
onChange={(e) => handleInputChange('business', 'currency', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Zona Horaria
|
||||
</label>
|
||||
<select
|
||||
value={config.business.timezone}
|
||||
onChange={(e) => handleInputChange('business', 'timezone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="Europe/Madrid">Madrid (GMT+1)</option>
|
||||
<option value="Europe/London">Londres (GMT)</option>
|
||||
<option value="America/New_York">Nueva York (GMT-5)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={config.business.language}
|
||||
onChange={(e) => handleInputChange('business', 'language', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-blue-600 bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BakeryConfigPage;
|
||||
@@ -3,4 +3,5 @@ export { default as ProfilePage } from './profile';
|
||||
export { default as BakeryConfigPage } from './bakery-config';
|
||||
export { default as TeamPage } from './team';
|
||||
export { default as SubscriptionPage } from './subscription';
|
||||
export { default as PreferencesPage } from './preferences';
|
||||
export { default as PreferencesPage } from './preferences';
|
||||
export { default as PreferencesPage } from './PreferencesPage';
|
||||
@@ -1,388 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
||||
import { Button, Card } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const PreferencesPage: React.FC = () => {
|
||||
const [preferences, setPreferences] = useState({
|
||||
notifications: {
|
||||
inventory: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
sales: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'hourly'
|
||||
},
|
||||
production: {
|
||||
app: true,
|
||||
email: false,
|
||||
sms: true,
|
||||
frequency: 'immediate'
|
||||
},
|
||||
system: {
|
||||
app: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'daily'
|
||||
},
|
||||
marketing: {
|
||||
app: false,
|
||||
email: true,
|
||||
sms: false,
|
||||
frequency: 'weekly'
|
||||
}
|
||||
},
|
||||
global: {
|
||||
doNotDisturb: false,
|
||||
quietHours: {
|
||||
enabled: false,
|
||||
start: '22:00',
|
||||
end: '07:00'
|
||||
},
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
soundEnabled: true,
|
||||
vibrationEnabled: true
|
||||
},
|
||||
channels: {
|
||||
email: 'panaderia@example.com',
|
||||
phone: '+34 600 123 456',
|
||||
slack: false,
|
||||
webhook: ''
|
||||
}
|
||||
});
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'inventory',
|
||||
name: 'Inventario',
|
||||
description: 'Alertas de stock, reposiciones y vencimientos',
|
||||
icon: '📦'
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
name: 'Ventas',
|
||||
description: 'Pedidos, transacciones y reportes de ventas',
|
||||
icon: '💰'
|
||||
},
|
||||
{
|
||||
id: 'production',
|
||||
name: 'Producción',
|
||||
description: 'Hornadas, calidad y tiempos de producción',
|
||||
icon: '🍞'
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
name: 'Sistema',
|
||||
description: 'Actualizaciones, mantenimiento y errores',
|
||||
icon: '⚙️'
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
name: 'Marketing',
|
||||
description: 'Campañas, promociones y análisis',
|
||||
icon: '📢'
|
||||
}
|
||||
];
|
||||
|
||||
const frequencies = [
|
||||
{ value: 'immediate', label: 'Inmediato' },
|
||||
{ value: 'hourly', label: 'Cada hora' },
|
||||
{ value: 'daily', label: 'Diario' },
|
||||
{ value: 'weekly', label: 'Semanal' }
|
||||
];
|
||||
|
||||
const handleNotificationChange = (category: string, channel: string, value: boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
[channel]: value
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (category: string, frequency: string) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
notifications: {
|
||||
...prev.notifications,
|
||||
[category]: {
|
||||
...prev.notifications[category as keyof typeof prev.notifications],
|
||||
frequency
|
||||
}
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalChange = (setting: string, value: any) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
global: {
|
||||
...prev.global,
|
||||
[setting]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleChannelChange = (channel: string, value: string | boolean) => {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Handle save logic
|
||||
console.log('Saving preferences:', preferences);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset to defaults
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const getChannelIcon = (channel: string) => {
|
||||
switch (channel) {
|
||||
case 'app':
|
||||
return <Bell className="w-4 h-4" />;
|
||||
case 'email':
|
||||
return <Mail className="w-4 h-4" />;
|
||||
case 'sms':
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
default:
|
||||
return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Preferencias de Comunicación"
|
||||
description="Configura cómo y cuándo recibir notificaciones"
|
||||
action={
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restaurar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Configuración General</h3>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.doNotDisturb}
|
||||
onChange={(e) => handleGlobalChange('doNotDisturb', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">No molestar</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Silencia todas las notificaciones</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.soundEnabled}
|
||||
onChange={(e) => handleGlobalChange('soundEnabled', e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Sonidos</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Reproducir sonidos de notificación</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.global.quietHours.enabled}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
enabled: e.target.checked
|
||||
})}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Horas silenciosas</span>
|
||||
</label>
|
||||
{preferences.global.quietHours.enabled && (
|
||||
<div className="flex space-x-4 ml-6">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Desde</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.start}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
start: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Hasta</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.global.quietHours.end}
|
||||
onChange={(e) => handleGlobalChange('quietHours', {
|
||||
...preferences.global.quietHours,
|
||||
end: e.target.value
|
||||
})}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Channel Settings */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Canales de Comunicación</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={preferences.channels.email}
|
||||
onChange={(e) => handleChannelChange('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="tu-email@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Teléfono (SMS)</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={preferences.channels.phone}
|
||||
onChange={(e) => handleChannelChange('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="+34 600 123 456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={preferences.channels.webhook}
|
||||
onChange={(e) => handleChannelChange('webhook', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="https://tu-webhook.com/notifications"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">URL para recibir notificaciones JSON</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Category Preferences */}
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const categoryPrefs = preferences.notifications[category.id as keyof typeof preferences.notifications];
|
||||
|
||||
return (
|
||||
<Card key={category.id} className="p-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="text-2xl">{category.icon}</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{category.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{category.description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Channel toggles */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Canales</h4>
|
||||
<div className="flex space-x-6">
|
||||
{['app', 'email', 'sms'].map((channel) => (
|
||||
<label key={channel} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={categoryPrefs[channel as keyof typeof categoryPrefs] as boolean}
|
||||
onChange={(e) => handleNotificationChange(category.id, channel, e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<div className="flex items-center space-x-1">
|
||||
{getChannelIcon(channel)}
|
||||
<span className="text-sm text-gray-700 capitalize">{channel}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Frecuencia</h4>
|
||||
<select
|
||||
value={categoryPrefs.frequency}
|
||||
onChange={(e) => handleFrequencyChange(category.id, e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
{frequencies.map((freq) => (
|
||||
<option key={freq.value} value={freq.value}>
|
||||
{freq.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save Changes Banner */}
|
||||
{hasChanges && (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 bg-blue-600 text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-4">
|
||||
<span className="text-sm">Tienes cambios sin guardar</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline" className="text-blue-600 bg-white" onClick={handleReset}>
|
||||
Descartar
|
||||
</Button>
|
||||
<Button size="sm" className="bg-blue-700 hover:bg-blue-800" onClick={handleSave}>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesPage;
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
subscriptionService,
|
||||
type UsageSummary,
|
||||
type AvailablePlans
|
||||
} from '../../../../services/api';
|
||||
} from '../../../../api/services';
|
||||
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
|
||||
|
||||
interface PlanComparisonProps {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
subscriptionService,
|
||||
type UsageSummary,
|
||||
type AvailablePlans
|
||||
} from '../../../../services/api';
|
||||
} from '../../../../api/services';
|
||||
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
|
||||
|
||||
interface PlanComparisonProps {
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
const TeamPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState('all');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const teamMembers = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'María González',
|
||||
email: 'maria.gonzalez@panaderia.com',
|
||||
phone: '+34 600 123 456',
|
||||
role: 'manager',
|
||||
department: 'Administración',
|
||||
status: 'active',
|
||||
joinDate: '2022-03-15',
|
||||
lastLogin: '2024-01-26 09:30:00',
|
||||
permissions: ['inventory', 'sales', 'reports', 'team'],
|
||||
avatar: '/avatars/maria.jpg',
|
||||
schedule: {
|
||||
monday: '07:00-15:00',
|
||||
tuesday: '07:00-15:00',
|
||||
wednesday: '07:00-15:00',
|
||||
thursday: '07:00-15:00',
|
||||
friday: '07:00-15:00',
|
||||
saturday: 'Libre',
|
||||
sunday: 'Libre'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Carlos Rodríguez',
|
||||
email: 'carlos.rodriguez@panaderia.com',
|
||||
phone: '+34 600 234 567',
|
||||
role: 'baker',
|
||||
department: 'Producción',
|
||||
status: 'active',
|
||||
joinDate: '2021-09-20',
|
||||
lastLogin: '2024-01-26 08:45:00',
|
||||
permissions: ['production', 'inventory'],
|
||||
avatar: '/avatars/carlos.jpg',
|
||||
schedule: {
|
||||
monday: '05:00-13:00',
|
||||
tuesday: '05:00-13:00',
|
||||
wednesday: '05:00-13:00',
|
||||
thursday: '05:00-13:00',
|
||||
friday: '05:00-13:00',
|
||||
saturday: '05:00-11:00',
|
||||
sunday: 'Libre'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Ana Martínez',
|
||||
email: 'ana.martinez@panaderia.com',
|
||||
phone: '+34 600 345 678',
|
||||
role: 'cashier',
|
||||
department: 'Ventas',
|
||||
status: 'active',
|
||||
joinDate: '2023-01-10',
|
||||
lastLogin: '2024-01-26 10:15:00',
|
||||
permissions: ['sales', 'pos'],
|
||||
avatar: '/avatars/ana.jpg',
|
||||
schedule: {
|
||||
monday: '08:00-16:00',
|
||||
tuesday: '08:00-16:00',
|
||||
wednesday: 'Libre',
|
||||
thursday: '08:00-16:00',
|
||||
friday: '08:00-16:00',
|
||||
saturday: '09:00-14:00',
|
||||
sunday: '09:00-14:00'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Luis Fernández',
|
||||
email: 'luis.fernandez@panaderia.com',
|
||||
phone: '+34 600 456 789',
|
||||
role: 'baker',
|
||||
department: 'Producción',
|
||||
status: 'inactive',
|
||||
joinDate: '2020-11-05',
|
||||
lastLogin: '2024-01-20 16:30:00',
|
||||
permissions: ['production'],
|
||||
avatar: '/avatars/luis.jpg',
|
||||
schedule: {
|
||||
monday: '13:00-21:00',
|
||||
tuesday: '13:00-21:00',
|
||||
wednesday: '13:00-21:00',
|
||||
thursday: 'Libre',
|
||||
friday: '13:00-21:00',
|
||||
saturday: 'Libre',
|
||||
sunday: '13:00-21:00'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Isabel Torres',
|
||||
email: 'isabel.torres@panaderia.com',
|
||||
phone: '+34 600 567 890',
|
||||
role: 'assistant',
|
||||
department: 'Ventas',
|
||||
status: 'active',
|
||||
joinDate: '2023-06-01',
|
||||
lastLogin: '2024-01-25 18:20:00',
|
||||
permissions: ['sales'],
|
||||
avatar: '/avatars/isabel.jpg',
|
||||
schedule: {
|
||||
monday: 'Libre',
|
||||
tuesday: '16:00-20:00',
|
||||
wednesday: '16:00-20:00',
|
||||
thursday: '16:00-20:00',
|
||||
friday: '16:00-20:00',
|
||||
saturday: '14:00-20:00',
|
||||
sunday: '14:00-20:00'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length },
|
||||
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length },
|
||||
{ value: 'baker', label: 'Panadero', count: teamMembers.filter(m => m.role === 'baker').length },
|
||||
{ value: 'cashier', label: 'Cajero', count: teamMembers.filter(m => m.role === 'cashier').length },
|
||||
{ value: 'assistant', label: 'Asistente', count: teamMembers.filter(m => m.role === 'assistant').length }
|
||||
];
|
||||
|
||||
const teamStats = {
|
||||
total: teamMembers.length,
|
||||
active: teamMembers.filter(m => m.status === 'active').length,
|
||||
departments: {
|
||||
production: teamMembers.filter(m => m.department === 'Producción').length,
|
||||
sales: teamMembers.filter(m => m.department === 'Ventas').length,
|
||||
admin: teamMembers.filter(m => m.department === 'Administración').length
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'manager': return 'purple';
|
||||
case 'baker': return 'green';
|
||||
case 'cashier': return 'blue';
|
||||
case 'assistant': return 'yellow';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
return status === 'active' ? 'green' : 'red';
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'manager': return 'Gerente';
|
||||
case 'baker': return 'Panadero';
|
||||
case 'cashier': return 'Cajero';
|
||||
case 'assistant': return 'Asistente';
|
||||
default: return role;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMembers = teamMembers.filter(member => {
|
||||
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
|
||||
const matchesSearch = member.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesRole && matchesSearch;
|
||||
});
|
||||
|
||||
const formatLastLogin = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffInDays === 0) {
|
||||
return 'Hoy ' + date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffInDays === 1) {
|
||||
return 'Ayer';
|
||||
} else {
|
||||
return `hace ${diffInDays} días`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Equipo"
|
||||
description="Administra los miembros del equipo, roles y permisos"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Miembro
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Team Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Equipo</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{teamStats.total}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Activos</p>
|
||||
<p className="text-3xl font-bold text-green-600">{teamStats.active}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<UserCheck className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Producción</p>
|
||||
<p className="text-3xl font-bold text-orange-600">{teamStats.departments.production}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{teamStats.departments.sales}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card className="p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Buscar miembros del equipo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role.value}
|
||||
onClick={() => setSelectedRole(role.value)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedRole === role.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{role.label} ({role.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Team Members List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{filteredMembers.map((member) => (
|
||||
<Card key={member.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{member.name}</h3>
|
||||
<Badge variant={getStatusColor(member.status)}>
|
||||
{member.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-3">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{member.email}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
{member.phone}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<Badge variant={getRoleBadgeColor(member.role)}>
|
||||
{getRoleLabel(member.role)}
|
||||
</Badge>
|
||||
<Badge variant="gray">
|
||||
{member.department}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 mb-3">
|
||||
<p>Se unió: {new Date(member.joinDate).toLocaleDateString('es-ES')}</p>
|
||||
<p>Última conexión: {formatLastLogin(member.lastLogin)}</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Permisos:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{member.permissions.map((permission, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule Preview */}
|
||||
<div className="text-xs text-gray-500">
|
||||
<p className="font-medium mb-1">Horario esta semana:</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{Object.entries(member.schedule).slice(0, 4).map(([day, hours]) => (
|
||||
<span key={day}>
|
||||
{day.charAt(0).toUpperCase()}: {hours}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={member.status === 'active' ? 'text-red-600 hover:text-red-700' : 'text-green-600 hover:text-green-700'}
|
||||
>
|
||||
{member.status === 'active' ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron miembros</h3>
|
||||
<p className="text-gray-600">
|
||||
No hay miembros del equipo que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add Member Modal Placeholder */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<Card className="p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nuevo Miembro del Equipo</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Formulario para agregar un nuevo miembro del equipo.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" onClick={() => setShowForm(false)}>
|
||||
Guardar
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamPage;
|
||||
@@ -24,10 +24,9 @@ const SalesAnalyticsPage = React.lazy(() => import('../pages/app/analytics/sales
|
||||
const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insights/AIInsightsPage'));
|
||||
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
|
||||
|
||||
// Communications pages
|
||||
const PreferencesPage = React.lazy(() => import('../pages/app/communications/preferences/PreferencesPage'));
|
||||
|
||||
// Settings pages
|
||||
const PreferencesPage = React.lazy(() => import('../pages/app/settings/preferences/PreferencesPage'));
|
||||
const ProfilePage = React.lazy(() => import('../pages/app/settings/profile/ProfilePage'));
|
||||
const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-config/BakeryConfigPage'));
|
||||
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
|
||||
@@ -176,17 +175,6 @@ export const AppRouter: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Communications Routes */}
|
||||
<Route
|
||||
path="/app/communications/preferences"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<PreferencesPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Settings Routes */}
|
||||
<Route
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
import {
|
||||
UserRegistration,
|
||||
UserLogin,
|
||||
UserData,
|
||||
TokenResponse,
|
||||
RefreshTokenRequest,
|
||||
PasswordChange,
|
||||
PasswordReset,
|
||||
PasswordResetConfirm,
|
||||
TokenVerification,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
OnboardingStatus,
|
||||
OnboardingProgressRequest
|
||||
} from '../../types/auth.types';
|
||||
|
||||
class AuthService {
|
||||
private readonly baseUrl = '/auth';
|
||||
|
||||
// Authentication endpoints
|
||||
async register(userData: UserRegistration): Promise<ApiResponse<TokenResponse>> {
|
||||
const response = await apiClient.post<TokenResponse>(`${this.baseUrl}/register`, userData);
|
||||
|
||||
if (response.success && response.data) {
|
||||
this.handleSuccessfulAuth(response.data);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async login(credentials: UserLogin): Promise<ApiResponse<TokenResponse>> {
|
||||
const response = await apiClient.post<TokenResponse>(`${this.baseUrl}/login`, credentials);
|
||||
|
||||
if (response.success && response.data) {
|
||||
this.handleSuccessfulAuth(response.data);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout(): Promise<ApiResponse<{ message: string; success: boolean }>> {
|
||||
try {
|
||||
const response = await apiClient.post(`${this.baseUrl}/logout`);
|
||||
this.clearAuthData();
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Even if logout fails on server, clear local data
|
||||
this.clearAuthData();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken?: string): Promise<ApiResponse<TokenResponse>> {
|
||||
const token = refreshToken || localStorage.getItem('refresh_token');
|
||||
if (!token) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await apiClient.post<TokenResponse>(`${this.baseUrl}/refresh`, {
|
||||
refresh_token: token
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
this.handleSuccessfulAuth(response.data);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async verifyToken(token?: string): Promise<ApiResponse<TokenVerification>> {
|
||||
const authToken = token || localStorage.getItem('access_token');
|
||||
return apiClient.post(`${this.baseUrl}/verify`, { token: authToken });
|
||||
}
|
||||
|
||||
// Password management
|
||||
async changePassword(passwordData: PasswordChange): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.post(`${this.baseUrl}/change-password`, passwordData);
|
||||
}
|
||||
|
||||
async resetPassword(email: string): Promise<ApiResponse<{ message: string; reset_token?: string }>> {
|
||||
return apiClient.post(`${this.baseUrl}/reset-password`, { email });
|
||||
}
|
||||
|
||||
async confirmPasswordReset(data: PasswordResetConfirm): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.post(`${this.baseUrl}/reset-password/confirm`, data);
|
||||
}
|
||||
|
||||
// User management
|
||||
async getCurrentUser(): Promise<ApiResponse<UserResponse>> {
|
||||
return apiClient.get(`${this.baseUrl}/me`);
|
||||
}
|
||||
|
||||
async updateProfile(userData: UserUpdate): Promise<ApiResponse<UserResponse>> {
|
||||
return apiClient.patch(`${this.baseUrl}/me`, userData);
|
||||
}
|
||||
|
||||
// Email verification
|
||||
async sendEmailVerification(): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.post(`${this.baseUrl}/verify-email`);
|
||||
}
|
||||
|
||||
async confirmEmailVerification(token: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.post(`${this.baseUrl}/verify-email/confirm`, { token });
|
||||
}
|
||||
|
||||
// Local auth state management - Now handled by Zustand store
|
||||
private handleSuccessfulAuth(tokenData: TokenResponse) {
|
||||
// Set auth token for API client
|
||||
apiClient.setAuthToken(tokenData.access_token);
|
||||
|
||||
// Set tenant ID for API client if available
|
||||
if (tokenData.user?.tenant_id) {
|
||||
apiClient.setTenantId(tokenData.user.tenant_id);
|
||||
}
|
||||
}
|
||||
|
||||
private clearAuthData() {
|
||||
// Clear API client tokens
|
||||
apiClient.removeAuthToken();
|
||||
}
|
||||
|
||||
// Utility methods - Now get data from Zustand store
|
||||
isAuthenticated(): boolean {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (!authStorage) return false;
|
||||
try {
|
||||
const { state } = JSON.parse(authStorage);
|
||||
return state?.isAuthenticated || false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentUserData(): UserData | null {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (!authStorage) return null;
|
||||
try {
|
||||
const { state } = JSON.parse(authStorage);
|
||||
return state?.user || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (!authStorage) return null;
|
||||
try {
|
||||
const { state } = JSON.parse(authStorage);
|
||||
return state?.token || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (!authStorage) return null;
|
||||
try {
|
||||
const { state } = JSON.parse(authStorage);
|
||||
return state?.refreshToken || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getTenantId(): string | null {
|
||||
const userData = this.getCurrentUserData();
|
||||
return userData?.tenant_id || null;
|
||||
}
|
||||
|
||||
// Check if token is expired (basic check)
|
||||
isTokenExpired(): boolean {
|
||||
const token = this.getAccessToken();
|
||||
if (!token) return true;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Date.now() / 1000;
|
||||
return payload.exp < currentTime;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh token if needed
|
||||
async ensureValidToken(): Promise<boolean> {
|
||||
if (!this.isAuthenticated()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isTokenExpired()) {
|
||||
try {
|
||||
await this.refreshToken();
|
||||
return true;
|
||||
} catch {
|
||||
this.clearAuthData();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Onboarding progress tracking (moved from onboarding)
|
||||
async checkOnboardingStatus(): Promise<ApiResponse<OnboardingStatus>> {
|
||||
try {
|
||||
// Use the /me endpoint which gets proxied to auth service
|
||||
const response = await apiClient.get<any>('/me');
|
||||
|
||||
if (response.success && response.data) {
|
||||
// Extract onboarding status from user profile
|
||||
const onboardingStatus = {
|
||||
completed: response.data.onboarding_completed || false,
|
||||
steps_completed: response.data.completed_steps || []
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: onboardingStatus,
|
||||
message: 'Onboarding status retrieved successfully'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: { completed: false, steps_completed: [] },
|
||||
message: 'Could not retrieve onboarding status',
|
||||
error: 'Invalid response data'
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Could not check onboarding status:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: { completed: false, steps_completed: [] },
|
||||
message: 'Could not retrieve onboarding status',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async completeOnboarding(metadata?: any): Promise<ApiResponse<{ message: string }>> {
|
||||
try {
|
||||
// Update user profile to mark onboarding as complete
|
||||
const response = await apiClient.patch<any>('/me', {
|
||||
onboarding_completed: true,
|
||||
completed_steps: ['setup', 'data-processing', 'review', 'inventory', 'suppliers', 'ml-training', 'completion'],
|
||||
onboarding_metadata: metadata
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: { message: 'Onboarding completed successfully' },
|
||||
message: 'Onboarding marked as complete'
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.warn('Could not mark onboarding as complete:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: { message: 'Failed to complete onboarding' },
|
||||
message: 'Could not complete onboarding',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
@@ -1,277 +0,0 @@
|
||||
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { ApiResponse, ApiError } from '../../types/api.types';
|
||||
|
||||
// Utility functions to access auth and tenant store data from localStorage
|
||||
const getAuthData = () => {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (!authStorage) return null;
|
||||
try {
|
||||
const { state } = JSON.parse(authStorage);
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getTenantData = () => {
|
||||
const tenantStorage = localStorage.getItem('tenant-storage');
|
||||
if (!tenantStorage) return null;
|
||||
try {
|
||||
const { state } = JSON.parse(tenantStorage);
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearAuthData = () => {
|
||||
localStorage.removeItem('auth-storage');
|
||||
};
|
||||
|
||||
// Client-specific error interface
|
||||
interface ClientError {
|
||||
success: boolean;
|
||||
error: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
class ApiClient {
|
||||
private axiosInstance: AxiosInstance;
|
||||
private refreshTokenPromise: Promise<string> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
// Helper method to build tenant-scoped URLs
|
||||
private buildTenantUrl(path: string): string {
|
||||
// If path already starts with /tenants, return as-is
|
||||
if (path.startsWith('/tenants/')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// If it's an auth endpoint, return as-is
|
||||
if (path.startsWith('/auth')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Get tenant ID from stores
|
||||
const tenantData = getTenantData();
|
||||
const authData = getAuthData();
|
||||
const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id;
|
||||
|
||||
if (!tenantId) {
|
||||
throw new Error('Tenant ID not available for API call');
|
||||
}
|
||||
|
||||
// Build tenant-scoped URL: /tenants/{tenant-id}{original-path}
|
||||
return `/tenants/${tenantId}${path}`;
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
// Request interceptor - add auth token and tenant ID
|
||||
this.axiosInstance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const authData = getAuthData();
|
||||
if (authData?.token) {
|
||||
config.headers.Authorization = `Bearer ${authData.token}`;
|
||||
}
|
||||
|
||||
// Get tenant ID from tenant store (priority) or fallback to user's tenant_id
|
||||
const tenantData = getTenantData();
|
||||
const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id;
|
||||
|
||||
if (tenantId) {
|
||||
config.headers['X-Tenant-ID'] = tenantId;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor - handle common responses and errors
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 - Token expired
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const authData = getAuthData();
|
||||
if (authData?.refreshToken) {
|
||||
const newToken = await this.refreshToken();
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return this.axiosInstance(originalRequest);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
this.handleAuthFailure();
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 403 - Forbidden
|
||||
if (error.response?.status === 403) {
|
||||
console.warn('Access denied - insufficient permissions');
|
||||
}
|
||||
|
||||
// Handle network errors
|
||||
if (!error.response) {
|
||||
const networkError: ClientError = {
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Network error - please check your connection',
|
||||
code: 'NETWORK_ERROR'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
return Promise.reject(networkError);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<string> {
|
||||
if (!this.refreshTokenPromise) {
|
||||
this.refreshTokenPromise = this.performTokenRefresh();
|
||||
}
|
||||
|
||||
const token = await this.refreshTokenPromise;
|
||||
this.refreshTokenPromise = null;
|
||||
return token;
|
||||
}
|
||||
|
||||
private async performTokenRefresh(): Promise<string> {
|
||||
const authData = getAuthData();
|
||||
if (!authData?.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refresh_token: authData.refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
|
||||
// Update the Zustand store by modifying the auth-storage directly
|
||||
const newAuthData = {
|
||||
...authData,
|
||||
token: access_token,
|
||||
refreshToken: refresh_token || authData.refreshToken
|
||||
};
|
||||
|
||||
localStorage.setItem('auth-storage', JSON.stringify({
|
||||
state: newAuthData,
|
||||
version: 0
|
||||
}));
|
||||
|
||||
return access_token;
|
||||
}
|
||||
|
||||
private handleAuthFailure() {
|
||||
clearAuthData();
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// HTTP Methods with consistent response format and automatic tenant scoping
|
||||
async get<T = any>(url: string, config = {}): Promise<ApiResponse<T>> {
|
||||
const tenantScopedUrl = this.buildTenantUrl(url);
|
||||
const response = await this.axiosInstance.get(tenantScopedUrl, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
|
||||
const tenantScopedUrl = this.buildTenantUrl(url);
|
||||
const response = await this.axiosInstance.post(tenantScopedUrl, data, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
|
||||
const tenantScopedUrl = this.buildTenantUrl(url);
|
||||
const response = await this.axiosInstance.put(tenantScopedUrl, data, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
|
||||
const tenantScopedUrl = this.buildTenantUrl(url);
|
||||
const response = await this.axiosInstance.patch(tenantScopedUrl, data, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config = {}): Promise<ApiResponse<T>> {
|
||||
const tenantScopedUrl = this.buildTenantUrl(url);
|
||||
const response = await this.axiosInstance.delete(tenantScopedUrl, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
// File upload helper with automatic tenant scoping
|
||||
async uploadFile<T = any>(url: string, file: File, progressCallback?: (progress: number) => void): Promise<ApiResponse<T>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent: any) => {
|
||||
if (progressCallback && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
progressCallback(progress);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const tenantScopedUrl = this.buildTenantUrl(url);
|
||||
const response = await this.axiosInstance.post(tenantScopedUrl, formData, config);
|
||||
return this.transformResponse(response);
|
||||
}
|
||||
|
||||
// Transform response to consistent format
|
||||
private transformResponse<T>(response: AxiosResponse): ApiResponse<T> {
|
||||
return {
|
||||
data: response.data,
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
message: response.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
// Utility methods - Now work with Zustand store
|
||||
setAuthToken(token: string) {
|
||||
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
removeAuthToken() {
|
||||
delete this.axiosInstance.defaults.headers.common['Authorization'];
|
||||
}
|
||||
|
||||
setTenantId(tenantId: string) {
|
||||
this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId;
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
return API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export { ApiClient };
|
||||
export const apiClient = new ApiClient();
|
||||
@@ -1,246 +0,0 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
import {
|
||||
WeatherData,
|
||||
WeatherDataParams,
|
||||
TrafficData,
|
||||
TrafficDataParams,
|
||||
TrafficPatternsParams,
|
||||
TrafficPattern,
|
||||
EventData,
|
||||
EventsParams,
|
||||
CustomEventCreate,
|
||||
LocationConfig,
|
||||
LocationCreate,
|
||||
ExternalFactorsImpact,
|
||||
ExternalFactorsParams,
|
||||
DataQualityReport,
|
||||
DataSettings,
|
||||
DataSettingsUpdate,
|
||||
RefreshDataResponse,
|
||||
DeleteResponse,
|
||||
WeatherCondition,
|
||||
EventType,
|
||||
RefreshInterval
|
||||
} from '../../types/data.types';
|
||||
|
||||
class DataService {
|
||||
private readonly baseUrl = '/data';
|
||||
|
||||
// Location management
|
||||
async getLocations(): Promise<ApiResponse<LocationConfig[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/locations`);
|
||||
}
|
||||
|
||||
async getLocation(locationId: string): Promise<ApiResponse<LocationConfig>> {
|
||||
return apiClient.get(`${this.baseUrl}/locations/${locationId}`);
|
||||
}
|
||||
|
||||
async createLocation(locationData: LocationCreate): Promise<ApiResponse<LocationConfig>> {
|
||||
return apiClient.post(`${this.baseUrl}/locations`, locationData);
|
||||
}
|
||||
|
||||
async updateLocation(locationId: string, locationData: Partial<LocationConfig>): Promise<ApiResponse<LocationConfig>> {
|
||||
return apiClient.put(`${this.baseUrl}/locations/${locationId}`, locationData);
|
||||
}
|
||||
|
||||
async deleteLocation(locationId: string): Promise<ApiResponse<DeleteResponse>> {
|
||||
return apiClient.delete(`${this.baseUrl}/locations/${locationId}`);
|
||||
}
|
||||
|
||||
// Weather data
|
||||
async getWeatherData(params?: WeatherDataParams): Promise<ApiResponse<{ items: WeatherData[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/weather?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/weather`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getCurrentWeather(locationId: string): Promise<ApiResponse<WeatherData>> {
|
||||
return apiClient.get(`${this.baseUrl}/weather/current/${locationId}`);
|
||||
}
|
||||
|
||||
async getWeatherForecast(locationId: string, days: number = 7): Promise<ApiResponse<WeatherData[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/weather/forecast/${locationId}?days=${days}`);
|
||||
}
|
||||
|
||||
async refreshWeatherData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
|
||||
const url = locationId
|
||||
? `${this.baseUrl}/weather/refresh/${locationId}`
|
||||
: `${this.baseUrl}/weather/refresh`;
|
||||
|
||||
return apiClient.post(url);
|
||||
}
|
||||
|
||||
// Traffic data
|
||||
async getTrafficData(params?: TrafficDataParams): Promise<ApiResponse<{ items: TrafficData[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/traffic?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/traffic`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getCurrentTraffic(locationId: string): Promise<ApiResponse<TrafficData>> {
|
||||
return apiClient.get(`${this.baseUrl}/traffic/current/${locationId}`);
|
||||
}
|
||||
|
||||
async getTrafficPatterns(locationId: string, params?: TrafficPatternsParams): Promise<ApiResponse<TrafficPattern[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/traffic/patterns/${locationId}?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/traffic/patterns/${locationId}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async refreshTrafficData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
|
||||
const url = locationId
|
||||
? `${this.baseUrl}/traffic/refresh/${locationId}`
|
||||
: `${this.baseUrl}/traffic/refresh`;
|
||||
|
||||
return apiClient.post(url);
|
||||
}
|
||||
|
||||
// Events data
|
||||
async getEvents(params?: EventsParams): Promise<ApiResponse<{ items: EventData[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/events?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/events`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getUpcomingEvents(locationId: string, days: number = 30): Promise<ApiResponse<EventData[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/events/upcoming/${locationId}?days=${days}`);
|
||||
}
|
||||
|
||||
async createCustomEvent(eventData: CustomEventCreate): Promise<ApiResponse<EventData>> {
|
||||
return apiClient.post(`${this.baseUrl}/events`, eventData);
|
||||
}
|
||||
|
||||
async updateEvent(eventId: string, eventData: Partial<EventData>): Promise<ApiResponse<EventData>> {
|
||||
return apiClient.put(`${this.baseUrl}/events/${eventId}`, eventData);
|
||||
}
|
||||
|
||||
async deleteEvent(eventId: string): Promise<ApiResponse<DeleteResponse>> {
|
||||
return apiClient.delete(`${this.baseUrl}/events/${eventId}`);
|
||||
}
|
||||
|
||||
async refreshEventsData(locationId?: string): Promise<ApiResponse<RefreshDataResponse>> {
|
||||
const url = locationId
|
||||
? `${this.baseUrl}/events/refresh/${locationId}`
|
||||
: `${this.baseUrl}/events/refresh`;
|
||||
|
||||
return apiClient.post(url);
|
||||
}
|
||||
|
||||
// Combined analytics
|
||||
async getExternalFactorsImpact(params?: ExternalFactorsParams): Promise<ApiResponse<ExternalFactorsImpact>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/impact-analysis?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/impact-analysis`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getDataQualityReport(): Promise<ApiResponse<DataQualityReport>> {
|
||||
return apiClient.get(`${this.baseUrl}/quality-report`);
|
||||
}
|
||||
|
||||
// Data configuration
|
||||
async getDataSettings(): Promise<ApiResponse<DataSettings>> {
|
||||
return apiClient.get(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
async updateDataSettings(settings: DataSettingsUpdate): Promise<ApiResponse<DataSettings>> {
|
||||
return apiClient.put(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getWeatherConditions(): WeatherCondition[] {
|
||||
return [
|
||||
{ value: 'sunny', label: 'Sunny', impact: 'positive' },
|
||||
{ value: 'cloudy', label: 'Cloudy', impact: 'neutral' },
|
||||
{ value: 'rainy', label: 'Rainy', impact: 'negative' },
|
||||
{ value: 'stormy', label: 'Stormy', impact: 'negative' },
|
||||
{ value: 'snowy', label: 'Snowy', impact: 'negative' },
|
||||
{ value: 'foggy', label: 'Foggy', impact: 'negative' },
|
||||
];
|
||||
}
|
||||
|
||||
getEventTypes(): EventType[] {
|
||||
return [
|
||||
{ value: 'festival', label: 'Festival', typical_impact: 'positive' },
|
||||
{ value: 'concert', label: 'Concert', typical_impact: 'positive' },
|
||||
{ value: 'sports_event', label: 'Sports Event', typical_impact: 'positive' },
|
||||
{ value: 'conference', label: 'Conference', typical_impact: 'positive' },
|
||||
{ value: 'construction', label: 'Construction', typical_impact: 'negative' },
|
||||
{ value: 'roadwork', label: 'Road Work', typical_impact: 'negative' },
|
||||
{ value: 'protest', label: 'Protest', typical_impact: 'negative' },
|
||||
{ value: 'holiday', label: 'Holiday', typical_impact: 'neutral' },
|
||||
];
|
||||
}
|
||||
|
||||
getRefreshIntervals(): RefreshInterval[] {
|
||||
return [
|
||||
{ value: 5, label: '5 minutes', suitable_for: ['traffic'] },
|
||||
{ value: 15, label: '15 minutes', suitable_for: ['traffic'] },
|
||||
{ value: 30, label: '30 minutes', suitable_for: ['weather', 'traffic'] },
|
||||
{ value: 60, label: '1 hour', suitable_for: ['weather'] },
|
||||
{ value: 240, label: '4 hours', suitable_for: ['weather'] },
|
||||
{ value: 1440, label: '24 hours', suitable_for: ['events'] },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const dataService = new DataService();
|
||||
@@ -1,268 +0,0 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
import {
|
||||
ForecastRequest,
|
||||
ForecastResponse,
|
||||
PredictionBatch,
|
||||
ModelPerformance
|
||||
} from '../../types/forecasting.types';
|
||||
|
||||
class ForecastingService {
|
||||
private readonly baseUrl = '/forecasting';
|
||||
|
||||
// Forecast generation
|
||||
async createForecast(forecastData: ForecastRequest): Promise<ApiResponse<ForecastResponse[]>> {
|
||||
return apiClient.post(`${this.baseUrl}/forecasts`, forecastData);
|
||||
}
|
||||
|
||||
async getForecasts(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
product_name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<ApiResponse<{ items: ForecastResponse[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/forecasts?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/forecasts`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getForecast(forecastId: string): Promise<ApiResponse<ForecastResponse>> {
|
||||
return apiClient.get(`${this.baseUrl}/forecasts/${forecastId}`);
|
||||
}
|
||||
|
||||
async updateForecastActual(forecastId: string, actualDemand: number): Promise<ApiResponse<ForecastResponse>> {
|
||||
return apiClient.patch(`${this.baseUrl}/forecasts/${forecastId}`, {
|
||||
actual_demand: actualDemand
|
||||
});
|
||||
}
|
||||
|
||||
// Batch predictions
|
||||
async createPredictionBatch(batchData: {
|
||||
name: string;
|
||||
description?: string;
|
||||
products: string[];
|
||||
days_ahead: number;
|
||||
parameters?: Record<string, any>;
|
||||
}): Promise<ApiResponse<PredictionBatch>> {
|
||||
return apiClient.post(`${this.baseUrl}/batches`, batchData);
|
||||
}
|
||||
|
||||
async getPredictionBatches(): Promise<ApiResponse<PredictionBatch[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/batches`);
|
||||
}
|
||||
|
||||
async getPredictionBatch(batchId: string): Promise<ApiResponse<PredictionBatch>> {
|
||||
return apiClient.get(`${this.baseUrl}/batches/${batchId}`);
|
||||
}
|
||||
|
||||
async getPredictionBatchResults(batchId: string): Promise<ApiResponse<ForecastResponse[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/batches/${batchId}/results`);
|
||||
}
|
||||
|
||||
// Model performance and metrics
|
||||
async getModelPerformance(): Promise<ApiResponse<ModelPerformance[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/models/performance`);
|
||||
}
|
||||
|
||||
async getAccuracyReport(params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
product_name?: string;
|
||||
}): Promise<ApiResponse<{
|
||||
overall_accuracy: number;
|
||||
product_accuracy: Array<{
|
||||
product_name: string;
|
||||
accuracy: number;
|
||||
total_predictions: number;
|
||||
recent_trend: 'improving' | 'stable' | 'declining';
|
||||
}>;
|
||||
accuracy_trends: Array<{
|
||||
date: string;
|
||||
accuracy: number;
|
||||
}>;
|
||||
}>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/accuracy?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/accuracy`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// Demand analytics
|
||||
async getDemandTrends(params?: {
|
||||
product_name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
granularity?: 'daily' | 'weekly' | 'monthly';
|
||||
}): Promise<ApiResponse<Array<{
|
||||
date: string;
|
||||
actual_demand: number;
|
||||
predicted_demand: number;
|
||||
confidence_lower: number;
|
||||
confidence_upper: number;
|
||||
accuracy: number;
|
||||
}>>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/trends?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/trends`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getSeasonalPatterns(productName?: string): Promise<ApiResponse<{
|
||||
product_name: string;
|
||||
seasonal_components: Array<{
|
||||
period: 'monthly' | 'weekly' | 'daily';
|
||||
strength: number;
|
||||
pattern: number[];
|
||||
}>;
|
||||
holiday_effects: Array<{
|
||||
holiday: string;
|
||||
impact_factor: number;
|
||||
confidence: number;
|
||||
}>;
|
||||
}>> {
|
||||
const url = productName
|
||||
? `${this.baseUrl}/patterns?product_name=${encodeURIComponent(productName)}`
|
||||
: `${this.baseUrl}/patterns`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// External factors impact
|
||||
async getWeatherImpact(params?: {
|
||||
product_name?: string;
|
||||
weather_conditions?: string[];
|
||||
}): Promise<ApiResponse<Array<{
|
||||
weather_condition: string;
|
||||
impact_factor: number;
|
||||
confidence: number;
|
||||
affected_products: string[];
|
||||
}>>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => queryParams.append(key, v));
|
||||
} else {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/weather-impact?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/weather-impact`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// Model management
|
||||
async retrainModel(params?: {
|
||||
model_type?: string;
|
||||
data_range?: { start_date: string; end_date: string };
|
||||
hyperparameters?: Record<string, any>;
|
||||
}): Promise<ApiResponse<{ task_id: string; message: string }>> {
|
||||
return apiClient.post(`${this.baseUrl}/models/retrain`, params);
|
||||
}
|
||||
|
||||
async getTrainingStatus(taskId: string): Promise<ApiResponse<{
|
||||
status: 'pending' | 'training' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
message: string;
|
||||
results?: {
|
||||
accuracy_improvement: number;
|
||||
training_time: number;
|
||||
model_size: number;
|
||||
};
|
||||
}>> {
|
||||
return apiClient.get(`${this.baseUrl}/models/training-status/${taskId}`);
|
||||
}
|
||||
|
||||
// Configuration
|
||||
async getForecastingSettings(): Promise<ApiResponse<{
|
||||
default_forecast_horizon: number;
|
||||
confidence_level: number;
|
||||
retraining_frequency: number;
|
||||
external_data_sources: string[];
|
||||
notification_preferences: Record<string, boolean>;
|
||||
}>> {
|
||||
return apiClient.get(`${this.baseUrl}/settings`);
|
||||
}
|
||||
|
||||
async updateForecastingSettings(settings: {
|
||||
default_forecast_horizon?: number;
|
||||
confidence_level?: number;
|
||||
retraining_frequency?: number;
|
||||
external_data_sources?: string[];
|
||||
notification_preferences?: Record<string, boolean>;
|
||||
}): Promise<ApiResponse<any>> {
|
||||
return apiClient.put(`${this.baseUrl}/settings`, settings);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getConfidenceLevels(): { value: number; label: string }[] {
|
||||
return [
|
||||
{ value: 0.80, label: '80%' },
|
||||
{ value: 0.90, label: '90%' },
|
||||
{ value: 0.95, label: '95%' },
|
||||
{ value: 0.99, label: '99%' },
|
||||
];
|
||||
}
|
||||
|
||||
getForecastHorizons(): { value: number; label: string }[] {
|
||||
return [
|
||||
{ value: 1, label: '1 day' },
|
||||
{ value: 7, label: '1 week' },
|
||||
{ value: 14, label: '2 weeks' },
|
||||
{ value: 30, label: '1 month' },
|
||||
{ value: 90, label: '3 months' },
|
||||
];
|
||||
}
|
||||
|
||||
getGranularityOptions(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export { ForecastingService };
|
||||
export const forecastingService = new ForecastingService();
|
||||
@@ -1,35 +0,0 @@
|
||||
// Export API client and types
|
||||
export * from './client';
|
||||
export { ApiClient } from './client';
|
||||
|
||||
// Export all services
|
||||
export * from './auth.service';
|
||||
export * from './tenant.service';
|
||||
export * from './inventory.service';
|
||||
export * from './production.service';
|
||||
export * from './sales.service';
|
||||
export * from './forecasting.service';
|
||||
export * from './training.service';
|
||||
export * from './orders.service';
|
||||
export * from './procurement.service';
|
||||
export * from './pos.service';
|
||||
export * from './data.service';
|
||||
export * from './notification.service';
|
||||
export * from './subscription.service';
|
||||
|
||||
// Service instances for easy importing
|
||||
export { authService } from './auth.service';
|
||||
export { tenantService } from './tenant.service';
|
||||
export { inventoryService } from './inventory.service';
|
||||
export { productionService } from './production.service';
|
||||
export { salesService } from './sales.service';
|
||||
export { forecastingService } from './forecasting.service';
|
||||
export { ordersService } from './orders.service';
|
||||
export { procurementService } from './procurement.service';
|
||||
export { posService } from './pos.service';
|
||||
export { dataService } from './data.service';
|
||||
export { notificationService } from './notification.service';
|
||||
export { subscriptionService } from './subscription.service';
|
||||
|
||||
// API client instance
|
||||
export { apiClient } from './client';
|
||||
@@ -1,485 +0,0 @@
|
||||
import { apiClient } from './client';
|
||||
import { ApiResponse } from '../../types/api.types';
|
||||
import {
|
||||
UnitOfMeasure,
|
||||
ProductType,
|
||||
StockMovementType,
|
||||
Ingredient,
|
||||
Stock,
|
||||
StockMovement,
|
||||
StockAlert,
|
||||
InventorySummary,
|
||||
StockLevelSummary,
|
||||
ProductSuggestion,
|
||||
ProductSuggestionsResponse,
|
||||
InventoryCreationResponse,
|
||||
BatchClassificationRequest
|
||||
} from '../../types/inventory.types';
|
||||
import { PaginatedResponse } from '../../types/api.types';
|
||||
|
||||
// Service-specific types for Create/Update operations
|
||||
interface IngredientCreate {
|
||||
name: string;
|
||||
product_type?: ProductType;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
description?: string;
|
||||
brand?: string;
|
||||
unit_of_measure: UnitOfMeasure;
|
||||
package_size?: number;
|
||||
average_cost?: number;
|
||||
standard_cost?: number;
|
||||
low_stock_threshold?: number;
|
||||
reorder_point?: number;
|
||||
reorder_quantity?: number;
|
||||
max_stock_level?: number;
|
||||
requires_refrigeration?: boolean;
|
||||
requires_freezing?: boolean;
|
||||
storage_temperature_min?: number;
|
||||
storage_temperature_max?: number;
|
||||
storage_humidity_max?: number;
|
||||
shelf_life_days?: number;
|
||||
storage_instructions?: string;
|
||||
is_perishable?: boolean;
|
||||
allergen_info?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface IngredientUpdate extends Partial<IngredientCreate> {
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface StockCreate {
|
||||
ingredient_id: string;
|
||||
batch_number?: string;
|
||||
lot_number?: string;
|
||||
supplier_batch_ref?: string;
|
||||
current_quantity: number;
|
||||
received_date?: string;
|
||||
expiration_date?: string;
|
||||
best_before_date?: string;
|
||||
unit_cost?: number;
|
||||
storage_location?: string;
|
||||
warehouse_zone?: string;
|
||||
shelf_position?: string;
|
||||
quality_status?: string;
|
||||
}
|
||||
|
||||
interface StockUpdate extends Partial<StockCreate> {
|
||||
reserved_quantity?: number;
|
||||
is_available?: boolean;
|
||||
}
|
||||
|
||||
interface StockMovementCreate {
|
||||
ingredient_id: string;
|
||||
stock_id?: string;
|
||||
movement_type: StockMovementType;
|
||||
quantity: number;
|
||||
unit_cost?: number;
|
||||
reference_number?: string;
|
||||
supplier_id?: string;
|
||||
notes?: string;
|
||||
reason_code?: string;
|
||||
movement_date?: string;
|
||||
}
|
||||
|
||||
// Type aliases for response consistency
|
||||
type IngredientResponse = Ingredient;
|
||||
type StockResponse = Stock;
|
||||
type StockMovementResponse = StockMovement;
|
||||
type StockAlertResponse = StockAlert;
|
||||
|
||||
class InventoryService {
|
||||
private readonly baseUrl = '/inventory';
|
||||
|
||||
// Ingredient management
|
||||
async getIngredients(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
category?: string;
|
||||
is_active?: boolean;
|
||||
is_low_stock?: boolean;
|
||||
needs_reorder?: boolean;
|
||||
search?: string;
|
||||
}): Promise<ApiResponse<PaginatedResponse<IngredientResponse>>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/ingredients?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/ingredients`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getIngredient(ingredientId: string): Promise<ApiResponse<IngredientResponse>> {
|
||||
return apiClient.get(`${this.baseUrl}/ingredients/${ingredientId}`);
|
||||
}
|
||||
|
||||
async createIngredient(ingredientData: IngredientCreate): Promise<ApiResponse<IngredientResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/ingredients`, ingredientData);
|
||||
}
|
||||
|
||||
async updateIngredient(ingredientId: string, ingredientData: IngredientUpdate): Promise<ApiResponse<IngredientResponse>> {
|
||||
return apiClient.put(`${this.baseUrl}/ingredients/${ingredientId}`, ingredientData);
|
||||
}
|
||||
|
||||
async deleteIngredient(ingredientId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.delete(`${this.baseUrl}/ingredients/${ingredientId}`);
|
||||
}
|
||||
|
||||
// Stock management
|
||||
async getStock(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
ingredient_id?: string;
|
||||
is_available?: boolean;
|
||||
is_expired?: boolean;
|
||||
expiring_within_days?: number;
|
||||
storage_location?: string;
|
||||
quality_status?: string;
|
||||
}): Promise<ApiResponse<PaginatedResponse<StockResponse>>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/stock?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/stock`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getStockById(stockId: string): Promise<ApiResponse<StockResponse>> {
|
||||
return apiClient.get(`${this.baseUrl}/stock/${stockId}`);
|
||||
}
|
||||
|
||||
async getIngredientStock(ingredientId: string): Promise<ApiResponse<StockResponse[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/ingredients/${ingredientId}/stock`);
|
||||
}
|
||||
|
||||
async createStock(stockData: StockCreate): Promise<ApiResponse<StockResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/stock`, stockData);
|
||||
}
|
||||
|
||||
async updateStock(stockId: string, stockData: StockUpdate): Promise<ApiResponse<StockResponse>> {
|
||||
return apiClient.put(`${this.baseUrl}/stock/${stockId}`, stockData);
|
||||
}
|
||||
|
||||
async deleteStock(stockId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.delete(`${this.baseUrl}/stock/${stockId}`);
|
||||
}
|
||||
|
||||
// Stock movements
|
||||
async getStockMovements(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
ingredient_id?: string;
|
||||
movement_type?: StockMovementType;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<ApiResponse<PaginatedResponse<StockMovementResponse>>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/movements?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/movements`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async createStockMovement(movementData: StockMovementCreate): Promise<ApiResponse<StockMovementResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/movements`, movementData);
|
||||
}
|
||||
|
||||
async getIngredientMovements(ingredientId: string, params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
movement_type?: StockMovementType;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<ApiResponse<PaginatedResponse<StockMovementResponse>>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/ingredients/${ingredientId}/movements?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/ingredients/${ingredientId}/movements`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// Alerts and notifications
|
||||
async getStockAlerts(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
alert_type?: string;
|
||||
severity?: string;
|
||||
is_active?: boolean;
|
||||
is_acknowledged?: boolean;
|
||||
is_resolved?: boolean;
|
||||
}): Promise<ApiResponse<PaginatedResponse<StockAlertResponse>>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/alerts?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/alerts`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async acknowledgeAlert(alertId: string): Promise<ApiResponse<StockAlertResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/alerts/${alertId}/acknowledge`);
|
||||
}
|
||||
|
||||
async resolveAlert(alertId: string, resolutionNotes?: string): Promise<ApiResponse<StockAlertResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/alerts/${alertId}/resolve`, {
|
||||
resolution_notes: resolutionNotes
|
||||
});
|
||||
}
|
||||
|
||||
// Dashboard and summaries
|
||||
async getInventorySummary(): Promise<ApiResponse<InventorySummary>> {
|
||||
return apiClient.get(`${this.baseUrl}/summary`);
|
||||
}
|
||||
|
||||
async getStockLevels(): Promise<ApiResponse<StockLevelSummary[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/stock-levels`);
|
||||
}
|
||||
|
||||
async getLowStockItems(): Promise<ApiResponse<IngredientResponse[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/low-stock`);
|
||||
}
|
||||
|
||||
async getExpiringItems(days: number = 7): Promise<ApiResponse<StockResponse[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/expiring?days=${days}`);
|
||||
}
|
||||
|
||||
async getExpiredItems(): Promise<ApiResponse<StockResponse[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/expired`);
|
||||
}
|
||||
|
||||
// Classification and categorization
|
||||
async classifyProduct(name: string, description?: string): Promise<ApiResponse<{
|
||||
category: string;
|
||||
subcategory: string;
|
||||
confidence: number;
|
||||
suggested_unit: UnitOfMeasure;
|
||||
is_perishable: boolean;
|
||||
storage_requirements: {
|
||||
requires_refrigeration: boolean;
|
||||
requires_freezing: boolean;
|
||||
estimated_shelf_life_days: number;
|
||||
};
|
||||
}>> {
|
||||
return apiClient.post(`${this.baseUrl}/classify`, {
|
||||
name,
|
||||
description
|
||||
});
|
||||
}
|
||||
|
||||
// Food safety and compliance
|
||||
async getFoodSafetyAlerts(): Promise<ApiResponse<any[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/food-safety/alerts`);
|
||||
}
|
||||
|
||||
async getTemperatureLog(locationId: string, startDate: string, endDate: string): Promise<ApiResponse<any[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/food-safety/temperature-log`, {
|
||||
location_id: locationId,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
}
|
||||
|
||||
// Batch operations
|
||||
async bulkUpdateStock(updates: Array<{ stock_id: string; quantity: number; notes?: string }>): Promise<ApiResponse<{ updated: number; errors: any[] }>> {
|
||||
return apiClient.post(`${this.baseUrl}/stock/bulk-update`, { updates });
|
||||
}
|
||||
|
||||
async bulkCreateIngredients(ingredients: IngredientCreate[]): Promise<ApiResponse<{ created: number; errors: any[] }>> {
|
||||
return apiClient.post(`${this.baseUrl}/ingredients/bulk-create`, { ingredients });
|
||||
}
|
||||
|
||||
// Import/Export
|
||||
async importInventory(file: File, progressCallback?: (progress: number) => void): Promise<ApiResponse<{
|
||||
imported: number;
|
||||
errors: any[];
|
||||
warnings: any[];
|
||||
}>> {
|
||||
return apiClient.uploadFile(`${this.baseUrl}/import`, file, progressCallback);
|
||||
}
|
||||
|
||||
async exportInventory(format: 'csv' | 'xlsx' = 'csv'): Promise<ApiResponse<{ download_url: string }>> {
|
||||
return apiClient.get(`${this.baseUrl}/export?format=${format}`);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getUnitOfMeasureOptions(): { value: UnitOfMeasure; label: string }[] {
|
||||
return [
|
||||
{ value: UnitOfMeasure.KILOGRAM, label: 'Kilogram (kg)' },
|
||||
{ value: UnitOfMeasure.GRAM, label: 'Gram (g)' },
|
||||
{ value: UnitOfMeasure.LITER, label: 'Liter (l)' },
|
||||
{ value: UnitOfMeasure.MILLILITER, label: 'Milliliter (ml)' },
|
||||
{ value: UnitOfMeasure.PIECE, label: 'Piece' },
|
||||
{ value: UnitOfMeasure.PACKAGE, label: 'Package' },
|
||||
{ value: UnitOfMeasure.BAG, label: 'Bag' },
|
||||
{ value: UnitOfMeasure.BOX, label: 'Box' },
|
||||
{ value: UnitOfMeasure.DOZEN, label: 'Dozen' },
|
||||
];
|
||||
}
|
||||
|
||||
getProductTypeOptions(): { value: ProductType; label: string }[] {
|
||||
return [
|
||||
{ value: ProductType.INGREDIENT, label: 'Ingredient' },
|
||||
{ value: ProductType.FINISHED_PRODUCT, label: 'Finished Product' },
|
||||
];
|
||||
}
|
||||
|
||||
getMovementTypeOptions(): { value: StockMovementType; label: string }[] {
|
||||
return [
|
||||
{ value: StockMovementType.PURCHASE, label: 'Purchase' },
|
||||
{ value: StockMovementType.SALE, label: 'Sale' },
|
||||
{ value: StockMovementType.USAGE, label: 'Usage' },
|
||||
{ value: StockMovementType.WASTE, label: 'Waste' },
|
||||
{ value: StockMovementType.ADJUSTMENT, label: 'Adjustment' },
|
||||
{ value: StockMovementType.TRANSFER, label: 'Transfer' },
|
||||
{ value: StockMovementType.RETURN, label: 'Return' },
|
||||
];
|
||||
}
|
||||
|
||||
getQualityStatusOptions(): { value: string; label: string; color: string }[] {
|
||||
return [
|
||||
{ value: 'good', label: 'Good', color: 'green' },
|
||||
{ value: 'fair', label: 'Fair', color: 'yellow' },
|
||||
{ value: 'poor', label: 'Poor', color: 'orange' },
|
||||
{ value: 'damaged', label: 'Damaged', color: 'red' },
|
||||
{ value: 'expired', label: 'Expired', color: 'red' },
|
||||
{ value: 'quarantine', label: 'Quarantine', color: 'purple' },
|
||||
];
|
||||
}
|
||||
|
||||
// AI-powered inventory classification and suggestions (moved from onboarding)
|
||||
async generateInventorySuggestions(
|
||||
productList: string[]
|
||||
): Promise<ApiResponse<ProductSuggestionsResponse>> {
|
||||
try {
|
||||
if (!productList || !Array.isArray(productList) || productList.length === 0) {
|
||||
throw new Error('Product list is empty or invalid');
|
||||
}
|
||||
|
||||
// Transform product list into the expected format for BatchClassificationRequest
|
||||
const products = productList.map(productName => ({
|
||||
product_name: productName,
|
||||
sales_data: {} // Additional context can be added later
|
||||
}));
|
||||
|
||||
const requestData: BatchClassificationRequest = {
|
||||
products: products
|
||||
};
|
||||
|
||||
const response = await apiClient.post<ProductSuggestionsResponse>(
|
||||
`${this.baseUrl}/classify-products-batch`,
|
||||
requestData
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Suggestion generation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createInventoryFromSuggestions(
|
||||
approvedSuggestions: ProductSuggestion[]
|
||||
): Promise<ApiResponse<InventoryCreationResponse>> {
|
||||
try {
|
||||
const createdItems: any[] = [];
|
||||
const failedItems: any[] = [];
|
||||
const inventoryMapping: { [productName: string]: string } = {};
|
||||
|
||||
// Create inventory items one by one using inventory service
|
||||
for (const suggestion of approvedSuggestions) {
|
||||
try {
|
||||
const ingredientData = {
|
||||
name: suggestion.suggested_name,
|
||||
category: suggestion.category,
|
||||
unit_of_measure: suggestion.unit_of_measure,
|
||||
shelf_life_days: suggestion.estimated_shelf_life_days,
|
||||
requires_refrigeration: suggestion.requires_refrigeration,
|
||||
requires_freezing: suggestion.requires_freezing,
|
||||
is_seasonal: suggestion.is_seasonal,
|
||||
product_type: suggestion.product_type
|
||||
};
|
||||
|
||||
const response = await apiClient.post<any>(
|
||||
'/ingredients',
|
||||
ingredientData
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
createdItems.push(response.data);
|
||||
inventoryMapping[suggestion.original_name] = response.data.id;
|
||||
} else {
|
||||
failedItems.push({ suggestion, error: response.error });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
failedItems.push({ suggestion, error: errorMessage });
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
created_items: createdItems,
|
||||
failed_items: failedItems,
|
||||
total_approved: approvedSuggestions.length,
|
||||
success_rate: createdItems.length / approvedSuggestions.length,
|
||||
inventory_mapping: inventoryMapping
|
||||
};
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('Inventory creation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { InventoryService };
|
||||
export const inventoryService = new InventoryService();
|
||||
@@ -1,406 +0,0 @@
|
||||
import { apiClient, ApiResponse } from './client';
|
||||
|
||||
// Notification types
|
||||
export interface Notification {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
type: 'info' | 'warning' | 'error' | 'success';
|
||||
category: 'system' | 'inventory' | 'production' | 'sales' | 'forecasting' | 'orders' | 'pos';
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, any>;
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
is_read: boolean;
|
||||
is_dismissed: boolean;
|
||||
read_at?: string;
|
||||
dismissed_at?: string;
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
type: string;
|
||||
subject_template: string;
|
||||
message_template: string;
|
||||
channels: ('email' | 'sms' | 'push' | 'in_app' | 'whatsapp')[];
|
||||
variables: Array<{
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'date' | 'boolean';
|
||||
required: boolean;
|
||||
description?: string;
|
||||
}>;
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains';
|
||||
value: any;
|
||||
}>;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
id: string;
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
email_notifications: boolean;
|
||||
sms_notifications: boolean;
|
||||
push_notifications: boolean;
|
||||
whatsapp_notifications: boolean;
|
||||
quiet_hours: {
|
||||
enabled: boolean;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
timezone: string;
|
||||
};
|
||||
categories: Record<string, {
|
||||
enabled: boolean;
|
||||
channels: string[];
|
||||
min_priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
}>;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertRule {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
rule_type: 'threshold' | 'trend' | 'anomaly' | 'schedule';
|
||||
conditions: Array<{
|
||||
metric: string;
|
||||
operator: string;
|
||||
value: any;
|
||||
time_window?: string;
|
||||
}>;
|
||||
actions: Array<{
|
||||
type: 'notification' | 'webhook' | 'email';
|
||||
config: Record<string, any>;
|
||||
}>;
|
||||
is_active: boolean;
|
||||
last_triggered?: string;
|
||||
trigger_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
private readonly baseUrl = '/notifications';
|
||||
|
||||
// Notification management
|
||||
async getNotifications(params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
category?: string;
|
||||
type?: string;
|
||||
priority?: string;
|
||||
is_read?: boolean;
|
||||
is_dismissed?: boolean;
|
||||
}): Promise<ApiResponse<{ items: Notification[]; total: number; page: number; size: number; pages: number; unread_count: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}?${queryParams.toString()}`
|
||||
: this.baseUrl;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getNotification(notificationId: string): Promise<ApiResponse<Notification>> {
|
||||
return apiClient.get(`${this.baseUrl}/${notificationId}`);
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: string): Promise<ApiResponse<Notification>> {
|
||||
return apiClient.post(`${this.baseUrl}/${notificationId}/read`);
|
||||
}
|
||||
|
||||
async markAsUnread(notificationId: string): Promise<ApiResponse<Notification>> {
|
||||
return apiClient.post(`${this.baseUrl}/${notificationId}/unread`);
|
||||
}
|
||||
|
||||
async dismiss(notificationId: string): Promise<ApiResponse<Notification>> {
|
||||
return apiClient.post(`${this.baseUrl}/${notificationId}/dismiss`);
|
||||
}
|
||||
|
||||
async markAllAsRead(category?: string): Promise<ApiResponse<{ updated_count: number }>> {
|
||||
const url = category
|
||||
? `${this.baseUrl}/mark-all-read?category=${encodeURIComponent(category)}`
|
||||
: `${this.baseUrl}/mark-all-read`;
|
||||
|
||||
return apiClient.post(url);
|
||||
}
|
||||
|
||||
async dismissAll(category?: string): Promise<ApiResponse<{ updated_count: number }>> {
|
||||
const url = category
|
||||
? `${this.baseUrl}/dismiss-all?category=${encodeURIComponent(category)}`
|
||||
: `${this.baseUrl}/dismiss-all`;
|
||||
|
||||
return apiClient.post(url);
|
||||
}
|
||||
|
||||
async deleteNotification(notificationId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.delete(`${this.baseUrl}/${notificationId}`);
|
||||
}
|
||||
|
||||
// Notification creation and sending
|
||||
async createNotification(notificationData: {
|
||||
type: Notification['type'];
|
||||
category: string;
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: Notification['priority'];
|
||||
data?: Record<string, any>;
|
||||
user_id?: string;
|
||||
expires_at?: string;
|
||||
channels?: string[];
|
||||
}): Promise<ApiResponse<Notification>> {
|
||||
return apiClient.post(this.baseUrl, notificationData);
|
||||
}
|
||||
|
||||
async sendBulkNotification(notificationData: {
|
||||
type: Notification['type'];
|
||||
category: string;
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: Notification['priority'];
|
||||
data?: Record<string, any>;
|
||||
user_ids?: string[];
|
||||
user_roles?: string[];
|
||||
channels?: string[];
|
||||
}): Promise<ApiResponse<{ sent_count: number; failed_count: number }>> {
|
||||
return apiClient.post(`${this.baseUrl}/bulk-send`, notificationData);
|
||||
}
|
||||
|
||||
// Template management
|
||||
async getTemplates(category?: string): Promise<ApiResponse<NotificationTemplate[]>> {
|
||||
const url = category
|
||||
? `${this.baseUrl}/templates?category=${encodeURIComponent(category)}`
|
||||
: `${this.baseUrl}/templates`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getTemplate(templateId: string): Promise<ApiResponse<NotificationTemplate>> {
|
||||
return apiClient.get(`${this.baseUrl}/templates/${templateId}`);
|
||||
}
|
||||
|
||||
async createTemplate(templateData: {
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
type: string;
|
||||
subject_template: string;
|
||||
message_template: string;
|
||||
channels: string[];
|
||||
variables: NotificationTemplate['variables'];
|
||||
conditions?: NotificationTemplate['conditions'];
|
||||
}): Promise<ApiResponse<NotificationTemplate>> {
|
||||
return apiClient.post(`${this.baseUrl}/templates`, templateData);
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: string, templateData: Partial<NotificationTemplate>): Promise<ApiResponse<NotificationTemplate>> {
|
||||
return apiClient.put(`${this.baseUrl}/templates/${templateId}`, templateData);
|
||||
}
|
||||
|
||||
async deleteTemplate(templateId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.delete(`${this.baseUrl}/templates/${templateId}`);
|
||||
}
|
||||
|
||||
async previewTemplate(templateId: string, variables: Record<string, any>): Promise<ApiResponse<{
|
||||
subject: string;
|
||||
message: string;
|
||||
rendered_html?: string;
|
||||
}>> {
|
||||
return apiClient.post(`${this.baseUrl}/templates/${templateId}/preview`, { variables });
|
||||
}
|
||||
|
||||
// User preferences
|
||||
async getPreferences(userId?: string): Promise<ApiResponse<NotificationPreferences>> {
|
||||
const url = userId
|
||||
? `${this.baseUrl}/preferences/${userId}`
|
||||
: `${this.baseUrl}/preferences`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async updatePreferences(preferencesData: Partial<NotificationPreferences>, userId?: string): Promise<ApiResponse<NotificationPreferences>> {
|
||||
const url = userId
|
||||
? `${this.baseUrl}/preferences/${userId}`
|
||||
: `${this.baseUrl}/preferences`;
|
||||
|
||||
return apiClient.put(url, preferencesData);
|
||||
}
|
||||
|
||||
// Alert rules
|
||||
async getAlertRules(): Promise<ApiResponse<AlertRule[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/alert-rules`);
|
||||
}
|
||||
|
||||
async getAlertRule(ruleId: string): Promise<ApiResponse<AlertRule>> {
|
||||
return apiClient.get(`${this.baseUrl}/alert-rules/${ruleId}`);
|
||||
}
|
||||
|
||||
async createAlertRule(ruleData: {
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
rule_type: AlertRule['rule_type'];
|
||||
conditions: AlertRule['conditions'];
|
||||
actions: AlertRule['actions'];
|
||||
}): Promise<ApiResponse<AlertRule>> {
|
||||
return apiClient.post(`${this.baseUrl}/alert-rules`, ruleData);
|
||||
}
|
||||
|
||||
async updateAlertRule(ruleId: string, ruleData: Partial<AlertRule>): Promise<ApiResponse<AlertRule>> {
|
||||
return apiClient.put(`${this.baseUrl}/alert-rules/${ruleId}`, ruleData);
|
||||
}
|
||||
|
||||
async deleteAlertRule(ruleId: string): Promise<ApiResponse<{ message: string }>> {
|
||||
return apiClient.delete(`${this.baseUrl}/alert-rules/${ruleId}`);
|
||||
}
|
||||
|
||||
async testAlertRule(ruleId: string): Promise<ApiResponse<{
|
||||
would_trigger: boolean;
|
||||
current_values: Record<string, any>;
|
||||
evaluation_result: string;
|
||||
}>> {
|
||||
return apiClient.post(`${this.baseUrl}/alert-rules/${ruleId}/test`);
|
||||
}
|
||||
|
||||
// Real-time notifications (SSE)
|
||||
async getSSEEndpoint(): Promise<ApiResponse<{ endpoint_url: string; auth_token: string }>> {
|
||||
return apiClient.get(`${this.baseUrl}/sse/endpoint`);
|
||||
}
|
||||
|
||||
connectSSE(onNotification: (notification: Notification) => void, onError?: (error: Error) => void): EventSource {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const tenantId = localStorage.getItem('tenant_id');
|
||||
|
||||
const url = new URL(`${apiClient.getBaseURL()}/notifications/sse/stream`);
|
||||
if (token) url.searchParams.append('token', token);
|
||||
if (tenantId) url.searchParams.append('tenant_id', tenantId);
|
||||
|
||||
const eventSource = new EventSource(url.toString());
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const notification = JSON.parse(event.data);
|
||||
onNotification(notification);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE notification:', error);
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE connection error:', error);
|
||||
onError?.(new Error('SSE connection failed'));
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
// Analytics and reporting
|
||||
async getNotificationStats(params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
category?: string;
|
||||
}): Promise<ApiResponse<{
|
||||
total_sent: number;
|
||||
total_read: number;
|
||||
total_dismissed: number;
|
||||
read_rate: number;
|
||||
dismiss_rate: number;
|
||||
by_category: Array<{
|
||||
category: string;
|
||||
sent: number;
|
||||
read: number;
|
||||
dismissed: number;
|
||||
}>;
|
||||
by_channel: Array<{
|
||||
channel: string;
|
||||
sent: number;
|
||||
delivered: number;
|
||||
failed: number;
|
||||
}>;
|
||||
trends: Array<{
|
||||
date: string;
|
||||
sent: number;
|
||||
read: number;
|
||||
}>;
|
||||
}>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/stats?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/stats`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getNotificationTypes(): { value: string; label: string; icon: string; color: string }[] {
|
||||
return [
|
||||
{ value: 'info', label: 'Information', icon: 'info', color: 'blue' },
|
||||
{ value: 'success', label: 'Success', icon: 'check', color: 'green' },
|
||||
{ value: 'warning', label: 'Warning', icon: 'warning', color: 'yellow' },
|
||||
{ value: 'error', label: 'Error', icon: 'error', color: 'red' },
|
||||
];
|
||||
}
|
||||
|
||||
getNotificationCategories(): { value: string; label: string; description: string }[] {
|
||||
return [
|
||||
{ value: 'system', label: 'System', description: 'System-wide notifications and updates' },
|
||||
{ value: 'inventory', label: 'Inventory', description: 'Stock levels, expiration alerts, reorder notifications' },
|
||||
{ value: 'production', label: 'Production', description: 'Production schedules, quality checks, batch completion' },
|
||||
{ value: 'sales', label: 'Sales', description: 'Sales targets, performance alerts, revenue notifications' },
|
||||
{ value: 'forecasting', label: 'Forecasting', description: 'Demand predictions, model updates, accuracy alerts' },
|
||||
{ value: 'orders', label: 'Orders', description: 'New orders, order updates, delivery notifications' },
|
||||
{ value: 'pos', label: 'POS', description: 'Point of sale integration, sync status, transaction alerts' },
|
||||
];
|
||||
}
|
||||
|
||||
getPriorityLevels(): { value: string; label: string; color: string; urgency: number }[] {
|
||||
return [
|
||||
{ value: 'low', label: 'Low', color: 'gray', urgency: 1 },
|
||||
{ value: 'normal', label: 'Normal', color: 'blue', urgency: 2 },
|
||||
{ value: 'high', label: 'High', color: 'orange', urgency: 3 },
|
||||
{ value: 'urgent', label: 'Urgent', color: 'red', urgency: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
getNotificationChannels(): { value: string; label: string; description: string; requires_setup: boolean }[] {
|
||||
return [
|
||||
{ value: 'in_app', label: 'In-App', description: 'Notifications within the application', requires_setup: false },
|
||||
{ value: 'email', label: 'Email', description: 'Email notifications', requires_setup: true },
|
||||
{ value: 'sms', label: 'SMS', description: 'Text message notifications', requires_setup: true },
|
||||
{ value: 'push', label: 'Push', description: 'Browser/mobile push notifications', requires_setup: true },
|
||||
{ value: 'whatsapp', label: 'WhatsApp', description: 'WhatsApp notifications', requires_setup: true },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
@@ -1 +0,0 @@
|
||||
/Users/urtzialfaro/Documents/bakery-ia/frontend/src/services/api/orders.service.ts
|
||||
@@ -1,192 +0,0 @@
|
||||
import { apiClient } from './client';
|
||||
import { ApiResponse } from '../../types/api.types';
|
||||
import {
|
||||
OrderStatus,
|
||||
OrderType,
|
||||
OrderItem,
|
||||
OrderCreate,
|
||||
OrderUpdate,
|
||||
OrderResponse,
|
||||
Customer,
|
||||
OrderAnalytics,
|
||||
OrderFilters,
|
||||
CustomerFilters,
|
||||
OrderTrendsParams,
|
||||
OrderTrendData
|
||||
} from '../../types/orders.types';
|
||||
|
||||
class OrdersService {
|
||||
private readonly baseUrl = '/orders';
|
||||
|
||||
// Order management
|
||||
async getOrders(params?: OrderFilters): Promise<ApiResponse<{ items: OrderResponse[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}?${queryParams.toString()}`
|
||||
: this.baseUrl;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getOrder(orderId: string): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.get(`${this.baseUrl}/${orderId}`);
|
||||
}
|
||||
|
||||
async createOrder(orderData: OrderCreate): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.post(this.baseUrl, orderData);
|
||||
}
|
||||
|
||||
async updateOrder(orderId: string, orderData: OrderUpdate): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.put(`${this.baseUrl}/${orderId}`, orderData);
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: string, reason?: string): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/${orderId}/cancel`, { reason });
|
||||
}
|
||||
|
||||
async confirmOrder(orderId: string): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/${orderId}/confirm`);
|
||||
}
|
||||
|
||||
async startPreparation(orderId: string): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/${orderId}/start-preparation`);
|
||||
}
|
||||
|
||||
async markReady(orderId: string): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/${orderId}/mark-ready`);
|
||||
}
|
||||
|
||||
async markDelivered(orderId: string): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/${orderId}/mark-delivered`);
|
||||
}
|
||||
|
||||
// Customer management
|
||||
async getCustomers(params?: CustomerFilters): Promise<ApiResponse<{ items: Customer[]; total: number; page: number; size: number; pages: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/customers?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/customers`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getCustomer(customerId: string): Promise<ApiResponse<Customer>> {
|
||||
return apiClient.get(`${this.baseUrl}/customers/${customerId}`);
|
||||
}
|
||||
|
||||
async getCustomerOrders(customerId: string): Promise<ApiResponse<OrderResponse[]>> {
|
||||
return apiClient.get(`${this.baseUrl}/customers/${customerId}/orders`);
|
||||
}
|
||||
|
||||
// Analytics and reporting
|
||||
async getOrderAnalytics(params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
order_type?: OrderType;
|
||||
}): Promise<ApiResponse<OrderAnalytics>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/analytics?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/analytics`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async getOrderTrends(params?: OrderTrendsParams): Promise<ApiResponse<OrderTrendData[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
queryParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/trends?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/trends`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
// Queue management
|
||||
async getOrderQueue(filterBy?: OrderStatus[]): Promise<ApiResponse<OrderResponse[]>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (filterBy) {
|
||||
filterBy.forEach(status => queryParams.append('status', status));
|
||||
}
|
||||
|
||||
const url = queryParams.toString()
|
||||
? `${this.baseUrl}/queue?${queryParams.toString()}`
|
||||
: `${this.baseUrl}/queue`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
async updateQueuePosition(orderId: string, newPosition: number): Promise<ApiResponse<OrderResponse>> {
|
||||
return apiClient.post(`${this.baseUrl}/${orderId}/queue-position`, { position: newPosition });
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getOrderStatusOptions(): { value: OrderStatus; label: string; color: string }[] {
|
||||
return [
|
||||
{ value: OrderStatus.PENDING, label: 'Pending', color: 'gray' },
|
||||
{ value: OrderStatus.CONFIRMED, label: 'Confirmed', color: 'blue' },
|
||||
{ value: OrderStatus.IN_PREPARATION, label: 'In Preparation', color: 'yellow' },
|
||||
{ value: OrderStatus.READY, label: 'Ready', color: 'green' },
|
||||
{ value: OrderStatus.DELIVERED, label: 'Delivered', color: 'green' },
|
||||
{ value: OrderStatus.CANCELLED, label: 'Cancelled', color: 'red' },
|
||||
];
|
||||
}
|
||||
|
||||
getOrderTypeOptions(): { value: OrderType; label: string }[] {
|
||||
return [
|
||||
{ value: OrderType.DINE_IN, label: 'Dine In' },
|
||||
{ value: OrderType.TAKEAWAY, label: 'Takeaway' },
|
||||
{ value: OrderType.DELIVERY, label: 'Delivery' },
|
||||
{ value: OrderType.CATERING, label: 'Catering' },
|
||||
];
|
||||
}
|
||||
|
||||
getPaymentMethods(): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'cash', label: 'Cash' },
|
||||
{ value: 'card', label: 'Card' },
|
||||
{ value: 'digital_wallet', label: 'Digital Wallet' },
|
||||
{ value: 'bank_transfer', label: 'Bank Transfer' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export { OrdersService };
|
||||
export { OrdersService as OrderService }; // Alias for compatibility
|
||||
export const ordersService = new OrdersService();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user