Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

@@ -11,6 +11,8 @@ export { useTraining } from './useTraining';
export { useForecast } from './useForecast';
export { useNotification } from './useNotification';
export { useOnboarding, useOnboardingStep } from './useOnboarding';
export { useInventory, useInventoryDashboard, useInventoryItem } from './useInventory';
export { useRecipes, useProduction } from './useRecipes';
// Import hooks for combined usage
import { useAuth } from './useAuth';

View File

@@ -0,0 +1,510 @@
// frontend/src/api/hooks/useInventory.ts
/**
* Inventory Management React Hook
* Provides comprehensive state management for inventory operations
*/
import { useState, useEffect, useCallback } from 'react';
import toast from 'react-hot-toast';
import {
inventoryService,
InventoryItem,
StockLevel,
StockMovement,
StockAlert,
InventorySearchParams,
CreateInventoryItemRequest,
UpdateInventoryItemRequest,
StockAdjustmentRequest,
PaginatedResponse,
InventoryDashboardData
} from '../services/inventory.service';
import { useTenantId } from '../../hooks/useTenantId';
// ========== HOOK INTERFACES ==========
interface UseInventoryReturn {
// State
items: InventoryItem[];
stockLevels: Record<string, StockLevel>;
movements: StockMovement[];
alerts: StockAlert[];
dashboardData: InventoryDashboardData | null;
isLoading: boolean;
error: string | null;
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
// Actions
loadItems: (params?: InventorySearchParams) => Promise<void>;
loadItem: (itemId: string) => Promise<InventoryItem | null>;
createItem: (data: CreateInventoryItemRequest) => Promise<InventoryItem | null>;
updateItem: (itemId: string, data: UpdateInventoryItemRequest) => Promise<InventoryItem | null>;
deleteItem: (itemId: string) => Promise<boolean>;
// Stock operations
loadStockLevels: () => Promise<void>;
adjustStock: (itemId: string, adjustment: StockAdjustmentRequest) => Promise<StockMovement | null>;
loadMovements: (params?: any) => Promise<void>;
// Alerts
loadAlerts: () => Promise<void>;
acknowledgeAlert: (alertId: string) => Promise<boolean>;
// Dashboard
loadDashboard: () => Promise<void>;
// Utility
searchItems: (query: string) => Promise<InventoryItem[]>;
refresh: () => Promise<void>;
clearError: () => void;
}
interface UseInventoryDashboardReturn {
dashboardData: InventoryDashboardData | null;
alerts: StockAlert[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
interface UseInventoryItemReturn {
item: InventoryItem | null;
stockLevel: StockLevel | null;
recentMovements: StockMovement[];
isLoading: boolean;
error: string | null;
updateItem: (data: UpdateInventoryItemRequest) => Promise<boolean>;
adjustStock: (adjustment: StockAdjustmentRequest) => Promise<boolean>;
refresh: () => Promise<void>;
}
// ========== MAIN INVENTORY HOOK ==========
export const useInventory = (autoLoad = true): UseInventoryReturn => {
const tenantId = useTenantId();
// State
const [items, setItems] = useState<InventoryItem[]>([]);
const [stockLevels, setStockLevels] = useState<Record<string, StockLevel>>({});
const [movements, setMovements] = useState<StockMovement[]>([]);
const [alerts, setAlerts] = useState<StockAlert[]>([]);
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Clear error
const clearError = useCallback(() => setError(null), []);
// Load inventory items
const loadItems = useCallback(async (params?: InventorySearchParams) => {
if (!tenantId) return;
setIsLoading(true);
setError(null);
try {
const response = await inventoryService.getInventoryItems(tenantId, params);
setItems(response.items);
setPagination({
page: response.page,
limit: response.limit,
total: response.total,
totalPages: response.total_pages
});
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading inventory items';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
}, [tenantId]);
// Load single item
const loadItem = useCallback(async (itemId: string): Promise<InventoryItem | null> => {
if (!tenantId) return null;
try {
const item = await inventoryService.getInventoryItem(tenantId, itemId);
// Update in local state if it exists
setItems(prev => prev.map(i => i.id === itemId ? item : i));
return item;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
setError(errorMessage);
return null;
}
}, [tenantId]);
// Create item
const createItem = useCallback(async (data: CreateInventoryItemRequest): Promise<InventoryItem | null> => {
if (!tenantId) return null;
setIsLoading(true);
try {
const newItem = await inventoryService.createInventoryItem(tenantId, data);
setItems(prev => [newItem, ...prev]);
toast.success(`Created ${newItem.name} successfully`);
return newItem;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error creating item';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsLoading(false);
}
}, [tenantId]);
// Update item
const updateItem = useCallback(async (
itemId: string,
data: UpdateInventoryItemRequest
): Promise<InventoryItem | null> => {
if (!tenantId) return null;
try {
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
setItems(prev => prev.map(i => i.id === itemId ? updatedItem : i));
toast.success(`Updated ${updatedItem.name} successfully`);
return updatedItem;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
setError(errorMessage);
toast.error(errorMessage);
return null;
}
}, [tenantId]);
// Delete item
const deleteItem = useCallback(async (itemId: string): Promise<boolean> => {
if (!tenantId) return false;
try {
await inventoryService.deleteInventoryItem(tenantId, itemId);
setItems(prev => prev.filter(i => i.id !== itemId));
toast.success('Item deleted successfully');
return true;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting item';
setError(errorMessage);
toast.error(errorMessage);
return false;
}
}, [tenantId]);
// Load stock levels
const loadStockLevels = useCallback(async () => {
if (!tenantId) return;
try {
const levels = await inventoryService.getAllStockLevels(tenantId);
const levelMap = levels.reduce((acc, level) => {
acc[level.item_id] = level;
return acc;
}, {} as Record<string, StockLevel>);
setStockLevels(levelMap);
} catch (err: any) {
console.error('Error loading stock levels:', err);
}
}, [tenantId]);
// Adjust stock
const adjustStock = useCallback(async (
itemId: string,
adjustment: StockAdjustmentRequest
): Promise<StockMovement | null> => {
if (!tenantId) return null;
try {
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
// Update local movements
setMovements(prev => [movement, ...prev.slice(0, 49)]); // Keep last 50
// Reload stock level for this item
const updatedLevel = await inventoryService.getStockLevel(tenantId, itemId);
setStockLevels(prev => ({ ...prev, [itemId]: updatedLevel }));
toast.success('Stock adjusted successfully');
return movement;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
setError(errorMessage);
toast.error(errorMessage);
return null;
}
}, [tenantId]);
// Load movements
const loadMovements = useCallback(async (params?: any) => {
if (!tenantId) return;
try {
const response = await inventoryService.getStockMovements(tenantId, params);
setMovements(response.items);
} catch (err: any) {
console.error('Error loading movements:', err);
}
}, [tenantId]);
// Load alerts
const loadAlerts = useCallback(async () => {
if (!tenantId) return;
try {
const alertsData = await inventoryService.getStockAlerts(tenantId);
setAlerts(alertsData);
} catch (err: any) {
console.error('Error loading alerts:', err);
}
}, [tenantId]);
// Acknowledge alert
const acknowledgeAlert = useCallback(async (alertId: string): Promise<boolean> => {
if (!tenantId) return false;
try {
await inventoryService.acknowledgeAlert(tenantId, alertId);
setAlerts(prev => prev.map(a =>
a.id === alertId ? { ...a, is_acknowledged: true, acknowledged_at: new Date().toISOString() } : a
));
return true;
} catch (err: any) {
toast.error('Error acknowledging alert');
return false;
}
}, [tenantId]);
// Load dashboard
const loadDashboard = useCallback(async () => {
if (!tenantId) return;
try {
const data = await inventoryService.getDashboardData(tenantId);
setDashboardData(data);
} catch (err: any) {
console.error('Error loading dashboard:', err);
}
}, [tenantId]);
// Search items
const searchItems = useCallback(async (query: string): Promise<InventoryItem[]> => {
if (!tenantId || !query.trim()) return [];
try {
return await inventoryService.searchItems(tenantId, query);
} catch (err: any) {
console.error('Error searching items:', err);
return [];
}
}, [tenantId]);
// Refresh all data
const refresh = useCallback(async () => {
await Promise.all([
loadItems(),
loadStockLevels(),
loadAlerts(),
loadDashboard()
]);
}, [loadItems, loadStockLevels, loadAlerts, loadDashboard]);
// Auto-load on mount
useEffect(() => {
if (autoLoad && tenantId) {
refresh();
}
}, [autoLoad, tenantId, refresh]);
return {
// State
items,
stockLevels,
movements,
alerts,
dashboardData,
isLoading,
error,
pagination,
// Actions
loadItems,
loadItem,
createItem,
updateItem,
deleteItem,
// Stock operations
loadStockLevels,
adjustStock,
loadMovements,
// Alerts
loadAlerts,
acknowledgeAlert,
// Dashboard
loadDashboard,
// Utility
searchItems,
refresh,
clearError
};
};
// ========== DASHBOARD HOOK ==========
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
const tenantId = useTenantId();
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
const [alerts, setAlerts] = useState<StockAlert[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!tenantId) return;
setIsLoading(true);
setError(null);
try {
const [dashboard, alertsData] = await Promise.all([
inventoryService.getDashboardData(tenantId),
inventoryService.getStockAlerts(tenantId)
]);
setDashboardData(dashboard);
setAlerts(alertsData);
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading dashboard';
setError(errorMessage);
} finally {
setIsLoading(false);
}
}, [tenantId]);
useEffect(() => {
if (tenantId) {
refresh();
}
}, [tenantId, refresh]);
return {
dashboardData,
alerts,
isLoading,
error,
refresh
};
};
// ========== SINGLE ITEM HOOK ==========
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
const tenantId = useTenantId();
const [item, setItem] = useState<InventoryItem | null>(null);
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!tenantId || !itemId) return;
setIsLoading(true);
setError(null);
try {
const [itemData, stockData, movementsData] = await Promise.all([
inventoryService.getInventoryItem(tenantId, itemId),
inventoryService.getStockLevel(tenantId, itemId),
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
]);
setItem(itemData);
setStockLevel(stockData);
setRecentMovements(movementsData.items);
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
setError(errorMessage);
} finally {
setIsLoading(false);
}
}, [tenantId, itemId]);
const updateItem = useCallback(async (data: UpdateInventoryItemRequest): Promise<boolean> => {
if (!tenantId || !itemId) return false;
try {
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
setItem(updatedItem);
toast.success('Item updated successfully');
return true;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
setError(errorMessage);
toast.error(errorMessage);
return false;
}
}, [tenantId, itemId]);
const adjustStock = useCallback(async (adjustment: StockAdjustmentRequest): Promise<boolean> => {
if (!tenantId || !itemId) return false;
try {
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
// Refresh data
const [updatedStock, updatedMovements] = await Promise.all([
inventoryService.getStockLevel(tenantId, itemId),
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
]);
setStockLevel(updatedStock);
setRecentMovements(updatedMovements.items);
toast.success('Stock adjusted successfully');
return true;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
setError(errorMessage);
toast.error(errorMessage);
return false;
}
}, [tenantId, itemId]);
useEffect(() => {
if (tenantId && itemId) {
refresh();
}
}, [tenantId, itemId, refresh]);
return {
item,
stockLevel,
recentMovements,
isLoading,
error,
updateItem,
adjustStock,
refresh
};
};

View File

@@ -0,0 +1,682 @@
// frontend/src/api/hooks/useRecipes.ts
/**
* React hooks for recipe and production management
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import {
RecipesService,
Recipe,
RecipeIngredient,
CreateRecipeRequest,
UpdateRecipeRequest,
RecipeSearchParams,
RecipeFeasibility,
RecipeStatistics,
ProductionBatch,
CreateProductionBatchRequest,
UpdateProductionBatchRequest,
ProductionBatchSearchParams,
ProductionStatistics
} from '../services/recipes.service';
import { useTenant } from './useTenant';
import { useAuth } from './useAuth';
const recipesService = new RecipesService();
// Recipe Management Hook
export interface UseRecipesReturn {
// Data
recipes: Recipe[];
selectedRecipe: Recipe | null;
categories: string[];
statistics: RecipeStatistics | null;
// State
isLoading: boolean;
isCreating: boolean;
isUpdating: boolean;
isDeleting: boolean;
error: string | null;
// Pagination
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
// Actions
loadRecipes: (params?: RecipeSearchParams) => Promise<void>;
loadRecipe: (recipeId: string) => Promise<void>;
createRecipe: (data: CreateRecipeRequest) => Promise<Recipe | null>;
updateRecipe: (recipeId: string, data: UpdateRecipeRequest) => Promise<Recipe | null>;
deleteRecipe: (recipeId: string) => Promise<boolean>;
duplicateRecipe: (recipeId: string, newName: string) => Promise<Recipe | null>;
activateRecipe: (recipeId: string) => Promise<Recipe | null>;
checkFeasibility: (recipeId: string, batchMultiplier?: number) => Promise<RecipeFeasibility | null>;
loadStatistics: () => Promise<void>;
loadCategories: () => Promise<void>;
clearError: () => void;
refresh: () => Promise<void>;
setPage: (page: number) => void;
}
export const useRecipes = (autoLoad: boolean = true): UseRecipesReturn => {
const { currentTenant } = useTenant();
const { user } = useAuth();
// State
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
const [categories, setCategories] = useState<string[]>([]);
const [statistics, setStatistics] = useState<RecipeStatistics | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentParams, setCurrentParams] = useState<RecipeSearchParams>({});
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Load recipes
const loadRecipes = useCallback(async (params: RecipeSearchParams = {}) => {
if (!currentTenant?.id) return;
setIsLoading(true);
setError(null);
try {
const searchParams = {
...params,
limit: pagination.limit,
offset: (pagination.page - 1) * pagination.limit
};
const recipesData = await recipesService.getRecipes(currentTenant.id, searchParams);
setRecipes(recipesData);
setCurrentParams(params);
// Calculate pagination (assuming we get total count somehow)
const total = recipesData.length; // This would need to be from a proper paginated response
setPagination(prev => ({
...prev,
total,
totalPages: Math.ceil(total / prev.limit)
}));
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading recipes';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
}, [currentTenant?.id, pagination.page, pagination.limit]);
// Load single recipe
const loadRecipe = useCallback(async (recipeId: string) => {
if (!currentTenant?.id) return;
setIsLoading(true);
setError(null);
try {
const recipe = await recipesService.getRecipe(currentTenant.id, recipeId);
setSelectedRecipe(recipe);
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading recipe';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
}, [currentTenant?.id]);
// Create recipe
const createRecipe = useCallback(async (data: CreateRecipeRequest): Promise<Recipe | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsCreating(true);
setError(null);
try {
const newRecipe = await recipesService.createRecipe(currentTenant.id, user.id, data);
// Add to local state
setRecipes(prev => [newRecipe, ...prev]);
toast.success('Recipe created successfully');
return newRecipe;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error creating recipe';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsCreating(false);
}
}, [currentTenant?.id, user?.id]);
// Update recipe
const updateRecipe = useCallback(async (recipeId: string, data: UpdateRecipeRequest): Promise<Recipe | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsUpdating(true);
setError(null);
try {
const updatedRecipe = await recipesService.updateRecipe(currentTenant.id, user.id, recipeId, data);
// Update local state
setRecipes(prev => prev.map(recipe =>
recipe.id === recipeId ? updatedRecipe : recipe
));
if (selectedRecipe?.id === recipeId) {
setSelectedRecipe(updatedRecipe);
}
toast.success('Recipe updated successfully');
return updatedRecipe;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error updating recipe';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsUpdating(false);
}
}, [currentTenant?.id, user?.id, selectedRecipe?.id]);
// Delete recipe
const deleteRecipe = useCallback(async (recipeId: string): Promise<boolean> => {
if (!currentTenant?.id) return false;
setIsDeleting(true);
setError(null);
try {
await recipesService.deleteRecipe(currentTenant.id, recipeId);
// Remove from local state
setRecipes(prev => prev.filter(recipe => recipe.id !== recipeId));
if (selectedRecipe?.id === recipeId) {
setSelectedRecipe(null);
}
toast.success('Recipe deleted successfully');
return true;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting recipe';
setError(errorMessage);
toast.error(errorMessage);
return false;
} finally {
setIsDeleting(false);
}
}, [currentTenant?.id, selectedRecipe?.id]);
// Duplicate recipe
const duplicateRecipe = useCallback(async (recipeId: string, newName: string): Promise<Recipe | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsCreating(true);
setError(null);
try {
const duplicatedRecipe = await recipesService.duplicateRecipe(currentTenant.id, user.id, recipeId, newName);
// Add to local state
setRecipes(prev => [duplicatedRecipe, ...prev]);
toast.success('Recipe duplicated successfully');
return duplicatedRecipe;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error duplicating recipe';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsCreating(false);
}
}, [currentTenant?.id, user?.id]);
// Activate recipe
const activateRecipe = useCallback(async (recipeId: string): Promise<Recipe | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsUpdating(true);
setError(null);
try {
const activatedRecipe = await recipesService.activateRecipe(currentTenant.id, user.id, recipeId);
// Update local state
setRecipes(prev => prev.map(recipe =>
recipe.id === recipeId ? activatedRecipe : recipe
));
if (selectedRecipe?.id === recipeId) {
setSelectedRecipe(activatedRecipe);
}
toast.success('Recipe activated successfully');
return activatedRecipe;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error activating recipe';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsUpdating(false);
}
}, [currentTenant?.id, user?.id, selectedRecipe?.id]);
// Check feasibility
const checkFeasibility = useCallback(async (recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility | null> => {
if (!currentTenant?.id) return null;
try {
const feasibility = await recipesService.checkRecipeFeasibility(currentTenant.id, recipeId, batchMultiplier);
return feasibility;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error checking recipe feasibility';
setError(errorMessage);
toast.error(errorMessage);
return null;
}
}, [currentTenant?.id]);
// Load statistics
const loadStatistics = useCallback(async () => {
if (!currentTenant?.id) return;
try {
const stats = await recipesService.getRecipeStatistics(currentTenant.id);
setStatistics(stats);
} catch (err: any) {
console.error('Error loading recipe statistics:', err);
}
}, [currentTenant?.id]);
// Load categories
const loadCategories = useCallback(async () => {
if (!currentTenant?.id) return;
try {
const cats = await recipesService.getRecipeCategories(currentTenant.id);
setCategories(cats);
} catch (err: any) {
console.error('Error loading recipe categories:', err);
}
}, [currentTenant?.id]);
// Clear error
const clearError = useCallback(() => {
setError(null);
}, []);
// Refresh
const refresh = useCallback(async () => {
await Promise.all([
loadRecipes(currentParams),
loadStatistics(),
loadCategories()
]);
}, [loadRecipes, currentParams, loadStatistics, loadCategories]);
// Set page
const setPage = useCallback((page: number) => {
setPagination(prev => ({ ...prev, page }));
}, []);
// Auto-load on mount and dependencies change
useEffect(() => {
if (autoLoad && currentTenant?.id) {
refresh();
}
}, [autoLoad, currentTenant?.id, pagination.page]);
return {
// Data
recipes,
selectedRecipe,
categories,
statistics,
// State
isLoading,
isCreating,
isUpdating,
isDeleting,
error,
pagination,
// Actions
loadRecipes,
loadRecipe,
createRecipe,
updateRecipe,
deleteRecipe,
duplicateRecipe,
activateRecipe,
checkFeasibility,
loadStatistics,
loadCategories,
clearError,
refresh,
setPage
};
};
// Production Management Hook
export interface UseProductionReturn {
// Data
batches: ProductionBatch[];
selectedBatch: ProductionBatch | null;
activeBatches: ProductionBatch[];
statistics: ProductionStatistics | null;
// State
isLoading: boolean;
isCreating: boolean;
isUpdating: boolean;
isDeleting: boolean;
error: string | null;
// Actions
loadBatches: (params?: ProductionBatchSearchParams) => Promise<void>;
loadBatch: (batchId: string) => Promise<void>;
loadActiveBatches: () => Promise<void>;
createBatch: (data: CreateProductionBatchRequest) => Promise<ProductionBatch | null>;
updateBatch: (batchId: string, data: UpdateProductionBatchRequest) => Promise<ProductionBatch | null>;
deleteBatch: (batchId: string) => Promise<boolean>;
startBatch: (batchId: string, data: any) => Promise<ProductionBatch | null>;
completeBatch: (batchId: string, data: any) => Promise<ProductionBatch | null>;
loadStatistics: (startDate?: string, endDate?: string) => Promise<void>;
clearError: () => void;
refresh: () => Promise<void>;
}
export const useProduction = (autoLoad: boolean = true): UseProductionReturn => {
const { currentTenant } = useTenant();
const { user } = useAuth();
// State
const [batches, setBatches] = useState<ProductionBatch[]>([]);
const [selectedBatch, setSelectedBatch] = useState<ProductionBatch | null>(null);
const [activeBatches, setActiveBatches] = useState<ProductionBatch[]>([]);
const [statistics, setStatistics] = useState<ProductionStatistics | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load batches
const loadBatches = useCallback(async (params: ProductionBatchSearchParams = {}) => {
if (!currentTenant?.id) return;
setIsLoading(true);
setError(null);
try {
const batchesData = await recipesService.getProductionBatches(currentTenant.id, params);
setBatches(batchesData);
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading production batches';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
}, [currentTenant?.id]);
// Load single batch
const loadBatch = useCallback(async (batchId: string) => {
if (!currentTenant?.id) return;
setIsLoading(true);
setError(null);
try {
const batch = await recipesService.getProductionBatch(currentTenant.id, batchId);
setSelectedBatch(batch);
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error loading production batch';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
}, [currentTenant?.id]);
// Load active batches
const loadActiveBatches = useCallback(async () => {
if (!currentTenant?.id) return;
try {
const activeBatchesData = await recipesService.getActiveProductionBatches(currentTenant.id);
setActiveBatches(activeBatchesData);
} catch (err: any) {
console.error('Error loading active batches:', err);
}
}, [currentTenant?.id]);
// Create batch
const createBatch = useCallback(async (data: CreateProductionBatchRequest): Promise<ProductionBatch | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsCreating(true);
setError(null);
try {
const newBatch = await recipesService.createProductionBatch(currentTenant.id, user.id, data);
// Add to local state
setBatches(prev => [newBatch, ...prev]);
toast.success('Production batch created successfully');
return newBatch;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error creating production batch';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsCreating(false);
}
}, [currentTenant?.id, user?.id]);
// Update batch
const updateBatch = useCallback(async (batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsUpdating(true);
setError(null);
try {
const updatedBatch = await recipesService.updateProductionBatch(currentTenant.id, user.id, batchId, data);
// Update local state
setBatches(prev => prev.map(batch =>
batch.id === batchId ? updatedBatch : batch
));
if (selectedBatch?.id === batchId) {
setSelectedBatch(updatedBatch);
}
toast.success('Production batch updated successfully');
return updatedBatch;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error updating production batch';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsUpdating(false);
}
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
// Delete batch
const deleteBatch = useCallback(async (batchId: string): Promise<boolean> => {
if (!currentTenant?.id) return false;
setIsDeleting(true);
setError(null);
try {
await recipesService.deleteProductionBatch(currentTenant.id, batchId);
// Remove from local state
setBatches(prev => prev.filter(batch => batch.id !== batchId));
if (selectedBatch?.id === batchId) {
setSelectedBatch(null);
}
toast.success('Production batch deleted successfully');
return true;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting production batch';
setError(errorMessage);
toast.error(errorMessage);
return false;
} finally {
setIsDeleting(false);
}
}, [currentTenant?.id, selectedBatch?.id]);
// Start batch
const startBatch = useCallback(async (batchId: string, data: any): Promise<ProductionBatch | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsUpdating(true);
setError(null);
try {
const startedBatch = await recipesService.startProductionBatch(currentTenant.id, user.id, batchId, data);
// Update local state
setBatches(prev => prev.map(batch =>
batch.id === batchId ? startedBatch : batch
));
if (selectedBatch?.id === batchId) {
setSelectedBatch(startedBatch);
}
toast.success('Production batch started successfully');
return startedBatch;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error starting production batch';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsUpdating(false);
}
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
// Complete batch
const completeBatch = useCallback(async (batchId: string, data: any): Promise<ProductionBatch | null> => {
if (!currentTenant?.id || !user?.id) return null;
setIsUpdating(true);
setError(null);
try {
const completedBatch = await recipesService.completeProductionBatch(currentTenant.id, user.id, batchId, data);
// Update local state
setBatches(prev => prev.map(batch =>
batch.id === batchId ? completedBatch : batch
));
if (selectedBatch?.id === batchId) {
setSelectedBatch(completedBatch);
}
toast.success('Production batch completed successfully');
return completedBatch;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Error completing production batch';
setError(errorMessage);
toast.error(errorMessage);
return null;
} finally {
setIsUpdating(false);
}
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
// Load statistics
const loadStatistics = useCallback(async (startDate?: string, endDate?: string) => {
if (!currentTenant?.id) return;
try {
const stats = await recipesService.getProductionStatistics(currentTenant.id, startDate, endDate);
setStatistics(stats);
} catch (err: any) {
console.error('Error loading production statistics:', err);
}
}, [currentTenant?.id]);
// Clear error
const clearError = useCallback(() => {
setError(null);
}, []);
// Refresh
const refresh = useCallback(async () => {
await Promise.all([
loadBatches(),
loadActiveBatches(),
loadStatistics()
]);
}, [loadBatches, loadActiveBatches, loadStatistics]);
// Auto-load on mount
useEffect(() => {
if (autoLoad && currentTenant?.id) {
refresh();
}
}, [autoLoad, currentTenant?.id]);
return {
// Data
batches,
selectedBatch,
activeBatches,
statistics,
// State
isLoading,
isCreating,
isUpdating,
isDeleting,
error,
// Actions
loadBatches,
loadBatch,
loadActiveBatches,
createBatch,
updateBatch,
deleteBatch,
startBatch,
completeBatch,
loadStatistics,
clearError,
refresh
};
};

View File

@@ -0,0 +1,890 @@
// frontend/src/api/hooks/useSuppliers.ts
/**
* React hooks for suppliers, purchase orders, and deliveries management
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
SuppliersService,
Supplier,
SupplierSummary,
CreateSupplierRequest,
UpdateSupplierRequest,
SupplierSearchParams,
SupplierStatistics,
PurchaseOrder,
CreatePurchaseOrderRequest,
PurchaseOrderSearchParams,
PurchaseOrderStatistics,
Delivery,
DeliverySearchParams,
DeliveryPerformanceStats
} from '../services/suppliers.service';
import { useAuth } from './useAuth';
const suppliersService = new SuppliersService();
// ============================================================================
// SUPPLIERS HOOK
// ============================================================================
export interface UseSuppliers {
// Data
suppliers: SupplierSummary[];
supplier: Supplier | null;
statistics: SupplierStatistics | null;
activeSuppliers: SupplierSummary[];
topSuppliers: SupplierSummary[];
suppliersNeedingReview: SupplierSummary[];
// States
isLoading: boolean;
isCreating: boolean;
isUpdating: boolean;
error: string | null;
// Pagination
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
// Actions
loadSuppliers: (params?: SupplierSearchParams) => Promise<void>;
loadSupplier: (supplierId: string) => Promise<void>;
loadStatistics: () => Promise<void>;
loadActiveSuppliers: () => Promise<void>;
loadTopSuppliers: (limit?: number) => Promise<void>;
loadSuppliersNeedingReview: (days?: number) => Promise<void>;
createSupplier: (data: CreateSupplierRequest) => Promise<Supplier | null>;
updateSupplier: (supplierId: string, data: UpdateSupplierRequest) => Promise<Supplier | null>;
deleteSupplier: (supplierId: string) => Promise<boolean>;
approveSupplier: (supplierId: string, action: 'approve' | 'reject', notes?: string) => Promise<Supplier | null>;
clearError: () => void;
refresh: () => Promise<void>;
setPage: (page: number) => void;
}
export function useSuppliers(): UseSuppliers {
const { user } = useAuth();
// State
const [suppliers, setSuppliers] = useState<SupplierSummary[]>([]);
const [supplier, setSupplier] = useState<Supplier | null>(null);
const [statistics, setStatistics] = useState<SupplierStatistics | null>(null);
const [activeSuppliers, setActiveSuppliers] = useState<SupplierSummary[]>([]);
const [topSuppliers, setTopSuppliers] = useState<SupplierSummary[]>([]);
const [suppliersNeedingReview, setSuppliersNeedingReview] = useState<SupplierSummary[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentParams, setCurrentParams] = useState<SupplierSearchParams>({});
const [pagination, setPagination] = useState({
page: 1,
limit: 50,
total: 0,
totalPages: 0
});
// Load suppliers
const loadSuppliers = useCallback(async (params: SupplierSearchParams = {}) => {
if (!user?.tenant_id) return;
try {
setIsLoading(true);
setError(null);
const searchParams = {
...params,
limit: pagination.limit,
offset: ((params.offset !== undefined ? Math.floor(params.offset / pagination.limit) : pagination.page) - 1) * pagination.limit
};
setCurrentParams(params);
const data = await suppliersService.getSuppliers(user.tenant_id, searchParams);
setSuppliers(data);
// Update pagination (Note: API doesn't return total count, so we estimate)
const hasMore = data.length === pagination.limit;
const currentPage = Math.floor((searchParams.offset || 0) / pagination.limit) + 1;
setPagination(prev => ({
...prev,
page: currentPage,
total: hasMore ? (currentPage * pagination.limit) + 1 : (currentPage - 1) * pagination.limit + data.length,
totalPages: hasMore ? currentPage + 1 : currentPage
}));
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load suppliers');
} finally {
setIsLoading(false);
}
}, [user?.tenant_id, pagination.limit]);
// Load single supplier
const loadSupplier = useCallback(async (supplierId: string) => {
if (!user?.tenant_id) return;
try {
setIsLoading(true);
setError(null);
const data = await suppliersService.getSupplier(user.tenant_id, supplierId);
setSupplier(data);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load supplier');
} finally {
setIsLoading(false);
}
}, [user?.tenant_id]);
// Load statistics
const loadStatistics = useCallback(async () => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getSupplierStatistics(user.tenant_id);
setStatistics(data);
} catch (err: any) {
console.error('Failed to load supplier statistics:', err);
}
}, [user?.tenant_id]);
// Load active suppliers
const loadActiveSuppliers = useCallback(async () => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getActiveSuppliers(user.tenant_id);
setActiveSuppliers(data);
} catch (err: any) {
console.error('Failed to load active suppliers:', err);
}
}, [user?.tenant_id]);
// Load top suppliers
const loadTopSuppliers = useCallback(async (limit: number = 10) => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getTopSuppliers(user.tenant_id, limit);
setTopSuppliers(data);
} catch (err: any) {
console.error('Failed to load top suppliers:', err);
}
}, [user?.tenant_id]);
// Load suppliers needing review
const loadSuppliersNeedingReview = useCallback(async (days: number = 30) => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getSuppliersNeedingReview(user.tenant_id, days);
setSuppliersNeedingReview(data);
} catch (err: any) {
console.error('Failed to load suppliers needing review:', err);
}
}, [user?.tenant_id]);
// Create supplier
const createSupplier = useCallback(async (data: CreateSupplierRequest): Promise<Supplier | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setIsCreating(true);
setError(null);
const supplier = await suppliersService.createSupplier(user.tenant_id, user.user_id, data);
// Refresh suppliers list
await loadSuppliers(currentParams);
await loadStatistics();
return supplier;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create supplier';
setError(errorMessage);
return null;
} finally {
setIsCreating(false);
}
}, [user?.tenant_id, user?.user_id, loadSuppliers, loadStatistics, currentParams]);
// Update supplier
const updateSupplier = useCallback(async (supplierId: string, data: UpdateSupplierRequest): Promise<Supplier | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setIsUpdating(true);
setError(null);
const updatedSupplier = await suppliersService.updateSupplier(user.tenant_id, user.user_id, supplierId, data);
// Update current supplier if it's the one being edited
if (supplier?.id === supplierId) {
setSupplier(updatedSupplier);
}
// Refresh suppliers list
await loadSuppliers(currentParams);
return updatedSupplier;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update supplier';
setError(errorMessage);
return null;
} finally {
setIsUpdating(false);
}
}, [user?.tenant_id, user?.user_id, supplier?.id, loadSuppliers, currentParams]);
// Delete supplier
const deleteSupplier = useCallback(async (supplierId: string): Promise<boolean> => {
if (!user?.tenant_id) return false;
try {
setError(null);
await suppliersService.deleteSupplier(user.tenant_id, supplierId);
// Clear current supplier if it's the one being deleted
if (supplier?.id === supplierId) {
setSupplier(null);
}
// Refresh suppliers list
await loadSuppliers(currentParams);
await loadStatistics();
return true;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete supplier';
setError(errorMessage);
return false;
}
}, [user?.tenant_id, supplier?.id, loadSuppliers, loadStatistics, currentParams]);
// Approve/reject supplier
const approveSupplier = useCallback(async (supplierId: string, action: 'approve' | 'reject', notes?: string): Promise<Supplier | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setError(null);
const updatedSupplier = await suppliersService.approveSupplier(user.tenant_id, user.user_id, supplierId, action, notes);
// Update current supplier if it's the one being approved/rejected
if (supplier?.id === supplierId) {
setSupplier(updatedSupplier);
}
// Refresh suppliers list and statistics
await loadSuppliers(currentParams);
await loadStatistics();
return updatedSupplier;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || `Failed to ${action} supplier`;
setError(errorMessage);
return null;
}
}, [user?.tenant_id, user?.user_id, supplier?.id, loadSuppliers, loadStatistics, currentParams]);
// Clear error
const clearError = useCallback(() => {
setError(null);
}, []);
// Refresh current data
const refresh = useCallback(async () => {
await loadSuppliers(currentParams);
if (statistics) await loadStatistics();
if (activeSuppliers.length > 0) await loadActiveSuppliers();
if (topSuppliers.length > 0) await loadTopSuppliers();
if (suppliersNeedingReview.length > 0) await loadSuppliersNeedingReview();
}, [currentParams, statistics, activeSuppliers.length, topSuppliers.length, suppliersNeedingReview.length, loadSuppliers, loadStatistics, loadActiveSuppliers, loadTopSuppliers, loadSuppliersNeedingReview]);
// Set page
const setPage = useCallback((page: number) => {
setPagination(prev => ({ ...prev, page }));
const offset = (page - 1) * pagination.limit;
loadSuppliers({ ...currentParams, offset });
}, [pagination.limit, currentParams, loadSuppliers]);
return {
// Data
suppliers,
supplier,
statistics,
activeSuppliers,
topSuppliers,
suppliersNeedingReview,
// States
isLoading,
isCreating,
isUpdating,
error,
// Pagination
pagination,
// Actions
loadSuppliers,
loadSupplier,
loadStatistics,
loadActiveSuppliers,
loadTopSuppliers,
loadSuppliersNeedingReview,
createSupplier,
updateSupplier,
deleteSupplier,
approveSupplier,
clearError,
refresh,
setPage
};
}
// ============================================================================
// PURCHASE ORDERS HOOK
// ============================================================================
export interface UsePurchaseOrders {
purchaseOrders: PurchaseOrder[];
purchaseOrder: PurchaseOrder | null;
statistics: PurchaseOrderStatistics | null;
ordersRequiringApproval: PurchaseOrder[];
overdueOrders: PurchaseOrder[];
isLoading: boolean;
isCreating: boolean;
error: string | null;
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
loadPurchaseOrders: (params?: PurchaseOrderSearchParams) => Promise<void>;
loadPurchaseOrder: (poId: string) => Promise<void>;
loadStatistics: () => Promise<void>;
loadOrdersRequiringApproval: () => Promise<void>;
loadOverdueOrders: () => Promise<void>;
createPurchaseOrder: (data: CreatePurchaseOrderRequest) => Promise<PurchaseOrder | null>;
updateOrderStatus: (poId: string, status: string, notes?: string) => Promise<PurchaseOrder | null>;
approveOrder: (poId: string, action: 'approve' | 'reject', notes?: string) => Promise<PurchaseOrder | null>;
sendToSupplier: (poId: string, sendEmail?: boolean) => Promise<PurchaseOrder | null>;
cancelOrder: (poId: string, reason: string) => Promise<PurchaseOrder | null>;
clearError: () => void;
refresh: () => Promise<void>;
setPage: (page: number) => void;
}
export function usePurchaseOrders(): UsePurchaseOrders {
const { user } = useAuth();
// State
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
const [purchaseOrder, setPurchaseOrder] = useState<PurchaseOrder | null>(null);
const [statistics, setStatistics] = useState<PurchaseOrderStatistics | null>(null);
const [ordersRequiringApproval, setOrdersRequiringApproval] = useState<PurchaseOrder[]>([]);
const [overdueOrders, setOverdueOrders] = useState<PurchaseOrder[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentParams, setCurrentParams] = useState<PurchaseOrderSearchParams>({});
const [pagination, setPagination] = useState({
page: 1,
limit: 50,
total: 0,
totalPages: 0
});
// Load purchase orders
const loadPurchaseOrders = useCallback(async (params: PurchaseOrderSearchParams = {}) => {
if (!user?.tenant_id) return;
try {
setIsLoading(true);
setError(null);
const searchParams = {
...params,
limit: pagination.limit,
offset: ((params.offset !== undefined ? Math.floor(params.offset / pagination.limit) : pagination.page) - 1) * pagination.limit
};
setCurrentParams(params);
const data = await suppliersService.getPurchaseOrders(user.tenant_id, searchParams);
setPurchaseOrders(data);
// Update pagination
const hasMore = data.length === pagination.limit;
const currentPage = Math.floor((searchParams.offset || 0) / pagination.limit) + 1;
setPagination(prev => ({
...prev,
page: currentPage,
total: hasMore ? (currentPage * pagination.limit) + 1 : (currentPage - 1) * pagination.limit + data.length,
totalPages: hasMore ? currentPage + 1 : currentPage
}));
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load purchase orders');
} finally {
setIsLoading(false);
}
}, [user?.tenant_id, pagination.limit]);
// Other purchase order methods...
const loadPurchaseOrder = useCallback(async (poId: string) => {
if (!user?.tenant_id) return;
try {
setIsLoading(true);
setError(null);
const data = await suppliersService.getPurchaseOrder(user.tenant_id, poId);
setPurchaseOrder(data);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load purchase order');
} finally {
setIsLoading(false);
}
}, [user?.tenant_id]);
const loadStatistics = useCallback(async () => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getPurchaseOrderStatistics(user.tenant_id);
setStatistics(data);
} catch (err: any) {
console.error('Failed to load purchase order statistics:', err);
}
}, [user?.tenant_id]);
const loadOrdersRequiringApproval = useCallback(async () => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getOrdersRequiringApproval(user.tenant_id);
setOrdersRequiringApproval(data);
} catch (err: any) {
console.error('Failed to load orders requiring approval:', err);
}
}, [user?.tenant_id]);
const loadOverdueOrders = useCallback(async () => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getOverdueOrders(user.tenant_id);
setOverdueOrders(data);
} catch (err: any) {
console.error('Failed to load overdue orders:', err);
}
}, [user?.tenant_id]);
const createPurchaseOrder = useCallback(async (data: CreatePurchaseOrderRequest): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setIsCreating(true);
setError(null);
const order = await suppliersService.createPurchaseOrder(user.tenant_id, user.user_id, data);
// Refresh orders list
await loadPurchaseOrders(currentParams);
await loadStatistics();
return order;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create purchase order';
setError(errorMessage);
return null;
} finally {
setIsCreating(false);
}
}, [user?.tenant_id, user?.user_id, loadPurchaseOrders, loadStatistics, currentParams]);
const updateOrderStatus = useCallback(async (poId: string, status: string, notes?: string): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setError(null);
const updatedOrder = await suppliersService.updatePurchaseOrderStatus(user.tenant_id, user.user_id, poId, status, notes);
if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder);
}
await loadPurchaseOrders(currentParams);
return updatedOrder;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update order status';
setError(errorMessage);
return null;
}
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
const approveOrder = useCallback(async (poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setError(null);
const updatedOrder = await suppliersService.approvePurchaseOrder(user.tenant_id, user.user_id, poId, action, notes);
if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder);
}
await loadPurchaseOrders(currentParams);
await loadOrdersRequiringApproval();
return updatedOrder;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || `Failed to ${action} order`;
setError(errorMessage);
return null;
}
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, loadOrdersRequiringApproval, currentParams]);
const sendToSupplier = useCallback(async (poId: string, sendEmail: boolean = true): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setError(null);
const updatedOrder = await suppliersService.sendToSupplier(user.tenant_id, user.user_id, poId, sendEmail);
if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder);
}
await loadPurchaseOrders(currentParams);
return updatedOrder;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to send order to supplier';
setError(errorMessage);
return null;
}
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
const cancelOrder = useCallback(async (poId: string, reason: string): Promise<PurchaseOrder | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setError(null);
const updatedOrder = await suppliersService.cancelPurchaseOrder(user.tenant_id, user.user_id, poId, reason);
if (purchaseOrder?.id === poId) {
setPurchaseOrder(updatedOrder);
}
await loadPurchaseOrders(currentParams);
return updatedOrder;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to cancel order';
setError(errorMessage);
return null;
}
}, [user?.tenant_id, user?.user_id, purchaseOrder?.id, loadPurchaseOrders, currentParams]);
const clearError = useCallback(() => {
setError(null);
}, []);
const refresh = useCallback(async () => {
await loadPurchaseOrders(currentParams);
if (statistics) await loadStatistics();
if (ordersRequiringApproval.length > 0) await loadOrdersRequiringApproval();
if (overdueOrders.length > 0) await loadOverdueOrders();
}, [currentParams, statistics, ordersRequiringApproval.length, overdueOrders.length, loadPurchaseOrders, loadStatistics, loadOrdersRequiringApproval, loadOverdueOrders]);
const setPage = useCallback((page: number) => {
setPagination(prev => ({ ...prev, page }));
const offset = (page - 1) * pagination.limit;
loadPurchaseOrders({ ...currentParams, offset });
}, [pagination.limit, currentParams, loadPurchaseOrders]);
return {
purchaseOrders,
purchaseOrder,
statistics,
ordersRequiringApproval,
overdueOrders,
isLoading,
isCreating,
error,
pagination,
loadPurchaseOrders,
loadPurchaseOrder,
loadStatistics,
loadOrdersRequiringApproval,
loadOverdueOrders,
createPurchaseOrder,
updateOrderStatus,
approveOrder,
sendToSupplier,
cancelOrder,
clearError,
refresh,
setPage
};
}
// ============================================================================
// DELIVERIES HOOK
// ============================================================================
export interface UseDeliveries {
deliveries: Delivery[];
delivery: Delivery | null;
todaysDeliveries: Delivery[];
overdueDeliveries: Delivery[];
performanceStats: DeliveryPerformanceStats | null;
isLoading: boolean;
error: string | null;
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
loadDeliveries: (params?: DeliverySearchParams) => Promise<void>;
loadDelivery: (deliveryId: string) => Promise<void>;
loadTodaysDeliveries: () => Promise<void>;
loadOverdueDeliveries: () => Promise<void>;
loadPerformanceStats: (daysBack?: number, supplierId?: string) => Promise<void>;
updateDeliveryStatus: (deliveryId: string, status: string, notes?: string) => Promise<Delivery | null>;
receiveDelivery: (deliveryId: string, receiptData: any) => Promise<Delivery | null>;
clearError: () => void;
refresh: () => Promise<void>;
setPage: (page: number) => void;
}
export function useDeliveries(): UseDeliveries {
const { user } = useAuth();
// State
const [deliveries, setDeliveries] = useState<Delivery[]>([]);
const [delivery, setDelivery] = useState<Delivery | null>(null);
const [todaysDeliveries, setTodaysDeliveries] = useState<Delivery[]>([]);
const [overdueDeliveries, setOverdueDeliveries] = useState<Delivery[]>([]);
const [performanceStats, setPerformanceStats] = useState<DeliveryPerformanceStats | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentParams, setCurrentParams] = useState<DeliverySearchParams>({});
const [pagination, setPagination] = useState({
page: 1,
limit: 50,
total: 0,
totalPages: 0
});
// Load deliveries
const loadDeliveries = useCallback(async (params: DeliverySearchParams = {}) => {
if (!user?.tenant_id) return;
try {
setIsLoading(true);
setError(null);
const searchParams = {
...params,
limit: pagination.limit,
offset: ((params.offset !== undefined ? Math.floor(params.offset / pagination.limit) : pagination.page) - 1) * pagination.limit
};
setCurrentParams(params);
const data = await suppliersService.getDeliveries(user.tenant_id, searchParams);
setDeliveries(data);
// Update pagination
const hasMore = data.length === pagination.limit;
const currentPage = Math.floor((searchParams.offset || 0) / pagination.limit) + 1;
setPagination(prev => ({
...prev,
page: currentPage,
total: hasMore ? (currentPage * pagination.limit) + 1 : (currentPage - 1) * pagination.limit + data.length,
totalPages: hasMore ? currentPage + 1 : currentPage
}));
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load deliveries');
} finally {
setIsLoading(false);
}
}, [user?.tenant_id, pagination.limit]);
const loadDelivery = useCallback(async (deliveryId: string) => {
if (!user?.tenant_id) return;
try {
setIsLoading(true);
setError(null);
const data = await suppliersService.getDelivery(user.tenant_id, deliveryId);
setDelivery(data);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load delivery');
} finally {
setIsLoading(false);
}
}, [user?.tenant_id]);
const loadTodaysDeliveries = useCallback(async () => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getTodaysDeliveries(user.tenant_id);
setTodaysDeliveries(data);
} catch (err: any) {
console.error('Failed to load today\'s deliveries:', err);
}
}, [user?.tenant_id]);
const loadOverdueDeliveries = useCallback(async () => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getOverdueDeliveries(user.tenant_id);
setOverdueDeliveries(data);
} catch (err: any) {
console.error('Failed to load overdue deliveries:', err);
}
}, [user?.tenant_id]);
const loadPerformanceStats = useCallback(async (daysBack: number = 30, supplierId?: string) => {
if (!user?.tenant_id) return;
try {
const data = await suppliersService.getDeliveryPerformanceStats(user.tenant_id, daysBack, supplierId);
setPerformanceStats(data);
} catch (err: any) {
console.error('Failed to load delivery performance stats:', err);
}
}, [user?.tenant_id]);
const updateDeliveryStatus = useCallback(async (deliveryId: string, status: string, notes?: string): Promise<Delivery | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setError(null);
const updatedDelivery = await suppliersService.updateDeliveryStatus(user.tenant_id, user.user_id, deliveryId, status, notes);
if (delivery?.id === deliveryId) {
setDelivery(updatedDelivery);
}
await loadDeliveries(currentParams);
return updatedDelivery;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update delivery status';
setError(errorMessage);
return null;
}
}, [user?.tenant_id, user?.user_id, delivery?.id, loadDeliveries, currentParams]);
const receiveDelivery = useCallback(async (deliveryId: string, receiptData: any): Promise<Delivery | null> => {
if (!user?.tenant_id || !user?.user_id) return null;
try {
setError(null);
const updatedDelivery = await suppliersService.receiveDelivery(user.tenant_id, user.user_id, deliveryId, receiptData);
if (delivery?.id === deliveryId) {
setDelivery(updatedDelivery);
}
await loadDeliveries(currentParams);
return updatedDelivery;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to receive delivery';
setError(errorMessage);
return null;
}
}, [user?.tenant_id, user?.user_id, delivery?.id, loadDeliveries, currentParams]);
const clearError = useCallback(() => {
setError(null);
}, []);
const refresh = useCallback(async () => {
await loadDeliveries(currentParams);
if (todaysDeliveries.length > 0) await loadTodaysDeliveries();
if (overdueDeliveries.length > 0) await loadOverdueDeliveries();
if (performanceStats) await loadPerformanceStats();
}, [currentParams, todaysDeliveries.length, overdueDeliveries.length, performanceStats, loadDeliveries, loadTodaysDeliveries, loadOverdueDeliveries, loadPerformanceStats]);
const setPage = useCallback((page: number) => {
setPagination(prev => ({ ...prev, page }));
const offset = (page - 1) * pagination.limit;
loadDeliveries({ ...currentParams, offset });
}, [pagination.limit, currentParams, loadDeliveries]);
return {
deliveries,
delivery,
todaysDeliveries,
overdueDeliveries,
performanceStats,
isLoading,
error,
pagination,
loadDeliveries,
loadDelivery,
loadTodaysDeliveries,
loadOverdueDeliveries,
loadPerformanceStats,
updateDeliveryStatus,
receiveDelivery,
clearError,
refresh,
setPage
};
}

View File

@@ -13,6 +13,8 @@ import { TrainingService } from './training.service';
import { ForecastingService } from './forecasting.service';
import { NotificationService } from './notification.service';
import { OnboardingService } from './onboarding.service';
import { InventoryService } from './inventory.service';
import { RecipesService } from './recipes.service';
// Create service instances
export const authService = new AuthService();
@@ -23,6 +25,8 @@ export const trainingService = new TrainingService();
export const forecastingService = new ForecastingService();
export const notificationService = new NotificationService();
export const onboardingService = new OnboardingService();
export const inventoryService = new InventoryService();
export const recipesService = new RecipesService();
// Export the classes as well
export {
@@ -33,7 +37,9 @@ export {
TrainingService,
ForecastingService,
NotificationService,
OnboardingService
OnboardingService,
InventoryService,
RecipesService
};
// Import base client
@@ -53,6 +59,8 @@ export const api = {
forecasting: forecastingService,
notification: notificationService,
onboarding: onboardingService,
inventory: inventoryService,
recipes: recipesService,
} as const;
// Service status checking

View File

@@ -0,0 +1,474 @@
// frontend/src/api/services/inventory.service.ts
/**
* Inventory Service
* Handles inventory management, stock tracking, and product operations
*/
import { apiClient } from '../client';
// ========== TYPES AND INTERFACES ==========
export type ProductType = 'ingredient' | 'finished_product';
export type UnitOfMeasure =
| 'kilograms' | 'grams' | 'liters' | 'milliliters'
| 'units' | 'pieces' | 'dozens' | 'boxes';
export type IngredientCategory =
| 'flour' | 'yeast' | 'dairy' | 'eggs' | 'sugar'
| 'fats' | 'salt' | 'spices' | 'additives' | 'packaging';
export type ProductCategory =
| 'bread' | 'croissants' | 'pastries' | 'cakes'
| 'cookies' | 'muffins' | 'sandwiches' | 'beverages' | 'other_products';
export type StockMovementType =
| 'purchase' | 'consumption' | 'adjustment'
| 'waste' | 'transfer' | 'return';
export interface InventoryItem {
id: string;
tenant_id: string;
name: string;
product_type: ProductType;
category: IngredientCategory | ProductCategory;
unit_of_measure: UnitOfMeasure;
estimated_shelf_life_days?: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
minimum_stock_level?: number;
maximum_stock_level?: number;
reorder_point?: number;
supplier?: string;
notes?: string;
barcode?: string;
cost_per_unit?: number;
is_active: boolean;
created_at: string;
updated_at: string;
// Computed fields
current_stock?: StockLevel;
low_stock_alert?: boolean;
expiring_soon_alert?: boolean;
recent_movements?: StockMovement[];
}
export interface StockLevel {
item_id: string;
current_quantity: number;
available_quantity: number;
reserved_quantity: number;
unit_of_measure: UnitOfMeasure;
value_estimate?: number;
last_updated: string;
// Batch information
batches?: StockBatch[];
oldest_batch_date?: string;
newest_batch_date?: string;
}
export interface StockBatch {
id: string;
item_id: string;
batch_number?: string;
quantity: number;
unit_cost?: number;
purchase_date?: string;
expiration_date?: string;
supplier?: string;
notes?: string;
is_expired: boolean;
days_until_expiration?: number;
}
export interface StockMovement {
id: string;
item_id: string;
movement_type: StockMovementType;
quantity: number;
unit_cost?: number;
total_cost?: number;
batch_id?: string;
reference_id?: string;
notes?: string;
movement_date: string;
created_by: string;
created_at: string;
// Related data
item_name?: string;
batch_info?: StockBatch;
}
export interface StockAlert {
id: string;
item_id: string;
alert_type: 'low_stock' | 'expired' | 'expiring_soon' | 'overstock';
severity: 'low' | 'medium' | 'high' | 'critical';
message: string;
threshold_value?: number;
current_value?: number;
is_acknowledged: boolean;
created_at: string;
acknowledged_at?: string;
acknowledged_by?: string;
// Related data
item?: InventoryItem;
}
// ========== REQUEST/RESPONSE TYPES ==========
export interface CreateInventoryItemRequest {
name: string;
product_type: ProductType;
category: IngredientCategory | ProductCategory;
unit_of_measure: UnitOfMeasure;
estimated_shelf_life_days?: number;
requires_refrigeration?: boolean;
requires_freezing?: boolean;
is_seasonal?: boolean;
minimum_stock_level?: number;
maximum_stock_level?: number;
reorder_point?: number;
supplier?: string;
notes?: string;
barcode?: string;
cost_per_unit?: number;
}
export interface UpdateInventoryItemRequest extends Partial<CreateInventoryItemRequest> {
is_active?: boolean;
}
export interface StockAdjustmentRequest {
movement_type: StockMovementType;
quantity: number;
unit_cost?: number;
batch_number?: string;
expiration_date?: string;
supplier?: string;
notes?: string;
}
export interface InventorySearchParams {
search?: string;
product_type?: ProductType;
category?: string;
is_active?: boolean;
low_stock_only?: boolean;
expiring_soon_only?: boolean;
page?: number;
limit?: number;
sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at';
sort_order?: 'asc' | 'desc';
}
export interface StockMovementSearchParams {
item_id?: string;
movement_type?: StockMovementType;
date_from?: string;
date_to?: string;
page?: number;
limit?: number;
}
export interface InventoryDashboardData {
total_items: number;
total_value: number;
low_stock_count: number;
expiring_soon_count: number;
recent_movements: StockMovement[];
top_items_by_value: InventoryItem[];
category_breakdown: {
category: string;
count: number;
value: number;
}[];
movement_trends: {
date: string;
purchases: number;
consumption: number;
waste: number;
}[];
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
total_pages: number;
}
// ========== INVENTORY SERVICE CLASS ==========
export class InventoryService {
private baseEndpoint = '/api/v1';
// ========== INVENTORY ITEMS ==========
/**
* Get inventory items with filtering and pagination
*/
async getInventoryItems(
tenantId: string,
params?: InventorySearchParams
): Promise<PaginatedResponse<InventoryItem>> {
const searchParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const query = searchParams.toString();
const url = `${this.baseEndpoint}/tenants/${tenantId}/inventory/items${query ? `?${query}` : ''}`;
return apiClient.get(url);
}
/**
* Get single inventory item by ID
*/
async getInventoryItem(tenantId: string, itemId: string): Promise<InventoryItem> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`);
}
/**
* Create new inventory item
*/
async createInventoryItem(
tenantId: string,
data: CreateInventoryItemRequest
): Promise<InventoryItem> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items`, data);
}
/**
* Update existing inventory item
*/
async updateInventoryItem(
tenantId: string,
itemId: string,
data: UpdateInventoryItemRequest
): Promise<InventoryItem> {
return apiClient.put(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`, data);
}
/**
* Delete inventory item (soft delete)
*/
async deleteInventoryItem(tenantId: string, itemId: string): Promise<void> {
return apiClient.delete(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`);
}
/**
* Bulk update inventory items
*/
async bulkUpdateInventoryItems(
tenantId: string,
updates: { id: string; data: UpdateInventoryItemRequest }[]
): Promise<{ success: number; failed: number; errors: string[] }> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/bulk-update`, {
updates
});
}
// ========== STOCK MANAGEMENT ==========
/**
* Get current stock level for an item
*/
async getStockLevel(tenantId: string, itemId: string): Promise<StockLevel> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/${itemId}`);
}
/**
* Get stock levels for all items
*/
async getAllStockLevels(tenantId: string): Promise<StockLevel[]> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock`);
}
/**
* Adjust stock level (purchase, consumption, waste, etc.)
*/
async adjustStock(
tenantId: string,
itemId: string,
adjustment: StockAdjustmentRequest
): Promise<StockMovement> {
return apiClient.post(
`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/${itemId}/adjust`,
adjustment
);
}
/**
* Bulk stock adjustments
*/
async bulkAdjustStock(
tenantId: string,
adjustments: { item_id: string; adjustment: StockAdjustmentRequest }[]
): Promise<{ success: number; failed: number; movements: StockMovement[]; errors: string[] }> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/bulk-adjust`, {
adjustments
});
}
/**
* Get stock movements with filtering
*/
async getStockMovements(
tenantId: string,
params?: StockMovementSearchParams
): Promise<PaginatedResponse<StockMovement>> {
const searchParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const query = searchParams.toString();
const url = `${this.baseEndpoint}/tenants/${tenantId}/inventory/movements${query ? `?${query}` : ''}`;
return apiClient.get(url);
}
// ========== ALERTS ==========
/**
* Get current stock alerts
*/
async getStockAlerts(tenantId: string): Promise<StockAlert[]> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts`);
}
/**
* Acknowledge alert
*/
async acknowledgeAlert(tenantId: string, alertId: string): Promise<void> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts/${alertId}/acknowledge`);
}
/**
* Bulk acknowledge alerts
*/
async bulkAcknowledgeAlerts(tenantId: string, alertIds: string[]): Promise<void> {
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts/bulk-acknowledge`, {
alert_ids: alertIds
});
}
// ========== DASHBOARD & ANALYTICS ==========
/**
* Get inventory dashboard data
*/
async getDashboardData(tenantId: string): Promise<InventoryDashboardData> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/dashboard`);
}
/**
* Get inventory value report
*/
async getInventoryValue(tenantId: string): Promise<{
total_value: number;
by_category: { category: string; value: number; percentage: number }[];
by_product_type: { type: ProductType; value: number; percentage: number }[];
}> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/value`);
}
/**
* Get low stock report
*/
async getLowStockReport(tenantId: string): Promise<{
items: InventoryItem[];
total_affected: number;
estimated_loss: number;
}> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/low-stock`);
}
/**
* Get expiring items report
*/
async getExpiringItemsReport(tenantId: string, days?: number): Promise<{
items: (InventoryItem & { batches: StockBatch[] })[];
total_affected: number;
estimated_loss: number;
}> {
const params = days ? `?days=${days}` : '';
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/expiring${params}`);
}
// ========== IMPORT/EXPORT ==========
/**
* Export inventory data to CSV
*/
async exportInventory(tenantId: string, format: 'csv' | 'excel' = 'csv'): Promise<Blob> {
const response = await apiClient.getRaw(
`${this.baseEndpoint}/tenants/${tenantId}/inventory/export?format=${format}`
);
return response.blob();
}
/**
* Import inventory from file
*/
async importInventory(tenantId: string, file: File): Promise<{
success: number;
failed: number;
errors: string[];
created_items: InventoryItem[];
}> {
const formData = new FormData();
formData.append('file', file);
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/import`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
// ========== SEARCH & SUGGESTIONS ==========
/**
* Search inventory items with autocomplete
*/
async searchItems(tenantId: string, query: string, limit = 10): Promise<InventoryItem[]> {
return apiClient.get(
`${this.baseEndpoint}/tenants/${tenantId}/inventory/search?q=${encodeURIComponent(query)}&limit=${limit}`
);
}
/**
* Get category suggestions based on product type
*/
async getCategorySuggestions(productType: ProductType): Promise<string[]> {
return apiClient.get(`${this.baseEndpoint}/inventory/categories?type=${productType}`);
}
/**
* Get supplier suggestions
*/
async getSupplierSuggestions(tenantId: string): Promise<string[]> {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`);
}
}
export const inventoryService = new InventoryService();

View File

@@ -29,6 +29,61 @@ export interface UpdateStepRequest {
data?: Record<string, any>;
}
export interface InventorySuggestion {
suggestion_id: string;
original_name: string;
suggested_name: string;
product_type: 'ingredient' | 'finished_product';
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;
user_approved?: boolean;
user_modifications?: Record<string, any>;
}
export interface BusinessModelAnalysis {
model: 'production' | 'retail' | 'hybrid';
confidence: number;
ingredient_count: number;
finished_product_count: number;
ingredient_ratio: number;
recommendations: string[];
}
export interface OnboardingAnalysisResult {
total_products_found: number;
inventory_suggestions: InventorySuggestion[];
business_model_analysis: BusinessModelAnalysis;
import_job_id: string;
status: string;
processed_rows: number;
errors: string[];
warnings: string[];
}
export interface InventoryCreationResult {
created_items: any[];
failed_items: any[];
total_approved: number;
success_rate: number;
}
export interface SalesImportResult {
import_job_id: string;
status: string;
processed_rows: number;
successful_imports: number;
failed_imports: number;
errors: string[];
warnings: string[];
}
export class OnboardingService {
private baseEndpoint = '/users/me/onboarding';
@@ -87,6 +142,64 @@ export class OnboardingService {
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
return apiClient.get(`${this.baseEndpoint}/can-access/${stepName}`);
}
// ========== AUTOMATED INVENTORY CREATION METHODS ==========
/**
* Phase 1: Analyze sales data and get AI suggestions
*/
async analyzeSalesDataForOnboarding(tenantId: string, file: File): Promise<OnboardingAnalysisResult> {
const formData = new FormData();
formData.append('file', file);
return apiClient.post(`/tenants/${tenantId}/onboarding/analyze`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Phase 2: Create inventory from approved suggestions
*/
async createInventoryFromSuggestions(
tenantId: string,
suggestions: InventorySuggestion[]
): Promise<InventoryCreationResult> {
return apiClient.post(`/tenants/${tenantId}/onboarding/create-inventory`, {
suggestions: suggestions.map(s => ({
suggestion_id: s.suggestion_id,
approved: s.user_approved ?? true,
modifications: s.user_modifications || {}
}))
});
}
/**
* Phase 3: Import sales data with inventory mapping
*/
async importSalesWithInventory(
tenantId: string,
file: File,
inventoryMapping: Record<string, string>
): Promise<SalesImportResult> {
const formData = new FormData();
formData.append('file', file);
formData.append('inventory_mapping', JSON.stringify(inventoryMapping));
return apiClient.post(`/tenants/${tenantId}/onboarding/import-sales`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Get business model guidance based on analysis
*/
async getBusinessModelGuide(tenantId: string, model: string): Promise<any> {
return apiClient.get(`/tenants/${tenantId}/onboarding/business-model-guide?model=${model}`);
}
}
export const onboardingService = new OnboardingService();

View File

@@ -0,0 +1,551 @@
// frontend/src/api/services/recipes.service.ts
/**
* Recipe Service API Client
* Handles all recipe and production management API calls
*/
import { apiClient } from '../client';
import type {
PaginatedResponse,
ApiResponse,
CreateResponse,
UpdateResponse
} from '../types';
// Recipe Types
export interface Recipe {
id: string;
tenant_id: string;
name: string;
recipe_code?: string;
version: string;
finished_product_id: string;
description?: string;
category?: string;
cuisine_type?: string;
difficulty_level: number;
yield_quantity: number;
yield_unit: string;
prep_time_minutes?: number;
cook_time_minutes?: number;
total_time_minutes?: number;
rest_time_minutes?: number;
estimated_cost_per_unit?: number;
last_calculated_cost?: number;
cost_calculation_date?: string;
target_margin_percentage?: number;
suggested_selling_price?: number;
instructions?: Record<string, any>;
preparation_notes?: string;
storage_instructions?: string;
quality_standards?: string;
serves_count?: number;
nutritional_info?: Record<string, any>;
allergen_info?: Record<string, any>;
dietary_tags?: Record<string, any>;
batch_size_multiplier: number;
minimum_batch_size?: number;
maximum_batch_size?: number;
optimal_production_temperature?: number;
optimal_humidity?: number;
quality_check_points?: Record<string, any>;
common_issues?: Record<string, any>;
status: 'draft' | 'active' | 'testing' | 'archived' | 'discontinued';
is_seasonal: boolean;
season_start_month?: number;
season_end_month?: number;
is_signature_item: boolean;
created_at: string;
updated_at: string;
created_by?: string;
updated_by?: string;
ingredients?: RecipeIngredient[];
}
export interface RecipeIngredient {
id: string;
tenant_id: string;
recipe_id: string;
ingredient_id: string;
quantity: number;
unit: string;
quantity_in_base_unit?: number;
alternative_quantity?: number;
alternative_unit?: string;
preparation_method?: string;
ingredient_notes?: string;
is_optional: boolean;
ingredient_order: number;
ingredient_group?: string;
substitution_options?: Record<string, any>;
substitution_ratio?: number;
unit_cost?: number;
total_cost?: number;
cost_updated_at?: string;
}
export interface CreateRecipeRequest {
name: string;
recipe_code?: string;
version?: string;
finished_product_id: string;
description?: string;
category?: string;
cuisine_type?: string;
difficulty_level?: number;
yield_quantity: number;
yield_unit: string;
prep_time_minutes?: number;
cook_time_minutes?: number;
total_time_minutes?: number;
rest_time_minutes?: number;
instructions?: Record<string, any>;
preparation_notes?: string;
storage_instructions?: string;
quality_standards?: string;
serves_count?: number;
nutritional_info?: Record<string, any>;
allergen_info?: Record<string, any>;
dietary_tags?: Record<string, any>;
batch_size_multiplier?: number;
minimum_batch_size?: number;
maximum_batch_size?: number;
optimal_production_temperature?: number;
optimal_humidity?: number;
quality_check_points?: Record<string, any>;
common_issues?: Record<string, any>;
is_seasonal?: boolean;
season_start_month?: number;
season_end_month?: number;
is_signature_item?: boolean;
target_margin_percentage?: number;
ingredients: CreateRecipeIngredientRequest[];
}
export interface CreateRecipeIngredientRequest {
ingredient_id: string;
quantity: number;
unit: string;
alternative_quantity?: number;
alternative_unit?: string;
preparation_method?: string;
ingredient_notes?: string;
is_optional?: boolean;
ingredient_order: number;
ingredient_group?: string;
substitution_options?: Record<string, any>;
substitution_ratio?: number;
}
export interface UpdateRecipeRequest {
name?: string;
recipe_code?: string;
version?: string;
description?: string;
category?: string;
cuisine_type?: string;
difficulty_level?: number;
yield_quantity?: number;
yield_unit?: string;
prep_time_minutes?: number;
cook_time_minutes?: number;
total_time_minutes?: number;
rest_time_minutes?: number;
instructions?: Record<string, any>;
preparation_notes?: string;
storage_instructions?: string;
quality_standards?: string;
serves_count?: number;
nutritional_info?: Record<string, any>;
allergen_info?: Record<string, any>;
dietary_tags?: Record<string, any>;
batch_size_multiplier?: number;
minimum_batch_size?: number;
maximum_batch_size?: number;
optimal_production_temperature?: number;
optimal_humidity?: number;
quality_check_points?: Record<string, any>;
common_issues?: Record<string, any>;
status?: 'draft' | 'active' | 'testing' | 'archived' | 'discontinued';
is_seasonal?: boolean;
season_start_month?: number;
season_end_month?: number;
is_signature_item?: boolean;
target_margin_percentage?: number;
ingredients?: CreateRecipeIngredientRequest[];
}
export interface RecipeSearchParams {
search_term?: string;
status?: string;
category?: string;
is_seasonal?: boolean;
is_signature?: boolean;
difficulty_level?: number;
limit?: number;
offset?: number;
}
export interface RecipeFeasibility {
recipe_id: string;
recipe_name: string;
batch_multiplier: number;
feasible: boolean;
missing_ingredients: Array<{
ingredient_id: string;
ingredient_name: string;
required_quantity: number;
unit: string;
}>;
insufficient_ingredients: Array<{
ingredient_id: string;
ingredient_name: string;
required_quantity: number;
available_quantity: number;
unit: string;
}>;
}
export interface RecipeStatistics {
total_recipes: number;
active_recipes: number;
signature_recipes: number;
seasonal_recipes: number;
category_breakdown: Array<{
category: string;
count: number;
}>;
}
// Production Types
export interface ProductionBatch {
id: string;
tenant_id: string;
recipe_id: string;
batch_number: string;
production_date: string;
planned_start_time?: string;
actual_start_time?: string;
planned_end_time?: string;
actual_end_time?: string;
planned_quantity: number;
actual_quantity?: number;
yield_percentage?: number;
batch_size_multiplier: number;
status: 'planned' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
priority: 'low' | 'normal' | 'high' | 'urgent';
assigned_staff?: string[];
production_notes?: string;
quality_score?: number;
quality_notes?: string;
defect_rate?: number;
rework_required: boolean;
planned_material_cost?: number;
actual_material_cost?: number;
labor_cost?: number;
overhead_cost?: number;
total_production_cost?: number;
cost_per_unit?: number;
production_temperature?: number;
production_humidity?: number;
oven_temperature?: number;
baking_time_minutes?: number;
waste_quantity: number;
waste_reason?: string;
efficiency_percentage?: number;
customer_order_reference?: string;
pre_order_quantity?: number;
shelf_quantity?: number;
created_at: string;
updated_at: string;
created_by?: string;
completed_by?: string;
ingredient_consumptions?: ProductionIngredientConsumption[];
}
export interface ProductionIngredientConsumption {
id: string;
tenant_id: string;
production_batch_id: string;
recipe_ingredient_id: string;
ingredient_id: string;
stock_id?: string;
planned_quantity: number;
actual_quantity: number;
unit: string;
variance_quantity?: number;
variance_percentage?: number;
unit_cost?: number;
total_cost?: number;
consumption_time: string;
consumption_notes?: string;
staff_member?: string;
ingredient_condition?: string;
quality_impact?: string;
substitution_used: boolean;
substitution_details?: string;
}
export interface CreateProductionBatchRequest {
recipe_id: string;
batch_number?: string;
production_date: string;
planned_start_time?: string;
planned_end_time?: string;
planned_quantity: number;
batch_size_multiplier?: number;
priority?: 'low' | 'normal' | 'high' | 'urgent';
assigned_staff?: string[];
production_notes?: string;
customer_order_reference?: string;
pre_order_quantity?: number;
shelf_quantity?: number;
}
export interface UpdateProductionBatchRequest {
batch_number?: string;
production_date?: string;
planned_start_time?: string;
actual_start_time?: string;
planned_end_time?: string;
actual_end_time?: string;
planned_quantity?: number;
actual_quantity?: number;
batch_size_multiplier?: number;
status?: 'planned' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
priority?: 'low' | 'normal' | 'high' | 'urgent';
assigned_staff?: string[];
production_notes?: string;
quality_score?: number;
quality_notes?: string;
defect_rate?: number;
rework_required?: boolean;
labor_cost?: number;
overhead_cost?: number;
production_temperature?: number;
production_humidity?: number;
oven_temperature?: number;
baking_time_minutes?: number;
waste_quantity?: number;
waste_reason?: string;
customer_order_reference?: string;
pre_order_quantity?: number;
shelf_quantity?: number;
}
export interface ProductionBatchSearchParams {
search_term?: string;
status?: string;
priority?: string;
start_date?: string;
end_date?: string;
recipe_id?: string;
limit?: number;
offset?: number;
}
export interface ProductionStatistics {
total_batches: number;
completed_batches: number;
failed_batches: number;
success_rate: number;
average_yield_percentage: number;
average_quality_score: number;
total_production_cost: number;
status_breakdown: Array<{
status: string;
count: number;
}>;
}
export class RecipesService {
private baseUrl = '/api/recipes/v1';
// Recipe Management
async getRecipes(tenantId: string, params?: RecipeSearchParams): Promise<Recipe[]> {
const response = await apiClient.get<Recipe[]>(`${this.baseUrl}/recipes`, {
headers: { 'X-Tenant-ID': tenantId },
params
});
return response.data;
}
async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> {
const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> {
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes`, data, {
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
});
return response.data;
}
async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> {
const response = await apiClient.put<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, data, {
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
});
return response.data;
}
async deleteRecipe(tenantId: string, recipeId: string): Promise<void> {
await apiClient.delete(`${this.baseUrl}/recipes/${recipeId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
}
async duplicateRecipe(tenantId: string, userId: string, recipeId: string, newName: string): Promise<Recipe> {
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes/${recipeId}/duplicate`,
{ new_name: newName },
{
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
}
);
return response.data;
}
async activateRecipe(tenantId: string, userId: string, recipeId: string): Promise<Recipe> {
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes/${recipeId}/activate`, {}, {
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
});
return response.data;
}
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> {
const response = await apiClient.get<RecipeFeasibility>(`${this.baseUrl}/recipes/${recipeId}/feasibility`, {
headers: { 'X-Tenant-ID': tenantId },
params: { batch_multiplier: batchMultiplier }
});
return response.data;
}
async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> {
const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
async getRecipeCategories(tenantId: string): Promise<string[]> {
const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data.categories;
}
// Production Management
async getProductionBatches(tenantId: string, params?: ProductionBatchSearchParams): Promise<ProductionBatch[]> {
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches`, {
headers: { 'X-Tenant-ID': tenantId },
params
});
return response.data;
}
async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> {
const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> {
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches`, data, {
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
});
return response.data;
}
async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> {
const response = await apiClient.put<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, data, {
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
});
return response.data;
}
async deleteProductionBatch(tenantId: string, batchId: string): Promise<void> {
await apiClient.delete(`${this.baseUrl}/production/batches/${batchId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
}
async getActiveProductionBatches(tenantId: string): Promise<ProductionBatch[]> {
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches/active/list`, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
async startProductionBatch(tenantId: string, userId: string, batchId: string, data: {
staff_member?: string;
production_notes?: string;
ingredient_consumptions: Array<{
recipe_ingredient_id: string;
ingredient_id: string;
stock_id?: string;
planned_quantity: number;
actual_quantity: number;
unit: string;
consumption_notes?: string;
ingredient_condition?: string;
substitution_used?: boolean;
substitution_details?: string;
}>;
}): Promise<ProductionBatch> {
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}/start`, data, {
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
});
return response.data;
}
async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: {
actual_quantity: number;
quality_score?: number;
quality_notes?: string;
defect_rate?: number;
waste_quantity?: number;
waste_reason?: string;
production_notes?: string;
staff_member?: string;
}): Promise<ProductionBatch> {
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}/complete`, data, {
headers: {
'X-Tenant-ID': tenantId,
'X-User-ID': userId
}
});
return response.data;
}
async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> {
const response = await apiClient.get<ProductionStatistics>(`${this.baseUrl}/production/statistics/dashboard`, {
headers: { 'X-Tenant-ID': tenantId },
params: { start_date: startDate, end_date: endDate }
});
return response.data;
}
}

View File

@@ -0,0 +1,622 @@
// frontend/src/api/services/suppliers.service.ts
/**
* Supplier & Procurement API Service
* Handles all communication with the supplier service backend
*/
import { apiClient } from '../client';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface Supplier {
id: string;
tenant_id: string;
name: string;
supplier_code?: string;
tax_id?: string;
registration_number?: string;
supplier_type: 'INGREDIENTS' | 'PACKAGING' | 'EQUIPMENT' | 'SERVICES' | 'UTILITIES' | 'MULTI';
status: 'ACTIVE' | 'INACTIVE' | 'PENDING_APPROVAL' | 'SUSPENDED' | 'BLACKLISTED';
contact_person?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
// Address
address_line1?: string;
address_line2?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
// Business terms
payment_terms: 'CASH_ON_DELIVERY' | 'NET_15' | 'NET_30' | 'NET_45' | 'NET_60' | 'PREPAID' | 'CREDIT_TERMS';
credit_limit?: number;
currency: string;
standard_lead_time: number;
minimum_order_amount?: number;
delivery_area?: string;
// Performance metrics
quality_rating?: number;
delivery_rating?: number;
total_orders: number;
total_amount: number;
// Approval info
approved_by?: string;
approved_at?: string;
rejection_reason?: string;
// Additional information
notes?: string;
certifications?: Record<string, any>;
business_hours?: Record<string, any>;
specializations?: Record<string, any>;
// Audit fields
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
}
export interface SupplierSummary {
id: string;
name: string;
supplier_code?: string;
supplier_type: string;
status: string;
contact_person?: string;
email?: string;
phone?: string;
city?: string;
country?: string;
quality_rating?: number;
delivery_rating?: number;
total_orders: number;
total_amount: number;
created_at: string;
}
export interface CreateSupplierRequest {
name: string;
supplier_code?: string;
tax_id?: string;
registration_number?: string;
supplier_type: string;
contact_person?: string;
email?: string;
phone?: string;
mobile?: string;
website?: string;
address_line1?: string;
address_line2?: string;
city?: string;
state_province?: string;
postal_code?: string;
country?: string;
payment_terms?: string;
credit_limit?: number;
currency?: string;
standard_lead_time?: number;
minimum_order_amount?: number;
delivery_area?: string;
notes?: string;
certifications?: Record<string, any>;
business_hours?: Record<string, any>;
specializations?: Record<string, any>;
}
export interface UpdateSupplierRequest extends Partial<CreateSupplierRequest> {
status?: string;
}
export interface PurchaseOrder {
id: string;
tenant_id: string;
supplier_id: string;
po_number: string;
reference_number?: string;
status: 'DRAFT' | 'PENDING_APPROVAL' | 'APPROVED' | 'SENT_TO_SUPPLIER' | 'CONFIRMED' | 'PARTIALLY_RECEIVED' | 'COMPLETED' | 'CANCELLED' | 'DISPUTED';
priority: string;
order_date: string;
required_delivery_date?: string;
estimated_delivery_date?: string;
// Financial information
subtotal: number;
tax_amount: number;
shipping_cost: number;
discount_amount: number;
total_amount: number;
currency: string;
// Delivery information
delivery_address?: string;
delivery_instructions?: string;
delivery_contact?: string;
delivery_phone?: string;
// Approval workflow
requires_approval: boolean;
approved_by?: string;
approved_at?: string;
rejection_reason?: string;
// Communication tracking
sent_to_supplier_at?: string;
supplier_confirmation_date?: string;
supplier_reference?: string;
// Additional information
notes?: string;
internal_notes?: string;
terms_and_conditions?: string;
// Audit fields
created_at: string;
updated_at: string;
created_by: string;
updated_by: string;
// Related data
supplier?: SupplierSummary;
items?: PurchaseOrderItem[];
}
export interface PurchaseOrderItem {
id: string;
tenant_id: string;
purchase_order_id: string;
price_list_item_id?: string;
ingredient_id: string;
product_code?: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
unit_price: number;
line_total: number;
received_quantity: number;
remaining_quantity: number;
quality_requirements?: string;
item_notes?: string;
created_at: string;
updated_at: string;
}
export interface CreatePurchaseOrderRequest {
supplier_id: string;
reference_number?: string;
priority?: string;
required_delivery_date?: string;
delivery_address?: string;
delivery_instructions?: string;
delivery_contact?: string;
delivery_phone?: string;
tax_amount?: number;
shipping_cost?: number;
discount_amount?: number;
notes?: string;
internal_notes?: string;
terms_and_conditions?: string;
items: {
ingredient_id: string;
product_code?: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
unit_price: number;
quality_requirements?: string;
item_notes?: string;
}[];
}
export interface Delivery {
id: string;
tenant_id: string;
purchase_order_id: string;
supplier_id: string;
delivery_number: string;
supplier_delivery_note?: string;
status: 'SCHEDULED' | 'IN_TRANSIT' | 'OUT_FOR_DELIVERY' | 'DELIVERED' | 'PARTIALLY_DELIVERED' | 'FAILED_DELIVERY' | 'RETURNED';
// Timing
scheduled_date?: string;
estimated_arrival?: string;
actual_arrival?: string;
completed_at?: string;
// Delivery details
delivery_address?: string;
delivery_contact?: string;
delivery_phone?: string;
carrier_name?: string;
tracking_number?: string;
// Quality inspection
inspection_passed?: boolean;
inspection_notes?: string;
quality_issues?: Record<string, any>;
// Receipt information
received_by?: string;
received_at?: string;
// Additional information
notes?: string;
photos?: Record<string, any>;
// Audit fields
created_at: string;
updated_at: string;
created_by: string;
// Related data
supplier?: SupplierSummary;
purchase_order?: { id: string; po_number: string };
items?: DeliveryItem[];
}
export interface DeliveryItem {
id: string;
tenant_id: string;
delivery_id: string;
purchase_order_item_id: string;
ingredient_id: string;
product_name: string;
ordered_quantity: number;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity: number;
batch_lot_number?: string;
expiry_date?: string;
quality_grade?: string;
quality_issues?: string;
rejection_reason?: string;
item_notes?: string;
created_at: string;
updated_at: string;
}
export interface SupplierSearchParams {
search_term?: string;
supplier_type?: string;
status?: string;
limit?: number;
offset?: number;
}
export interface PurchaseOrderSearchParams {
supplier_id?: string;
status?: string;
priority?: string;
date_from?: string;
date_to?: string;
search_term?: string;
limit?: number;
offset?: number;
}
export interface DeliverySearchParams {
supplier_id?: string;
status?: string;
date_from?: string;
date_to?: string;
search_term?: string;
limit?: number;
offset?: number;
}
export interface SupplierStatistics {
total_suppliers: number;
active_suppliers: number;
pending_suppliers: number;
avg_quality_rating: number;
avg_delivery_rating: number;
total_spend: number;
}
export interface PurchaseOrderStatistics {
total_orders: number;
status_counts: Record<string, number>;
this_month_orders: number;
this_month_spend: number;
avg_order_value: number;
overdue_count: number;
pending_approval: number;
}
export interface DeliveryPerformanceStats {
total_deliveries: number;
on_time_deliveries: number;
late_deliveries: number;
failed_deliveries: number;
on_time_percentage: number;
avg_delay_hours: number;
quality_pass_rate: number;
}
// ============================================================================
// SUPPLIERS SERVICE CLASS
// ============================================================================
export class SuppliersService {
private baseUrl = '/api/v1/suppliers';
// Suppliers CRUD Operations
async getSuppliers(tenantId: string, params?: SupplierSearchParams): Promise<SupplierSummary[]> {
const searchParams = new URLSearchParams();
if (params?.search_term) searchParams.append('search_term', params.search_term);
if (params?.supplier_type) searchParams.append('supplier_type', params.supplier_type);
if (params?.status) searchParams.append('status', params.status);
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const response = await apiClient.get<SupplierSummary[]>(
`${this.baseUrl}?${searchParams.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getSupplier(tenantId: string, supplierId: string): Promise<Supplier> {
const response = await apiClient.get<Supplier>(
`${this.baseUrl}/${supplierId}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async createSupplier(tenantId: string, userId: string, data: CreateSupplierRequest): Promise<Supplier> {
const response = await apiClient.post<Supplier>(
this.baseUrl,
data,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async updateSupplier(tenantId: string, userId: string, supplierId: string, data: UpdateSupplierRequest): Promise<Supplier> {
const response = await apiClient.put<Supplier>(
`${this.baseUrl}/${supplierId}`,
data,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async deleteSupplier(tenantId: string, supplierId: string): Promise<void> {
await apiClient.delete(
`${this.baseUrl}/${supplierId}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
}
async approveSupplier(tenantId: string, userId: string, supplierId: string, action: 'approve' | 'reject', notes?: string): Promise<Supplier> {
const response = await apiClient.post<Supplier>(
`${this.baseUrl}/${supplierId}/approve`,
{ action, notes },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
// Supplier Analytics & Lists
async getSupplierStatistics(tenantId: string): Promise<SupplierStatistics> {
const response = await apiClient.get<SupplierStatistics>(
`${this.baseUrl}/statistics`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getActiveSuppliers(tenantId: string): Promise<SupplierSummary[]> {
const response = await apiClient.get<SupplierSummary[]>(
`${this.baseUrl}/active`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getTopSuppliers(tenantId: string, limit: number = 10): Promise<SupplierSummary[]> {
const response = await apiClient.get<SupplierSummary[]>(
`${this.baseUrl}/top?limit=${limit}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getSuppliersByType(tenantId: string, supplierType: string): Promise<SupplierSummary[]> {
const response = await apiClient.get<SupplierSummary[]>(
`${this.baseUrl}/types/${supplierType}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getSuppliersNeedingReview(tenantId: string, daysSinceLastOrder: number = 30): Promise<SupplierSummary[]> {
const response = await apiClient.get<SupplierSummary[]>(
`${this.baseUrl}/pending-review?days_since_last_order=${daysSinceLastOrder}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
// Purchase Orders
async getPurchaseOrders(tenantId: string, params?: PurchaseOrderSearchParams): Promise<PurchaseOrder[]> {
const searchParams = new URLSearchParams();
if (params?.supplier_id) searchParams.append('supplier_id', params.supplier_id);
if (params?.status) searchParams.append('status', params.status);
if (params?.priority) searchParams.append('priority', params.priority);
if (params?.date_from) searchParams.append('date_from', params.date_from);
if (params?.date_to) searchParams.append('date_to', params.date_to);
if (params?.search_term) searchParams.append('search_term', params.search_term);
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const response = await apiClient.get<PurchaseOrder[]>(
`/api/v1/purchase-orders?${searchParams.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getPurchaseOrder(tenantId: string, poId: string): Promise<PurchaseOrder> {
const response = await apiClient.get<PurchaseOrder>(
`/api/v1/purchase-orders/${poId}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async createPurchaseOrder(tenantId: string, userId: string, data: CreatePurchaseOrderRequest): Promise<PurchaseOrder> {
const response = await apiClient.post<PurchaseOrder>(
'/api/v1/purchase-orders',
data,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async updatePurchaseOrderStatus(tenantId: string, userId: string, poId: string, status: string, notes?: string): Promise<PurchaseOrder> {
const response = await apiClient.patch<PurchaseOrder>(
`/api/v1/purchase-orders/${poId}/status`,
{ status, notes },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async approvePurchaseOrder(tenantId: string, userId: string, poId: string, action: 'approve' | 'reject', notes?: string): Promise<PurchaseOrder> {
const response = await apiClient.post<PurchaseOrder>(
`/api/v1/purchase-orders/${poId}/approve`,
{ action, notes },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async sendToSupplier(tenantId: string, userId: string, poId: string, sendEmail: boolean = true): Promise<PurchaseOrder> {
const response = await apiClient.post<PurchaseOrder>(
`/api/v1/purchase-orders/${poId}/send-to-supplier?send_email=${sendEmail}`,
{},
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async cancelPurchaseOrder(tenantId: string, userId: string, poId: string, reason: string): Promise<PurchaseOrder> {
const response = await apiClient.post<PurchaseOrder>(
`/api/v1/purchase-orders/${poId}/cancel?cancellation_reason=${encodeURIComponent(reason)}`,
{},
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async getPurchaseOrderStatistics(tenantId: string): Promise<PurchaseOrderStatistics> {
const response = await apiClient.get<PurchaseOrderStatistics>(
'/api/v1/purchase-orders/statistics',
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getOrdersRequiringApproval(tenantId: string): Promise<PurchaseOrder[]> {
const response = await apiClient.get<PurchaseOrder[]>(
'/api/v1/purchase-orders/pending-approval',
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getOverdueOrders(tenantId: string): Promise<PurchaseOrder[]> {
const response = await apiClient.get<PurchaseOrder[]>(
'/api/v1/purchase-orders/overdue',
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
// Deliveries
async getDeliveries(tenantId: string, params?: DeliverySearchParams): Promise<Delivery[]> {
const searchParams = new URLSearchParams();
if (params?.supplier_id) searchParams.append('supplier_id', params.supplier_id);
if (params?.status) searchParams.append('status', params.status);
if (params?.date_from) searchParams.append('date_from', params.date_from);
if (params?.date_to) searchParams.append('date_to', params.date_to);
if (params?.search_term) searchParams.append('search_term', params.search_term);
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const response = await apiClient.get<Delivery[]>(
`/api/v1/deliveries?${searchParams.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getDelivery(tenantId: string, deliveryId: string): Promise<Delivery> {
const response = await apiClient.get<Delivery>(
`/api/v1/deliveries/${deliveryId}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getTodaysDeliveries(tenantId: string): Promise<Delivery[]> {
const response = await apiClient.get<Delivery[]>(
'/api/v1/deliveries/today',
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async getOverdueDeliveries(tenantId: string): Promise<Delivery[]> {
const response = await apiClient.get<Delivery[]>(
'/api/v1/deliveries/overdue',
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
async updateDeliveryStatus(tenantId: string, userId: string, deliveryId: string, status: string, notes?: string): Promise<Delivery> {
const response = await apiClient.patch<Delivery>(
`/api/v1/deliveries/${deliveryId}/status`,
{ status, notes, update_timestamps: true },
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async receiveDelivery(tenantId: string, userId: string, deliveryId: string, receiptData: {
inspection_passed?: boolean;
inspection_notes?: string;
quality_issues?: Record<string, any>;
notes?: string;
}): Promise<Delivery> {
const response = await apiClient.post<Delivery>(
`/api/v1/deliveries/${deliveryId}/receive`,
receiptData,
{ headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId } }
);
return response.data;
}
async getDeliveryPerformanceStats(tenantId: string, daysBack: number = 30, supplierId?: string): Promise<DeliveryPerformanceStats> {
const params = new URLSearchParams();
params.append('days_back', daysBack.toString());
if (supplierId) params.append('supplier_id', supplierId);
const response = await apiClient.get<DeliveryPerformanceStats>(
`/api/v1/deliveries/performance-stats?${params.toString()}`,
{ headers: { 'X-Tenant-ID': tenantId } }
);
return response.data;
}
}

View File

@@ -0,0 +1,249 @@
import React from 'react';
import {
Package,
TrendingDown,
AlertTriangle,
Calendar,
BarChart3,
ArrowRight,
Loader,
RefreshCw
} from 'lucide-react';
import { useInventoryDashboard } from '../../api/hooks/useInventory';
interface InventoryDashboardWidgetProps {
onViewInventory?: () => void;
className?: string;
}
const InventoryDashboardWidget: React.FC<InventoryDashboardWidgetProps> = ({
onViewInventory,
className = ''
}) => {
const { dashboardData, alerts, isLoading, error, refresh } = useInventoryDashboard();
// Get alert counts
const criticalAlerts = alerts.filter(a => !a.is_acknowledged && a.severity === 'critical').length;
const lowStockAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'low_stock').length;
const expiringAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'expiring_soon').length;
if (isLoading) {
return (
<div className={`bg-white rounded-xl shadow-sm border p-6 ${className}`}>
<div className="flex items-center space-x-3 mb-4">
<Package className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
</div>
<div className="flex items-center justify-center py-8">
<Loader className="w-6 h-6 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Cargando datos...</span>
</div>
</div>
);
}
if (error) {
return (
<div className={`bg-white rounded-xl shadow-sm border p-6 ${className}`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<Package className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
</div>
<button
onClick={refresh}
className="p-1 hover:bg-gray-100 rounded"
title="Refrescar"
>
<RefreshCw className="w-4 h-4 text-gray-600" />
</button>
</div>
<div className="text-center py-6">
<AlertTriangle className="w-8 h-8 text-red-500 mx-auto mb-2" />
<p className="text-sm text-red-600">Error al cargar datos de inventario</p>
<button
onClick={refresh}
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
>
Reintentar
</button>
</div>
</div>
);
}
return (
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<Package className="w-6 h-6 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
</div>
<div className="flex items-center space-x-2">
<button
onClick={refresh}
className="p-1 hover:bg-gray-100 rounded transition-colors"
title="Refrescar"
>
<RefreshCw className="w-4 h-4 text-gray-600" />
</button>
{onViewInventory && (
<button
onClick={onViewInventory}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<span>Ver todo</span>
<ArrowRight className="w-3 h-3" />
</button>
)}
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{dashboardData?.total_items || 0}
</div>
<div className="text-sm text-gray-600">Total Productos</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{(dashboardData?.total_value || 0).toLocaleString()}
</div>
<div className="text-sm text-gray-600">Valor Total</div>
</div>
</div>
{/* Alerts Summary */}
{criticalAlerts > 0 || lowStockAlerts > 0 || expiringAlerts > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900 flex items-center">
<AlertTriangle className="w-4 h-4 text-amber-500 mr-2" />
Alertas Activas
</h4>
<div className="space-y-2">
{criticalAlerts > 0 && (
<div className="flex items-center justify-between p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
<span className="text-sm text-red-800">Críticas</span>
</div>
<span className="text-sm font-medium text-red-900">{criticalAlerts}</span>
</div>
)}
{lowStockAlerts > 0 && (
<div className="flex items-center justify-between p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center space-x-2">
<TrendingDown className="w-4 h-4 text-yellow-600" />
<span className="text-sm text-yellow-800">Stock Bajo</span>
</div>
<span className="text-sm font-medium text-yellow-900">{lowStockAlerts}</span>
</div>
)}
{expiringAlerts > 0 && (
<div className="flex items-center justify-between p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4 text-orange-600" />
<span className="text-sm text-orange-800">Por Vencer</span>
</div>
<span className="text-sm font-medium text-orange-900">{expiringAlerts}</span>
</div>
)}
</div>
</div>
) : (
<div className="text-center py-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Package className="w-6 h-6 text-green-600" />
</div>
<h4 className="text-sm font-medium text-gray-900 mb-1">Todo en orden</h4>
<p className="text-xs text-gray-600">No hay alertas activas en tu inventario</p>
</div>
)}
{/* Top Categories */}
{dashboardData?.category_breakdown && dashboardData.category_breakdown.length > 0 && (
<div className="mt-6 pt-4 border-t">
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
<BarChart3 className="w-4 h-4 text-gray-600 mr-2" />
Top Categorías por Valor
</h4>
<div className="space-y-2">
{dashboardData.category_breakdown.slice(0, 3).map((category, index) => (
<div key={category.category} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${
index === 0 ? 'bg-blue-500' :
index === 1 ? 'bg-green-500' :
'bg-purple-500'
}`}></div>
<span className="text-sm text-gray-700 capitalize">
{category.category}
</span>
</div>
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{category.value.toLocaleString()}
</div>
<div className="text-xs text-gray-500">
{category.count} productos
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Recent Activity */}
{dashboardData?.recent_movements && dashboardData.recent_movements.length > 0 && (
<div className="mt-6 pt-4 border-t">
<h4 className="text-sm font-medium text-gray-900 mb-3">Actividad Reciente</h4>
<div className="space-y-2">
{dashboardData.recent_movements.slice(0, 3).map((movement) => (
<div key={movement.id} className="flex items-center space-x-3 text-sm">
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
movement.movement_type === 'purchase' ? 'bg-green-100' :
movement.movement_type === 'consumption' ? 'bg-blue-100' :
movement.movement_type === 'waste' ? 'bg-red-100' :
'bg-gray-100'
}`}>
{movement.movement_type === 'purchase' ? '+' :
movement.movement_type === 'consumption' ? '-' :
movement.movement_type === 'waste' ? '×' :
'~'}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">
{movement.item_name || 'Producto'}
</div>
<div className="text-gray-500">
{movement.quantity} {new Date(movement.movement_date).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default InventoryDashboardWidget;

View File

@@ -0,0 +1,424 @@
import React, { useState } from 'react';
import {
Package,
AlertTriangle,
Clock,
Thermometer,
Snowflake,
Calendar,
TrendingDown,
TrendingUp,
Edit3,
Trash2,
Plus,
Minus,
Eye,
MoreVertical
} from 'lucide-react';
import {
InventoryItem,
StockLevel,
ProductType,
StockAdjustmentRequest
} from '../../api/services/inventory.service';
interface InventoryItemCardProps {
item: InventoryItem;
stockLevel?: StockLevel;
compact?: boolean;
showActions?: boolean;
onEdit?: (item: InventoryItem) => void;
onDelete?: (item: InventoryItem) => void;
onViewDetails?: (item: InventoryItem) => void;
onStockAdjust?: (item: InventoryItem, adjustment: StockAdjustmentRequest) => void;
className?: string;
}
const InventoryItemCard: React.FC<InventoryItemCardProps> = ({
item,
stockLevel,
compact = false,
showActions = true,
onEdit,
onDelete,
onViewDetails,
onStockAdjust,
className = ''
}) => {
const [showQuickAdjust, setShowQuickAdjust] = useState(false);
const [adjustmentQuantity, setAdjustmentQuantity] = useState('');
// Get stock status
const getStockStatus = () => {
if (!stockLevel) return null;
const { current_quantity, available_quantity } = stockLevel;
const { minimum_stock_level, reorder_point } = item;
if (current_quantity <= 0) {
return { status: 'out_of_stock', label: 'Sin stock', color: 'red' };
}
if (minimum_stock_level && current_quantity <= minimum_stock_level) {
return { status: 'low_stock', label: 'Stock bajo', color: 'yellow' };
}
if (reorder_point && current_quantity <= reorder_point) {
return { status: 'reorder', label: 'Reordenar', color: 'orange' };
}
return { status: 'good', label: 'Stock OK', color: 'green' };
};
const stockStatus = getStockStatus();
// Get expiration status
const getExpirationStatus = () => {
if (!stockLevel?.batches || stockLevel.batches.length === 0) return null;
const expiredBatches = stockLevel.batches.filter(b => b.is_expired);
const expiringSoon = stockLevel.batches.filter(b =>
!b.is_expired && b.days_until_expiration !== undefined && b.days_until_expiration <= 3
);
if (expiredBatches.length > 0) {
return { status: 'expired', label: 'Vencido', color: 'red' };
}
if (expiringSoon.length > 0) {
return { status: 'expiring', label: 'Por vencer', color: 'yellow' };
}
return null;
};
const expirationStatus = getExpirationStatus();
// Get category display info
const getCategoryInfo = () => {
const categoryLabels: Record<string, string> = {
// Ingredients
flour: 'Harina',
yeast: 'Levadura',
dairy: 'Lácteos',
eggs: 'Huevos',
sugar: 'Azúcar',
fats: 'Grasas',
salt: 'Sal',
spices: 'Especias',
additives: 'Aditivos',
packaging: 'Embalaje',
// Finished Products
bread: 'Pan',
croissants: 'Croissants',
pastries: 'Repostería',
cakes: 'Tartas',
cookies: 'Galletas',
muffins: 'Magdalenas',
sandwiches: 'Sandwiches',
beverages: 'Bebidas',
other_products: 'Otros'
};
return categoryLabels[item.category] || item.category;
};
// Handle quick stock adjustment
const handleQuickAdjust = (type: 'add' | 'remove') => {
if (!adjustmentQuantity || !onStockAdjust) return;
const quantity = parseFloat(adjustmentQuantity);
if (isNaN(quantity) || quantity <= 0) return;
const adjustment: StockAdjustmentRequest = {
movement_type: type === 'add' ? 'purchase' : 'consumption',
quantity: type === 'add' ? quantity : -quantity,
notes: `Quick ${type === 'add' ? 'addition' : 'consumption'} via inventory card`
};
onStockAdjust(item, adjustment);
setAdjustmentQuantity('');
setShowQuickAdjust(false);
};
if (compact) {
return (
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
item.product_type === 'ingredient' ? 'bg-blue-100' : 'bg-green-100'
}`}>
<Package className={`w-5 h-5 ${
item.product_type === 'ingredient' ? 'text-blue-600' : 'text-green-600'
}`} />
</div>
<div>
<h4 className="font-medium text-gray-900">{item.name}</h4>
<p className="text-sm text-gray-500">{getCategoryInfo()}</p>
</div>
</div>
<div className="flex items-center space-x-2">
{stockLevel && (
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{stockLevel.current_quantity} {stockLevel.unit_of_measure}
</div>
{stockStatus && (
<div className={`text-xs px-2 py-1 rounded-full ${
stockStatus.color === 'red' ? 'bg-red-100 text-red-800' :
stockStatus.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
stockStatus.color === 'orange' ? 'bg-orange-100 text-orange-800' :
'bg-green-100 text-green-800'
}`}>
{stockStatus.label}
</div>
)}
</div>
)}
{showActions && onViewDetails && (
<button
onClick={() => onViewDetails(item)}
className="p-1 hover:bg-gray-100 rounded"
>
<Eye className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
</div>
</div>
);
}
return (
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
item.product_type === 'ingredient' ? 'bg-blue-100' : 'bg-green-100'
}`}>
<Package className={`w-6 h-6 ${
item.product_type === 'ingredient' ? 'text-blue-600' : 'text-green-600'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">{item.name}</h3>
{!item.is_active && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
Inactivo
</span>
)}
</div>
<div className="flex items-center space-x-4 text-sm text-gray-600">
<span className={`px-2 py-1 rounded-full text-xs ${
item.product_type === 'ingredient' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
}`}>
{item.product_type === 'ingredient' ? 'Ingrediente' : 'Producto Final'}
</span>
<span>{getCategoryInfo()}</span>
<span>{item.unit_of_measure}</span>
</div>
{/* Special requirements */}
{(item.requires_refrigeration || item.requires_freezing || item.is_seasonal) && (
<div className="flex items-center space-x-2 mt-2">
{item.requires_refrigeration && (
<div className="flex items-center space-x-1 text-xs text-blue-600">
<Thermometer className="w-3 h-3" />
<span>Refrigeración</span>
</div>
)}
{item.requires_freezing && (
<div className="flex items-center space-x-1 text-xs text-blue-600">
<Snowflake className="w-3 h-3" />
<span>Congelación</span>
</div>
)}
{item.is_seasonal && (
<div className="flex items-center space-x-1 text-xs text-amber-600">
<Calendar className="w-3 h-3" />
<span>Estacional</span>
</div>
)}
</div>
)}
</div>
</div>
{showActions && (
<div className="flex items-center space-x-1">
{onEdit && (
<button
onClick={() => onEdit(item)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Editar"
>
<Edit3 className="w-4 h-4 text-gray-600" />
</button>
)}
{onViewDetails && (
<button
onClick={() => onViewDetails(item)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Ver detalles"
>
<Eye className="w-4 h-4 text-gray-600" />
</button>
)}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<MoreVertical className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
</div>
</div>
{/* Stock Information */}
{stockLevel && (
<div className="px-6 pb-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-700">Stock Actual</h4>
{(stockStatus || expirationStatus) && (
<div className="flex items-center space-x-2">
{expirationStatus && (
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
expirationStatus.color === 'red' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'
}`}>
<Clock className="w-3 h-3" />
<span>{expirationStatus.label}</span>
</div>
)}
{stockStatus && (
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
stockStatus.color === 'red' ? 'bg-red-100 text-red-800' :
stockStatus.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
stockStatus.color === 'orange' ? 'bg-orange-100 text-orange-800' :
'bg-green-100 text-green-800'
}`}>
{stockStatus.color === 'red' ? <AlertTriangle className="w-3 h-3" /> :
stockStatus.color === 'green' ? <TrendingUp className="w-3 h-3" /> :
<TrendingDown className="w-3 h-3" />}
<span>{stockStatus.label}</span>
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<div className="text-2xl font-bold text-gray-900">
{stockLevel.current_quantity}
</div>
<div className="text-sm text-gray-500">Cantidad Total</div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">
{stockLevel.available_quantity}
</div>
<div className="text-sm text-gray-500">Disponible</div>
</div>
<div>
<div className="text-2xl font-bold text-amber-600">
{stockLevel.reserved_quantity}
</div>
<div className="text-sm text-gray-500">Reservado</div>
</div>
</div>
{/* Stock Levels */}
{(item.minimum_stock_level || item.reorder_point) && (
<div className="flex items-center justify-between text-sm text-gray-600 mb-4">
{item.minimum_stock_level && (
<span>Mínimo: {item.minimum_stock_level}</span>
)}
{item.reorder_point && (
<span>Reorden: {item.reorder_point}</span>
)}
</div>
)}
{/* Quick Adjust */}
{showActions && onStockAdjust && (
<div className="border-t pt-4">
{!showQuickAdjust ? (
<button
onClick={() => setShowQuickAdjust(true)}
className="w-full px-4 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors text-sm font-medium"
>
Ajustar Stock
</button>
) : (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<input
type="number"
value={adjustmentQuantity}
onChange={(e) => setAdjustmentQuantity(e.target.value)}
placeholder="Cantidad"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="text-sm text-gray-500">{item.unit_of_measure}</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleQuickAdjust('add')}
disabled={!adjustmentQuantity}
className="flex-1 flex items-center justify-center space-x-1 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
<span>Agregar</span>
</button>
<button
onClick={() => handleQuickAdjust('remove')}
disabled={!adjustmentQuantity}
className="flex-1 flex items-center justify-center space-x-1 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Minus className="w-4 h-4" />
<span>Consumir</span>
</button>
<button
onClick={() => {
setShowQuickAdjust(false);
setAdjustmentQuantity('');
}}
className="px-3 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancelar
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* No Stock Data */}
{!stockLevel && (
<div className="px-6 pb-6">
<div className="text-center py-4 border-2 border-dashed border-gray-200 rounded-lg">
<Package className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-500">No hay datos de stock</p>
</div>
</div>
)}
</div>
);
};
export default InventoryItemCard;

View File

@@ -0,0 +1,359 @@
import React, { useState } from 'react';
import {
AlertTriangle,
Clock,
Package,
TrendingDown,
CheckCircle,
X,
Filter,
Bell,
BellOff,
Calendar
} from 'lucide-react';
import { StockAlert } from '../../api/services/inventory.service';
interface StockAlertsPanelProps {
alerts: StockAlert[];
onAcknowledge?: (alertId: string) => void;
onAcknowledgeAll?: (alertIds: string[]) => void;
onViewItem?: (itemId: string) => void;
className?: string;
}
type AlertFilter = 'all' | 'unacknowledged' | 'low_stock' | 'expired' | 'expiring_soon';
const StockAlertsPanel: React.FC<StockAlertsPanelProps> = ({
alerts,
onAcknowledge,
onAcknowledgeAll,
onViewItem,
className = ''
}) => {
const [filter, setFilter] = useState<AlertFilter>('all');
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
// Filter alerts based on current filter
const filteredAlerts = alerts.filter(alert => {
switch (filter) {
case 'unacknowledged':
return !alert.is_acknowledged;
case 'low_stock':
return alert.alert_type === 'low_stock';
case 'expired':
return alert.alert_type === 'expired';
case 'expiring_soon':
return alert.alert_type === 'expiring_soon';
default:
return true;
}
});
// Get alert icon
const getAlertIcon = (alert: StockAlert) => {
switch (alert.alert_type) {
case 'low_stock':
return <TrendingDown className="w-5 h-5" />;
case 'expired':
return <X className="w-5 h-5" />;
case 'expiring_soon':
return <Clock className="w-5 h-5" />;
case 'overstock':
return <Package className="w-5 h-5" />;
default:
return <AlertTriangle className="w-5 h-5" />;
}
};
// Get alert color classes
const getAlertClasses = (alert: StockAlert) => {
const baseClasses = 'border-l-4';
if (alert.is_acknowledged) {
return `${baseClasses} border-gray-300 bg-gray-50`;
}
switch (alert.severity) {
case 'critical':
return `${baseClasses} border-red-500 bg-red-50`;
case 'high':
return `${baseClasses} border-orange-500 bg-orange-50`;
case 'medium':
return `${baseClasses} border-yellow-500 bg-yellow-50`;
case 'low':
return `${baseClasses} border-blue-500 bg-blue-50`;
default:
return `${baseClasses} border-gray-500 bg-gray-50`;
}
};
// Get alert text color
const getAlertTextColor = (alert: StockAlert) => {
if (alert.is_acknowledged) {
return 'text-gray-600';
}
switch (alert.severity) {
case 'critical':
return 'text-red-700';
case 'high':
return 'text-orange-700';
case 'medium':
return 'text-yellow-700';
case 'low':
return 'text-blue-700';
default:
return 'text-gray-700';
}
};
// Get alert icon color
const getAlertIconColor = (alert: StockAlert) => {
if (alert.is_acknowledged) {
return 'text-gray-400';
}
switch (alert.severity) {
case 'critical':
return 'text-red-500';
case 'high':
return 'text-orange-500';
case 'medium':
return 'text-yellow-500';
case 'low':
return 'text-blue-500';
default:
return 'text-gray-500';
}
};
// Handle alert selection
const toggleAlertSelection = (alertId: string) => {
const newSelection = new Set(selectedAlerts);
if (newSelection.has(alertId)) {
newSelection.delete(alertId);
} else {
newSelection.add(alertId);
}
setSelectedAlerts(newSelection);
};
// Handle acknowledge all selected
const handleAcknowledgeSelected = () => {
if (onAcknowledgeAll && selectedAlerts.size > 0) {
onAcknowledgeAll(Array.from(selectedAlerts));
setSelectedAlerts(new Set());
}
};
// Format time ago
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) {
return 'Hace menos de 1 hora';
} else if (diffInHours < 24) {
return `Hace ${diffInHours} horas`;
} else {
const diffInDays = Math.floor(diffInHours / 24);
return `Hace ${diffInDays} días`;
}
};
// Get filter counts
const getFilterCounts = () => {
return {
all: alerts.length,
unacknowledged: alerts.filter(a => !a.is_acknowledged).length,
low_stock: alerts.filter(a => a.alert_type === 'low_stock').length,
expired: alerts.filter(a => a.alert_type === 'expired').length,
expiring_soon: alerts.filter(a => a.alert_type === 'expiring_soon').length,
};
};
const filterCounts = getFilterCounts();
return (
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
{/* Header */}
<div className="p-6 border-b">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Bell className="w-5 h-5 text-gray-600" />
<h2 className="text-lg font-semibold text-gray-900">Alertas de Stock</h2>
{filterCounts.unacknowledged > 0 && (
<span className="bg-red-100 text-red-800 text-xs px-2 py-1 rounded-full">
{filterCounts.unacknowledged} pendientes
</span>
)}
</div>
{selectedAlerts.size > 0 && (
<button
onClick={handleAcknowledgeSelected}
className="flex items-center space-x-1 px-3 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
>
<CheckCircle className="w-4 h-4" />
<span>Confirmar ({selectedAlerts.size})</span>
</button>
)}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
{[
{ key: 'all', label: 'Todas', count: filterCounts.all },
{ key: 'unacknowledged', label: 'Pendientes', count: filterCounts.unacknowledged },
{ key: 'low_stock', label: 'Stock Bajo', count: filterCounts.low_stock },
{ key: 'expired', label: 'Vencidas', count: filterCounts.expired },
{ key: 'expiring_soon', label: 'Por Vencer', count: filterCounts.expiring_soon },
].map(({ key, label, count }) => (
<button
key={key}
onClick={() => setFilter(key as AlertFilter)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filter === key
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{label} ({count})
</button>
))}
</div>
</div>
{/* Alerts List */}
<div className="divide-y">
{filteredAlerts.length === 0 ? (
<div className="p-8 text-center">
<BellOff className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{filter === 'all' ? 'No hay alertas' : 'No hay alertas con este filtro'}
</h3>
<p className="text-gray-500">
{filter === 'all'
? 'Tu inventario está en buen estado'
: 'Prueba con un filtro diferente'
}
</p>
</div>
) : (
filteredAlerts.map((alert) => (
<div
key={alert.id}
className={`p-4 hover:bg-gray-50 transition-colors ${getAlertClasses(alert)}`}
>
<div className="flex items-start space-x-3">
{/* Selection checkbox */}
{!alert.is_acknowledged && (
<input
type="checkbox"
checked={selectedAlerts.has(alert.id)}
onChange={() => toggleAlertSelection(alert.id)}
className="mt-1 w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
)}
{/* Alert Icon */}
<div className={`mt-0.5 ${getAlertIconColor(alert)}`}>
{getAlertIcon(alert)}
</div>
{/* Alert Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h4 className={`font-medium ${getAlertTextColor(alert)}`}>
{alert.item?.name || 'Producto desconocido'}
</h4>
<p className={`text-sm mt-1 ${getAlertTextColor(alert)}`}>
{alert.message}
</p>
{/* Additional Info */}
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
<div className="flex items-center space-x-1">
<Calendar className="w-3 h-3" />
<span>{formatTimeAgo(alert.created_at)}</span>
</div>
{alert.threshold_value && alert.current_value && (
<span>
Umbral: {alert.threshold_value} | Actual: {alert.current_value}
</span>
)}
<span className="capitalize">
Severidad: {alert.severity}
</span>
</div>
{/* Acknowledged Info */}
{alert.is_acknowledged && alert.acknowledged_at && (
<div className="mt-2 text-xs text-gray-500">
<span> Confirmada {formatTimeAgo(alert.acknowledged_at)}</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center space-x-2 ml-4">
{onViewItem && alert.item_id && (
<button
onClick={() => onViewItem(alert.item_id)}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Ver producto
</button>
)}
{!alert.is_acknowledged && onAcknowledge && (
<button
onClick={() => onAcknowledge(alert.id)}
className="text-green-600 hover:text-green-800 text-sm font-medium"
>
Confirmar
</button>
)}
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* Footer with bulk actions */}
{filteredAlerts.length > 0 && filterCounts.unacknowledged > 0 && (
<div className="p-4 border-t bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
{filterCounts.unacknowledged} alertas pendientes
</span>
<button
onClick={() => {
if (onAcknowledgeAll) {
const unacknowledgedIds = alerts
.filter(a => !a.is_acknowledged)
.map(a => a.id);
onAcknowledgeAll(unacknowledgedIds);
}
}}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
Confirmar todas
</button>
</div>
</div>
)}
</div>
);
};
export default StockAlertsPanel;

View File

@@ -0,0 +1,727 @@
import React, { useState, useCallback } from 'react';
import {
Upload,
Brain,
Check,
AlertTriangle,
Loader,
Store,
Factory,
Settings2,
Package,
Coffee,
Wheat,
Eye,
EyeOff,
CheckCircle2,
XCircle,
ArrowRight,
Lightbulb
} from 'lucide-react';
import toast from 'react-hot-toast';
import {
OnboardingAnalysisResult,
InventorySuggestion,
BusinessModelAnalysis,
InventoryCreationResult,
SalesImportResult,
onboardingService
} from '../../api/services/onboarding.service';
interface SmartHistoricalDataImportProps {
tenantId: string;
onComplete: (result: SalesImportResult) => void;
onBack?: () => void;
}
type ImportPhase = 'upload' | 'analysis' | 'review' | 'creation' | 'import' | 'complete';
interface PhaseState {
phase: ImportPhase;
file?: File;
analysisResult?: OnboardingAnalysisResult;
reviewedSuggestions?: InventorySuggestion[];
creationResult?: InventoryCreationResult;
importResult?: SalesImportResult;
error?: string;
}
const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
tenantId,
onComplete,
onBack
}) => {
const [state, setState] = useState<PhaseState>({ phase: 'upload' });
const [isProcessing, setIsProcessing] = useState(false);
const [showAllSuggestions, setShowAllSuggestions] = useState(false);
const handleFileUpload = useCallback(async (file: File) => {
setState(prev => ({ ...prev, file, phase: 'analysis' }));
setIsProcessing(true);
try {
toast.loading('🧠 Analizando tu archivo con IA...', { id: 'analysis' });
const analysisResult = await onboardingService.analyzeSalesDataForOnboarding(tenantId, file);
toast.success(`¡Análisis completado! ${analysisResult.total_products_found} productos encontrados`, {
id: 'analysis'
});
setState(prev => ({
...prev,
analysisResult,
reviewedSuggestions: analysisResult.inventory_suggestions.map(s => ({
...s,
user_approved: s.confidence_score >= 0.7
})),
phase: 'review'
}));
} catch (error: any) {
toast.error('Error al analizar el archivo', { id: 'analysis' });
setState(prev => ({
...prev,
error: error.message || 'Error desconocido',
phase: 'upload'
}));
} finally {
setIsProcessing(false);
}
}, [tenantId]);
const handleSuggestionUpdate = useCallback((suggestionId: string, updates: Partial<InventorySuggestion>) => {
setState(prev => ({
...prev,
reviewedSuggestions: prev.reviewedSuggestions?.map(s =>
s.suggestion_id === suggestionId ? { ...s, ...updates } : s
)
}));
}, []);
const handleCreateInventory = useCallback(async () => {
if (!state.reviewedSuggestions) return;
setState(prev => ({ ...prev, phase: 'creation' }));
setIsProcessing(true);
try {
const approvedSuggestions = state.reviewedSuggestions.filter(s => s.user_approved);
if (approvedSuggestions.length === 0) {
toast.error('Debes aprobar al menos un producto para continuar');
setState(prev => ({ ...prev, phase: 'review' }));
setIsProcessing(false);
return;
}
toast.loading(`Creando ${approvedSuggestions.length} productos en tu inventario...`, { id: 'creation' });
const creationResult = await onboardingService.createInventoryFromSuggestions(
tenantId,
approvedSuggestions
);
toast.success(`¡${creationResult.created_items.length} productos creados exitosamente!`, {
id: 'creation'
});
setState(prev => ({ ...prev, creationResult, phase: 'import' }));
// Auto-proceed to final import
setTimeout(() => handleFinalImport(creationResult), 1500);
} catch (error: any) {
toast.error('Error al crear productos en inventario', { id: 'creation' });
setState(prev => ({
...prev,
error: error.message || 'Error al crear inventario',
phase: 'review'
}));
} finally {
setIsProcessing(false);
}
}, [state.reviewedSuggestions, tenantId]);
const handleFinalImport = useCallback(async (creationResult?: InventoryCreationResult) => {
if (!state.file || !state.reviewedSuggestions) return;
const currentCreationResult = creationResult || state.creationResult;
if (!currentCreationResult) return;
setIsProcessing(true);
try {
// Create mapping from product names to inventory IDs
const inventoryMapping: Record<string, string> = {};
currentCreationResult.created_items.forEach(item => {
// Find the original suggestion that created this item
const suggestion = state.reviewedSuggestions!.find(s =>
s.suggested_name === item.name || s.original_name === item.original_name
);
if (suggestion) {
inventoryMapping[suggestion.original_name] = item.id;
}
});
toast.loading('Importando datos históricos con inventario...', { id: 'import' });
const importResult = await onboardingService.importSalesWithInventory(
tenantId,
state.file,
inventoryMapping
);
toast.success(
`¡Importación completada! ${importResult.successful_imports} registros importados`,
{ id: 'import' }
);
setState(prev => ({ ...prev, importResult, phase: 'complete' }));
// Complete the process
setTimeout(() => onComplete(importResult), 2000);
} catch (error: any) {
toast.error('Error en importación final', { id: 'import' });
setState(prev => ({
...prev,
error: error.message || 'Error en importación final',
phase: 'creation'
}));
} finally {
setIsProcessing(false);
}
}, [state.file, state.reviewedSuggestions, state.creationResult, tenantId, onComplete]);
const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => {
const modelConfig = {
production: {
icon: Factory,
title: 'Panadería de Producción',
description: 'Produces items from raw ingredients',
color: 'blue',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
textColor: 'text-blue-900'
},
retail: {
icon: Store,
title: 'Panadería de Distribución',
description: 'Sells finished products from suppliers',
color: 'green',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-900'
},
hybrid: {
icon: Settings2,
title: 'Modelo Híbrido',
description: 'Both produces and distributes products',
color: 'purple',
bgColor: 'bg-purple-50',
borderColor: 'border-purple-200',
textColor: 'text-purple-900'
}
};
const config = modelConfig[analysis.model];
const IconComponent = config.icon;
return (
<div className={`${config.bgColor} ${config.borderColor} border rounded-xl p-6 mb-6`}>
<div className="flex items-start space-x-4">
<div className={`w-12 h-12 ${config.bgColor} rounded-lg flex items-center justify-center`}>
<IconComponent className={`w-6 h-6 text-${config.color}-600`} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h3 className={`font-semibold ${config.textColor}`}>{config.title}</h3>
<span className={`px-3 py-1 bg-white rounded-full text-sm font-medium text-${config.color}-600`}>
{Math.round(analysis.confidence * 100)}% confianza
</span>
</div>
<p className={`text-sm ${config.textColor} mb-3`}>{config.description}</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="flex items-center space-x-2">
<Wheat className="w-4 h-4 text-amber-500" />
<span className="text-sm text-gray-700">
{analysis.ingredient_count} ingredientes
</span>
</div>
<div className="flex items-center space-x-2">
<Coffee className="w-4 h-4 text-brown-500" />
<span className="text-sm text-gray-700">
{analysis.finished_product_count} productos finales
</span>
</div>
</div>
{analysis.recommendations.length > 0 && (
<div>
<h4 className={`text-sm font-medium ${config.textColor} mb-2`}>
Recomendaciones personalizadas:
</h4>
<ul className="space-y-1">
{analysis.recommendations.slice(0, 2).map((rec, idx) => (
<li key={idx} className={`text-sm ${config.textColor} flex items-center space-x-2`}>
<Lightbulb className="w-3 h-3" />
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
);
};
const renderSuggestionCard = (suggestion: InventorySuggestion) => {
const isHighConfidence = suggestion.confidence_score >= 0.7;
const isMediumConfidence = suggestion.confidence_score >= 0.4;
return (
<div
key={suggestion.suggestion_id}
className={`border rounded-lg p-4 transition-all ${
suggestion.user_approved
? 'border-green-300 bg-green-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center space-x-3">
<button
onClick={() => handleSuggestionUpdate(suggestion.suggestion_id, {
user_approved: !suggestion.user_approved
})}
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
suggestion.user_approved
? 'bg-green-500 border-green-500 text-white'
: 'border-gray-300 hover:border-green-300'
}`}
>
{suggestion.user_approved && <Check className="w-3 h-3" />}
</button>
<div>
<h4 className="font-medium text-gray-900">{suggestion.suggested_name}</h4>
{suggestion.original_name !== suggestion.suggested_name && (
<p className="text-sm text-gray-500">"{suggestion.original_name}"</p>
)}
</div>
</div>
</div>
<div className="text-right">
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
isHighConfidence ? 'bg-green-100 text-green-800' :
isMediumConfidence ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{Math.round(suggestion.confidence_score * 100)}% confianza
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Tipo:</span>
<span className="ml-2 font-medium">
{suggestion.product_type === 'ingredient' ? 'Ingrediente' : 'Producto Final'}
</span>
</div>
<div>
<span className="text-gray-500">Categoría:</span>
<span className="ml-2 font-medium">{suggestion.category}</span>
</div>
<div>
<span className="text-gray-500">Unidad:</span>
<span className="ml-2 font-medium">{suggestion.unit_of_measure}</span>
</div>
{suggestion.estimated_shelf_life_days && (
<div>
<span className="text-gray-500">Duración:</span>
<span className="ml-2 font-medium">{suggestion.estimated_shelf_life_days} días</span>
</div>
)}
</div>
{(suggestion.requires_refrigeration || suggestion.requires_freezing || suggestion.is_seasonal) && (
<div className="mt-3 flex flex-wrap gap-2">
{suggestion.requires_refrigeration && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
Refrigeración
</span>
)}
{suggestion.requires_freezing && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
🧊 Congelación
</span>
)}
{suggestion.is_seasonal && (
<span className="px-2 py-1 bg-orange-100 text-orange-800 text-xs rounded-full">
🍂 Estacional
</span>
)}
</div>
)}
{!isHighConfidence && suggestion.notes && (
<div className="mt-3 p-2 bg-amber-50 border border-amber-200 rounded text-xs text-amber-800">
💡 {suggestion.notes}
</div>
)}
</div>
);
};
// Main render logic based on current phase
switch (state.phase) {
case 'upload':
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-4">
<Brain className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Importación Inteligente de Datos
</h2>
<p className="text-gray-600">
Nuestra IA analizará tus datos históricos y creará automáticamente tu inventario
</p>
</div>
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
🚀 ¿Cómo funciona la magia?
</h3>
<div className="grid md:grid-cols-3 gap-4">
<div className="text-center">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-3">
<Upload className="w-6 h-6 text-white" />
</div>
<div className="font-medium text-gray-900">1. Subes tu archivo</div>
<div className="text-sm text-gray-600 mt-1">CSV, Excel o JSON</div>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center mx-auto mb-3">
<Brain className="w-6 h-6 text-white" />
</div>
<div className="font-medium text-gray-900">2. IA analiza productos</div>
<div className="text-sm text-gray-600 mt-1">Clasificación inteligente</div>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-3">
<Package className="w-6 h-6 text-white" />
</div>
<div className="font-medium text-gray-900">3. Inventario listo</div>
<div className="text-sm text-gray-600 mt-1">Con categorías y detalles</div>
</div>
</div>
</div>
<div className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center hover:border-blue-300 transition-colors">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<label htmlFor="smart-upload" className="cursor-pointer">
<span className="text-lg font-medium text-gray-900 block mb-2">
Sube tu archivo de datos históricos
</span>
<span className="text-gray-600">
Arrastra tu archivo aquí o haz clic para seleccionar
</span>
<span className="block text-sm text-gray-400 mt-2">
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
</span>
</label>
<input
id="smart-upload"
type="file"
accept=".csv,.xlsx,.xls,.json"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 10 * 1024 * 1024) {
toast.error('El archivo es demasiado grande. Máximo 10MB.');
return;
}
handleFileUpload(file);
}
}}
className="hidden"
disabled={isProcessing}
/>
</div>
{state.error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex">
<XCircle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<p className="text-sm text-red-700 mt-1">{state.error}</p>
</div>
</div>
</div>
)}
</div>
);
case 'analysis':
return (
<div className="text-center py-12">
<div className="w-20 h-20 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
<Brain className="w-10 h-10 text-white" />
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-3">
🧠 Analizando tu archivo con IA...
</h2>
<p className="text-gray-600 mb-6">
Esto puede tomar unos momentos mientras clasificamos tus productos
</p>
<div className="bg-white rounded-lg shadow-sm p-4 max-w-md mx-auto">
<div className="flex items-center justify-between text-sm text-gray-600">
<span>Archivo:</span>
<span className="font-medium">{state.file?.name}</span>
</div>
<div className="mt-2 bg-gray-200 rounded-full h-2">
<div className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full w-1/2 animate-pulse"></div>
</div>
</div>
</div>
);
case 'review':
if (!state.analysisResult) return null;
const { analysisResult, reviewedSuggestions } = state;
const approvedCount = reviewedSuggestions?.filter(s => s.user_approved).length || 0;
const highConfidenceCount = reviewedSuggestions?.filter(s => s.confidence_score >= 0.7).length || 0;
const visibleSuggestions = showAllSuggestions
? reviewedSuggestions
: reviewedSuggestions?.slice(0, 6);
return (
<div className="space-y-6">
<div className="text-center">
<div className="w-16 h-16 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
¡Análisis Completado! 🎉
</h2>
<p className="text-gray-600">
Hemos encontrado <strong>{analysisResult.total_products_found} productos</strong> y
sugerimos <strong>{approvedCount} para tu inventario</strong>
</p>
</div>
{renderBusinessModelInsight(analysisResult.business_model_analysis)}
<div className="bg-white border rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Productos Sugeridos para tu Inventario
</h3>
<p className="text-sm text-gray-600">
{highConfidenceCount} con alta confianza {approvedCount} pre-aprobados
</p>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => {
const allApproved = approvedCount === reviewedSuggestions?.length;
setState(prev => ({
...prev,
reviewedSuggestions: prev.reviewedSuggestions?.map(s => ({
...s,
user_approved: !allApproved
}))
}));
}}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
>
{approvedCount === reviewedSuggestions?.length ? 'Desaprobar todos' : 'Aprobar todos'}
</button>
{(reviewedSuggestions?.length || 0) > 6 && (
<button
onClick={() => setShowAllSuggestions(!showAllSuggestions)}
className="flex items-center px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg"
>
{showAllSuggestions ? (
<>
<EyeOff className="w-4 h-4 mr-1" />
Ver menos
</>
) : (
<>
<Eye className="w-4 h-4 mr-1" />
Ver todos ({reviewedSuggestions?.length})
</>
)}
</button>
)}
</div>
</div>
<div className="grid gap-4 mb-6">
{visibleSuggestions?.map(renderSuggestionCard)}
</div>
{analysisResult.warnings.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-amber-400" />
<div className="ml-3">
<h4 className="text-sm font-medium text-amber-800">Advertencias</h4>
<ul className="mt-2 text-sm text-amber-700 space-y-1">
{analysisResult.warnings.map((warning, idx) => (
<li key={idx}> {warning}</li>
))}
</ul>
</div>
</div>
</div>
)}
<div className="flex justify-between items-center">
{onBack && (
<button
onClick={onBack}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Volver
</button>
)}
<button
onClick={handleCreateInventory}
disabled={approvedCount === 0 || isProcessing}
className="flex items-center px-6 py-3 bg-gradient-to-r from-green-500 to-blue-500 text-white rounded-xl hover:from-green-600 hover:to-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isProcessing ? (
<>
<Loader className="w-5 h-5 mr-2 animate-spin" />
Creando inventario...
</>
) : (
<>
Crear inventario ({approvedCount} productos)
<ArrowRight className="w-5 h-5 ml-2" />
</>
)}
</button>
</div>
</div>
</div>
);
case 'creation':
case 'import':
const isCreating = state.phase === 'creation';
return (
<div className="text-center py-12">
<div className="w-20 h-20 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
{isCreating ? (
<Package className="w-10 h-10 text-white" />
) : (
<Upload className="w-10 h-10 text-white" />
)}
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-3">
{isCreating ? '📦 Creando productos en tu inventario...' : '📊 Importando datos históricos...'}
</h2>
<p className="text-gray-600 mb-6">
{isCreating
? 'Configurando cada producto con sus detalles específicos'
: 'Vinculando tus ventas históricas con el nuevo inventario'
}
</p>
<div className="bg-white rounded-lg shadow-sm p-6 max-w-md mx-auto">
{state.creationResult && (
<div className="mb-4">
<div className="flex items-center justify-center space-x-2 text-green-600 mb-2">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">
{state.creationResult.created_items.length} productos creados
</span>
</div>
</div>
)}
<div className="bg-gray-200 rounded-full h-3">
<div className="bg-gradient-to-r from-green-400 to-blue-500 h-3 rounded-full w-3/4 animate-pulse"></div>
</div>
<p className="text-sm text-gray-500 mt-2">
{isCreating ? 'Creando inventario...' : 'Procesando importación final...'}
</p>
</div>
</div>
);
case 'complete':
if (!state.importResult || !state.creationResult) return null;
return (
<div className="text-center py-12">
<div className="w-24 h-24 bg-gradient-to-r from-green-400 to-green-600 rounded-full flex items-center justify-center mx-auto mb-6 animate-bounce">
<CheckCircle2 className="w-12 h-12 text-white" />
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-4">
¡Importación Completada! 🎉
</h2>
<p className="text-xl text-gray-600 mb-8">
Tu inventario inteligente está listo
</p>
<div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-3xl p-8 max-w-2xl mx-auto">
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
<Package className="w-8 h-8 text-white" />
</div>
<div className="text-2xl font-bold text-green-600">
{state.creationResult.created_items.length}
</div>
<div className="text-sm text-gray-600">Productos en inventario</div>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-4">
<Upload className="w-8 h-8 text-white" />
</div>
<div className="text-2xl font-bold text-blue-600">
{state.importResult.successful_imports}
</div>
<div className="text-sm text-gray-600">Registros históricos</div>
</div>
</div>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
Tu IA está lista para predecir la demanda con precisión
</p>
</div>
</div>
</div>
);
default:
return null;
}
};
export default SmartHistoricalDataImport;

View File

@@ -0,0 +1,323 @@
// frontend/src/components/recipes/IngredientList.tsx
import React from 'react';
import {
Plus,
Minus,
Edit2,
Trash2,
GripVertical,
Info,
AlertCircle,
Package,
Droplets,
Scale,
Euro
} from 'lucide-react';
import { RecipeIngredient } from '../../api/services/recipes.service';
interface IngredientListProps {
ingredients: RecipeIngredient[];
editable?: boolean;
showCosts?: boolean;
showGroups?: boolean;
batchMultiplier?: number;
onAddIngredient?: () => void;
onEditIngredient?: (ingredient: RecipeIngredient) => void;
onRemoveIngredient?: (ingredientId: string) => void;
onReorderIngredients?: (ingredients: RecipeIngredient[]) => void;
className?: string;
}
const IngredientList: React.FC<IngredientListProps> = ({
ingredients,
editable = false,
showCosts = false,
showGroups = true,
batchMultiplier = 1,
onAddIngredient,
onEditIngredient,
onRemoveIngredient,
onReorderIngredients,
className = ''
}) => {
// Group ingredients by ingredient_group
const groupedIngredients = React.useMemo(() => {
if (!showGroups) {
return { 'All Ingredients': ingredients };
}
const groups: Record<string, RecipeIngredient[]> = {};
ingredients.forEach(ingredient => {
const group = ingredient.ingredient_group || 'Other';
if (!groups[group]) {
groups[group] = [];
}
groups[group].push(ingredient);
});
// Sort ingredients within each group by order
Object.keys(groups).forEach(group => {
groups[group].sort((a, b) => a.ingredient_order - b.ingredient_order);
});
return groups;
}, [ingredients, showGroups]);
// Get unit icon
const getUnitIcon = (unit: string) => {
switch (unit.toLowerCase()) {
case 'g':
case 'kg':
return <Scale className="w-4 h-4" />;
case 'ml':
case 'l':
return <Droplets className="w-4 h-4" />;
case 'units':
case 'pieces':
case 'pcs':
return <Package className="w-4 h-4" />;
default:
return <Scale className="w-4 h-4" />;
}
};
// Format quantity with multiplier
const formatQuantity = (quantity: number, unit: string) => {
const adjustedQuantity = quantity * batchMultiplier;
return `${adjustedQuantity} ${unit}`;
};
// Calculate total cost
const getTotalCost = () => {
return ingredients.reduce((total, ingredient) => {
const cost = ingredient.total_cost || 0;
return total + (cost * batchMultiplier);
}, 0);
};
return (
<div className={`bg-white rounded-lg border ${className}`}>
{/* Header */}
<div className="p-4 border-b bg-gray-50 rounded-t-lg">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Ingredients</h3>
<p className="text-sm text-gray-600">
{ingredients.length} ingredient{ingredients.length !== 1 ? 's' : ''}
{batchMultiplier !== 1 && (
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
×{batchMultiplier} batch
</span>
)}
</p>
</div>
<div className="flex items-center space-x-2">
{showCosts && (
<div className="text-right">
<div className="text-sm text-gray-600">Total Cost</div>
<div className="text-lg font-semibold text-gray-900 flex items-center">
<Euro className="w-4 h-4 mr-1" />
{getTotalCost().toFixed(2)}
</div>
</div>
)}
{editable && onAddIngredient && (
<button
onClick={onAddIngredient}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<Plus className="w-4 h-4" />
<span>Add Ingredient</span>
</button>
)}
</div>
</div>
</div>
{/* Ingredients List */}
<div className="divide-y">
{Object.entries(groupedIngredients).map(([groupName, groupIngredients]) => (
<div key={groupName}>
{/* Group Header */}
{showGroups && Object.keys(groupedIngredients).length > 1 && (
<div className="px-4 py-2 bg-gray-25 border-b">
<h4 className="text-sm font-medium text-gray-700 uppercase tracking-wide">
{groupName}
</h4>
</div>
)}
{/* Group Ingredients */}
{groupIngredients.map((ingredient, index) => (
<div
key={ingredient.id}
className="p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center space-x-4">
{/* Drag Handle */}
{editable && onReorderIngredients && (
<div className="cursor-move text-gray-400 hover:text-gray-600">
<GripVertical className="w-4 h-4" />
</div>
)}
{/* Order Number */}
<div className="w-6 h-6 bg-gray-200 rounded-full flex items-center justify-center text-xs font-medium text-gray-600">
{ingredient.ingredient_order}
</div>
{/* Ingredient Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h4 className="font-medium text-gray-900">
{ingredient.ingredient_id} {/* This would be ingredient name from inventory */}
</h4>
{ingredient.is_optional && (
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
Optional
</span>
)}
</div>
{/* Quantity */}
<div className="flex items-center space-x-2 text-sm text-gray-600">
{getUnitIcon(ingredient.unit)}
<span className="font-medium">
{formatQuantity(ingredient.quantity, ingredient.unit)}
</span>
{ingredient.alternative_quantity && ingredient.alternative_unit && (
<span className="text-gray-500">
( {formatQuantity(ingredient.alternative_quantity, ingredient.alternative_unit)})
</span>
)}
</div>
{/* Preparation Method */}
{ingredient.preparation_method && (
<div className="text-sm text-gray-600 mt-1">
<span className="font-medium">Prep:</span> {ingredient.preparation_method}
</div>
)}
{/* Notes */}
{ingredient.ingredient_notes && (
<div className="text-sm text-gray-600 mt-1 flex items-start">
<Info className="w-3 h-3 mr-1 mt-0.5 flex-shrink-0" />
<span>{ingredient.ingredient_notes}</span>
</div>
)}
{/* Substitutions */}
{ingredient.substitution_options && (
<div className="text-sm text-blue-600 mt-1">
<span className="font-medium">Substitutions available</span>
</div>
)}
</div>
{/* Cost */}
{showCosts && (
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{((ingredient.total_cost || 0) * batchMultiplier).toFixed(2)}
</div>
{ingredient.unit_cost && (
<div className="text-xs text-gray-600">
{ingredient.unit_cost.toFixed(2)}/{ingredient.unit}
</div>
)}
{ingredient.cost_updated_at && (
<div className="text-xs text-gray-500">
{new Date(ingredient.cost_updated_at).toLocaleDateString()}
</div>
)}
</div>
)}
{/* Actions */}
{editable && (
<div className="flex items-center space-x-2">
<button
onClick={() => onEditIngredient?.(ingredient)}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="Edit ingredient"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => onRemoveIngredient?.(ingredient.id)}
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
title="Remove ingredient"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
))}
{/* Empty State */}
{ingredients.length === 0 && (
<div className="p-8 text-center">
<Package className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No ingredients yet</h3>
<p className="text-gray-600 mb-4">
Add ingredients to start building your recipe
</p>
{editable && onAddIngredient && (
<button
onClick={onAddIngredient}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Add First Ingredient
</button>
)}
</div>
)}
</div>
{/* Summary */}
{ingredients.length > 0 && (
<div className="p-4 bg-gray-50 border-t rounded-b-lg">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-4">
<span className="text-gray-600">
{ingredients.length} total ingredients
</span>
{ingredients.filter(i => i.is_optional).length > 0 && (
<span className="text-yellow-600">
{ingredients.filter(i => i.is_optional).length} optional
</span>
)}
{ingredients.some(i => i.substitution_options) && (
<span className="text-blue-600">
{ingredients.filter(i => i.substitution_options).length} with substitutions
</span>
)}
</div>
{showCosts && (
<div className="font-medium text-gray-900">
Total: {getTotalCost().toFixed(2)}
</div>
)}
</div>
</div>
)}
</div>
);
};
export default IngredientList;

View File

@@ -0,0 +1,547 @@
// frontend/src/components/recipes/ProductionBatchCard.tsx
import React, { useState } from 'react';
import {
Clock,
Users,
Play,
Pause,
CheckCircle,
XCircle,
AlertTriangle,
BarChart3,
Thermometer,
Target,
TrendingUp,
TrendingDown,
Calendar,
Package,
Star,
MoreVertical,
Eye,
Edit,
Euro
} from 'lucide-react';
import { ProductionBatch } from '../../api/services/recipes.service';
interface ProductionBatchCardProps {
batch: ProductionBatch;
compact?: boolean;
showActions?: boolean;
onView?: (batch: ProductionBatch) => void;
onEdit?: (batch: ProductionBatch) => void;
onStart?: (batch: ProductionBatch) => void;
onComplete?: (batch: ProductionBatch) => void;
onCancel?: (batch: ProductionBatch) => void;
className?: string;
}
const ProductionBatchCard: React.FC<ProductionBatchCardProps> = ({
batch,
compact = false,
showActions = true,
onView,
onEdit,
onStart,
onComplete,
onCancel,
className = ''
}) => {
const [showMenu, setShowMenu] = useState(false);
// Status styling
const getStatusColor = (status: string) => {
switch (status) {
case 'planned':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'in_progress':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'completed':
return 'bg-green-100 text-green-800 border-green-200';
case 'failed':
return 'bg-red-100 text-red-800 border-red-200';
case 'cancelled':
return 'bg-gray-100 text-gray-800 border-gray-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
// Priority styling
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent':
return 'bg-red-100 text-red-800';
case 'high':
return 'bg-orange-100 text-orange-800';
case 'normal':
return 'bg-gray-100 text-gray-800';
case 'low':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// Status icon
const getStatusIcon = (status: string) => {
switch (status) {
case 'planned':
return <Clock className="w-4 h-4" />;
case 'in_progress':
return <Play className="w-4 h-4" />;
case 'completed':
return <CheckCircle className="w-4 h-4" />;
case 'failed':
return <XCircle className="w-4 h-4" />;
case 'cancelled':
return <Pause className="w-4 h-4" />;
default:
return <Clock className="w-4 h-4" />;
}
};
// Format time
const formatTime = (dateString?: string) => {
if (!dateString) return null;
return new Date(dateString).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
};
// Calculate progress percentage
const getProgressPercentage = () => {
if (batch.status === 'completed') return 100;
if (batch.status === 'failed' || batch.status === 'cancelled') return 0;
if (batch.status === 'in_progress') {
// Calculate based on time if available
if (batch.actual_start_time && batch.planned_end_time) {
const start = new Date(batch.actual_start_time).getTime();
const end = new Date(batch.planned_end_time).getTime();
const now = Date.now();
const progress = ((now - start) / (end - start)) * 100;
return Math.min(Math.max(progress, 0), 100);
}
return 50; // Default for in progress
}
return 0;
};
const progress = getProgressPercentage();
if (compact) {
return (
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 flex-1 min-w-0">
{/* Batch Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h3 className="font-medium text-gray-900">{batch.batch_number}</h3>
{batch.priority !== 'normal' && (
<span className={`px-2 py-1 rounded text-xs ${getPriorityColor(batch.priority)}`}>
{batch.priority}
</span>
)}
</div>
<div className="flex items-center space-x-3 text-sm text-gray-600">
<span className={`px-2 py-1 rounded-full text-xs border flex items-center ${getStatusColor(batch.status)}`}>
{getStatusIcon(batch.status)}
<span className="ml-1">{batch.status}</span>
</span>
<div className="flex items-center">
<Calendar className="w-3 h-3 mr-1" />
{new Date(batch.production_date).toLocaleDateString()}
</div>
<div className="flex items-center">
<Package className="w-3 h-3 mr-1" />
{batch.planned_quantity} units
</div>
</div>
</div>
{/* Progress */}
<div className="w-24">
<div className="w-full bg-gray-200 rounded-full h-2 mb-1">
<div
className={`h-2 rounded-full transition-all duration-300 ${
batch.status === 'completed' ? 'bg-green-500' :
batch.status === 'failed' ? 'bg-red-500' :
batch.status === 'in_progress' ? 'bg-yellow-500' :
'bg-blue-500'
}`}
style={{ width: `${progress}%` }}
></div>
</div>
<div className="text-xs text-gray-600 text-center">
{Math.round(progress)}%
</div>
</div>
{/* Yield */}
{batch.actual_quantity && (
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{batch.actual_quantity} / {batch.planned_quantity}
</div>
{batch.yield_percentage && (
<div className={`text-xs flex items-center justify-end ${
batch.yield_percentage >= 95 ? 'text-green-600' :
batch.yield_percentage >= 80 ? 'text-yellow-600' :
'text-red-600'
}`}>
{batch.yield_percentage >= 100 ? (
<TrendingUp className="w-3 h-3 mr-1" />
) : (
<TrendingDown className="w-3 h-3 mr-1" />
)}
{batch.yield_percentage.toFixed(1)}%
</div>
)}
</div>
)}
</div>
{/* Actions */}
{showActions && (
<div className="flex items-center space-x-2 ml-4">
<button
onClick={() => onView?.(batch)}
className="p-1 text-gray-600 hover:text-blue-600"
>
<Eye className="w-4 h-4" />
</button>
{batch.status === 'planned' && (
<button
onClick={() => onStart?.(batch)}
className="p-1 text-gray-600 hover:text-green-600"
>
<Play className="w-4 h-4" />
</button>
)}
{batch.status === 'in_progress' && (
<button
onClick={() => onComplete?.(batch)}
className="p-1 text-gray-600 hover:text-green-600"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
</div>
);
}
return (
<div className={`bg-white rounded-xl border shadow-sm hover:shadow-md transition-shadow ${className}`}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-lg font-semibold text-gray-900">{batch.batch_number}</h3>
{batch.priority !== 'normal' && (
<span className={`px-2 py-1 rounded text-sm ${getPriorityColor(batch.priority)}`}>
{batch.priority} priority
</span>
)}
</div>
{batch.production_notes && (
<p className="text-sm text-gray-600 line-clamp-2">{batch.production_notes}</p>
)}
</div>
{showActions && (
<div className="relative ml-4">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<MoreVertical className="w-5 h-5" />
</button>
{showMenu && (
<div className="absolute right-0 top-8 bg-white border rounded-lg shadow-lg py-1 z-10 min-w-[140px]">
<button
onClick={() => {
onView?.(batch);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
>
<Eye className="w-4 h-4 mr-2" />
View Batch
</button>
<button
onClick={() => {
onEdit?.(batch);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
>
<Edit className="w-4 h-4 mr-2" />
Edit Batch
</button>
{batch.status === 'planned' && (
<button
onClick={() => {
onStart?.(batch);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-green-600"
>
<Play className="w-4 h-4 mr-2" />
Start Production
</button>
)}
{batch.status === 'in_progress' && (
<button
onClick={() => {
onComplete?.(batch);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-green-600"
>
<CheckCircle className="w-4 h-4 mr-2" />
Complete Batch
</button>
)}
{(batch.status === 'planned' || batch.status === 'in_progress') && (
<button
onClick={() => {
onCancel?.(batch);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-red-600"
>
<XCircle className="w-4 h-4 mr-2" />
Cancel Batch
</button>
)}
</div>
)}
</div>
)}
</div>
{/* Status & Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className={`px-3 py-1 rounded-full text-sm border flex items-center ${getStatusColor(batch.status)}`}>
{getStatusIcon(batch.status)}
<span className="ml-2">{batch.status}</span>
</span>
<div className="text-sm text-gray-600">
{Math.round(progress)}% complete
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
batch.status === 'completed' ? 'bg-green-500' :
batch.status === 'failed' ? 'bg-red-500' :
batch.status === 'in_progress' ? 'bg-yellow-500' :
'bg-blue-500'
}`}
style={{ width: `${progress}%` }}
></div>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg font-semibold text-gray-900">
{batch.actual_quantity || batch.planned_quantity} / {batch.planned_quantity}
</div>
<div className="text-sm text-gray-600">Quantity</div>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg font-semibold text-gray-900">
{batch.yield_percentage ? (
<>
{batch.yield_percentage.toFixed(1)}%
{batch.yield_percentage >= 100 ? (
<TrendingUp className="w-4 h-4 inline ml-1 text-green-500" />
) : (
<TrendingDown className="w-4 h-4 inline ml-1 text-red-500" />
)}
</>
) : (
'-'
)}
</div>
<div className="text-sm text-gray-600">Yield</div>
</div>
</div>
{/* Time Information */}
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600 mb-4">
<div>
<div className="font-medium text-gray-900 mb-1">Scheduled</div>
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{new Date(batch.production_date).toLocaleDateString()}
</div>
{batch.planned_start_time && (
<div className="flex items-center mt-1">
<Clock className="w-4 h-4 mr-1" />
{formatTime(batch.planned_start_time)}
</div>
)}
</div>
<div>
<div className="font-medium text-gray-900 mb-1">Actual</div>
{batch.actual_start_time && (
<div className="flex items-center">
<Play className="w-4 h-4 mr-1" />
Started {formatTime(batch.actual_start_time)}
</div>
)}
{batch.actual_end_time && (
<div className="flex items-center mt-1">
<CheckCircle className="w-4 h-4 mr-1" />
Completed {formatTime(batch.actual_end_time)}
</div>
)}
</div>
</div>
{/* Quality & Cost */}
{(batch.quality_score || batch.total_production_cost) && (
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
{batch.quality_score && (
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-lg font-semibold text-blue-900 flex items-center justify-center">
<Star className="w-4 h-4 mr-1" />
{batch.quality_score.toFixed(1)}/10
</div>
<div className="text-sm text-blue-700">Quality Score</div>
</div>
)}
{batch.total_production_cost && (
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-lg font-semibold text-green-900 flex items-center justify-center">
<Euro className="w-4 h-4 mr-1" />
{batch.total_production_cost.toFixed(2)}
</div>
<div className="text-sm text-green-700">Total Cost</div>
</div>
)}
</div>
)}
{/* Staff & Environment */}
<div className="text-sm text-gray-600">
{batch.assigned_staff && batch.assigned_staff.length > 0 && (
<div className="flex items-center mb-2">
<Users className="w-4 h-4 mr-2" />
<span>{batch.assigned_staff.length} staff assigned</span>
</div>
)}
{batch.production_temperature && (
<div className="flex items-center mb-2">
<Thermometer className="w-4 h-4 mr-2" />
<span>{batch.production_temperature}°C</span>
{batch.production_humidity && (
<span className="ml-2"> {batch.production_humidity}% humidity</span>
)}
</div>
)}
{batch.efficiency_percentage && (
<div className="flex items-center">
<BarChart3 className="w-4 h-4 mr-2" />
<span>
{batch.efficiency_percentage.toFixed(1)}% efficiency
</span>
</div>
)}
</div>
{/* Alerts */}
{(batch.rework_required || (batch.defect_rate && batch.defect_rate > 0) || batch.waste_quantity > 0) && (
<div className="mt-4 p-3 rounded-lg border border-yellow-200 bg-yellow-50">
<div className="flex items-center text-yellow-800 text-sm font-medium mb-2">
<AlertTriangle className="w-4 h-4 mr-2" />
Quality Issues
</div>
<div className="text-sm text-yellow-700 space-y-1">
{batch.rework_required && (
<div> Rework required</div>
)}
{batch.defect_rate && batch.defect_rate > 0 && (
<div> {batch.defect_rate.toFixed(1)}% defect rate</div>
)}
{batch.waste_quantity > 0 && (
<div> {batch.waste_quantity} units wasted</div>
)}
</div>
</div>
)}
</div>
{/* Actions Footer */}
{showActions && (
<div className="px-6 py-4 bg-gray-50 rounded-b-xl border-t">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<button
onClick={() => onView?.(batch)}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
View Details
</button>
{batch.status === 'planned' && (
<button
onClick={() => onStart?.(batch)}
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
Start Production
</button>
)}
{batch.status === 'in_progress' && (
<button
onClick={() => onComplete?.(batch)}
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
Complete Batch
</button>
)}
</div>
<div className="text-xs text-gray-500">
Updated {new Date(batch.updated_at).toLocaleDateString()}
</div>
</div>
</div>
)}
</div>
);
};
export default ProductionBatchCard;

View File

@@ -0,0 +1,445 @@
// frontend/src/components/recipes/RecipeCard.tsx
import React, { useState } from 'react';
import {
Clock,
Users,
ChefHat,
Star,
Eye,
Edit,
Copy,
Play,
MoreVertical,
Leaf,
Thermometer,
Calendar,
TrendingUp,
AlertTriangle,
CheckCircle,
Euro
} from 'lucide-react';
import { Recipe, RecipeFeasibility } from '../../api/services/recipes.service';
interface RecipeCardProps {
recipe: Recipe;
compact?: boolean;
showActions?: boolean;
onView?: (recipe: Recipe) => void;
onEdit?: (recipe: Recipe) => void;
onDuplicate?: (recipe: Recipe) => void;
onActivate?: (recipe: Recipe) => void;
onCheckFeasibility?: (recipe: Recipe) => void;
feasibility?: RecipeFeasibility | null;
className?: string;
}
const RecipeCard: React.FC<RecipeCardProps> = ({
recipe,
compact = false,
showActions = true,
onView,
onEdit,
onDuplicate,
onActivate,
onCheckFeasibility,
feasibility,
className = ''
}) => {
const [showMenu, setShowMenu] = useState(false);
const [isCheckingFeasibility, setIsCheckingFeasibility] = useState(false);
// Status styling
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800 border-green-200';
case 'draft':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'testing':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'archived':
return 'bg-gray-100 text-gray-800 border-gray-200';
case 'discontinued':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
// Difficulty display
const getDifficultyStars = (level: number) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={`w-3 h-3 ${
i < level ? 'text-yellow-400 fill-current' : 'text-gray-300'
}`}
/>
));
};
// Format time
const formatTime = (minutes?: number) => {
if (!minutes) return null;
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
// Handle feasibility check
const handleCheckFeasibility = async () => {
if (!onCheckFeasibility) return;
setIsCheckingFeasibility(true);
try {
await onCheckFeasibility(recipe);
} finally {
setIsCheckingFeasibility(false);
}
};
if (compact) {
return (
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 flex-1 min-w-0">
{/* Recipe Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h3 className="font-medium text-gray-900 truncate">{recipe.name}</h3>
{recipe.is_signature_item && (
<Star className="w-4 h-4 text-yellow-500 fill-current" />
)}
{recipe.is_seasonal && (
<Leaf className="w-4 h-4 text-green-500" />
)}
</div>
<div className="flex items-center space-x-3 text-sm text-gray-600">
<span className={`px-2 py-1 rounded-full text-xs border ${getStatusColor(recipe.status)}`}>
{recipe.status}
</span>
{recipe.category && (
<span className="text-gray-500"> {recipe.category}</span>
)}
{recipe.total_time_minutes && (
<div className="flex items-center">
<Clock className="w-3 h-3 mr-1" />
{formatTime(recipe.total_time_minutes)}
</div>
)}
{recipe.serves_count && (
<div className="flex items-center">
<Users className="w-3 h-3 mr-1" />
{recipe.serves_count}
</div>
)}
</div>
</div>
{/* Cost & Yield */}
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{recipe.yield_quantity} {recipe.yield_unit}
</div>
{recipe.last_calculated_cost && (
<div className="text-sm text-gray-600 flex items-center">
<Euro className="w-3 h-3 mr-1" />
{recipe.last_calculated_cost.toFixed(2)}
</div>
)}
</div>
</div>
{/* Actions */}
{showActions && (
<div className="flex items-center space-x-2 ml-4">
{feasibility && (
<div className={`p-1 rounded ${
feasibility.feasible ? 'text-green-600' : 'text-red-600'
}`}>
{feasibility.feasible ? (
<CheckCircle className="w-4 h-4" />
) : (
<AlertTriangle className="w-4 h-4" />
)}
</div>
)}
<button
onClick={() => onView?.(recipe)}
className="p-1 text-gray-600 hover:text-blue-600"
>
<Eye className="w-4 h-4" />
</button>
{recipe.status === 'active' && (
<button
onClick={handleCheckFeasibility}
disabled={isCheckingFeasibility}
className="p-1 text-gray-600 hover:text-green-600 disabled:opacity-50"
>
<TrendingUp className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
</div>
);
}
return (
<div className={`bg-white rounded-xl border shadow-sm hover:shadow-md transition-shadow ${className}`}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-lg font-semibold text-gray-900 truncate">{recipe.name}</h3>
{recipe.is_signature_item && (
<Star className="w-5 h-5 text-yellow-500 fill-current" />
)}
{recipe.is_seasonal && (
<Leaf className="w-5 h-5 text-green-500" />
)}
</div>
{recipe.description && (
<p className="text-sm text-gray-600 line-clamp-2">{recipe.description}</p>
)}
</div>
{showActions && (
<div className="relative ml-4">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<MoreVertical className="w-5 h-5" />
</button>
{showMenu && (
<div className="absolute right-0 top-8 bg-white border rounded-lg shadow-lg py-1 z-10 min-w-[140px]">
<button
onClick={() => {
onView?.(recipe);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
>
<Eye className="w-4 h-4 mr-2" />
View Recipe
</button>
<button
onClick={() => {
onEdit?.(recipe);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
>
<Edit className="w-4 h-4 mr-2" />
Edit Recipe
</button>
<button
onClick={() => {
onDuplicate?.(recipe);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
>
<Copy className="w-4 h-4 mr-2" />
Duplicate
</button>
{recipe.status === 'draft' && (
<button
onClick={() => {
onActivate?.(recipe);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-green-600"
>
<Play className="w-4 h-4 mr-2" />
Activate
</button>
)}
{recipe.status === 'active' && (
<button
onClick={() => {
handleCheckFeasibility();
setShowMenu(false);
}}
disabled={isCheckingFeasibility}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-blue-600 disabled:opacity-50"
>
<TrendingUp className="w-4 h-4 mr-2" />
Check Feasibility
</button>
)}
</div>
)}
</div>
)}
</div>
{/* Status & Category */}
<div className="flex items-center space-x-3 mb-4">
<span className={`px-3 py-1 rounded-full text-sm border ${getStatusColor(recipe.status)}`}>
{recipe.status}
</span>
{recipe.category && (
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
{recipe.category}
</span>
)}
<div className="flex items-center">
{getDifficultyStars(recipe.difficulty_level)}
<span className="ml-2 text-sm text-gray-600">
Level {recipe.difficulty_level}
</span>
</div>
</div>
{/* Metrics */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg font-semibold text-gray-900">
{recipe.yield_quantity} {recipe.yield_unit}
</div>
<div className="text-sm text-gray-600">Yield</div>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg font-semibold text-gray-900">
{recipe.last_calculated_cost ? (
<>{recipe.last_calculated_cost.toFixed(2)}</>
) : (
'-'
)}
</div>
<div className="text-sm text-gray-600">Cost/Unit</div>
</div>
</div>
{/* Time Information */}
{(recipe.prep_time_minutes || recipe.cook_time_minutes || recipe.total_time_minutes) && (
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-4">
{recipe.prep_time_minutes && (
<div className="flex items-center">
<ChefHat className="w-4 h-4 mr-1" />
Prep: {formatTime(recipe.prep_time_minutes)}
</div>
)}
{recipe.cook_time_minutes && (
<div className="flex items-center">
<Thermometer className="w-4 h-4 mr-1" />
Cook: {formatTime(recipe.cook_time_minutes)}
</div>
)}
{recipe.total_time_minutes && (
<div className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
Total: {formatTime(recipe.total_time_minutes)}
</div>
)}
</div>
)}
{/* Special Properties */}
<div className="flex items-center space-x-3 text-sm">
{recipe.serves_count && (
<div className="flex items-center text-gray-600">
<Users className="w-4 h-4 mr-1" />
Serves {recipe.serves_count}
</div>
)}
{recipe.is_seasonal && (
<div className="flex items-center text-green-600">
<Calendar className="w-4 h-4 mr-1" />
Seasonal
</div>
)}
{recipe.optimal_production_temperature && (
<div className="flex items-center text-gray-600">
<Thermometer className="w-4 h-4 mr-1" />
{recipe.optimal_production_temperature}°C
</div>
)}
</div>
{/* Feasibility Status */}
{feasibility && (
<div className={`mt-4 p-3 rounded-lg border ${
feasibility.feasible
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}>
<div className={`flex items-center text-sm font-medium ${
feasibility.feasible ? 'text-green-800' : 'text-red-800'
}`}>
{feasibility.feasible ? (
<CheckCircle className="w-4 h-4 mr-2" />
) : (
<AlertTriangle className="w-4 h-4 mr-2" />
)}
{feasibility.feasible ? 'Ready to produce' : 'Cannot produce - missing ingredients'}
</div>
{!feasibility.feasible && feasibility.missing_ingredients.length > 0 && (
<div className="mt-2 text-sm text-red-700">
Missing: {feasibility.missing_ingredients.map(ing => ing.ingredient_name).join(', ')}
</div>
)}
</div>
)}
</div>
{/* Actions Footer */}
{showActions && (
<div className="px-6 py-4 bg-gray-50 rounded-b-xl border-t">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<button
onClick={() => onView?.(recipe)}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
View Details
</button>
{recipe.status === 'active' && (
<button
onClick={handleCheckFeasibility}
disabled={isCheckingFeasibility}
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors disabled:opacity-50"
>
{isCheckingFeasibility ? 'Checking...' : 'Check Feasibility'}
</button>
)}
</div>
<div className="text-xs text-gray-500">
Updated {new Date(recipe.updated_at).toLocaleDateString()}
</div>
</div>
</div>
)}
</div>
);
};
export default RecipeCard;

View File

@@ -0,0 +1,487 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
TrendingUp,
TrendingDown,
DollarSign,
Package,
ShoppingCart,
BarChart3,
PieChart,
Calendar,
Filter,
Download,
RefreshCw,
Target,
Users,
Clock,
Star,
AlertTriangle
} from 'lucide-react';
import { useSales } from '../../api/hooks/useSales';
import { useInventory } from '../../api/hooks/useInventory';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface AnalyticsFilters {
period: 'last_7_days' | 'last_30_days' | 'last_90_days' | 'last_year';
channel?: string;
product_id?: string;
}
const SalesAnalyticsDashboard: React.FC = () => {
const { user } = useAuth();
const {
getSalesAnalytics,
getSalesData,
getProductsList,
isLoading: salesLoading,
error: salesError
} = useSales();
const {
ingredients: products,
loadIngredients: loadProducts,
isLoading: inventoryLoading
} = useInventory();
const [filters, setFilters] = useState<AnalyticsFilters>({
period: 'last_30_days'
});
const [analytics, setAnalytics] = useState<any>(null);
const [salesData, setSalesData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Load all analytics data
useEffect(() => {
if (user?.tenant_id) {
loadAnalyticsData();
}
}, [user?.tenant_id, filters]);
const loadAnalyticsData = async () => {
if (!user?.tenant_id) return;
setIsLoading(true);
try {
const [analyticsResponse, salesResponse] = await Promise.all([
getSalesAnalytics(user.tenant_id, getDateRange().start, getDateRange().end),
getSalesData(user.tenant_id, {
tenant_id: user.tenant_id,
start_date: getDateRange().start,
end_date: getDateRange().end,
limit: 1000
}),
loadProducts()
]);
setAnalytics(analyticsResponse);
setSalesData(salesResponse);
} catch (error) {
console.error('Error loading analytics data:', error);
} finally {
setIsLoading(false);
}
};
// Get date range for filters
const getDateRange = () => {
const end = new Date();
const start = new Date();
switch (filters.period) {
case 'last_7_days':
start.setDate(end.getDate() - 7);
break;
case 'last_30_days':
start.setDate(end.getDate() - 30);
break;
case 'last_90_days':
start.setDate(end.getDate() - 90);
break;
case 'last_year':
start.setFullYear(end.getFullYear() - 1);
break;
}
return {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0]
};
};
// Period options
const periodOptions = [
{ value: 'last_7_days', label: 'Últimos 7 días' },
{ value: 'last_30_days', label: 'Últimos 30 días' },
{ value: 'last_90_days', label: 'Últimos 90 días' },
{ value: 'last_year', label: 'Último año' }
];
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
// Calculate advanced metrics
const advancedMetrics = useMemo(() => {
if (!salesData.length) return null;
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
const totalUnits = salesData.reduce((sum, sale) => sum + sale.quantity_sold, 0);
const avgOrderValue = salesData.length > 0 ? totalRevenue / salesData.length : 0;
// Channel distribution
const channelDistribution = salesData.reduce((acc, sale) => {
acc[sale.sales_channel] = (acc[sale.sales_channel] || 0) + sale.revenue;
return acc;
}, {} as Record<string, number>);
// Product performance
const productPerformance = salesData.reduce((acc, sale) => {
const key = sale.inventory_product_id;
if (!acc[key]) {
acc[key] = { revenue: 0, units: 0, orders: 0 };
}
acc[key].revenue += sale.revenue;
acc[key].units += sale.quantity_sold;
acc[key].orders += 1;
return acc;
}, {} as Record<string, any>);
// Top products
const topProducts = Object.entries(productPerformance)
.map(([productId, data]) => ({
productId,
...data as any,
avgPrice: data.revenue / data.units
}))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 5);
// Daily trends
const dailyTrends = salesData.reduce((acc, sale) => {
const date = sale.date.split('T')[0];
if (!acc[date]) {
acc[date] = { revenue: 0, units: 0, orders: 0 };
}
acc[date].revenue += sale.revenue;
acc[date].units += sale.quantity_sold;
acc[date].orders += 1;
return acc;
}, {} as Record<string, any>);
return {
totalRevenue,
totalUnits,
avgOrderValue,
totalOrders: salesData.length,
channelDistribution,
topProducts,
dailyTrends
};
}, [salesData]);
// Key performance indicators
const kpis = useMemo(() => {
if (!advancedMetrics) return [];
const growth = Math.random() * 20 - 10; // Mock growth calculation
return [
{
title: 'Ingresos Totales',
value: formatCurrency(advancedMetrics.totalRevenue),
change: `${growth > 0 ? '+' : ''}${growth.toFixed(1)}%`,
changeType: growth > 0 ? 'positive' as const : 'negative' as const,
icon: DollarSign,
color: 'blue'
},
{
title: 'Pedidos Totales',
value: advancedMetrics.totalOrders.toString(),
change: '+5.2%',
changeType: 'positive' as const,
icon: ShoppingCart,
color: 'green'
},
{
title: 'Valor Promedio Pedido',
value: formatCurrency(advancedMetrics.avgOrderValue),
change: '+2.8%',
changeType: 'positive' as const,
icon: Target,
color: 'purple'
},
{
title: 'Unidades Vendidas',
value: advancedMetrics.totalUnits.toString(),
change: '+8.1%',
changeType: 'positive' as const,
icon: Package,
color: 'orange'
}
];
}, [advancedMetrics]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Panel de Análisis de Ventas</h1>
<p className="text-gray-600">Insights detallados sobre el rendimiento de ventas</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={filters.period}
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{periodOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<Button
variant="outline"
onClick={loadAnalyticsData}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{kpis.map((kpi, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600 mb-1">{kpi.title}</p>
<p className="text-2xl font-bold text-gray-900 mb-2">{kpi.value}</p>
<div className={`flex items-center space-x-1 text-sm ${
kpi.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
}`}>
{kpi.changeType === 'positive' ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
<span>{kpi.change}</span>
<span className="text-gray-500">vs período anterior</span>
</div>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
kpi.color === 'blue' ? 'bg-blue-100' :
kpi.color === 'green' ? 'bg-green-100' :
kpi.color === 'purple' ? 'bg-purple-100' :
'bg-orange-100'
}`}>
<kpi.icon className={`w-6 h-6 ${
kpi.color === 'blue' ? 'text-blue-600' :
kpi.color === 'green' ? 'text-green-600' :
kpi.color === 'purple' ? 'text-purple-600' :
'text-orange-600'
}`} />
</div>
</div>
</Card>
))}
</div>
{/* Charts and Analysis */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Products */}
{advancedMetrics?.topProducts && (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<BarChart3 className="w-5 h-5 text-blue-500 mr-2" />
Productos Más Vendidos
</h3>
</div>
<div className="space-y-4">
{advancedMetrics.topProducts.map((product: any, index: number) => {
const inventoryProduct = products.find((p: any) => p.id === product.productId);
return (
<div key={product.productId} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
index < 3 ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-800'
}`}>
{index + 1}
</div>
<div>
<p className="font-medium text-gray-900">
{inventoryProduct?.name || `Producto ${product.productId.slice(0, 8)}...`}
</p>
<p className="text-sm text-gray-500">
{product.units} unidades {product.orders} pedidos
</p>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">
{formatCurrency(product.revenue)}
</p>
<p className="text-xs text-gray-500">
{formatCurrency(product.avgPrice)} avg
</p>
</div>
</div>
);
})}
</div>
</div>
</Card>
)}
{/* Channel Distribution */}
{advancedMetrics?.channelDistribution && (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<PieChart className="w-5 h-5 text-purple-500 mr-2" />
Ventas por Canal
</h3>
</div>
<div className="space-y-4">
{Object.entries(advancedMetrics.channelDistribution).map(([channel, revenue], index) => {
const percentage = (revenue as number / advancedMetrics.totalRevenue * 100);
const channelLabels: Record<string, string> = {
'in_store': 'Tienda',
'online': 'Online',
'delivery': 'Delivery'
};
return (
<div key={channel} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className="w-4 h-4 rounded-full"
style={{
backgroundColor: [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'
][index % 5]
}}
/>
<span className="text-sm font-medium text-gray-700">
{channelLabels[channel] || channel}
</span>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
{formatCurrency(revenue as number)}
</div>
<div className="text-xs text-gray-600">
{percentage.toFixed(1)}%
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</Card>
)}
</div>
{/* Insights and Recommendations */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Target className="w-5 h-5 text-indigo-500 mr-2" />
Insights y Recomendaciones
</h3>
</div>
<div className="space-y-4">
{/* Performance insights */}
{advancedMetrics && advancedMetrics.avgOrderValue > 15 && (
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
<TrendingUp className="w-5 h-5 text-green-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-900">
Excelente valor promedio de pedido
</p>
<p className="text-xs text-green-800">
Con {formatCurrency(advancedMetrics.avgOrderValue)} por pedido, estás por encima del promedio.
Considera estrategias de up-selling para mantener esta tendencia.
</p>
</div>
</div>
)}
{advancedMetrics && advancedMetrics.totalOrders < 10 && (
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-900">
Volumen de pedidos bajo
</p>
<p className="text-xs text-yellow-800">
Solo {advancedMetrics.totalOrders} pedidos en el período.
Considera estrategias de marketing para aumentar el tráfico.
</p>
</div>
</div>
)}
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg">
<BarChart3 className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">
Oportunidad de diversificación
</p>
<p className="text-xs text-blue-800">
Analiza los productos de menor rendimiento para optimizar tu catálogo
o considera promociones específicas.
</p>
</div>
</div>
</div>
</div>
</Card>
</div>
);
};
export default SalesAnalyticsDashboard;

View File

@@ -0,0 +1,353 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
TrendingUp,
TrendingDown,
DollarSign,
ShoppingCart,
Eye,
ArrowRight,
Clock,
Package,
AlertTriangle
} from 'lucide-react';
import { useSales } from '../../api/hooks/useSales';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface SalesDashboardWidgetProps {
onViewAll?: () => void;
compact?: boolean;
}
const SalesDashboardWidget: React.FC<SalesDashboardWidgetProps> = ({
onViewAll,
compact = false
}) => {
const { user } = useAuth();
const {
salesData,
getSalesData,
getSalesAnalytics,
isLoading,
error
} = useSales();
const [realtimeStats, setRealtimeStats] = useState<any>(null);
const [todaysSales, setTodaysSales] = useState<any[]>([]);
// Load real-time sales data
useEffect(() => {
if (user?.tenant_id) {
loadRealtimeData();
// Set up polling for real-time updates every 30 seconds
const interval = setInterval(loadRealtimeData, 30000);
return () => clearInterval(interval);
}
}, [user?.tenant_id]);
const loadRealtimeData = async () => {
if (!user?.tenant_id) return;
try {
const today = new Date().toISOString().split('T')[0];
// Get today's sales data
const todayData = await getSalesData(user.tenant_id, {
tenant_id: user.tenant_id,
start_date: today,
end_date: today,
limit: 50
});
setTodaysSales(todayData);
// Get analytics for today
const analytics = await getSalesAnalytics(user.tenant_id, today, today);
setRealtimeStats(analytics);
} catch (error) {
console.error('Error loading realtime sales data:', error);
}
};
// Calculate today's metrics
const todaysMetrics = useMemo(() => {
if (!todaysSales.length) {
return {
totalRevenue: 0,
totalOrders: 0,
avgOrderValue: 0,
topProduct: null,
hourlyTrend: []
};
}
const totalRevenue = todaysSales.reduce((sum, sale) => sum + sale.revenue, 0);
const totalOrders = todaysSales.length;
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
// Find top selling product
const productSales: Record<string, { revenue: number; count: number }> = {};
todaysSales.forEach(sale => {
if (!productSales[sale.inventory_product_id]) {
productSales[sale.inventory_product_id] = { revenue: 0, count: 0 };
}
productSales[sale.inventory_product_id].revenue += sale.revenue;
productSales[sale.inventory_product_id].count += 1;
});
const topProduct = Object.entries(productSales)
.sort(([,a], [,b]) => b.revenue - a.revenue)[0];
// Calculate hourly trend (last 6 hours)
const now = new Date();
const hourlyTrend = [];
for (let i = 5; i >= 0; i--) {
const hour = new Date(now.getTime() - i * 60 * 60 * 1000);
const hourSales = todaysSales.filter(sale => {
const saleHour = new Date(sale.date).getHours();
return saleHour === hour.getHours();
});
hourlyTrend.push({
hour: hour.getHours(),
revenue: hourSales.reduce((sum, sale) => sum + sale.revenue, 0),
orders: hourSales.length
});
}
return {
totalRevenue,
totalOrders,
avgOrderValue,
topProduct,
hourlyTrend
};
}, [todaysSales]);
// Get recent sales for display
const recentSales = useMemo(() => {
return todaysSales
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 3);
}, [todaysSales]);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
};
// Format time
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
};
if (compact) {
return (
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">Ventas de Hoy</h3>
{onViewAll && (
<Button variant="ghost" size="sm" onClick={onViewAll}>
<Eye className="w-4 h-4" />
</Button>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" />
</div>
) : (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Ingresos</span>
<span className="font-semibold text-green-600">
{formatCurrency(todaysMetrics.totalRevenue)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Pedidos</span>
<span className="font-semibold text-blue-600">
{todaysMetrics.totalOrders}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Promedio</span>
<span className="font-semibold text-purple-600">
{formatCurrency(todaysMetrics.avgOrderValue)}
</span>
</div>
</div>
)}
</Card>
);
}
return (
<Card>
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<ShoppingCart className="w-5 h-5 text-blue-500" />
<h3 className="text-lg font-semibold text-gray-900">
Ventas en Tiempo Real
</h3>
<div className="flex items-center text-xs text-green-600 bg-green-50 px-2 py-1 rounded-full">
<div className="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></div>
En vivo
</div>
</div>
{onViewAll && (
<Button variant="outline" size="sm" onClick={onViewAll}>
<Eye className="w-4 h-4 mr-2" />
Ver Todo
</Button>
)}
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
<AlertTriangle className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-700">{error}</span>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner size="md" />
</div>
) : (
<div className="space-y-6">
{/* Today's Metrics */}
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600 mb-1">
{formatCurrency(todaysMetrics.totalRevenue)}
</div>
<div className="text-xs text-gray-600">Ingresos Hoy</div>
<div className="flex items-center justify-center mt-1">
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
<span className="text-xs text-green-600">+12%</span>
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600 mb-1">
{todaysMetrics.totalOrders}
</div>
<div className="text-xs text-gray-600">Pedidos</div>
<div className="flex items-center justify-center mt-1">
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
<span className="text-xs text-green-600">+8%</span>
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600 mb-1">
{formatCurrency(todaysMetrics.avgOrderValue)}
</div>
<div className="text-xs text-gray-600">Promedio</div>
<div className="flex items-center justify-center mt-1">
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
<span className="text-xs text-green-600">+5%</span>
</div>
</div>
</div>
{/* Hourly Trend */}
{todaysMetrics.hourlyTrend.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">
Tendencia por Horas
</h4>
<div className="flex items-end justify-between space-x-1 h-16">
{todaysMetrics.hourlyTrend.map((data, index) => {
const maxRevenue = Math.max(...todaysMetrics.hourlyTrend.map(h => h.revenue));
const height = maxRevenue > 0 ? (data.revenue / maxRevenue) * 100 : 0;
return (
<div key={index} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-blue-500 rounded-t"
style={{ height: `${Math.max(height, 4)}%` }}
title={`${data.hour}:00 - ${formatCurrency(data.revenue)}`}
/>
<div className="text-xs text-gray-500 mt-1">
{data.hour}h
</div>
</div>
);
})}
</div>
</div>
)}
{/* Recent Sales */}
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">
Ventas Recientes
</h4>
{recentSales.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
No hay ventas registradas hoy
</div>
) : (
<div className="space-y-2">
{recentSales.map((sale) => (
<div key={sale.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm font-medium">
{sale.quantity_sold}x Producto
</span>
<span className="text-xs text-gray-500">
{formatTime(sale.date)}
</span>
</div>
<span className="text-sm font-semibold text-green-600">
{formatCurrency(sale.revenue)}
</span>
</div>
))}
</div>
)}
</div>
{/* Call to Action */}
{onViewAll && (
<div className="flex items-center justify-center pt-2">
<button
onClick={onViewAll}
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
>
<span>Ver análisis completo</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
</div>
</Card>
);
};
export default SalesDashboardWidget;

View File

@@ -0,0 +1,315 @@
import React, { useState } from 'react';
import {
Calendar,
DollarSign,
Package,
TrendingUp,
TrendingDown,
Eye,
Edit3,
MoreHorizontal,
MapPin,
ShoppingCart,
Star,
AlertTriangle,
CheckCircle,
Clock
} from 'lucide-react';
import { SalesData } from '../../api/types';
import Card from '../ui/Card';
import Button from '../ui/Button';
interface SalesDataCardProps {
salesData: SalesData;
compact?: boolean;
showActions?: boolean;
inventoryProduct?: {
id: string;
name: string;
category: string;
};
onEdit?: (salesData: SalesData) => void;
onDelete?: (salesData: SalesData) => void;
onViewDetails?: (salesData: SalesData) => void;
}
const SalesDataCard: React.FC<SalesDataCardProps> = ({
salesData,
compact = false,
showActions = true,
inventoryProduct,
onEdit,
onDelete,
onViewDetails
}) => {
const [showMenu, setShowMenu] = useState(false);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
};
// Format date
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
};
// Format time
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
};
// Get sales channel icon and label
const getSalesChannelInfo = () => {
switch (salesData.sales_channel) {
case 'online':
return { icon: ShoppingCart, label: 'Online', color: 'text-blue-600' };
case 'delivery':
return { icon: MapPin, label: 'Delivery', color: 'text-green-600' };
case 'in_store':
default:
return { icon: Package, label: 'Tienda', color: 'text-purple-600' };
}
};
// Get validation status
const getValidationStatus = () => {
if (salesData.is_validated) {
return { icon: CheckCircle, label: 'Validado', color: 'text-green-600', bg: 'bg-green-50' };
}
return { icon: Clock, label: 'Pendiente', color: 'text-yellow-600', bg: 'bg-yellow-50' };
};
// Calculate profit margin
const profitMargin = salesData.cost_of_goods
? ((salesData.revenue - salesData.cost_of_goods) / salesData.revenue * 100)
: null;
const channelInfo = getSalesChannelInfo();
const validationStatus = getValidationStatus();
if (compact) {
return (
<Card className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Package className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">
{inventoryProduct?.name || `Producto ${salesData.inventory_product_id.slice(0, 8)}...`}
</p>
<div className="flex items-center space-x-2 text-sm text-gray-500">
<span>{salesData.quantity_sold} unidades</span>
<span></span>
<span>{formatDate(salesData.date)}</span>
</div>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">
{formatCurrency(salesData.revenue)}
</p>
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${channelInfo.color} bg-gray-50`}>
<channelInfo.icon className="w-3 h-3 mr-1" />
{channelInfo.label}
</div>
</div>
</div>
</Card>
);
}
return (
<Card className="p-6 hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<Package className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">
{inventoryProduct?.name || `Producto ${salesData.inventory_product_id.slice(0, 8)}...`}
</h3>
<div className="flex items-center space-x-2 text-sm text-gray-500">
{inventoryProduct?.category && (
<>
<span className="capitalize">{inventoryProduct.category}</span>
<span></span>
</>
)}
<span>ID: {salesData.id.slice(0, 8)}...</span>
</div>
</div>
</div>
{showActions && (
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{showMenu && (
<div className="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-10">
<div className="py-1">
{onViewDetails && (
<button
onClick={() => {
onViewDetails(salesData);
setShowMenu(false);
}}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 w-full text-left"
>
<Eye className="w-4 h-4 mr-2" />
Ver Detalles
</button>
)}
{onEdit && (
<button
onClick={() => {
onEdit(salesData);
setShowMenu(false);
}}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 w-full text-left"
>
<Edit3 className="w-4 h-4 mr-2" />
Editar
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* Sales Metrics */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div className="text-center">
<div className="text-xl font-bold text-gray-900">{salesData.quantity_sold}</div>
<div className="text-xs text-gray-600">Cantidad Vendida</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-green-600">
{formatCurrency(salesData.revenue)}
</div>
<div className="text-xs text-gray-600">Ingresos</div>
</div>
{salesData.unit_price && (
<div className="text-center">
<div className="text-xl font-bold text-blue-600">
{formatCurrency(salesData.unit_price)}
</div>
<div className="text-xs text-gray-600">Precio Unitario</div>
</div>
)}
{profitMargin !== null && (
<div className="text-center">
<div className={`text-xl font-bold ${profitMargin > 0 ? 'text-green-600' : 'text-red-600'}`}>
{profitMargin.toFixed(1)}%
</div>
<div className="text-xs text-gray-600">Margen</div>
</div>
)}
</div>
{/* Details Row */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-4">
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
<span>{formatDate(salesData.date)} {formatTime(salesData.date)}</span>
</div>
<div className={`flex items-center ${channelInfo.color}`}>
<channelInfo.icon className="w-4 h-4 mr-1" />
<span>{channelInfo.label}</span>
</div>
{salesData.location_id && (
<div className="flex items-center">
<MapPin className="w-4 h-4 mr-1" />
<span>Local {salesData.location_id}</span>
</div>
)}
<div className={`flex items-center px-2 py-1 rounded-full text-xs ${validationStatus.bg} ${validationStatus.color}`}>
<validationStatus.icon className="w-3 h-3 mr-1" />
{validationStatus.label}
</div>
</div>
{/* Additional Info */}
<div className="border-t pt-3">
<div className="flex flex-wrap items-center justify-between text-xs text-gray-500">
<div className="flex items-center space-x-4">
<span>Origen: {salesData.source}</span>
{salesData.discount_applied && salesData.discount_applied > 0 && (
<span>Descuento: {salesData.discount_applied}%</span>
)}
</div>
{salesData.weather_condition && (
<div className="flex items-center">
<span className="mr-1">
{salesData.weather_condition.includes('rain') ? '🌧️' :
salesData.weather_condition.includes('sun') ? '☀️' :
salesData.weather_condition.includes('cloud') ? '☁️' : '🌤️'}
</span>
<span className="capitalize">{salesData.weather_condition}</span>
</div>
)}
</div>
</div>
{/* Actions */}
{showActions && (
<div className="flex items-center space-x-3 mt-4 pt-3 border-t">
{onViewDetails && (
<Button
variant="outline"
size="sm"
onClick={() => onViewDetails(salesData)}
>
<Eye className="w-4 h-4 mr-2" />
Ver Detalles
</Button>
)}
{onEdit && (
<Button
variant="outline"
size="sm"
onClick={() => onEdit(salesData)}
>
<Edit3 className="w-4 h-4 mr-2" />
Editar
</Button>
)}
</div>
)}
</Card>
);
};
export default SalesDataCard;

View File

@@ -0,0 +1,534 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Search,
Filter,
RefreshCw,
ChevronDown,
Plus,
Download,
Calendar,
Package,
ShoppingCart,
MapPin,
Grid3X3,
List,
AlertCircle,
TrendingUp,
BarChart3
} from 'lucide-react';
import { useSales } from '../../api/hooks/useSales';
import { useInventory } from '../../api/hooks/useInventory';
import { useAuth } from '../../api/hooks/useAuth';
import { SalesData, SalesDataQuery } from '../../api/types';
import SalesDataCard from './SalesDataCard';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface SalesFilters {
search: string;
channel: string;
product_id: string;
date_from: string;
date_to: string;
min_revenue: string;
max_revenue: string;
is_validated?: boolean;
}
const SalesManagementPage: React.FC = () => {
const { user } = useAuth();
const {
salesData,
getSalesData,
getSalesAnalytics,
exportSalesData,
isLoading,
error,
clearError
} = useSales();
const {
ingredients: products,
loadIngredients: loadProducts,
isLoading: inventoryLoading
} = useInventory();
const [filters, setFilters] = useState<SalesFilters>({
search: '',
channel: '',
product_id: '',
date_from: '',
date_to: '',
min_revenue: '',
max_revenue: ''
});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
const [selectedSale, setSelectedSale] = useState<SalesData | null>(null);
const [analytics, setAnalytics] = useState<any>(null);
// Load initial data
useEffect(() => {
if (user?.tenant_id) {
loadSalesData();
loadProducts();
loadAnalytics();
}
}, [user?.tenant_id]);
// Apply filters
useEffect(() => {
if (user?.tenant_id) {
loadSalesData();
}
}, [filters]);
const loadSalesData = async () => {
if (!user?.tenant_id) return;
const query: SalesDataQuery = {};
if (filters.search) {
query.search_term = filters.search;
}
if (filters.channel) {
query.sales_channel = filters.channel;
}
if (filters.product_id) {
query.inventory_product_id = filters.product_id;
}
if (filters.date_from) {
query.start_date = filters.date_from;
}
if (filters.date_to) {
query.end_date = filters.date_to;
}
if (filters.min_revenue) {
query.min_revenue = parseFloat(filters.min_revenue);
}
if (filters.max_revenue) {
query.max_revenue = parseFloat(filters.max_revenue);
}
if (filters.is_validated !== undefined) {
query.is_validated = filters.is_validated;
}
await getSalesData(user.tenant_id, query);
};
const loadAnalytics = async () => {
if (!user?.tenant_id) return;
try {
const analyticsData = await getSalesAnalytics(user.tenant_id);
setAnalytics(analyticsData);
} catch (error) {
console.error('Error loading analytics:', error);
}
};
// Channel options
const channelOptions = [
{ value: '', label: 'Todos los canales' },
{ value: 'in_store', label: 'Tienda' },
{ value: 'online', label: 'Online' },
{ value: 'delivery', label: 'Delivery' }
];
// Clear all filters
const handleClearFilters = () => {
setFilters({
search: '',
channel: '',
product_id: '',
date_from: '',
date_to: '',
min_revenue: '',
max_revenue: ''
});
};
// Export sales data
const handleExport = async () => {
if (!user?.tenant_id) return;
const query: SalesDataQuery = {};
if (filters.date_from) query.start_date = filters.date_from;
if (filters.date_to) query.end_date = filters.date_to;
if (filters.channel) query.sales_channel = filters.channel;
await exportSalesData(user.tenant_id, 'csv', query);
};
// Get product info by ID
const getProductInfo = (productId: string) => {
return products.find(p => p.id === productId);
};
// Quick stats
const quickStats = useMemo(() => {
if (!salesData.length) return null;
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
const totalUnits = salesData.reduce((sum, sale) => sum + sale.quantity_sold, 0);
const avgOrderValue = salesData.length > 0 ? totalRevenue / salesData.length : 0;
const todaySales = salesData.filter(sale => {
const saleDate = new Date(sale.date).toDateString();
const today = new Date().toDateString();
return saleDate === today;
});
return {
totalRevenue,
totalUnits,
avgOrderValue,
totalOrders: salesData.length,
todayOrders: todaySales.length,
todayRevenue: todaySales.reduce((sum, sale) => sum + sale.revenue, 0)
};
}, [salesData]);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
if (isLoading && !salesData.length) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Ventas</h1>
<p className="text-gray-600">Administra y analiza todos tus datos de ventas</p>
</div>
<div className="flex items-center space-x-3">
<Button
variant="outline"
onClick={loadSalesData}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button
variant="outline"
onClick={handleExport}
>
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-700">{error}</span>
</div>
<button
onClick={clearError}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
)}
{/* Quick Stats */}
{quickStats && (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Ingresos Totales</p>
<p className="text-lg font-bold text-green-600">
{formatCurrency(quickStats.totalRevenue)}
</p>
</div>
<TrendingUp className="w-8 h-8 text-green-600" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Pedidos Totales</p>
<p className="text-lg font-bold text-blue-600">
{quickStats.totalOrders}
</p>
</div>
<ShoppingCart className="w-8 h-8 text-blue-600" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Valor Promedio</p>
<p className="text-lg font-bold text-purple-600">
{formatCurrency(quickStats.avgOrderValue)}
</p>
</div>
<BarChart3 className="w-8 h-8 text-purple-600" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Unidades Vendidas</p>
<p className="text-lg font-bold text-orange-600">
{quickStats.totalUnits}
</p>
</div>
<Package className="w-8 h-8 text-orange-600" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Pedidos Hoy</p>
<p className="text-lg font-bold text-indigo-600">
{quickStats.todayOrders}
</p>
</div>
<Calendar className="w-8 h-8 text-indigo-600" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Ingresos Hoy</p>
<p className="text-lg font-bold text-emerald-600">
{formatCurrency(quickStats.todayRevenue)}
</p>
</div>
<TrendingUp className="w-8 h-8 text-emerald-600" />
</div>
</Card>
</div>
)}
{/* Filters and Search */}
<Card>
<div className="flex flex-col lg:flex-row lg:items-center justify-between space-y-4 lg:space-y-0">
<div className="flex items-center space-x-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar ventas..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-64"
/>
</div>
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center space-x-2 px-3 py-2 border rounded-lg transition-colors ${
showFilters ? 'bg-blue-50 border-blue-200 text-blue-700' : 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<Filter className="w-4 h-4" />
<span>Filtros</span>
<ChevronDown className={`w-4 h-4 transform transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{/* Active filters indicator */}
{(filters.channel || filters.product_id || filters.date_from || filters.date_to) && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Filtros activos:</span>
{filters.channel && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{channelOptions.find(opt => opt.value === filters.channel)?.label}
</span>
)}
<button
onClick={handleClearFilters}
className="text-xs text-red-600 hover:text-red-700"
>
Limpiar
</button>
</div>
)}
</div>
<div className="flex items-center space-x-3">
{/* View Mode Toggle */}
<div className="flex items-center space-x-1 border border-gray-300 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Canal de Venta
</label>
<select
value={filters.channel}
onChange={(e) => setFilters(prev => ({ ...prev, channel: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{channelOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Producto
</label>
<select
value={filters.product_id}
onChange={(e) => setFilters(prev => ({ ...prev, product_id: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={inventoryLoading}
>
<option value="">Todos los productos</option>
{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-1">
Fecha Desde
</label>
<input
type="date"
value={filters.date_from}
onChange={(e) => setFilters(prev => ({ ...prev, date_from: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Hasta
</label>
<input
type="date"
value={filters.date_to}
onChange={(e) => setFilters(prev => ({ ...prev, date_to: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
)}
</Card>
{/* Sales List */}
<div>
{salesData.length === 0 ? (
<Card className="text-center py-12">
<ShoppingCart className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron ventas</h3>
<p className="text-gray-600 mb-4">
{filters.search || filters.channel || filters.date_from || filters.date_to
? 'Intenta ajustar tus filtros de búsqueda'
: 'Las ventas aparecerán aquí cuando se registren'
}
</p>
</Card>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'space-y-4'
}>
{salesData.map(sale => (
<SalesDataCard
key={sale.id}
salesData={sale}
compact={viewMode === 'list'}
inventoryProduct={getProductInfo(sale.inventory_product_id)}
onViewDetails={(sale) => setSelectedSale(sale)}
onEdit={(sale) => {
console.log('Edit sale:', sale);
// TODO: Implement edit functionality
}}
/>
))}
</div>
)}
</div>
{/* Sale Details Modal */}
{selectedSale && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden mx-4">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Detalles de Venta: {selectedSale.id.slice(0, 8)}...
</h2>
<button
onClick={() => setSelectedSale(null)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[70vh]">
<SalesDataCard
salesData={selectedSale}
compact={false}
showActions={true}
inventoryProduct={getProductInfo(selectedSale.inventory_product_id)}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default SalesManagementPage;

View File

@@ -0,0 +1,484 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
TrendingUp,
TrendingDown,
Target,
AlertTriangle,
CheckCircle,
Brain,
BarChart3,
Zap,
Clock,
Star,
ArrowRight,
LightBulb,
Calendar,
Package
} from 'lucide-react';
import { useSales } from '../../api/hooks/useSales';
import { useForecast } from '../../api/hooks/useForecast';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface PerformanceInsight {
id: string;
type: 'success' | 'warning' | 'info' | 'forecast';
title: string;
description: string;
value?: string;
change?: string;
action?: {
label: string;
onClick: () => void;
};
priority: 'high' | 'medium' | 'low';
}
interface SalesPerformanceInsightsProps {
onActionClick?: (actionType: string, data: any) => void;
}
const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
onActionClick
}) => {
const { user } = useAuth();
const {
getSalesAnalytics,
getSalesData,
isLoading: salesLoading
} = useSales();
const {
predictions,
loadPredictions,
performance,
loadPerformance,
isLoading: forecastLoading
} = useForecast();
const [salesAnalytics, setSalesAnalytics] = useState<any>(null);
const [salesData, setSalesData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Load all performance data
useEffect(() => {
if (user?.tenant_id) {
loadPerformanceData();
}
}, [user?.tenant_id]);
const loadPerformanceData = async () => {
if (!user?.tenant_id) return;
setIsLoading(true);
try {
const endDate = new Date().toISOString().split('T')[0];
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const [analytics, sales] = await Promise.all([
getSalesAnalytics(user.tenant_id, startDate, endDate),
getSalesData(user.tenant_id, {
tenant_id: user.tenant_id,
start_date: startDate,
end_date: endDate,
limit: 1000
}),
loadPredictions(),
loadPerformance()
]);
setSalesAnalytics(analytics);
setSalesData(sales);
} catch (error) {
console.error('Error loading performance data:', error);
} finally {
setIsLoading(false);
}
};
// Generate AI-powered insights
const insights = useMemo((): PerformanceInsight[] => {
if (!salesAnalytics || !salesData.length) return [];
const insights: PerformanceInsight[] = [];
// Calculate metrics
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
const totalOrders = salesData.length;
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
// Revenue performance insight
const revenueGrowth = Math.random() * 30 - 10; // Mock growth calculation
if (revenueGrowth > 10) {
insights.push({
id: 'revenue_growth',
type: 'success',
title: 'Excelente crecimiento de ingresos',
description: `Los ingresos han aumentado un ${revenueGrowth.toFixed(1)}% en las últimas 4 semanas, superando las expectativas.`,
value: `+${revenueGrowth.toFixed(1)}%`,
priority: 'high',
action: {
label: 'Ver detalles',
onClick: () => onActionClick?.('view_revenue_details', { growth: revenueGrowth })
}
});
} else if (revenueGrowth < -5) {
insights.push({
id: 'revenue_decline',
type: 'warning',
title: 'Declive en ingresos detectado',
description: `Los ingresos han disminuido un ${Math.abs(revenueGrowth).toFixed(1)}% en las últimas semanas. Considera estrategias de recuperación.`,
value: `${revenueGrowth.toFixed(1)}%`,
priority: 'high',
action: {
label: 'Ver estrategias',
onClick: () => onActionClick?.('view_recovery_strategies', { decline: revenueGrowth })
}
});
}
// Order volume insights
if (totalOrders < 50) {
insights.push({
id: 'low_volume',
type: 'warning',
title: 'Volumen de pedidos bajo',
description: `Solo ${totalOrders} pedidos en los últimos 30 días. Considera campañas para aumentar el tráfico.`,
value: `${totalOrders} pedidos`,
priority: 'medium',
action: {
label: 'Estrategias marketing',
onClick: () => onActionClick?.('marketing_strategies', { orders: totalOrders })
}
});
} else if (totalOrders > 200) {
insights.push({
id: 'high_volume',
type: 'success',
title: 'Alto volumen de pedidos',
description: `${totalOrders} pedidos en el último mes. ¡Excelente rendimiento! Asegúrate de mantener la calidad del servicio.`,
value: `${totalOrders} pedidos`,
priority: 'medium',
action: {
label: 'Optimizar operaciones',
onClick: () => onActionClick?.('optimize_operations', { orders: totalOrders })
}
});
}
// Average order value insights
if (avgOrderValue > 20) {
insights.push({
id: 'high_aov',
type: 'success',
title: 'Valor promedio de pedido alto',
description: `Con €${avgOrderValue.toFixed(2)} por pedido, estás maximizando el valor por cliente.`,
value: `${avgOrderValue.toFixed(2)}`,
priority: 'low',
action: {
label: 'Mantener estrategias',
onClick: () => onActionClick?.('maintain_aov_strategies', { aov: avgOrderValue })
}
});
} else if (avgOrderValue < 12) {
insights.push({
id: 'low_aov',
type: 'info',
title: 'Oportunidad de up-selling',
description: `El valor promedio por pedido es €${avgOrderValue.toFixed(2)}. Considera ofertas de productos complementarios.`,
value: `${avgOrderValue.toFixed(2)}`,
priority: 'medium',
action: {
label: 'Estrategias up-sell',
onClick: () => onActionClick?.('upsell_strategies', { aov: avgOrderValue })
}
});
}
// Forecasting insights
if (predictions.length > 0) {
const todayPrediction = predictions.find(p => {
const predDate = new Date(p.date).toDateString();
const today = new Date().toDateString();
return predDate === today;
});
if (todayPrediction) {
insights.push({
id: 'forecast_today',
type: 'forecast',
title: 'Predicción para hoy',
description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${
todayPrediction.confidence === 'high' ? 'alta' :
todayPrediction.confidence === 'medium' ? 'media' : 'baja'
} confianza.`,
value: `${todayPrediction.predicted_demand} unidades`,
priority: 'high',
action: {
label: 'Ajustar producción',
onClick: () => onActionClick?.('adjust_production', todayPrediction)
}
});
}
}
// Performance vs forecast insight
if (performance) {
const accuracy = performance.accuracy || 0;
if (accuracy > 85) {
insights.push({
id: 'forecast_accuracy',
type: 'success',
title: 'Alta precisión de predicciones',
description: `Las predicciones de IA tienen un ${accuracy.toFixed(1)}% de precisión. Confía en las recomendaciones.`,
value: `${accuracy.toFixed(1)}%`,
priority: 'low'
});
} else if (accuracy < 70) {
insights.push({
id: 'forecast_improvement',
type: 'info',
title: 'Mejorando precisión de IA',
description: `La precisión actual es ${accuracy.toFixed(1)}%. Más datos históricos mejorarán las predicciones.`,
value: `${accuracy.toFixed(1)}%`,
priority: 'medium',
action: {
label: 'Mejorar datos',
onClick: () => onActionClick?.('improve_data_quality', { accuracy })
}
});
}
}
// Seasonal trends insight
const currentMonth = new Date().getMonth();
const isWinterMonth = currentMonth === 11 || currentMonth === 0 || currentMonth === 1;
const isSummerMonth = currentMonth >= 5 && currentMonth <= 8;
if (isWinterMonth) {
insights.push({
id: 'winter_season',
type: 'info',
title: 'Tendencias de temporada',
description: 'En invierno, productos calientes como chocolate caliente y pan tostado suelen tener mayor demanda.',
priority: 'low',
action: {
label: 'Ver productos estacionales',
onClick: () => onActionClick?.('seasonal_products', { season: 'winter' })
}
});
} else if (isSummerMonth) {
insights.push({
id: 'summer_season',
type: 'info',
title: 'Tendencias de temporada',
description: 'En verano, productos frescos y bebidas frías tienen mayor demanda. Considera helados y batidos.',
priority: 'low',
action: {
label: 'Ver productos estacionales',
onClick: () => onActionClick?.('seasonal_products', { season: 'summer' })
}
});
}
// Sort by priority
const priorityOrder = { high: 3, medium: 2, low: 1 };
return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
}, [salesAnalytics, salesData, predictions, performance, onActionClick]);
// Get insight icon
const getInsightIcon = (type: PerformanceInsight['type']) => {
switch (type) {
case 'success':
return CheckCircle;
case 'warning':
return AlertTriangle;
case 'forecast':
return Brain;
case 'info':
default:
return LightBulb;
}
};
// Get insight color
const getInsightColor = (type: PerformanceInsight['type']) => {
switch (type) {
case 'success':
return 'green';
case 'warning':
return 'yellow';
case 'forecast':
return 'purple';
case 'info':
default:
return 'blue';
}
};
if (isLoading) {
return (
<Card className="p-6">
<div className="flex items-center justify-center h-32">
<LoadingSpinner size="md" />
</div>
</Card>
);
}
return (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-2">
<Brain className="w-5 h-5 text-purple-500" />
<h3 className="text-lg font-semibold text-gray-900">
Insights de Rendimiento IA
</h3>
<div className="flex items-center text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full">
<Zap className="w-3 h-3 mr-1" />
Powered by AI
</div>
</div>
<Button variant="outline" size="sm" onClick={loadPerformanceData}>
<BarChart3 className="w-4 h-4 mr-2" />
Actualizar
</Button>
</div>
{insights.length === 0 ? (
<div className="text-center py-8">
<Brain className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h4 className="text-lg font-medium text-gray-900 mb-2">
Generando insights...
</h4>
<p className="text-gray-600">
La IA está analizando tus datos para generar recomendaciones personalizadas.
</p>
</div>
) : (
<div className="space-y-4">
{insights.map((insight) => {
const Icon = getInsightIcon(insight.type);
const color = getInsightColor(insight.type);
return (
<div
key={insight.id}
className={`p-4 rounded-lg border-l-4 ${
color === 'green' ? 'bg-green-50 border-green-400' :
color === 'yellow' ? 'bg-yellow-50 border-yellow-400' :
color === 'purple' ? 'bg-purple-50 border-purple-400' :
'bg-blue-50 border-blue-400'
}`}
>
<div className="flex items-start space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
color === 'green' ? 'bg-green-100' :
color === 'yellow' ? 'bg-yellow-100' :
color === 'purple' ? 'bg-purple-100' :
'bg-blue-100'
}`}>
<Icon className={`w-4 h-4 ${
color === 'green' ? 'text-green-600' :
color === 'yellow' ? 'text-yellow-600' :
color === 'purple' ? 'text-purple-600' :
'text-blue-600'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h4 className={`font-medium ${
color === 'green' ? 'text-green-900' :
color === 'yellow' ? 'text-yellow-900' :
color === 'purple' ? 'text-purple-900' :
'text-blue-900'
}`}>
{insight.title}
</h4>
{insight.value && (
<span className={`text-sm font-semibold ${
color === 'green' ? 'text-green-700' :
color === 'yellow' ? 'text-yellow-700' :
color === 'purple' ? 'text-purple-700' :
'text-blue-700'
}`}>
{insight.value}
</span>
)}
</div>
<p className={`text-sm ${
color === 'green' ? 'text-green-800' :
color === 'yellow' ? 'text-yellow-800' :
color === 'purple' ? 'text-purple-800' :
'text-blue-800'
}`}>
{insight.description}
</p>
{insight.action && (
<button
onClick={insight.action.onClick}
className={`mt-3 flex items-center space-x-1 text-sm font-medium ${
color === 'green' ? 'text-green-700 hover:text-green-800' :
color === 'yellow' ? 'text-yellow-700 hover:text-yellow-800' :
color === 'purple' ? 'text-purple-700 hover:text-purple-800' :
'text-blue-700 hover:text-blue-800'
}`}
>
<span>{insight.action.label}</span>
<ArrowRight className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Quick Actions */}
<div className="mt-6 pt-4 border-t">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onActionClick?.('view_full_analytics', {})}
>
<BarChart3 className="w-4 h-4 mr-2" />
Analytics Completos
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onActionClick?.('optimize_inventory', {})}
>
<Package className="w-4 h-4 mr-2" />
Optimizar Inventario
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onActionClick?.('forecast_planning', {})}
>
<Calendar className="w-4 h-4 mr-2" />
Planificación IA
</Button>
</div>
</div>
</div>
</Card>
);
};
export default SalesPerformanceInsights;

View File

@@ -0,0 +1,6 @@
// Sales Components Exports
export { default as SalesDataCard } from './SalesDataCard';
export { default as SalesAnalyticsDashboard } from './SalesAnalyticsDashboard';
export { default as SalesManagementPage } from './SalesManagementPage';
export { default as SalesDashboardWidget } from './SalesDashboardWidget';
export { default as SalesPerformanceInsights } from './SalesPerformanceInsights';

View File

@@ -0,0 +1,611 @@
import React, { useState } from 'react';
import {
Truck,
Package,
MapPin,
Calendar,
Clock,
CheckCircle,
AlertCircle,
XCircle,
Eye,
Edit3,
MoreVertical,
User,
Phone,
FileText,
Star,
AlertTriangle,
Thermometer,
ClipboardCheck
} from 'lucide-react';
import {
Delivery,
DeliveryItem
} from '../../api/services/suppliers.service';
interface DeliveryCardProps {
delivery: Delivery;
compact?: boolean;
showActions?: boolean;
onEdit?: (delivery: Delivery) => void;
onViewDetails?: (delivery: Delivery) => void;
onUpdateStatus?: (delivery: Delivery, status: string, notes?: string) => void;
onReceive?: (delivery: Delivery, receiptData: any) => void;
className?: string;
}
const DeliveryCard: React.FC<DeliveryCardProps> = ({
delivery,
compact = false,
showActions = true,
onEdit,
onViewDetails,
onUpdateStatus,
onReceive,
className = ''
}) => {
const [showReceiptDialog, setShowReceiptDialog] = useState(false);
const [receiptData, setReceiptData] = useState({
inspection_passed: true,
inspection_notes: '',
quality_issues: '',
notes: ''
});
// Get status display info
const getStatusInfo = () => {
const statusConfig = {
SCHEDULED: { label: 'Programado', color: 'blue', icon: Calendar },
IN_TRANSIT: { label: 'En Tránsito', color: 'blue', icon: Truck },
OUT_FOR_DELIVERY: { label: 'En Reparto', color: 'orange', icon: Truck },
DELIVERED: { label: 'Entregado', color: 'green', icon: CheckCircle },
PARTIALLY_DELIVERED: { label: 'Parcialmente Entregado', color: 'yellow', icon: Package },
FAILED_DELIVERY: { label: 'Fallo en Entrega', color: 'red', icon: XCircle },
RETURNED: { label: 'Devuelto', color: 'red', icon: AlertCircle }
};
return statusConfig[delivery.status as keyof typeof statusConfig] || statusConfig.SCHEDULED;
};
const statusInfo = getStatusInfo();
const StatusIcon = statusInfo.icon;
// Format date and time
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const formatDateTime = (dateString: string) => {
return new Date(dateString).toLocaleString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Check if delivery is overdue
const isOverdue = () => {
if (!delivery.scheduled_date) return false;
return new Date(delivery.scheduled_date) < new Date() &&
!['DELIVERED', 'PARTIALLY_DELIVERED', 'FAILED_DELIVERY', 'RETURNED'].includes(delivery.status);
};
// Check if delivery is on time
const isOnTime = () => {
if (!delivery.scheduled_date || !delivery.actual_arrival) return null;
return new Date(delivery.actual_arrival) <= new Date(delivery.scheduled_date);
};
// Calculate delay
const getDelay = () => {
if (!delivery.scheduled_date || !delivery.actual_arrival) return null;
const scheduled = new Date(delivery.scheduled_date);
const actual = new Date(delivery.actual_arrival);
const diffMs = actual.getTime() - scheduled.getTime();
const diffHours = Math.round(diffMs / (1000 * 60 * 60));
return diffHours > 0 ? diffHours : 0;
};
const delay = getDelay();
const onTimeStatus = isOnTime();
// Handle receipt submission
const handleReceiptSubmission = () => {
if (!onReceive) return;
const qualityIssues = receiptData.quality_issues.trim() ?
{ general: receiptData.quality_issues } : undefined;
onReceive(delivery, {
inspection_passed: receiptData.inspection_passed,
inspection_notes: receiptData.inspection_notes.trim() || undefined,
quality_issues: qualityIssues,
notes: receiptData.notes.trim() || undefined
});
setShowReceiptDialog(false);
setReceiptData({
inspection_passed: true,
inspection_notes: '',
quality_issues: '',
notes: ''
});
};
if (compact) {
return (
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
statusInfo.color === 'green' ? 'bg-green-100' :
statusInfo.color === 'blue' ? 'bg-blue-100' :
statusInfo.color === 'orange' ? 'bg-orange-100' :
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
statusInfo.color === 'red' ? 'bg-red-100' :
'bg-gray-100'
}`}>
<StatusIcon className={`w-5 h-5 ${
statusInfo.color === 'green' ? 'text-green-600' :
statusInfo.color === 'blue' ? 'text-blue-600' :
statusInfo.color === 'orange' ? 'text-orange-600' :
statusInfo.color === 'yellow' ? 'text-yellow-600' :
statusInfo.color === 'red' ? 'text-red-600' :
'text-gray-600'
}`} />
</div>
<div>
<h4 className="font-medium text-gray-900">{delivery.delivery_number}</h4>
<p className="text-sm text-gray-500">
{delivery.supplier?.name || 'Proveedor no disponible'}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="text-right">
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
statusInfo.color === 'orange' ? 'bg-orange-100 text-orange-800' :
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
<StatusIcon className="w-3 h-3" />
<span>{statusInfo.label}</span>
</div>
{delivery.scheduled_date && (
<div className="text-xs text-gray-500 mt-1">
{formatDate(delivery.scheduled_date)}
</div>
)}
</div>
{showActions && onViewDetails && (
<button
onClick={() => onViewDetails(delivery)}
className="p-1 hover:bg-gray-100 rounded"
>
<Eye className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
</div>
{isOverdue() && (
<div className="mt-3 flex items-center space-x-1 text-red-600 text-xs">
<AlertTriangle className="w-3 h-3" />
<span>Entrega vencida</span>
</div>
)}
{onTimeStatus !== null && delivery.status === 'DELIVERED' && (
<div className="mt-3 flex items-center justify-between text-xs">
<div className={`flex items-center space-x-1 ${
onTimeStatus ? 'text-green-600' : 'text-red-600'
}`}>
<Clock className="w-3 h-3" />
<span>
{onTimeStatus ? 'A tiempo' : `${delay}h retraso`}
</span>
</div>
{delivery.inspection_passed !== null && (
<div className={`flex items-center space-x-1 ${
delivery.inspection_passed ? 'text-green-600' : 'text-red-600'
}`}>
<ClipboardCheck className="w-3 h-3" />
<span>
{delivery.inspection_passed ? 'Inspección OK' : 'Fallos calidad'}
</span>
</div>
)}
</div>
)}
</div>
);
}
return (
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
statusInfo.color === 'green' ? 'bg-green-100' :
statusInfo.color === 'blue' ? 'bg-blue-100' :
statusInfo.color === 'orange' ? 'bg-orange-100' :
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
statusInfo.color === 'red' ? 'bg-red-100' :
'bg-gray-100'
}`}>
<StatusIcon className={`w-6 h-6 ${
statusInfo.color === 'green' ? 'text-green-600' :
statusInfo.color === 'blue' ? 'text-blue-600' :
statusInfo.color === 'orange' ? 'text-orange-600' :
statusInfo.color === 'yellow' ? 'text-yellow-600' :
statusInfo.color === 'red' ? 'text-red-600' :
'text-gray-600'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">{delivery.delivery_number}</h3>
{delivery.supplier_delivery_note && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
Nota: {delivery.supplier_delivery_note}
</span>
)}
{isOverdue() && (
<span className="px-2 py-1 bg-red-100 text-red-600 text-xs rounded-full flex items-center space-x-1">
<AlertTriangle className="w-3 h-3" />
<span>Vencido</span>
</span>
)}
</div>
<div className="flex items-center space-x-4 text-sm text-gray-600">
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
statusInfo.color === 'orange' ? 'bg-orange-100 text-orange-800' :
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
<StatusIcon className="w-3 h-3" />
<span>{statusInfo.label}</span>
</div>
{delivery.purchase_order && (
<span>PO: {delivery.purchase_order.po_number}</span>
)}
</div>
{/* Supplier and tracking information */}
<div className="flex items-center space-x-4 mt-2 text-sm text-gray-600">
{delivery.supplier && (
<div className="flex items-center space-x-1">
<Package className="w-3 h-3" />
<span>{delivery.supplier.name}</span>
</div>
)}
{delivery.tracking_number && (
<div className="flex items-center space-x-1">
<Truck className="w-3 h-3" />
<span>#{delivery.tracking_number}</span>
</div>
)}
{delivery.carrier_name && (
<div className="flex items-center space-x-1">
<FileText className="w-3 h-3" />
<span>{delivery.carrier_name}</span>
</div>
)}
</div>
</div>
</div>
{showActions && (
<div className="flex items-center space-x-1">
{delivery.status === 'OUT_FOR_DELIVERY' && onReceive && (
<button
onClick={() => setShowReceiptDialog(true)}
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
title="Marcar como recibido"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
{onEdit && ['SCHEDULED', 'IN_TRANSIT'].includes(delivery.status) && (
<button
onClick={() => onEdit(delivery)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Editar"
>
<Edit3 className="w-4 h-4 text-gray-600" />
</button>
)}
{onViewDetails && (
<button
onClick={() => onViewDetails(delivery)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Ver detalles"
>
<Eye className="w-4 h-4 text-gray-600" />
</button>
)}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<MoreVertical className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
</div>
</div>
{/* Delivery Timeline */}
<div className="px-6 pb-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-gray-900">
<Calendar className="w-4 h-4 text-gray-500" />
<span>
{delivery.scheduled_date
? formatDate(delivery.scheduled_date)
: 'N/A'
}
</span>
</div>
<div className="text-sm text-gray-500">Programado</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-blue-600">
<Clock className="w-4 h-4 text-blue-500" />
<span>
{delivery.estimated_arrival
? formatDateTime(delivery.estimated_arrival)
: 'N/A'
}
</span>
</div>
<div className="text-sm text-gray-500">Estimado</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-green-600">
<Truck className="w-4 h-4 text-green-500" />
<span>
{delivery.actual_arrival
? formatDateTime(delivery.actual_arrival)
: 'Pendiente'
}
</span>
</div>
<div className="text-sm text-gray-500">Llegada Real</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-purple-600">
<CheckCircle className="w-4 h-4 text-purple-500" />
<span>
{delivery.completed_at
? formatDateTime(delivery.completed_at)
: 'Pendiente'
}
</span>
</div>
<div className="text-sm text-gray-500">Completado</div>
</div>
</div>
</div>
{/* Delivery Performance Indicators */}
{(onTimeStatus !== null || delivery.inspection_passed !== null) && (
<div className="px-6 pb-4">
<div className="flex items-center justify-center space-x-6">
{onTimeStatus !== null && (
<div className={`flex items-center space-x-2 px-3 py-2 rounded-full ${
onTimeStatus ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
<Clock className="w-4 h-4" />
<span className="font-medium">
{onTimeStatus ? 'A Tiempo' : `${delay}h Retraso`}
</span>
</div>
)}
{delivery.inspection_passed !== null && (
<div className={`flex items-center space-x-2 px-3 py-2 rounded-full ${
delivery.inspection_passed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
<ClipboardCheck className="w-4 h-4" />
<span className="font-medium">
{delivery.inspection_passed ? 'Calidad OK' : 'Fallos Calidad'}
</span>
</div>
)}
</div>
</div>
)}
{/* Contact and Address Information */}
{(delivery.delivery_contact || delivery.delivery_phone || delivery.delivery_address) && (
<div className="px-6 pb-4">
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Información de Entrega</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
{delivery.delivery_contact && (
<div className="flex items-center space-x-2">
<User className="w-4 h-4 text-gray-500" />
<span>{delivery.delivery_contact}</span>
</div>
)}
{delivery.delivery_phone && (
<div className="flex items-center space-x-2">
<Phone className="w-4 h-4 text-gray-500" />
<span>{delivery.delivery_phone}</span>
</div>
)}
{delivery.delivery_address && (
<div className="flex items-start space-x-2 md:col-span-2">
<MapPin className="w-4 h-4 text-gray-500 mt-0.5" />
<span>{delivery.delivery_address}</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Notes and Quality Information */}
{(delivery.notes || delivery.inspection_notes || delivery.quality_issues) && (
<div className="px-6 pb-6">
<div className="space-y-3">
{delivery.notes && (
<div className="bg-blue-50 rounded-lg p-3">
<span className="text-sm font-medium text-blue-900">Notas: </span>
<span className="text-sm text-blue-800">{delivery.notes}</span>
</div>
)}
{delivery.inspection_notes && (
<div className="bg-yellow-50 rounded-lg p-3">
<span className="text-sm font-medium text-yellow-900">Notas de Inspección: </span>
<span className="text-sm text-yellow-800">{delivery.inspection_notes}</span>
</div>
)}
{delivery.quality_issues && Object.keys(delivery.quality_issues).length > 0 && (
<div className="bg-red-50 rounded-lg p-3">
<span className="text-sm font-medium text-red-900">Problemas de Calidad: </span>
<span className="text-sm text-red-800">
{JSON.stringify(delivery.quality_issues)}
</span>
</div>
)}
</div>
</div>
)}
{/* Receipt Dialog */}
{showReceiptDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Recibir Entrega: {delivery.delivery_number}
</h3>
<div className="space-y-4">
<div className="flex items-center space-x-3">
<input
type="checkbox"
id="inspection-passed"
checked={receiptData.inspection_passed}
onChange={(e) => setReceiptData(prev => ({
...prev,
inspection_passed: e.target.checked
}))}
className="w-4 h-4 text-green-600 rounded focus:ring-green-500"
/>
<label htmlFor="inspection-passed" className="text-sm font-medium text-gray-700">
Inspección pasada correctamente
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas de Inspección
</label>
<textarea
value={receiptData.inspection_notes}
onChange={(e) => setReceiptData(prev => ({
...prev,
inspection_notes: e.target.value
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows={2}
placeholder="Observaciones de la inspección..."
/>
</div>
{!receiptData.inspection_passed && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Problemas de Calidad
</label>
<textarea
value={receiptData.quality_issues}
onChange={(e) => setReceiptData(prev => ({
...prev,
quality_issues: e.target.value
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows={2}
placeholder="Descripción de los problemas encontrados..."
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas Adicionales
</label>
<textarea
value={receiptData.notes}
onChange={(e) => setReceiptData(prev => ({
...prev,
notes: e.target.value
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows={2}
placeholder="Notas adicionales sobre la recepción..."
/>
</div>
</div>
<div className="flex space-x-3 mt-6">
<button
onClick={handleReceiptSubmission}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Confirmar Recepción
</button>
<button
onClick={() => {
setShowReceiptDialog(false);
setReceiptData({
inspection_passed: true,
inspection_notes: '',
quality_issues: '',
notes: ''
});
}}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default DeliveryCard;

View File

@@ -0,0 +1,347 @@
import React, { useEffect } from 'react';
import {
Truck,
Package,
Clock,
CheckCircle,
AlertCircle,
Calendar,
TrendingUp,
ChevronRight,
BarChart3,
MapPin
} from 'lucide-react';
import { useDeliveries } from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import DeliveryCard from './DeliveryCard';
import LoadingSpinner from '../ui/LoadingSpinner';
interface DeliveryDashboardWidgetProps {
onViewAll?: () => void;
className?: string;
}
const DeliveryDashboardWidget: React.FC<DeliveryDashboardWidgetProps> = ({
onViewAll,
className = ''
}) => {
const { user } = useAuth();
const {
todaysDeliveries,
overdueDeliveries,
performanceStats,
isLoading,
loadTodaysDeliveries,
loadOverdueDeliveries,
loadPerformanceStats,
updateDeliveryStatus,
receiveDelivery
} = useDeliveries();
// Load data on mount
useEffect(() => {
if (user?.tenant_id) {
loadTodaysDeliveries();
loadOverdueDeliveries();
loadPerformanceStats(7); // Last 7 days for dashboard
}
}, [user?.tenant_id]);
// Format percentage
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`;
};
// Handle delivery receipt
const handleReceiveDelivery = async (delivery: any, receiptData: any) => {
const updatedDelivery = await receiveDelivery(delivery.id, receiptData);
if (updatedDelivery) {
// Refresh relevant lists
loadTodaysDeliveries();
loadOverdueDeliveries();
loadPerformanceStats(7);
}
};
// Handle status update
const handleUpdateDeliveryStatus = async (delivery: any, status: string, notes?: string) => {
const updatedDelivery = await updateDeliveryStatus(delivery.id, status, notes);
if (updatedDelivery) {
loadTodaysDeliveries();
loadOverdueDeliveries();
}
};
if (isLoading && !performanceStats && !todaysDeliveries.length && !overdueDeliveries.length) {
return (
<Card className={className}>
<div className="flex items-center justify-center h-32">
<LoadingSpinner />
</div>
</Card>
);
}
return (
<div className={`space-y-6 ${className}`}>
{/* Delivery Performance Overview */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Truck className="w-5 h-5 text-blue-500 mr-2" />
Resumen de Entregas
</h3>
{onViewAll && (
<button
onClick={onViewAll}
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-sm font-medium"
>
<span>Ver todas</span>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
{performanceStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<Package className="w-4 h-4 text-blue-500" />
<span className="text-2xl font-bold text-gray-900">
{performanceStats.total_deliveries}
</span>
</div>
<p className="text-sm text-gray-600">Total (7 días)</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-2xl font-bold text-green-600">
{performanceStats.on_time_deliveries}
</span>
</div>
<p className="text-sm text-gray-600">A Tiempo</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<Clock className="w-4 h-4 text-yellow-500" />
<span className="text-2xl font-bold text-yellow-600">
{performanceStats.late_deliveries}
</span>
</div>
<p className="text-sm text-gray-600">Tardías</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<TrendingUp className="w-4 h-4 text-purple-500" />
<span className="text-lg font-bold text-purple-600">
{formatPercentage(performanceStats.quality_pass_rate)}
</span>
</div>
<p className="text-sm text-gray-600">Calidad OK</p>
</div>
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500">No hay datos de entregas disponibles</p>
</div>
)}
{/* Performance Indicator */}
{performanceStats && performanceStats.total_deliveries > 0 && (
<div className="mt-6 pt-4 border-t">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<BarChart3 className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-gray-700">
Rendimiento General
</span>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
{formatPercentage(performanceStats.on_time_percentage)}
</div>
<div className="text-xs text-gray-500">Entregas a tiempo</div>
</div>
<div className="w-16 h-2 bg-gray-200 rounded-full">
<div
className="h-2 bg-green-500 rounded-full"
style={{ width: `${performanceStats.on_time_percentage}%` }}
/>
</div>
</div>
</div>
</div>
)}
</Card>
{/* Overdue Deliveries Alert */}
{overdueDeliveries.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 flex items-center">
<AlertCircle className="w-4 h-4 text-red-500 mr-2" />
Entregas Vencidas
</h4>
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs">
¡{overdueDeliveries.length} vencidas!
</span>
</div>
<div className="space-y-3">
{overdueDeliveries.slice(0, 2).map(delivery => (
<DeliveryCard
key={delivery.id}
delivery={delivery}
compact
showActions={false}
/>
))}
{overdueDeliveries.length > 2 && (
<div className="text-center pt-2">
<button
onClick={onViewAll}
className="text-sm text-red-600 hover:text-red-700 font-medium"
>
Ver {overdueDeliveries.length - 2} entregas vencidas más...
</button>
</div>
)}
</div>
</Card>
)}
{/* Today's Deliveries */}
{todaysDeliveries.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 flex items-center">
<Calendar className="w-4 h-4 text-blue-500 mr-2" />
Entregas de Hoy
</h4>
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs">
{todaysDeliveries.length}
</span>
</div>
<div className="space-y-3">
{todaysDeliveries.slice(0, 3).map(delivery => (
<DeliveryCard
key={delivery.id}
delivery={delivery}
compact
onReceive={handleReceiveDelivery}
onUpdateStatus={handleUpdateDeliveryStatus}
/>
))}
{todaysDeliveries.length > 3 && (
<div className="text-center pt-2">
<button
onClick={onViewAll}
className="text-sm text-blue-600 hover:text-blue-700"
>
Ver {todaysDeliveries.length - 3} entregas más...
</button>
</div>
)}
</div>
</Card>
)}
{/* Delivery Tips */}
{performanceStats && performanceStats.late_deliveries > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 flex items-center">
<TrendingUp className="w-4 h-4 text-orange-500 mr-2" />
Oportunidades de Mejora
</h4>
</div>
<div className="space-y-3">
{performanceStats.avg_delay_hours > 2 && (
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
<Clock className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-900">
Retrasos Frecuentes
</p>
<p className="text-xs text-yellow-800">
Retraso promedio de {performanceStats.avg_delay_hours.toFixed(1)} horas.
Considera revisar los tiempos de entrega con tus proveedores.
</p>
</div>
</div>
)}
{performanceStats.quality_pass_rate < 90 && (
<div className="flex items-start space-x-3 p-3 bg-red-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-900">
Problemas de Calidad
</p>
<p className="text-xs text-red-800">
Solo {formatPercentage(performanceStats.quality_pass_rate)} de las entregas
pasan la inspección. Revisa los estándares de calidad con tus proveedores.
</p>
</div>
</div>
)}
{performanceStats.on_time_percentage > 95 && performanceStats.quality_pass_rate > 95 && (
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-900">
¡Excelente Rendimiento!
</p>
<p className="text-xs text-green-800">
Tus entregas están funcionando muy bien. Mantén la buena comunicación
con tus proveedores.
</p>
</div>
</div>
)}
</div>
</Card>
)}
{/* Empty State */}
{!isLoading &&
(!performanceStats || performanceStats.total_deliveries === 0) &&
todaysDeliveries.length === 0 &&
overdueDeliveries.length === 0 && (
<Card className="text-center py-8">
<Truck className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No hay entregas programadas
</h3>
<p className="text-gray-600 mb-4">
Las entregas aparecerán aquí cuando tus proveedores confirmen los envíos
</p>
{onViewAll && (
<button
onClick={onViewAll}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<MapPin className="w-4 h-4 mr-2" />
Ver Seguimiento
</button>
)}
</Card>
)}
</div>
);
};
export default DeliveryDashboardWidget;

View File

@@ -0,0 +1,651 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Search,
Filter,
RefreshCw,
ChevronDown,
Truck,
Package,
Clock,
AlertCircle,
CheckCircle,
Calendar,
TrendingUp,
Grid3X3,
List,
Download,
MapPin,
BarChart3
} from 'lucide-react';
import {
useDeliveries,
Delivery,
DeliverySearchParams
} from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import DeliveryCard from './DeliveryCard';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface DeliveryFilters {
search: string;
supplier_id: string;
status: string;
date_from: string;
date_to: string;
}
const DeliveryTrackingPage: React.FC = () => {
const { user } = useAuth();
const {
deliveries,
delivery: selectedDelivery,
todaysDeliveries,
overdueDeliveries,
performanceStats,
isLoading,
error,
pagination,
loadDeliveries,
loadDelivery,
loadTodaysDeliveries,
loadOverdueDeliveries,
loadPerformanceStats,
updateDeliveryStatus,
receiveDelivery,
clearError,
refresh,
setPage
} = useDeliveries();
const [filters, setFilters] = useState<DeliveryFilters>({
search: '',
supplier_id: '',
status: '',
date_from: '',
date_to: ''
});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
const [selectedDeliveryForDetails, setSelectedDeliveryForDetails] = useState<Delivery | null>(null);
// Load initial data
useEffect(() => {
if (user?.tenant_id) {
loadDeliveries();
loadTodaysDeliveries();
loadOverdueDeliveries();
loadPerformanceStats(30); // Last 30 days
}
}, [user?.tenant_id]);
// Apply filters
useEffect(() => {
const searchParams: DeliverySearchParams = {};
if (filters.search) {
searchParams.search_term = filters.search;
}
if (filters.supplier_id) {
searchParams.supplier_id = filters.supplier_id;
}
if (filters.status) {
searchParams.status = filters.status;
}
if (filters.date_from) {
searchParams.date_from = filters.date_from;
}
if (filters.date_to) {
searchParams.date_to = filters.date_to;
}
loadDeliveries(searchParams);
}, [filters]);
// Status options
const statusOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'SCHEDULED', label: 'Programado' },
{ value: 'IN_TRANSIT', label: 'En Tránsito' },
{ value: 'OUT_FOR_DELIVERY', label: 'En Reparto' },
{ value: 'DELIVERED', label: 'Entregado' },
{ value: 'PARTIALLY_DELIVERED', label: 'Parcialmente Entregado' },
{ value: 'FAILED_DELIVERY', label: 'Fallo en Entrega' },
{ value: 'RETURNED', label: 'Devuelto' }
];
// Handle delivery receipt
const handleReceiveDelivery = async (delivery: Delivery, receiptData: any) => {
const updatedDelivery = await receiveDelivery(delivery.id, receiptData);
if (updatedDelivery) {
// Refresh relevant lists
loadTodaysDeliveries();
loadOverdueDeliveries();
loadPerformanceStats(30);
}
};
// Handle status update
const handleUpdateDeliveryStatus = async (delivery: Delivery, status: string, notes?: string) => {
const updatedDelivery = await updateDeliveryStatus(delivery.id, status, notes);
if (updatedDelivery) {
loadTodaysDeliveries();
loadOverdueDeliveries();
}
};
// Handle clear filters
const handleClearFilters = () => {
setFilters({
search: '',
supplier_id: '',
status: '',
date_from: '',
date_to: ''
});
};
// Format percentage
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`;
};
// Statistics cards data
const statsCards = useMemo(() => {
if (!performanceStats) return [];
return [
{
title: 'Total Entregas',
value: performanceStats.total_deliveries.toString(),
icon: Package,
color: 'blue'
},
{
title: 'A Tiempo',
value: `${performanceStats.on_time_deliveries} (${formatPercentage(performanceStats.on_time_percentage)})`,
icon: CheckCircle,
color: 'green'
},
{
title: 'Entregas Tardías',
value: performanceStats.late_deliveries.toString(),
icon: Clock,
color: 'yellow'
},
{
title: 'Calidad OK',
value: formatPercentage(performanceStats.quality_pass_rate),
icon: TrendingUp,
color: 'purple'
}
];
}, [performanceStats]);
if (isLoading && !deliveries.length) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Seguimiento de Entregas</h1>
<p className="text-gray-600">Monitorea y gestiona las entregas de tus proveedores</p>
</div>
<div className="flex items-center space-x-3">
<Button
variant="outline"
onClick={refresh}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
</div>
</div>
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-700">{error}</span>
</div>
<button
onClick={clearError}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
)}
{/* Performance Statistics Cards */}
{performanceStats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map((stat, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{stat.title}</p>
<p className="text-lg font-bold text-gray-900">{stat.value}</p>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
stat.color === 'blue' ? 'bg-blue-100' :
stat.color === 'green' ? 'bg-green-100' :
stat.color === 'yellow' ? 'bg-yellow-100' :
'bg-purple-100'
}`}>
<stat.icon className={`w-6 h-6 ${
stat.color === 'blue' ? 'text-blue-600' :
stat.color === 'green' ? 'text-green-600' :
stat.color === 'yellow' ? 'text-yellow-600' :
'text-purple-600'
}`} />
</div>
</div>
</Card>
))}
</div>
)}
{/* Today's and Overdue Deliveries */}
{(todaysDeliveries.length > 0 || overdueDeliveries.length > 0) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Today's Deliveries */}
{todaysDeliveries.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Calendar className="w-5 h-5 text-blue-500 mr-2" />
Entregas de Hoy
</h3>
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-sm">
{todaysDeliveries.length}
</span>
</div>
<div className="space-y-3">
{todaysDeliveries.slice(0, 3).map(delivery => (
<DeliveryCard
key={delivery.id}
delivery={delivery}
compact
onReceive={handleReceiveDelivery}
onUpdateStatus={handleUpdateDeliveryStatus}
onViewDetails={(delivery) => setSelectedDeliveryForDetails(delivery)}
/>
))}
{todaysDeliveries.length > 3 && (
<button
onClick={() => {
const today = new Date().toISOString().split('T')[0];
setFilters(prev => ({ ...prev, date_from: today, date_to: today }));
}}
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
Ver {todaysDeliveries.length - 3} más...
</button>
)}
</div>
</Card>
)}
{/* Overdue Deliveries */}
{overdueDeliveries.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
Entregas Vencidas
</h3>
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-sm">
{overdueDeliveries.length}
</span>
</div>
<div className="space-y-3">
{overdueDeliveries.slice(0, 3).map(delivery => (
<DeliveryCard
key={delivery.id}
delivery={delivery}
compact
onReceive={handleReceiveDelivery}
onUpdateStatus={handleUpdateDeliveryStatus}
onViewDetails={(delivery) => setSelectedDeliveryForDetails(delivery)}
/>
))}
{overdueDeliveries.length > 3 && (
<button
onClick={() => {
const today = new Date().toISOString().split('T')[0];
setFilters(prev => ({ ...prev, date_to: today }));
}}
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
Ver {overdueDeliveries.length - 3} más...
</button>
)}
</div>
</Card>
)}
</div>
)}
{/* Performance Insights */}
{performanceStats && (
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<BarChart3 className="w-5 h-5 text-purple-500 mr-2" />
Resumen de Rendimiento (Últimos 30 días)
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="relative w-24 h-24 mx-auto mb-3">
<svg className="w-24 h-24 transform -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="45"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-gray-200"
/>
<circle
cx="50"
cy="50"
r="45"
stroke="currentColor"
strokeWidth="8"
fill="none"
strokeDasharray={`${performanceStats.on_time_percentage * 2.83} 283`}
className="text-green-500"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-gray-900">
{formatPercentage(performanceStats.on_time_percentage)}
</span>
</div>
</div>
<h4 className="font-semibold text-gray-900">Entregas a Tiempo</h4>
<p className="text-sm text-gray-600">
{performanceStats.on_time_deliveries} de {performanceStats.total_deliveries}
</p>
</div>
<div className="text-center">
<div className="relative w-24 h-24 mx-auto mb-3">
<svg className="w-24 h-24 transform -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="45"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-gray-200"
/>
<circle
cx="50"
cy="50"
r="45"
stroke="currentColor"
strokeWidth="8"
fill="none"
strokeDasharray={`${performanceStats.quality_pass_rate * 2.83} 283`}
className="text-blue-500"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-gray-900">
{formatPercentage(performanceStats.quality_pass_rate)}
</span>
</div>
</div>
<h4 className="font-semibold text-gray-900">Calidad Aprobada</h4>
<p className="text-sm text-gray-600">Inspecciones exitosas</p>
</div>
<div className="text-center">
<div className="w-24 h-24 mx-auto mb-3 flex items-center justify-center bg-yellow-100 rounded-full">
<Clock className="w-12 h-12 text-yellow-600" />
</div>
<h4 className="font-semibold text-gray-900">Retraso Promedio</h4>
<p className="text-lg font-bold text-yellow-600">
{performanceStats.avg_delay_hours.toFixed(1)}h
</p>
</div>
</div>
</Card>
)}
{/* Filters and Search */}
<Card>
<div className="flex flex-col lg:flex-row lg:items-center justify-between space-y-4 lg:space-y-0">
<div className="flex items-center space-x-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar entregas..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-64"
/>
</div>
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center space-x-2 px-3 py-2 border rounded-lg transition-colors ${
showFilters ? 'bg-blue-50 border-blue-200 text-blue-700' : 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<Filter className="w-4 h-4" />
<span>Filtros</span>
<ChevronDown className={`w-4 h-4 transform transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{/* Active filters indicator */}
{(filters.status || filters.supplier_id || filters.date_from || filters.date_to) && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Filtros activos:</span>
{filters.status && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{statusOptions.find(opt => opt.value === filters.status)?.label}
</span>
)}
<button
onClick={handleClearFilters}
className="text-xs text-red-600 hover:text-red-700"
>
Limpiar
</button>
</div>
)}
</div>
<div className="flex items-center space-x-3">
{/* View Mode Toggle */}
<div className="flex items-center space-x-1 border border-gray-300 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{statusOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Desde
</label>
<input
type="date"
value={filters.date_from}
onChange={(e) => setFilters(prev => ({ ...prev, date_from: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Hasta
</label>
<input
type="date"
value={filters.date_to}
onChange={(e) => setFilters(prev => ({ ...prev, date_to: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
)}
</Card>
{/* Deliveries List */}
<div>
{deliveries.length === 0 ? (
<Card className="text-center py-12">
<Truck className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron entregas</h3>
<p className="text-gray-600 mb-4">
{filters.search || filters.status || filters.date_from || filters.date_to
? 'Intenta ajustar tus filtros de búsqueda'
: 'Las entregas aparecerán aquí cuando se programen'
}
</p>
</Card>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'space-y-4'
}>
{deliveries.map(delivery => (
<DeliveryCard
key={delivery.id}
delivery={delivery}
compact={viewMode === 'list'}
onViewDetails={(delivery) => setSelectedDeliveryForDetails(delivery)}
onReceive={handleReceiveDelivery}
onUpdateStatus={handleUpdateDeliveryStatus}
/>
))}
</div>
)}
{/* Pagination */}
{deliveries.length > 0 && pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="text-sm text-gray-700">
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
{pagination.total} entregas
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setPage(pagination.page - 1)}
disabled={pagination.page === 1}
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Anterior
</button>
<span className="px-3 py-2 bg-blue-50 text-blue-600 rounded-lg">
{pagination.page}
</span>
<button
onClick={() => setPage(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Siguiente
</button>
</div>
</div>
)}
</div>
{/* Delivery Details Modal */}
{selectedDeliveryForDetails && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden mx-4">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Detalles de Entrega: {selectedDeliveryForDetails.delivery_number}
</h2>
<button
onClick={() => setSelectedDeliveryForDetails(null)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[70vh]">
<DeliveryCard
delivery={selectedDeliveryForDetails}
compact={false}
showActions={true}
onReceive={handleReceiveDelivery}
onUpdateStatus={handleUpdateDeliveryStatus}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default DeliveryTrackingPage;

View File

@@ -0,0 +1,482 @@
import React, { useState } from 'react';
import {
FileText,
Building,
Calendar,
DollarSign,
Package,
Clock,
CheckCircle,
AlertCircle,
XCircle,
Truck,
Eye,
Edit3,
MoreVertical,
Send,
X,
AlertTriangle
} from 'lucide-react';
import {
PurchaseOrder,
UpdateSupplierRequest
} from '../../api/services/suppliers.service';
interface PurchaseOrderCardProps {
order: PurchaseOrder;
compact?: boolean;
showActions?: boolean;
onEdit?: (order: PurchaseOrder) => void;
onViewDetails?: (order: PurchaseOrder) => void;
onUpdateStatus?: (order: PurchaseOrder, status: string, notes?: string) => void;
onApprove?: (order: PurchaseOrder, action: 'approve' | 'reject', notes?: string) => void;
onSendToSupplier?: (order: PurchaseOrder, sendEmail?: boolean) => void;
onCancel?: (order: PurchaseOrder, reason: string) => void;
className?: string;
}
const PurchaseOrderCard: React.FC<PurchaseOrderCardProps> = ({
order,
compact = false,
showActions = true,
onEdit,
onViewDetails,
onUpdateStatus,
onApprove,
onSendToSupplier,
onCancel,
className = ''
}) => {
const [showApprovalDialog, setShowApprovalDialog] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [approvalNotes, setApprovalNotes] = useState('');
const [cancelReason, setCancelReason] = useState('');
// Get status display info
const getStatusInfo = () => {
const statusConfig = {
DRAFT: { label: 'Borrador', color: 'gray', icon: FileText },
PENDING_APPROVAL: { label: 'Pendiente Aprobación', color: 'yellow', icon: Clock },
APPROVED: { label: 'Aprobado', color: 'green', icon: CheckCircle },
SENT_TO_SUPPLIER: { label: 'Enviado a Proveedor', color: 'blue', icon: Send },
CONFIRMED: { label: 'Confirmado', color: 'green', icon: CheckCircle },
PARTIALLY_RECEIVED: { label: 'Recibido Parcial', color: 'blue', icon: Package },
COMPLETED: { label: 'Completado', color: 'green', icon: CheckCircle },
CANCELLED: { label: 'Cancelado', color: 'red', icon: XCircle },
DISPUTED: { label: 'En Disputa', color: 'red', icon: AlertTriangle }
};
return statusConfig[order.status as keyof typeof statusConfig] || statusConfig.DRAFT;
};
const statusInfo = getStatusInfo();
const StatusIcon = statusInfo.icon;
// Get priority display info
const getPriorityInfo = () => {
const priorityConfig = {
LOW: { label: 'Baja', color: 'gray' },
NORMAL: { label: 'Normal', color: 'blue' },
HIGH: { label: 'Alta', color: 'orange' },
URGENT: { label: 'Urgente', color: 'red' }
};
return priorityConfig[order.priority as keyof typeof priorityConfig] || priorityConfig.NORMAL;
};
const priorityInfo = getPriorityInfo();
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: order.currency || 'EUR'
}).format(amount);
};
// Format date
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
// Check if order is overdue
const isOverdue = () => {
if (!order.required_delivery_date) return false;
return new Date(order.required_delivery_date) < new Date() &&
!['COMPLETED', 'CANCELLED'].includes(order.status);
};
// Handle approval action
const handleApprovalAction = (action: 'approve' | 'reject') => {
if (!onApprove) return;
if (action === 'reject' && !approvalNotes.trim()) {
alert('Se requiere una razón para rechazar el pedido');
return;
}
onApprove(order, action, approvalNotes.trim() || undefined);
setShowApprovalDialog(false);
setApprovalNotes('');
};
// Handle cancel order
const handleCancelOrder = () => {
if (!onCancel) return;
if (!cancelReason.trim()) {
alert('Se requiere una razón para cancelar el pedido');
return;
}
onCancel(order, cancelReason.trim());
setShowCancelDialog(false);
setCancelReason('');
};
if (compact) {
return (
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
statusInfo.color === 'green' ? 'bg-green-100' :
statusInfo.color === 'blue' ? 'bg-blue-100' :
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
statusInfo.color === 'red' ? 'bg-red-100' :
'bg-gray-100'
}`}>
<StatusIcon className={`w-5 h-5 ${
statusInfo.color === 'green' ? 'text-green-600' :
statusInfo.color === 'blue' ? 'text-blue-600' :
statusInfo.color === 'yellow' ? 'text-yellow-600' :
statusInfo.color === 'red' ? 'text-red-600' :
'text-gray-600'
}`} />
</div>
<div>
<h4 className="font-medium text-gray-900">{order.po_number}</h4>
<p className="text-sm text-gray-500">
{order.supplier?.name || 'Proveedor no disponible'}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="text-right">
<div className="text-sm font-medium text-gray-900">
{formatCurrency(order.total_amount)}
</div>
<div className={`text-xs px-2 py-1 rounded-full ${
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{statusInfo.label}
</div>
</div>
{showActions && onViewDetails && (
<button
onClick={() => onViewDetails(order)}
className="p-1 hover:bg-gray-100 rounded"
>
<Eye className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
</div>
{isOverdue() && (
<div className="mt-3 flex items-center space-x-1 text-red-600 text-xs">
<AlertTriangle className="w-3 h-3" />
<span>Fecha de entrega vencida</span>
</div>
)}
</div>
);
}
return (
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
statusInfo.color === 'green' ? 'bg-green-100' :
statusInfo.color === 'blue' ? 'bg-blue-100' :
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
statusInfo.color === 'red' ? 'bg-red-100' :
'bg-gray-100'
}`}>
<StatusIcon className={`w-6 h-6 ${
statusInfo.color === 'green' ? 'text-green-600' :
statusInfo.color === 'blue' ? 'text-blue-600' :
statusInfo.color === 'yellow' ? 'text-yellow-600' :
statusInfo.color === 'red' ? 'text-red-600' :
'text-gray-600'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">{order.po_number}</h3>
{order.reference_number && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
Ref: {order.reference_number}
</span>
)}
{isOverdue() && (
<span className="px-2 py-1 bg-red-100 text-red-600 text-xs rounded-full flex items-center space-x-1">
<AlertTriangle className="w-3 h-3" />
<span>Vencido</span>
</span>
)}
</div>
<div className="flex items-center space-x-4 text-sm text-gray-600">
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
<StatusIcon className="w-3 h-3" />
<span>{statusInfo.label}</span>
</div>
<div className={`px-2 py-1 rounded-full text-xs ${
priorityInfo.color === 'red' ? 'bg-red-100 text-red-800' :
priorityInfo.color === 'orange' ? 'bg-orange-100 text-orange-800' :
priorityInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{priorityInfo.label}
</div>
</div>
{/* Supplier information */}
<div className="flex items-center space-x-1 mt-2 text-sm text-gray-600">
<Building className="w-3 h-3" />
<span>{order.supplier?.name || 'Proveedor no disponible'}</span>
</div>
</div>
</div>
{showActions && (
<div className="flex items-center space-x-1">
{order.status === 'PENDING_APPROVAL' && onApprove && (
<button
onClick={() => setShowApprovalDialog(true)}
className="p-2 hover:bg-yellow-50 text-yellow-600 rounded-lg transition-colors"
title="Revisar aprobación"
>
<Clock className="w-4 h-4" />
</button>
)}
{order.status === 'APPROVED' && onSendToSupplier && (
<button
onClick={() => onSendToSupplier(order, true)}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
title="Enviar a proveedor"
>
<Send className="w-4 h-4" />
</button>
)}
{onEdit && ['DRAFT', 'PENDING_APPROVAL'].includes(order.status) && (
<button
onClick={() => onEdit(order)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Editar"
>
<Edit3 className="w-4 h-4 text-gray-600" />
</button>
)}
{onViewDetails && (
<button
onClick={() => onViewDetails(order)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Ver detalles"
>
<Eye className="w-4 h-4 text-gray-600" />
</button>
)}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<MoreVertical className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
</div>
</div>
{/* Order Details */}
<div className="px-6 pb-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-gray-900">
<DollarSign className="w-5 h-5 text-gray-500" />
<span>{formatCurrency(order.total_amount)}</span>
</div>
<div className="text-sm text-gray-500">Total</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-blue-600">
<Package className="w-4 h-4 text-blue-500" />
<span>{order.items?.length || 0}</span>
</div>
<div className="text-sm text-gray-500">Artículos</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-green-600">
<Calendar className="w-4 h-4 text-green-500" />
<span>{formatDate(order.order_date)}</span>
</div>
<div className="text-sm text-gray-500">Pedido</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-purple-600">
<Truck className="w-4 h-4 text-purple-500" />
<span>
{order.required_delivery_date
? formatDate(order.required_delivery_date)
: 'N/A'
}
</span>
</div>
<div className="text-sm text-gray-500">Entrega</div>
</div>
</div>
</div>
{/* Additional Information */}
{(order.notes || order.internal_notes) && (
<div className="px-6 pb-6">
<div className="bg-gray-50 rounded-lg p-4">
{order.notes && (
<div className="mb-2">
<span className="text-sm font-medium text-gray-700">Notas: </span>
<span className="text-sm text-gray-600">{order.notes}</span>
</div>
)}
{order.internal_notes && (
<div>
<span className="text-sm font-medium text-gray-700">Notas internas: </span>
<span className="text-sm text-gray-600">{order.internal_notes}</span>
</div>
)}
</div>
</div>
)}
{/* Approval Dialog */}
{showApprovalDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Revisar Pedido: {order.po_number}
</h3>
<div className="mb-4">
<label htmlFor="approval-notes" className="block text-sm font-medium text-gray-700 mb-2">
Notas (opcional para aprobación, requerido para rechazo)
</label>
<textarea
id="approval-notes"
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows={3}
placeholder="Escribe tus comentarios aquí..."
/>
</div>
<div className="flex space-x-3">
<button
onClick={() => handleApprovalAction('approve')}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Aprobar
</button>
<button
onClick={() => handleApprovalAction('reject')}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Rechazar
</button>
<button
onClick={() => {
setShowApprovalDialog(false);
setApprovalNotes('');
}}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
</div>
</div>
</div>
)}
{/* Cancel Dialog */}
{showCancelDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Cancelar Pedido: {order.po_number}
</h3>
<div className="mb-4">
<label htmlFor="cancel-reason" className="block text-sm font-medium text-gray-700 mb-2">
Razón de cancelación *
</label>
<textarea
id="cancel-reason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows={3}
placeholder="Explica por qué se cancela el pedido..."
/>
</div>
<div className="flex space-x-3">
<button
onClick={handleCancelOrder}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Cancelar Pedido
</button>
<button
onClick={() => {
setShowCancelDialog(false);
setCancelReason('');
}}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cerrar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PurchaseOrderCard;

View File

@@ -0,0 +1,848 @@
import React, { useState, useEffect } from 'react';
import {
X,
Building,
Calendar,
Package,
DollarSign,
Plus,
Trash2,
FileText
} from 'lucide-react';
import {
CreatePurchaseOrderRequest,
PurchaseOrder,
SupplierSummary
} from '../../api/services/suppliers.service';
import { useSuppliers } from '../../api/hooks/useSuppliers';
import { useInventory } from '../../api/hooks/useInventory';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface PurchaseOrderFormProps {
order?: PurchaseOrder | null;
isOpen: boolean;
isCreating?: boolean;
onSubmit: (data: CreatePurchaseOrderRequest) => Promise<void>;
onClose: () => void;
}
interface OrderItem {
ingredient_id: string;
product_code: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
unit_price: number;
quality_requirements: string;
item_notes: string;
}
interface FormData {
supplier_id: string;
reference_number: string;
priority: string;
required_delivery_date: string;
delivery_address: string;
delivery_instructions: string;
delivery_contact: string;
delivery_phone: string;
tax_amount: string;
shipping_cost: string;
discount_amount: string;
notes: string;
internal_notes: string;
terms_and_conditions: string;
items: OrderItem[];
}
const initialFormData: FormData = {
supplier_id: '',
reference_number: '',
priority: 'NORMAL',
required_delivery_date: '',
delivery_address: '',
delivery_instructions: '',
delivery_contact: '',
delivery_phone: '',
tax_amount: '',
shipping_cost: '',
discount_amount: '',
notes: '',
internal_notes: '',
terms_and_conditions: '',
items: []
};
const initialOrderItem: OrderItem = {
ingredient_id: '',
product_code: '',
product_name: '',
ordered_quantity: 0,
unit_of_measure: '',
unit_price: 0,
quality_requirements: '',
item_notes: ''
};
const PurchaseOrderForm: React.FC<PurchaseOrderFormProps> = ({
order,
isOpen,
isCreating = false,
onSubmit,
onClose
}) => {
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [activeTab, setActiveTab] = useState<'basic' | 'delivery' | 'items' | 'financial'>('basic');
const { activeSuppliers, loadActiveSuppliers } = useSuppliers();
const { ingredients, loadInventoryItems } = useInventory();
// Initialize form data when order changes
useEffect(() => {
if (order) {
setFormData({
supplier_id: order.supplier_id || '',
reference_number: order.reference_number || '',
priority: order.priority || 'NORMAL',
required_delivery_date: order.required_delivery_date ? order.required_delivery_date.split('T')[0] : '',
delivery_address: order.delivery_address || '',
delivery_instructions: order.delivery_instructions || '',
delivery_contact: order.delivery_contact || '',
delivery_phone: order.delivery_phone || '',
tax_amount: order.tax_amount?.toString() || '',
shipping_cost: order.shipping_cost?.toString() || '',
discount_amount: order.discount_amount?.toString() || '',
notes: order.notes || '',
internal_notes: order.internal_notes || '',
terms_and_conditions: order.terms_and_conditions || '',
items: order.items?.map(item => ({
ingredient_id: item.ingredient_id,
product_code: item.product_code || '',
product_name: item.product_name,
ordered_quantity: item.ordered_quantity,
unit_of_measure: item.unit_of_measure,
unit_price: item.unit_price,
quality_requirements: item.quality_requirements || '',
item_notes: item.item_notes || ''
})) || []
});
} else {
setFormData(initialFormData);
}
setErrors({});
setActiveTab('basic');
}, [order]);
// Load suppliers and ingredients
useEffect(() => {
if (isOpen) {
loadActiveSuppliers();
loadInventoryItems({ product_type: 'ingredient' });
}
}, [isOpen]);
// Priority options
const priorityOptions = [
{ value: 'LOW', label: 'Baja' },
{ value: 'NORMAL', label: 'Normal' },
{ value: 'HIGH', label: 'Alta' },
{ value: 'URGENT', label: 'Urgente' }
];
// Handle input change
const handleInputChange = (field: keyof FormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
// Handle item change
const handleItemChange = (index: number, field: keyof OrderItem, value: string | number) => {
setFormData(prev => ({
...prev,
items: prev.items.map((item, i) =>
i === index ? { ...item, [field]: value } : item
)
}));
};
// Add new item
const addItem = () => {
setFormData(prev => ({
...prev,
items: [...prev.items, { ...initialOrderItem }]
}));
};
// Remove item
const removeItem = (index: number) => {
setFormData(prev => ({
...prev,
items: prev.items.filter((_, i) => i !== index)
}));
};
// Select ingredient
const selectIngredient = (index: number, ingredientId: string) => {
const ingredient = ingredients.find(ing => ing.id === ingredientId);
if (ingredient) {
handleItemChange(index, 'ingredient_id', ingredientId);
handleItemChange(index, 'product_name', ingredient.name);
handleItemChange(index, 'unit_of_measure', ingredient.unit_of_measure);
handleItemChange(index, 'product_code', ingredient.sku || '');
}
};
// Calculate totals
const calculateTotals = () => {
const subtotal = formData.items.reduce((sum, item) =>
sum + (item.ordered_quantity * item.unit_price), 0
);
const tax = parseFloat(formData.tax_amount) || 0;
const shipping = parseFloat(formData.shipping_cost) || 0;
const discount = parseFloat(formData.discount_amount) || 0;
const total = subtotal + tax + shipping - discount;
return { subtotal, tax, shipping, discount, total };
};
const totals = calculateTotals();
// Validate form
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Required fields
if (!formData.supplier_id) {
newErrors.supplier_id = 'Proveedor es requerido';
}
if (formData.items.length === 0) {
newErrors.items = 'Debe agregar al menos un artículo';
}
// Validate items
formData.items.forEach((item, index) => {
if (!item.ingredient_id) {
newErrors[`item_${index}_ingredient`] = 'Ingrediente es requerido';
}
if (!item.product_name) {
newErrors[`item_${index}_name`] = 'Nombre es requerido';
}
if (item.ordered_quantity <= 0) {
newErrors[`item_${index}_quantity`] = 'Cantidad debe ser mayor a 0';
}
if (item.unit_price < 0) {
newErrors[`item_${index}_price`] = 'Precio debe ser mayor o igual a 0';
}
});
// Date validation
if (formData.required_delivery_date && new Date(formData.required_delivery_date) < new Date()) {
newErrors.required_delivery_date = 'Fecha de entrega no puede ser en el pasado';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
// Prepare submission data
const submissionData: CreatePurchaseOrderRequest = {
supplier_id: formData.supplier_id,
reference_number: formData.reference_number || undefined,
priority: formData.priority || undefined,
required_delivery_date: formData.required_delivery_date || undefined,
delivery_address: formData.delivery_address || undefined,
delivery_instructions: formData.delivery_instructions || undefined,
delivery_contact: formData.delivery_contact || undefined,
delivery_phone: formData.delivery_phone || undefined,
tax_amount: formData.tax_amount ? parseFloat(formData.tax_amount) : undefined,
shipping_cost: formData.shipping_cost ? parseFloat(formData.shipping_cost) : undefined,
discount_amount: formData.discount_amount ? parseFloat(formData.discount_amount) : undefined,
notes: formData.notes || undefined,
internal_notes: formData.internal_notes || undefined,
terms_and_conditions: formData.terms_and_conditions || undefined,
items: formData.items.map(item => ({
ingredient_id: item.ingredient_id,
product_code: item.product_code || undefined,
product_name: item.product_name,
ordered_quantity: item.ordered_quantity,
unit_of_measure: item.unit_of_measure,
unit_price: item.unit_price,
quality_requirements: item.quality_requirements || undefined,
item_notes: item.item_notes || undefined
}))
};
await onSubmit(submissionData);
};
if (!isOpen) return null;
const tabs = [
{ id: 'basic' as const, label: 'Información Básica', icon: FileText },
{ id: 'items' as const, label: 'Artículos', icon: Package },
{ id: 'delivery' as const, label: 'Entrega', icon: Calendar },
{ id: 'financial' as const, label: 'Información Financiera', icon: DollarSign }
];
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-6xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
{order ? 'Editar Orden de Compra' : 'Nueva Orden de Compra'}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center space-x-2 px-4 py-3 border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<tab.icon className="w-4 h-4" />
<span className="text-sm font-medium">{tab.label}</span>
</button>
))}
</div>
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-6">
{/* Basic Information Tab */}
{activeTab === 'basic' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Proveedor *
</label>
<select
value={formData.supplier_id}
onChange={(e) => handleInputChange('supplier_id', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.supplier_id ? 'border-red-300' : 'border-gray-300'
}`}
>
<option value="">Seleccionar proveedor</option>
{activeSuppliers.map(supplier => (
<option key={supplier.id} value={supplier.id}>
{supplier.name}
</option>
))}
</select>
{errors.supplier_id && <p className="text-red-600 text-sm mt-1">{errors.supplier_id}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Número de Referencia
</label>
<input
type="text"
value={formData.reference_number}
onChange={(e) => handleInputChange('reference_number', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="REF-001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Prioridad
</label>
<select
value={formData.priority}
onChange={(e) => handleInputChange('priority', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{priorityOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha de Entrega Requerida
</label>
<input
type="date"
value={formData.required_delivery_date}
onChange={(e) => handleInputChange('required_delivery_date', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.required_delivery_date ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.required_delivery_date && <p className="text-red-600 text-sm mt-1">{errors.required_delivery_date}</p>}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas
</label>
<textarea
value={formData.notes}
onChange={(e) => handleInputChange('notes', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Notas sobre el pedido..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas Internas
</label>
<textarea
value={formData.internal_notes}
onChange={(e) => handleInputChange('internal_notes', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Notas internas (no visibles para el proveedor)..."
/>
</div>
</div>
)}
{/* Items Tab */}
{activeTab === 'items' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">Artículos del Pedido</h3>
<Button
type="button"
variant="outline"
onClick={addItem}
>
<Plus className="w-4 h-4 mr-2" />
Agregar Artículo
</Button>
</div>
{errors.items && <p className="text-red-600 text-sm">{errors.items}</p>}
<div className="space-y-4">
{formData.items.map((item, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900">Artículo {index + 1}</h4>
<button
type="button"
onClick={() => removeItem(index)}
className="text-red-600 hover:text-red-700 p-1"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ingrediente *
</label>
<select
value={item.ingredient_id}
onChange={(e) => selectIngredient(index, e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors[`item_${index}_ingredient`] ? 'border-red-300' : 'border-gray-300'
}`}
>
<option value="">Seleccionar ingrediente</option>
{ingredients.map(ingredient => (
<option key={ingredient.id} value={ingredient.id}>
{ingredient.name}
</option>
))}
</select>
{errors[`item_${index}_ingredient`] && (
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_ingredient`]}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Código de Producto
</label>
<input
type="text"
value={item.product_code}
onChange={(e) => handleItemChange(index, 'product_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Código"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre del Producto *
</label>
<input
type="text"
value={item.product_name}
onChange={(e) => handleItemChange(index, 'product_name', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors[`item_${index}_name`] ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Nombre del producto"
/>
{errors[`item_${index}_name`] && (
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_name`]}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cantidad *
</label>
<input
type="number"
step="0.01"
value={item.ordered_quantity}
onChange={(e) => handleItemChange(index, 'ordered_quantity', parseFloat(e.target.value) || 0)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors[`item_${index}_quantity`] ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0"
/>
{errors[`item_${index}_quantity`] && (
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_quantity`]}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Unidad de Medida
</label>
<input
type="text"
value={item.unit_of_measure}
onChange={(e) => handleItemChange(index, 'unit_of_measure', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="kg, L, unidad"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Precio Unitario
</label>
<input
type="number"
step="0.01"
value={item.unit_price}
onChange={(e) => handleItemChange(index, 'unit_price', parseFloat(e.target.value) || 0)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors[`item_${index}_price`] ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0.00"
/>
{errors[`item_${index}_price`] && (
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_price`]}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Requisitos de Calidad
</label>
<textarea
value={item.quality_requirements}
onChange={(e) => handleItemChange(index, 'quality_requirements', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Especificaciones de calidad..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas del Artículo
</label>
<textarea
value={item.item_notes}
onChange={(e) => handleItemChange(index, 'item_notes', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Notas específicas para este artículo..."
/>
</div>
</div>
<div className="mt-3 text-right">
<span className="text-sm text-gray-600">
Subtotal: <span className="font-medium">
{new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(item.ordered_quantity * item.unit_price)}
</span>
</span>
</div>
</div>
))}
</div>
{formData.items.length === 0 && (
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
<Package className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay artículos</h3>
<p className="text-gray-600 mb-4">Agregar artículos a tu orden de compra</p>
<Button
type="button"
variant="outline"
onClick={addItem}
>
<Plus className="w-4 h-4 mr-2" />
Agregar Primer Artículo
</Button>
</div>
)}
</div>
)}
{/* Delivery Tab */}
{activeTab === 'delivery' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dirección de Entrega
</label>
<textarea
value={formData.delivery_address}
onChange={(e) => handleInputChange('delivery_address', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Dirección completa de entrega..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Persona de Contacto
</label>
<input
type="text"
value={formData.delivery_contact}
onChange={(e) => handleInputChange('delivery_contact', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Nombre del contacto"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Teléfono de Contacto
</label>
<input
type="tel"
value={formData.delivery_phone}
onChange={(e) => handleInputChange('delivery_phone', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="+34 123 456 789"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones de Entrega
</label>
<textarea
value={formData.delivery_instructions}
onChange={(e) => handleInputChange('delivery_instructions', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Instrucciones específicas para la entrega..."
/>
</div>
</div>
)}
{/* Financial Tab */}
{activeTab === 'financial' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Impuestos
</label>
<input
type="number"
step="0.01"
value={formData.tax_amount}
onChange={(e) => handleInputChange('tax_amount', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Costo de Envío
</label>
<input
type="number"
step="0.01"
value={formData.shipping_cost}
onChange={(e) => handleInputChange('shipping_cost', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Descuento
</label>
<input
type="number"
step="0.01"
value={formData.discount_amount}
onChange={(e) => handleInputChange('discount_amount', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
/>
</div>
</div>
{/* Order Summary */}
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50">
<h4 className="font-medium text-gray-900 mb-3">Resumen del Pedido</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal:</span>
<span className="font-medium">
{new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(totals.subtotal)}
</span>
</div>
{totals.tax > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">Impuestos:</span>
<span className="font-medium">
{new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(totals.tax)}
</span>
</div>
)}
{totals.shipping > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">Envío:</span>
<span className="font-medium">
{new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(totals.shipping)}
</span>
</div>
)}
{totals.discount > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">Descuento:</span>
<span className="font-medium text-red-600">
-{new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(totals.discount)}
</span>
</div>
)}
<div className="border-t pt-2">
<div className="flex justify-between">
<span className="font-semibold text-gray-900">Total:</span>
<span className="font-bold text-lg">
{new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(totals.total)}
</span>
</div>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Términos y Condiciones
</label>
<textarea
value={formData.terms_and_conditions}
onChange={(e) => handleInputChange('terms_and_conditions', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Términos y condiciones del pedido..."
/>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end space-x-3 p-6 border-t bg-gray-50">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isCreating}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isCreating || formData.items.length === 0}
>
{isCreating ? (
<div className="flex items-center space-x-2">
<LoadingSpinner size="sm" />
<span>Guardando...</span>
</div>
) : (
<span>{order ? 'Actualizar Orden' : 'Crear Orden'}</span>
)}
</Button>
</div>
</form>
</div>
</div>
);
};
export default PurchaseOrderForm;

View File

@@ -0,0 +1,619 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Search,
Filter,
Plus,
Download,
RefreshCw,
ChevronDown,
FileText,
TrendingUp,
Clock,
AlertCircle,
Package,
DollarSign,
Grid3X3,
List
} from 'lucide-react';
import {
usePurchaseOrders,
PurchaseOrder,
CreatePurchaseOrderRequest
} from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import PurchaseOrderCard from './PurchaseOrderCard';
import PurchaseOrderForm from './PurchaseOrderForm';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface PurchaseOrderFilters {
search: string;
supplier_id: string;
status: string;
priority: string;
date_from: string;
date_to: string;
}
const PurchaseOrderManagementPage: React.FC = () => {
const { user } = useAuth();
const {
purchaseOrders,
purchaseOrder: selectedPurchaseOrder,
statistics,
ordersRequiringApproval,
overdueOrders,
isLoading,
isCreating,
error,
pagination,
loadPurchaseOrders,
loadPurchaseOrder,
loadStatistics,
loadOrdersRequiringApproval,
loadOverdueOrders,
createPurchaseOrder,
updateOrderStatus,
approveOrder,
sendToSupplier,
cancelOrder,
clearError,
refresh,
setPage
} = usePurchaseOrders();
const [filters, setFilters] = useState<PurchaseOrderFilters>({
search: '',
supplier_id: '',
status: '',
priority: '',
date_from: '',
date_to: ''
});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
const [showPurchaseOrderForm, setShowPurchaseOrderForm] = useState(false);
const [selectedOrder, setSelectedOrder] = useState<PurchaseOrder | null>(null);
// Load initial data
useEffect(() => {
if (user?.tenant_id) {
loadPurchaseOrders();
loadStatistics();
loadOrdersRequiringApproval();
loadOverdueOrders();
}
}, [user?.tenant_id]);
// Apply filters
useEffect(() => {
const searchParams: any = {};
if (filters.search) {
searchParams.search_term = filters.search;
}
if (filters.supplier_id) {
searchParams.supplier_id = filters.supplier_id;
}
if (filters.status) {
searchParams.status = filters.status;
}
if (filters.priority) {
searchParams.priority = filters.priority;
}
if (filters.date_from) {
searchParams.date_from = filters.date_from;
}
if (filters.date_to) {
searchParams.date_to = filters.date_to;
}
loadPurchaseOrders(searchParams);
}, [filters]);
// Status options
const statusOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'DRAFT', label: 'Borrador' },
{ value: 'PENDING_APPROVAL', label: 'Pendiente Aprobación' },
{ value: 'APPROVED', label: 'Aprobado' },
{ value: 'SENT_TO_SUPPLIER', label: 'Enviado a Proveedor' },
{ value: 'CONFIRMED', label: 'Confirmado' },
{ value: 'PARTIALLY_RECEIVED', label: 'Recibido Parcial' },
{ value: 'COMPLETED', label: 'Completado' },
{ value: 'CANCELLED', label: 'Cancelado' },
{ value: 'DISPUTED', label: 'En Disputa' }
];
// Priority options
const priorityOptions = [
{ value: '', label: 'Todas las prioridades' },
{ value: 'LOW', label: 'Baja' },
{ value: 'NORMAL', label: 'Normal' },
{ value: 'HIGH', label: 'Alta' },
{ value: 'URGENT', label: 'Urgente' }
];
// Handle purchase order creation
const handleCreatePurchaseOrder = async (orderData: CreatePurchaseOrderRequest) => {
const order = await createPurchaseOrder(orderData);
if (order) {
setShowPurchaseOrderForm(false);
// Refresh statistics and special lists
loadStatistics();
if (order.status === 'PENDING_APPROVAL') loadOrdersRequiringApproval();
}
};
// Handle order approval
const handleApproveOrder = async (
order: PurchaseOrder,
action: 'approve' | 'reject',
notes?: string
) => {
const updatedOrder = await approveOrder(order.id, action, notes);
if (updatedOrder) {
// Refresh relevant lists
loadOrdersRequiringApproval();
loadStatistics();
}
};
// Handle send to supplier
const handleSendToSupplier = async (order: PurchaseOrder, sendEmail: boolean = true) => {
const updatedOrder = await sendToSupplier(order.id, sendEmail);
if (updatedOrder) {
loadStatistics();
}
};
// Handle cancel order
const handleCancelOrder = async (order: PurchaseOrder, reason: string) => {
const updatedOrder = await cancelOrder(order.id, reason);
if (updatedOrder) {
loadStatistics();
}
};
// Handle clear filters
const handleClearFilters = () => {
setFilters({
search: '',
supplier_id: '',
status: '',
priority: '',
date_from: '',
date_to: ''
});
};
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
// Statistics cards data
const statsCards = useMemo(() => {
if (!statistics) return [];
return [
{
title: 'Total Pedidos',
value: statistics.total_orders.toString(),
icon: FileText,
color: 'blue'
},
{
title: 'Este Mes',
value: statistics.this_month_orders.toString(),
icon: TrendingUp,
color: 'green'
},
{
title: 'Pendientes Aprobación',
value: statistics.pending_approval.toString(),
icon: Clock,
color: 'yellow'
},
{
title: 'Gasto Este Mes',
value: formatCurrency(statistics.this_month_spend),
icon: DollarSign,
color: 'purple'
}
];
}, [statistics]);
if (isLoading && !purchaseOrders.length) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Órdenes de Compra</h1>
<p className="text-gray-600">Gestiona tus pedidos y compras a proveedores</p>
</div>
<div className="flex items-center space-x-3">
<Button
variant="outline"
onClick={refresh}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button
onClick={() => setShowPurchaseOrderForm(true)}
disabled={isCreating}
>
<Plus className="w-4 h-4 mr-2" />
Nueva Orden
</Button>
</div>
</div>
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-700">{error}</span>
</div>
<button
onClick={clearError}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
)}
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map((stat, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{stat.title}</p>
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
stat.color === 'blue' ? 'bg-blue-100' :
stat.color === 'green' ? 'bg-green-100' :
stat.color === 'yellow' ? 'bg-yellow-100' :
'bg-purple-100'
}`}>
<stat.icon className={`w-6 h-6 ${
stat.color === 'blue' ? 'text-blue-600' :
stat.color === 'green' ? 'text-green-600' :
stat.color === 'yellow' ? 'text-yellow-600' :
'text-purple-600'
}`} />
</div>
</div>
</Card>
))}
</div>
{/* Quick Lists */}
{(ordersRequiringApproval.length > 0 || overdueOrders.length > 0) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Orders Requiring Approval */}
{ordersRequiringApproval.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Clock className="w-5 h-5 text-yellow-500 mr-2" />
Requieren Aprobación
</h3>
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-sm">
{ordersRequiringApproval.length}
</span>
</div>
<div className="space-y-3">
{ordersRequiringApproval.slice(0, 3).map(order => (
<PurchaseOrderCard
key={order.id}
order={order}
compact
onApprove={handleApproveOrder}
onViewDetails={(order) => setSelectedOrder(order)}
/>
))}
{ordersRequiringApproval.length > 3 && (
<button
onClick={() => setFilters(prev => ({ ...prev, status: 'PENDING_APPROVAL' }))}
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
Ver {ordersRequiringApproval.length - 3} más...
</button>
)}
</div>
</Card>
)}
{/* Overdue Orders */}
{overdueOrders.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
Pedidos Vencidos
</h3>
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-sm">
{overdueOrders.length}
</span>
</div>
<div className="space-y-3">
{overdueOrders.slice(0, 3).map(order => (
<PurchaseOrderCard
key={order.id}
order={order}
compact
onViewDetails={(order) => setSelectedOrder(order)}
/>
))}
{overdueOrders.length > 3 && (
<button
onClick={() => {
const today = new Date().toISOString().split('T')[0];
setFilters(prev => ({ ...prev, date_to: today }));
}}
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
Ver {overdueOrders.length - 3} más...
</button>
)}
</div>
</Card>
)}
</div>
)}
{/* Filters and Search */}
<Card>
<div className="flex flex-col lg:flex-row lg:items-center justify-between space-y-4 lg:space-y-0">
<div className="flex items-center space-x-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar pedidos..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-64"
/>
</div>
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center space-x-2 px-3 py-2 border rounded-lg transition-colors ${
showFilters ? 'bg-blue-50 border-blue-200 text-blue-700' : 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<Filter className="w-4 h-4" />
<span>Filtros</span>
<ChevronDown className={`w-4 h-4 transform transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{/* Active filters indicator */}
{(filters.status || filters.priority || filters.supplier_id || filters.date_from || filters.date_to) && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Filtros activos:</span>
{filters.status && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{statusOptions.find(opt => opt.value === filters.status)?.label}
</span>
)}
{filters.priority && (
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
{priorityOptions.find(opt => opt.value === filters.priority)?.label}
</span>
)}
<button
onClick={handleClearFilters}
className="text-xs text-red-600 hover:text-red-700"
>
Limpiar
</button>
</div>
)}
</div>
<div className="flex items-center space-x-3">
{/* View Mode Toggle */}
<div className="flex items-center space-x-1 border border-gray-300 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{statusOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Prioridad
</label>
<select
value={filters.priority}
onChange={(e) => setFilters(prev => ({ ...prev, priority: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{priorityOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Fecha Desde
</label>
<input
type="date"
value={filters.date_from}
onChange={(e) => setFilters(prev => ({ ...prev, date_from: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
)}
</Card>
{/* Purchase Orders List */}
<div>
{purchaseOrders.length === 0 ? (
<Card className="text-center py-12">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron pedidos</h3>
<p className="text-gray-600 mb-4">
{filters.search || filters.status || filters.priority
? 'Intenta ajustar tus filtros de búsqueda'
: 'Comienza creando tu primera orden de compra'
}
</p>
{!(filters.search || filters.status || filters.priority) && (
<Button onClick={() => setShowPurchaseOrderForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nueva Orden
</Button>
)}
</Card>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'space-y-4'
}>
{purchaseOrders.map(order => (
<PurchaseOrderCard
key={order.id}
order={order}
compact={viewMode === 'list'}
onEdit={(order) => {
setSelectedOrder(order);
setShowPurchaseOrderForm(true);
}}
onViewDetails={(order) => setSelectedOrder(order)}
onApprove={handleApproveOrder}
onSendToSupplier={handleSendToSupplier}
onCancel={handleCancelOrder}
/>
))}
</div>
)}
{/* Pagination */}
{purchaseOrders.length > 0 && pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="text-sm text-gray-700">
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
{pagination.total} pedidos
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setPage(pagination.page - 1)}
disabled={pagination.page === 1}
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Anterior
</button>
<span className="px-3 py-2 bg-blue-50 text-blue-600 rounded-lg">
{pagination.page}
</span>
<button
onClick={() => setPage(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Siguiente
</button>
</div>
</div>
)}
</div>
{/* Purchase Order Form Modal */}
{showPurchaseOrderForm && (
<PurchaseOrderForm
order={selectedOrder}
isOpen={showPurchaseOrderForm}
isCreating={isCreating}
onSubmit={selectedOrder ?
(data) => {
// Handle update logic here if needed
setShowPurchaseOrderForm(false);
setSelectedOrder(null);
} :
handleCreatePurchaseOrder
}
onClose={() => {
setShowPurchaseOrderForm(false);
setSelectedOrder(null);
}}
/>
)}
</div>
);
};
export default PurchaseOrderManagementPage;

View File

@@ -0,0 +1,610 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
BarChart3,
TrendingUp,
TrendingDown,
DollarSign,
Package,
Clock,
Star,
AlertCircle,
Building,
Truck,
CheckCircle,
Calendar,
Filter,
Download,
RefreshCw
} from 'lucide-react';
import {
useSuppliers,
usePurchaseOrders,
useDeliveries
} from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface AnalyticsFilters {
period: 'last_7_days' | 'last_30_days' | 'last_90_days' | 'last_year';
supplier_id?: string;
supplier_type?: string;
}
const SupplierAnalyticsDashboard: React.FC = () => {
const { user } = useAuth();
const {
statistics: supplierStats,
activeSuppliers,
topSuppliers,
loadStatistics: loadSupplierStats,
loadActiveSuppliers,
loadTopSuppliers
} = useSuppliers();
const {
statistics: orderStats,
loadStatistics: loadOrderStats
} = usePurchaseOrders();
const {
performanceStats: deliveryStats,
loadPerformanceStats: loadDeliveryStats
} = useDeliveries();
const [filters, setFilters] = useState<AnalyticsFilters>({
period: 'last_30_days'
});
const [isLoading, setIsLoading] = useState(true);
// Load all analytics data
useEffect(() => {
if (user?.tenant_id) {
loadAnalyticsData();
}
}, [user?.tenant_id, filters]);
const loadAnalyticsData = async () => {
setIsLoading(true);
try {
await Promise.all([
loadSupplierStats(),
loadActiveSuppliers(),
loadTopSuppliers(10),
loadOrderStats(),
loadDeliveryStats(getPeriodDays(filters.period))
]);
} catch (error) {
console.error('Error loading analytics data:', error);
} finally {
setIsLoading(false);
}
};
// Convert period to days
const getPeriodDays = (period: string) => {
switch (period) {
case 'last_7_days': return 7;
case 'last_30_days': return 30;
case 'last_90_days': return 90;
case 'last_year': return 365;
default: return 30;
}
};
// Period options
const periodOptions = [
{ value: 'last_7_days', label: 'Últimos 7 días' },
{ value: 'last_30_days', label: 'Últimos 30 días' },
{ value: 'last_90_days', label: 'Últimos 90 días' },
{ value: 'last_year', label: 'Último año' }
];
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
// Format percentage
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`;
};
// Calculate performance metrics
const performanceMetrics = useMemo(() => {
if (!supplierStats || !orderStats || !deliveryStats) return null;
return {
supplierGrowth: supplierStats.active_suppliers > 0 ?
((supplierStats.total_suppliers - supplierStats.active_suppliers) / supplierStats.active_suppliers * 100) : 0,
orderGrowth: orderStats.this_month_orders > 0 ? 15 : 0, // Mock growth calculation
spendEfficiency: deliveryStats.quality_pass_rate,
deliveryReliability: deliveryStats.on_time_percentage
};
}, [supplierStats, orderStats, deliveryStats]);
// Key performance indicators
const kpis = useMemo(() => {
if (!supplierStats || !orderStats || !deliveryStats) return [];
return [
{
title: 'Gasto Total',
value: formatCurrency(supplierStats.total_spend),
change: '+12.5%',
changeType: 'positive' as const,
icon: DollarSign,
color: 'blue'
},
{
title: 'Pedidos Este Mes',
value: orderStats.this_month_orders.toString(),
change: '+8.3%',
changeType: 'positive' as const,
icon: Package,
color: 'green'
},
{
title: 'Entregas a Tiempo',
value: formatPercentage(deliveryStats.on_time_percentage),
change: deliveryStats.on_time_percentage > 85 ? '+2.1%' : '-1.5%',
changeType: deliveryStats.on_time_percentage > 85 ? 'positive' as const : 'negative' as const,
icon: Clock,
color: 'orange'
},
{
title: 'Calidad Promedio',
value: formatPercentage(supplierStats.avg_quality_rating * 20), // Convert from 5-star to percentage
change: '+3.2%',
changeType: 'positive' as const,
icon: Star,
color: 'purple'
}
];
}, [supplierStats, orderStats, deliveryStats]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Panel de Análisis de Proveedores</h1>
<p className="text-gray-600">Insights y métricas de rendimiento de tus proveedores</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={filters.period}
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{periodOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<Button
variant="outline"
onClick={loadAnalyticsData}
>
<RefreshCw className="w-4 h-4 mr-2" />
Actualizar
</Button>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{kpis.map((kpi, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600 mb-1">{kpi.title}</p>
<p className="text-2xl font-bold text-gray-900 mb-2">{kpi.value}</p>
<div className={`flex items-center space-x-1 text-sm ${
kpi.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
}`}>
{kpi.changeType === 'positive' ? (
<TrendingUp className="w-4 h-4" />
) : (
<TrendingDown className="w-4 h-4" />
)}
<span>{kpi.change}</span>
<span className="text-gray-500">vs período anterior</span>
</div>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
kpi.color === 'blue' ? 'bg-blue-100' :
kpi.color === 'green' ? 'bg-green-100' :
kpi.color === 'orange' ? 'bg-orange-100' :
'bg-purple-100'
}`}>
<kpi.icon className={`w-6 h-6 ${
kpi.color === 'blue' ? 'text-blue-600' :
kpi.color === 'green' ? 'text-green-600' :
kpi.color === 'orange' ? 'text-orange-600' :
'text-purple-600'
}`} />
</div>
</div>
</Card>
))}
</div>
{/* Performance Overview */}
{performanceMetrics && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Supplier Performance */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Building className="w-5 h-5 text-blue-500 mr-2" />
Rendimiento de Proveedores
</h3>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Proveedores Activos</span>
<div className="flex items-center space-x-2">
<div className="w-32 h-2 bg-gray-200 rounded-full">
<div
className="h-2 bg-green-500 rounded-full"
style={{
width: `${(supplierStats?.active_suppliers / supplierStats?.total_suppliers * 100) || 0}%`
}}
/>
</div>
<span className="text-sm font-semibold text-gray-900">
{supplierStats?.active_suppliers}/{supplierStats?.total_suppliers}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Calidad Promedio</span>
<div className="flex items-center space-x-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${
i < (supplierStats?.avg_quality_rating || 0)
? 'text-yellow-400 fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
<span className="text-sm font-semibold text-gray-900">
{supplierStats?.avg_quality_rating.toFixed(1)}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Entregas Puntuales</span>
<div className="flex items-center space-x-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${
i < (supplierStats?.avg_delivery_rating || 0)
? 'text-blue-400 fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
<span className="text-sm font-semibold text-gray-900">
{supplierStats?.avg_delivery_rating.toFixed(1)}
</span>
</div>
</div>
</div>
</div>
</Card>
{/* Delivery Performance */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Truck className="w-5 h-5 text-green-500 mr-2" />
Rendimiento de Entregas
</h3>
</div>
<div className="space-y-6">
<div className="text-center">
<div className="relative w-32 h-32 mx-auto mb-4">
<svg className="w-32 h-32 transform -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="40"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-gray-200"
/>
<circle
cx="50"
cy="50"
r="40"
stroke="currentColor"
strokeWidth="8"
fill="none"
strokeDasharray={`${(deliveryStats?.on_time_percentage || 0) * 2.51} 251`}
className="text-green-500"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{formatPercentage(deliveryStats?.on_time_percentage || 0)}
</div>
<div className="text-xs text-gray-500">A tiempo</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-xl font-bold text-green-600">
{deliveryStats?.on_time_deliveries || 0}
</div>
<div className="text-xs text-gray-600">A Tiempo</div>
</div>
<div>
<div className="text-xl font-bold text-red-600">
{deliveryStats?.late_deliveries || 0}
</div>
<div className="text-xs text-gray-600">Tardías</div>
</div>
</div>
</div>
</div>
</Card>
</div>
)}
{/* Top Suppliers and Insights */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Suppliers */}
{topSuppliers.length > 0 && (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<TrendingUp className="w-5 h-5 text-purple-500 mr-2" />
Mejores Proveedores
</h3>
</div>
<div className="space-y-4">
{topSuppliers.slice(0, 5).map((supplier, index) => (
<div key={supplier.id} className="flex items-center justify-between py-2">
<div className="flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
index === 0 ? 'bg-yellow-100 text-yellow-800' :
index === 1 ? 'bg-gray-100 text-gray-800' :
index === 2 ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'
}`}>
{index + 1}
</div>
<div>
<p className="font-medium text-gray-900">{supplier.name}</p>
<p className="text-sm text-gray-500">
{supplier.total_orders} pedidos
</p>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">
{formatCurrency(supplier.total_amount)}
</p>
<div className="flex items-center space-x-1">
<Star className="w-3 h-3 text-yellow-400 fill-current" />
<span className="text-xs text-gray-600">
{supplier.quality_rating?.toFixed(1) || 'N/A'}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</Card>
)}
{/* Insights and Recommendations */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<BarChart3 className="w-5 h-5 text-indigo-500 mr-2" />
Insights y Recomendaciones
</h3>
</div>
<div className="space-y-4">
{/* Performance insights */}
{deliveryStats && deliveryStats.on_time_percentage > 90 && (
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-900">
Excelente rendimiento de entregas
</p>
<p className="text-xs text-green-800">
{formatPercentage(deliveryStats.on_time_percentage)} de entregas a tiempo.
¡Mantén la buena comunicación con tus proveedores!
</p>
</div>
</div>
)}
{deliveryStats && deliveryStats.on_time_percentage < 80 && (
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-900">
Oportunidad de mejora en entregas
</p>
<p className="text-xs text-yellow-800">
Solo {formatPercentage(deliveryStats.on_time_percentage)} de entregas a tiempo.
Considera revisar los acuerdos de servicio con tus proveedores.
</p>
</div>
</div>
)}
{supplierStats && supplierStats.pending_suppliers > 0 && (
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg">
<Clock className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">
Proveedores pendientes de aprobación
</p>
<p className="text-xs text-blue-800">
Tienes {supplierStats.pending_suppliers} proveedores esperando aprobación.
Revísalos para acelerar tu cadena de suministro.
</p>
</div>
</div>
)}
{orderStats && orderStats.overdue_count > 0 && (
<div className="flex items-start space-x-3 p-3 bg-red-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-900">
Pedidos vencidos
</p>
<p className="text-xs text-red-800">
{orderStats.overdue_count} pedidos han superado su fecha de entrega.
Contacta con tus proveedores para actualizar el estado.
</p>
</div>
</div>
)}
{supplierStats && supplierStats.avg_quality_rating > 4 && (
<div className="flex items-start space-x-3 p-3 bg-purple-50 rounded-lg">
<Star className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-purple-900">
Alta calidad de proveedores
</p>
<p className="text-xs text-purple-800">
Calidad promedio de {supplierStats.avg_quality_rating.toFixed(1)}/5.
Considera destacar estos proveedores como preferidos.
</p>
</div>
</div>
)}
</div>
</div>
</Card>
</div>
{/* Detailed Metrics */}
{orderStats && (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Package className="w-5 h-5 text-blue-500 mr-2" />
Métricas Detalladas de Pedidos
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">
{orderStats.total_orders}
</div>
<p className="text-sm text-gray-600">Total Pedidos</p>
<div className="mt-2 text-xs text-gray-500">
{formatCurrency(orderStats.avg_order_value)} valor promedio
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">
{orderStats.this_month_orders}
</div>
<p className="text-sm text-gray-600">Este Mes</p>
<div className="mt-2 text-xs text-gray-500">
{formatCurrency(orderStats.this_month_spend)} gastado
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">
{orderStats.pending_approval}
</div>
<p className="text-sm text-gray-600">Pendientes Aprobación</p>
<div className="mt-2 text-xs text-gray-500">
Requieren revisión
</div>
</div>
</div>
{/* Order Status Breakdown */}
{orderStats.status_counts && (
<div className="mt-8">
<h4 className="font-medium text-gray-900 mb-4">Distribución por Estado</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(orderStats.status_counts).map(([status, count]) => (
<div key={status} className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg font-semibold text-gray-900">
{count as number}
</div>
<div className="text-xs text-gray-600 capitalize">
{status.toLowerCase().replace('_', ' ')}
</div>
</div>
))}
</div>
</div>
)}
</div>
</Card>
)}
</div>
);
};
export default SupplierAnalyticsDashboard;

View File

@@ -0,0 +1,391 @@
import React, { useState } from 'react';
import {
Building,
User,
Mail,
Phone,
MapPin,
CreditCard,
Star,
AlertCircle,
CheckCircle,
Clock,
Trash2,
Edit3,
Eye,
MoreVertical,
Package,
TrendingUp,
Calendar,
DollarSign
} from 'lucide-react';
import {
Supplier,
SupplierSummary,
UpdateSupplierRequest
} from '../../api/services/suppliers.service';
interface SupplierCardProps {
supplier: SupplierSummary;
compact?: boolean;
showActions?: boolean;
onEdit?: (supplier: SupplierSummary) => void;
onDelete?: (supplier: SupplierSummary) => void;
onViewDetails?: (supplier: SupplierSummary) => void;
onApprove?: (supplier: SupplierSummary, action: 'approve' | 'reject', notes?: string) => void;
className?: string;
}
const SupplierCard: React.FC<SupplierCardProps> = ({
supplier,
compact = false,
showActions = true,
onEdit,
onDelete,
onViewDetails,
onApprove,
className = ''
}) => {
const [showApprovalDialog, setShowApprovalDialog] = useState(false);
const [approvalNotes, setApprovalNotes] = useState('');
// Get supplier status display info
const getStatusInfo = () => {
const statusConfig = {
ACTIVE: { label: 'Activo', color: 'green', icon: CheckCircle },
INACTIVE: { label: 'Inactivo', color: 'gray', icon: AlertCircle },
PENDING_APPROVAL: { label: 'Pendiente', color: 'yellow', icon: Clock },
SUSPENDED: { label: 'Suspendido', color: 'red', icon: AlertCircle },
BLACKLISTED: { label: 'Lista Negra', color: 'red', icon: AlertCircle }
};
return statusConfig[supplier.status as keyof typeof statusConfig] || statusConfig.INACTIVE;
};
const statusInfo = getStatusInfo();
const StatusIcon = statusInfo.icon;
// Get supplier type display
const getSupplierTypeLabel = () => {
const typeLabels = {
INGREDIENTS: 'Ingredientes',
PACKAGING: 'Embalaje',
EQUIPMENT: 'Equipamiento',
SERVICES: 'Servicios',
UTILITIES: 'Utilidades',
MULTI: 'Multi-categoría'
};
return typeLabels[supplier.supplier_type as keyof typeof typeLabels] || supplier.supplier_type;
};
// Format rating display
const formatRating = (rating: number | undefined) => {
if (!rating) return 'N/A';
return rating.toFixed(1);
};
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
// Handle approval action
const handleApprovalAction = (action: 'approve' | 'reject') => {
if (!onApprove) return;
if (action === 'reject' && !approvalNotes.trim()) {
alert('Se requiere una razón para rechazar el proveedor');
return;
}
onApprove(supplier, action, approvalNotes.trim() || undefined);
setShowApprovalDialog(false);
setApprovalNotes('');
};
if (compact) {
return (
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
supplier.supplier_type === 'INGREDIENTS' ? 'bg-blue-100' :
supplier.supplier_type === 'PACKAGING' ? 'bg-green-100' :
supplier.supplier_type === 'EQUIPMENT' ? 'bg-purple-100' :
'bg-gray-100'
}`}>
<Building className={`w-5 h-5 ${
supplier.supplier_type === 'INGREDIENTS' ? 'text-blue-600' :
supplier.supplier_type === 'PACKAGING' ? 'text-green-600' :
supplier.supplier_type === 'EQUIPMENT' ? 'text-purple-600' :
'text-gray-600'
}`} />
</div>
<div>
<h4 className="font-medium text-gray-900">{supplier.name}</h4>
<p className="text-sm text-gray-500">{getSupplierTypeLabel()}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="text-right">
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
<StatusIcon className="w-3 h-3" />
<span>{statusInfo.label}</span>
</div>
{supplier.total_orders > 0 && (
<div className="text-xs text-gray-500 mt-1">
{supplier.total_orders} pedidos
</div>
)}
</div>
{showActions && onViewDetails && (
<button
onClick={() => onViewDetails(supplier)}
className="p-1 hover:bg-gray-100 rounded"
>
<Eye className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
</div>
</div>
);
}
return (
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
supplier.supplier_type === 'INGREDIENTS' ? 'bg-blue-100' :
supplier.supplier_type === 'PACKAGING' ? 'bg-green-100' :
supplier.supplier_type === 'EQUIPMENT' ? 'bg-purple-100' :
'bg-gray-100'
}`}>
<Building className={`w-6 h-6 ${
supplier.supplier_type === 'INGREDIENTS' ? 'text-blue-600' :
supplier.supplier_type === 'PACKAGING' ? 'text-green-600' :
supplier.supplier_type === 'EQUIPMENT' ? 'text-purple-600' :
'text-gray-600'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">{supplier.name}</h3>
{supplier.supplier_code && (
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
{supplier.supplier_code}
</span>
)}
</div>
<div className="flex items-center space-x-4 text-sm text-gray-600">
<span className={`px-2 py-1 rounded-full text-xs ${
supplier.supplier_type === 'INGREDIENTS' ? 'bg-blue-100 text-blue-800' :
supplier.supplier_type === 'PACKAGING' ? 'bg-green-100 text-green-800' :
supplier.supplier_type === 'EQUIPMENT' ? 'bg-purple-100 text-purple-800' :
'bg-gray-100 text-gray-800'
}`}>
{getSupplierTypeLabel()}
</span>
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
<StatusIcon className="w-3 h-3" />
<span>{statusInfo.label}</span>
</div>
</div>
{/* Contact information */}
{(supplier.contact_person || supplier.email || supplier.phone) && (
<div className="flex items-center space-x-4 mt-2 text-sm text-gray-600">
{supplier.contact_person && (
<div className="flex items-center space-x-1">
<User className="w-3 h-3" />
<span>{supplier.contact_person}</span>
</div>
)}
{supplier.email && (
<div className="flex items-center space-x-1">
<Mail className="w-3 h-3" />
<span>{supplier.email}</span>
</div>
)}
{supplier.phone && (
<div className="flex items-center space-x-1">
<Phone className="w-3 h-3" />
<span>{supplier.phone}</span>
</div>
)}
</div>
)}
{/* Location */}
{(supplier.city || supplier.country) && (
<div className="flex items-center space-x-1 mt-2 text-sm text-gray-600">
<MapPin className="w-3 h-3" />
<span>{[supplier.city, supplier.country].filter(Boolean).join(', ')}</span>
</div>
)}
</div>
</div>
{showActions && (
<div className="flex items-center space-x-1">
{supplier.status === 'PENDING_APPROVAL' && onApprove && (
<button
onClick={() => setShowApprovalDialog(true)}
className="p-2 hover:bg-yellow-50 text-yellow-600 rounded-lg transition-colors"
title="Revisar aprobación"
>
<Clock className="w-4 h-4" />
</button>
)}
{onEdit && (
<button
onClick={() => onEdit(supplier)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Editar"
>
<Edit3 className="w-4 h-4 text-gray-600" />
</button>
)}
{onViewDetails && (
<button
onClick={() => onViewDetails(supplier)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="Ver detalles"
>
<Eye className="w-4 h-4 text-gray-600" />
</button>
)}
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<MoreVertical className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
</div>
</div>
{/* Performance Metrics */}
<div className="px-6 pb-4">
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-gray-900">
<Package className="w-5 h-5 text-gray-500" />
<span>{supplier.total_orders}</span>
</div>
<div className="text-sm text-gray-500">Pedidos</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-green-600">
<DollarSign className="w-5 h-5 text-green-500" />
<span>{formatCurrency(supplier.total_amount)}</span>
</div>
<div className="text-sm text-gray-500">Total</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-blue-600">
<Star className="w-5 h-5 text-blue-500" />
<span>{formatRating(supplier.quality_rating)}</span>
</div>
<div className="text-sm text-gray-500">Calidad</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-purple-600">
<TrendingUp className="w-5 h-5 text-purple-500" />
<span>{formatRating(supplier.delivery_rating)}</span>
</div>
<div className="text-sm text-gray-500">Entrega</div>
</div>
</div>
</div>
{/* Registration date */}
<div className="px-6 pb-6">
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Calendar className="w-3 h-3" />
<span>Registrado: {new Date(supplier.created_at).toLocaleDateString('es-ES')}</span>
</div>
</div>
</div>
{/* Approval Dialog */}
{showApprovalDialog && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Revisar Proveedor: {supplier.name}
</h3>
<div className="mb-4">
<label htmlFor="approval-notes" className="block text-sm font-medium text-gray-700 mb-2">
Notas (opcional para aprobación, requerido para rechazo)
</label>
<textarea
id="approval-notes"
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows={3}
placeholder="Escribe tus comentarios aquí..."
/>
</div>
<div className="flex space-x-3">
<button
onClick={() => handleApprovalAction('approve')}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Aprobar
</button>
<button
onClick={() => handleApprovalAction('reject')}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Rechazar
</button>
<button
onClick={() => {
setShowApprovalDialog(false);
setApprovalNotes('');
}}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default SupplierCard;

View File

@@ -0,0 +1,599 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
DollarSign,
TrendingUp,
TrendingDown,
BarChart3,
PieChart,
Calendar,
Building,
Package,
AlertTriangle,
Target,
Filter,
Download,
Percent
} from 'lucide-react';
import {
useSuppliers,
usePurchaseOrders,
SupplierSummary
} from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface CostAnalysisFilters {
period: 'last_30_days' | 'last_90_days' | 'last_year' | 'ytd';
supplier_type?: string;
min_spend?: number;
}
interface CostTrend {
month: string;
amount: number;
orders: number;
}
interface SupplierCostData extends SupplierSummary {
cost_per_order: number;
market_share_percentage: number;
cost_trend: 'increasing' | 'decreasing' | 'stable';
cost_efficiency_score: number;
}
const SupplierCostAnalysis: React.FC = () => {
const { user } = useAuth();
const {
activeSuppliers,
statistics: supplierStats,
loadActiveSuppliers,
loadStatistics: loadSupplierStats
} = useSuppliers();
const {
statistics: orderStats,
loadStatistics: loadOrderStats
} = usePurchaseOrders();
const [filters, setFilters] = useState<CostAnalysisFilters>({
period: 'last_90_days',
min_spend: 500
});
const [isLoading, setIsLoading] = useState(true);
// Load data
useEffect(() => {
if (user?.tenant_id) {
loadCostAnalysisData();
}
}, [user?.tenant_id, filters]);
const loadCostAnalysisData = async () => {
setIsLoading(true);
try {
await Promise.all([
loadActiveSuppliers(),
loadSupplierStats(),
loadOrderStats()
]);
} catch (error) {
console.error('Error loading cost analysis data:', error);
} finally {
setIsLoading(false);
}
};
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
// Format percentage
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`;
};
// Enhanced supplier cost data
const supplierCostData = useMemo(() => {
if (!activeSuppliers.length || !supplierStats) return [];
const totalSpend = activeSuppliers.reduce((sum, supplier) => sum + supplier.total_amount, 0);
return activeSuppliers
.filter(supplier => supplier.total_amount >= (filters.min_spend || 0))
.map(supplier => {
const cost_per_order = supplier.total_orders > 0 ? supplier.total_amount / supplier.total_orders : 0;
const market_share_percentage = totalSpend > 0 ? (supplier.total_amount / totalSpend) * 100 : 0;
// Mock cost trend calculation (in real app, would compare with historical data)
const cost_trend = cost_per_order > 1000 ? 'increasing' :
cost_per_order < 500 ? 'decreasing' : 'stable';
// Cost efficiency score (based on cost per order vs quality rating)
const quality_factor = supplier.quality_rating || 3;
const cost_efficiency_score = quality_factor > 0 ?
Math.min((quality_factor * 20) - (cost_per_order / 50), 100) : 0;
return {
...supplier,
cost_per_order,
market_share_percentage,
cost_trend,
cost_efficiency_score: Math.max(0, cost_efficiency_score)
} as SupplierCostData;
})
.sort((a, b) => b.total_amount - a.total_amount);
}, [activeSuppliers, supplierStats, filters.min_spend]);
// Cost distribution analysis
const costDistribution = useMemo(() => {
const ranges = [
{ label: '< €500', min: 0, max: 500, count: 0, amount: 0 },
{ label: '€500 - €2K', min: 500, max: 2000, count: 0, amount: 0 },
{ label: '€2K - €5K', min: 2000, max: 5000, count: 0, amount: 0 },
{ label: '€5K - €10K', min: 5000, max: 10000, count: 0, amount: 0 },
{ label: '> €10K', min: 10000, max: Infinity, count: 0, amount: 0 }
];
supplierCostData.forEach(supplier => {
const range = ranges.find(r => supplier.total_amount >= r.min && supplier.total_amount < r.max);
if (range) {
range.count++;
range.amount += supplier.total_amount;
}
});
return ranges.filter(range => range.count > 0);
}, [supplierCostData]);
// Top cost categories
const topCostCategories = useMemo(() => {
const categories: Record<string, { count: number; amount: number; suppliers: string[] }> = {};
supplierCostData.forEach(supplier => {
if (!categories[supplier.supplier_type]) {
categories[supplier.supplier_type] = { count: 0, amount: 0, suppliers: [] };
}
categories[supplier.supplier_type].count++;
categories[supplier.supplier_type].amount += supplier.total_amount;
categories[supplier.supplier_type].suppliers.push(supplier.name);
});
return Object.entries(categories)
.map(([type, data]) => ({
type,
...data,
avg_spend: data.count > 0 ? data.amount / data.count : 0
}))
.sort((a, b) => b.amount - a.amount);
}, [supplierCostData]);
// Cost savings opportunities
const costSavingsOpportunities = useMemo(() => {
const opportunities = [];
// High cost per order suppliers
const highCostSuppliers = supplierCostData.filter(s =>
s.cost_per_order > 1500 && s.quality_rating && s.quality_rating < 4
);
if (highCostSuppliers.length > 0) {
opportunities.push({
type: 'high_cost_low_quality',
title: 'Proveedores de Alto Costo y Baja Calidad',
description: `${highCostSuppliers.length} proveedores con costo promedio alto y calidad mejorable`,
potential_savings: highCostSuppliers.reduce((sum, s) => sum + (s.cost_per_order * 0.15), 0),
suppliers: highCostSuppliers.slice(0, 3).map(s => s.name)
});
}
// Suppliers with declining efficiency
const inefficientSuppliers = supplierCostData.filter(s => s.cost_efficiency_score < 40);
if (inefficientSuppliers.length > 0) {
opportunities.push({
type: 'low_efficiency',
title: 'Proveedores con Baja Eficiencia de Costos',
description: `${inefficientSuppliers.length} proveedores con puntuación de eficiencia baja`,
potential_savings: inefficientSuppliers.reduce((sum, s) => sum + (s.total_amount * 0.1), 0),
suppliers: inefficientSuppliers.slice(0, 3).map(s => s.name)
});
}
// Single supplier concentration risk
const totalSpend = supplierCostData.reduce((sum, s) => sum + s.total_amount, 0);
const highConcentrationSuppliers = supplierCostData.filter(s =>
s.market_share_percentage > 25
);
if (highConcentrationSuppliers.length > 0) {
opportunities.push({
type: 'concentration_risk',
title: 'Riesgo de Concentración de Proveedores',
description: `${highConcentrationSuppliers.length} proveedores representan más del 25% del gasto`,
potential_savings: 0, // Risk mitigation, not direct savings
suppliers: highConcentrationSuppliers.map(s => s.name)
});
}
return opportunities;
}, [supplierCostData]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Análisis de Costos de Proveedores</h1>
<p className="text-gray-600">Insights detallados sobre gastos y eficiencia de costos</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={filters.period}
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="last_30_days">Últimos 30 días</option>
<option value="last_90_days">Últimos 90 días</option>
<option value="last_year">Último año</option>
<option value="ytd">Año hasta la fecha</option>
</select>
</div>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar Análisis
</Button>
</div>
</div>
{/* Cost Overview 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">Gasto Total</p>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(supplierCostData.reduce((sum, s) => sum + s.total_amount, 0))}
</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<DollarSign className="w-6 h-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">Costo Promedio por Pedido</p>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(
supplierCostData.length > 0
? supplierCostData.reduce((sum, s) => sum + s.cost_per_order, 0) / supplierCostData.length
: 0
)}
</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<Package className="w-6 h-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">Proveedores Activos</p>
<p className="text-2xl font-bold text-gray-900">{supplierCostData.length}</p>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<Building className="w-6 h-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">Ahorro Potencial</p>
<p className="text-2xl font-bold text-green-600">
{formatCurrency(
costSavingsOpportunities.reduce((sum, opp) => sum + opp.potential_savings, 0)
)}
</p>
</div>
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
<Target className="w-6 h-6 text-orange-600" />
</div>
</div>
</Card>
</div>
{/* Cost Analysis Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Suppliers by Spend */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<BarChart3 className="w-5 h-5 text-blue-500 mr-2" />
Top 10 Proveedores por Gasto
</h3>
</div>
<div className="space-y-4">
{supplierCostData.slice(0, 10).map((supplier, index) => (
<div key={supplier.id} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
index < 3 ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-800'
}`}>
{index + 1}
</div>
<div>
<p className="font-medium text-gray-900">{supplier.name}</p>
<p className="text-sm text-gray-500">{supplier.supplier_type}</p>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">
{formatCurrency(supplier.total_amount)}
</p>
<p className="text-xs text-gray-500">
{formatPercentage(supplier.market_share_percentage)} del total
</p>
</div>
</div>
))}
</div>
</div>
</Card>
{/* Cost Distribution */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<PieChart className="w-5 h-5 text-purple-500 mr-2" />
Distribución de Costos
</h3>
</div>
<div className="space-y-4">
{costDistribution.map((range, index) => (
<div key={range.label} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className="w-4 h-4 rounded-full"
style={{
backgroundColor: [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'
][index % 5]
}}
/>
<span className="text-sm font-medium text-gray-700">{range.label}</span>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
{range.count} proveedores
</div>
<div className="text-xs text-gray-600">
{formatCurrency(range.amount)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</Card>
</div>
{/* Cost Efficiency Analysis */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Percent className="w-5 h-5 text-indigo-500 mr-2" />
Análisis de Eficiencia de Costos
</h3>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Gasto mínimo:</span>
<input
type="number"
min="0"
value={filters.min_spend || 500}
onChange={(e) => setFilters(prev => ({
...prev,
min_spend: parseInt(e.target.value) || 500
}))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="text-sm text-gray-600"></span>
</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">
Proveedor
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Gasto Total
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Costo/Pedido
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Cuota Mercado
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Eficiencia
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tendencia
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{supplierCostData.map((supplier) => (
<tr key={supplier.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Building className="w-5 h-5 text-blue-600" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{supplier.name}
</div>
<div className="text-sm text-gray-500">
{supplier.supplier_type}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{formatCurrency(supplier.total_amount)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(supplier.cost_per_order)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-16 h-2 bg-gray-200 rounded-full mr-2">
<div
className="h-2 bg-blue-500 rounded-full"
style={{ width: `${Math.min(supplier.market_share_percentage, 100)}%` }}
/>
</div>
<span className="text-sm text-gray-600">
{formatPercentage(supplier.market_share_percentage)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-1 bg-gray-200 rounded-full h-2 mr-2">
<div
className={`h-2 rounded-full ${
supplier.cost_efficiency_score >= 70 ? 'bg-green-600' :
supplier.cost_efficiency_score >= 40 ? 'bg-yellow-600' :
'bg-red-600'
}`}
style={{ width: `${supplier.cost_efficiency_score}%` }}
/>
</div>
<span className="text-sm font-medium text-gray-900">
{supplier.cost_efficiency_score.toFixed(0)}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`flex items-center ${
supplier.cost_trend === 'increasing' ? 'text-red-600' :
supplier.cost_trend === 'decreasing' ? 'text-green-600' :
'text-gray-600'
}`}>
{supplier.cost_trend === 'increasing' ? (
<TrendingUp className="w-4 h-4 mr-1" />
) : supplier.cost_trend === 'decreasing' ? (
<TrendingDown className="w-4 h-4 mr-1" />
) : (
<div className="w-4 h-0.5 bg-gray-400 mr-1" />
)}
<span className="text-xs capitalize">{supplier.cost_trend}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Card>
{/* Cost Savings Opportunities */}
{costSavingsOpportunities.length > 0 && (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Target className="w-5 h-5 text-green-500 mr-2" />
Oportunidades de Ahorro de Costos
</h3>
</div>
<div className="space-y-4">
{costSavingsOpportunities.map((opportunity, index) => (
<div
key={opportunity.type}
className={`flex items-start space-x-3 p-4 rounded-lg ${
opportunity.type === 'concentration_risk' ? 'bg-yellow-50' :
opportunity.potential_savings > 1000 ? 'bg-green-50' : 'bg-blue-50'
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
opportunity.type === 'concentration_risk' ? 'bg-yellow-200 text-yellow-800' :
opportunity.potential_savings > 1000 ? 'bg-green-200 text-green-800' : 'bg-blue-200 text-blue-800'
}`}>
{opportunity.type === 'concentration_risk' ? (
<AlertTriangle className="w-4 h-4" />
) : (
<DollarSign className="w-4 h-4" />
)}
</div>
<div className="flex-1">
<h4 className={`font-medium ${
opportunity.type === 'concentration_risk' ? 'text-yellow-900' :
opportunity.potential_savings > 1000 ? 'text-green-900' : 'text-blue-900'
}`}>
{opportunity.title}
</h4>
<p className={`text-sm ${
opportunity.type === 'concentration_risk' ? 'text-yellow-800' :
opportunity.potential_savings > 1000 ? 'text-green-800' : 'text-blue-800'
}`}>
{opportunity.description}
</p>
{opportunity.potential_savings > 0 && (
<p className="text-sm font-semibold mt-1 text-green-600">
Ahorro potencial: {formatCurrency(opportunity.potential_savings)}
</p>
)}
<p className="text-xs text-gray-600 mt-1">
Proveedores: {opportunity.suppliers.join(', ')}
</p>
</div>
</div>
))}
</div>
</div>
</Card>
)}
</div>
);
};
export default SupplierCostAnalysis;

View File

@@ -0,0 +1,314 @@
import React, { useEffect } from 'react';
import {
Building,
Users,
AlertCircle,
TrendingUp,
Package,
Clock,
Star,
DollarSign,
ChevronRight
} from 'lucide-react';
import { useSuppliers } from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import SupplierCard from './SupplierCard';
import LoadingSpinner from '../ui/LoadingSpinner';
interface SupplierDashboardWidgetProps {
onViewAll?: () => void;
className?: string;
}
const SupplierDashboardWidget: React.FC<SupplierDashboardWidgetProps> = ({
onViewAll,
className = ''
}) => {
const { user } = useAuth();
const {
statistics,
activeSuppliers,
topSuppliers,
suppliersNeedingReview,
isLoading,
loadStatistics,
loadActiveSuppliers,
loadTopSuppliers,
loadSuppliersNeedingReview
} = useSuppliers();
// Load data on mount
useEffect(() => {
if (user?.tenant_id) {
loadStatistics();
loadActiveSuppliers();
loadTopSuppliers(5);
loadSuppliersNeedingReview();
}
}, [user?.tenant_id]);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
// Format rating
const formatRating = (rating: number) => {
return rating.toFixed(1);
};
if (isLoading && !statistics) {
return (
<Card className={className}>
<div className="flex items-center justify-center h-32">
<LoadingSpinner />
</div>
</Card>
);
}
return (
<div className={`space-y-6 ${className}`}>
{/* Statistics Overview */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Building className="w-5 h-5 text-blue-500 mr-2" />
Resumen de Proveedores
</h3>
{onViewAll && (
<button
onClick={onViewAll}
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-sm font-medium"
>
<span>Ver todos</span>
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
{statistics ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<Building className="w-4 h-4 text-blue-500" />
<span className="text-2xl font-bold text-gray-900">
{statistics.total_suppliers}
</span>
</div>
<p className="text-sm text-gray-600">Total</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<Users className="w-4 h-4 text-green-500" />
<span className="text-2xl font-bold text-green-600">
{statistics.active_suppliers}
</span>
</div>
<p className="text-sm text-gray-600">Activos</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-2xl font-bold text-yellow-600">
{statistics.pending_suppliers}
</span>
</div>
<p className="text-sm text-gray-600">Pendientes</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<DollarSign className="w-4 h-4 text-purple-500" />
<span className="text-lg font-bold text-purple-600">
{formatCurrency(statistics.total_spend)}
</span>
</div>
<p className="text-sm text-gray-600">Gasto Total</p>
</div>
</div>
) : (
<div className="text-center py-8">
<p className="text-gray-500">No hay datos de proveedores disponibles</p>
</div>
)}
{/* Quality Metrics */}
{statistics && (statistics.avg_quality_rating > 0 || statistics.avg_delivery_rating > 0) && (
<div className="mt-6 pt-4 border-t">
<div className="grid grid-cols-2 gap-4">
{statistics.avg_quality_rating > 0 && (
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<Star className="w-4 h-4 text-blue-500" />
<span className="text-lg font-semibold text-blue-600">
{formatRating(statistics.avg_quality_rating)}
</span>
</div>
<p className="text-sm text-gray-600">Calidad Promedio</p>
</div>
)}
{statistics.avg_delivery_rating > 0 && (
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-1">
<TrendingUp className="w-4 h-4 text-green-500" />
<span className="text-lg font-semibold text-green-600">
{formatRating(statistics.avg_delivery_rating)}
</span>
</div>
<p className="text-sm text-gray-600">Entrega Promedio</p>
</div>
)}
</div>
</div>
)}
</Card>
{/* Suppliers Requiring Attention */}
{suppliersNeedingReview.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 flex items-center">
<Clock className="w-4 h-4 text-yellow-500 mr-2" />
Requieren Aprobación
</h4>
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs">
{suppliersNeedingReview.length}
</span>
</div>
<div className="space-y-3">
{suppliersNeedingReview.slice(0, 3).map(supplier => (
<SupplierCard
key={supplier.id}
supplier={supplier}
compact
showActions={false}
/>
))}
{suppliersNeedingReview.length > 3 && (
<div className="text-center pt-2">
<button
onClick={onViewAll}
className="text-sm text-blue-600 hover:text-blue-700"
>
Ver {suppliersNeedingReview.length - 3} proveedores más...
</button>
</div>
)}
</div>
</Card>
)}
{/* Top Performing Suppliers */}
{topSuppliers.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 flex items-center">
<TrendingUp className="w-4 h-4 text-green-500 mr-2" />
Top Proveedores
</h4>
</div>
<div className="space-y-3">
{topSuppliers.slice(0, 3).map((supplier, index) => (
<div key={supplier.id} className="flex items-center space-x-3">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${
index === 0 ? 'bg-yellow-100 text-yellow-800' :
index === 1 ? 'bg-gray-100 text-gray-800' :
'bg-orange-100 text-orange-800'
}`}>
{index + 1}
</div>
<div className="flex-1">
<SupplierCard
supplier={supplier}
compact
showActions={false}
/>
</div>
</div>
))}
</div>
</Card>
)}
{/* Recent Active Suppliers */}
{activeSuppliers.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h4 className="font-semibold text-gray-900 flex items-center">
<Package className="w-4 h-4 text-blue-500 mr-2" />
Proveedores Activos
</h4>
<span className="text-sm text-gray-500">
{activeSuppliers.length} activos
</span>
</div>
<div className="space-y-3">
{activeSuppliers.slice(0, 3).map(supplier => (
<SupplierCard
key={supplier.id}
supplier={supplier}
compact
showActions={false}
/>
))}
{activeSuppliers.length > 3 && (
<div className="text-center pt-2">
<button
onClick={onViewAll}
className="text-sm text-blue-600 hover:text-blue-700"
>
Ver {activeSuppliers.length - 3} proveedores más...
</button>
</div>
)}
</div>
</Card>
)}
{/* Empty State */}
{!isLoading &&
(!statistics || statistics.total_suppliers === 0) &&
topSuppliers.length === 0 &&
suppliersNeedingReview.length === 0 && (
<Card className="text-center py-8">
<Building className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No hay proveedores
</h3>
<p className="text-gray-600 mb-4">
Comienza agregando tus primeros proveedores para gestionar tu cadena de suministro
</p>
{onViewAll && (
<button
onClick={onViewAll}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Building className="w-4 h-4 mr-2" />
Agregar Proveedor
</button>
)}
</Card>
)}
</div>
);
};
export default SupplierDashboardWidget;

View File

@@ -0,0 +1,789 @@
import React, { useState, useEffect } from 'react';
import {
X,
Building,
User,
Mail,
Phone,
MapPin,
CreditCard,
Globe,
Package,
FileText,
Clock,
DollarSign
} from 'lucide-react';
import {
CreateSupplierRequest,
UpdateSupplierRequest,
SupplierSummary
} from '../../api/services/suppliers.service';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface SupplierFormProps {
supplier?: SupplierSummary | null;
isOpen: boolean;
isCreating?: boolean;
onSubmit: (data: CreateSupplierRequest | UpdateSupplierRequest) => Promise<void>;
onClose: () => void;
}
interface FormData {
name: string;
supplier_code: string;
tax_id: string;
registration_number: string;
supplier_type: string;
contact_person: string;
email: string;
phone: string;
mobile: string;
website: string;
// Address
address_line1: string;
address_line2: string;
city: string;
state_province: string;
postal_code: string;
country: string;
// Business terms
payment_terms: string;
credit_limit: string;
currency: string;
standard_lead_time: string;
minimum_order_amount: string;
delivery_area: string;
// Additional information
notes: string;
certifications: string;
business_hours: string;
specializations: string;
}
const initialFormData: FormData = {
name: '',
supplier_code: '',
tax_id: '',
registration_number: '',
supplier_type: 'INGREDIENTS',
contact_person: '',
email: '',
phone: '',
mobile: '',
website: '',
address_line1: '',
address_line2: '',
city: '',
state_province: '',
postal_code: '',
country: '',
payment_terms: 'NET_30',
credit_limit: '',
currency: 'EUR',
standard_lead_time: '7',
minimum_order_amount: '',
delivery_area: '',
notes: '',
certifications: '',
business_hours: '',
specializations: ''
};
const SupplierForm: React.FC<SupplierFormProps> = ({
supplier,
isOpen,
isCreating = false,
onSubmit,
onClose
}) => {
const [formData, setFormData] = useState<FormData>(initialFormData);
const [errors, setErrors] = useState<Partial<FormData>>({});
const [activeTab, setActiveTab] = useState<'basic' | 'contact' | 'business' | 'additional'>('basic');
// Initialize form data when supplier changes
useEffect(() => {
if (supplier) {
setFormData({
name: supplier.name || '',
supplier_code: supplier.supplier_code || '',
tax_id: '', // Not available in summary
registration_number: '',
supplier_type: supplier.supplier_type || 'INGREDIENTS',
contact_person: supplier.contact_person || '',
email: supplier.email || '',
phone: supplier.phone || '',
mobile: '',
website: '',
address_line1: '',
address_line2: '',
city: supplier.city || '',
state_province: '',
postal_code: '',
country: supplier.country || '',
payment_terms: 'NET_30',
credit_limit: '',
currency: 'EUR',
standard_lead_time: '7',
minimum_order_amount: '',
delivery_area: '',
notes: '',
certifications: '',
business_hours: '',
specializations: ''
});
} else {
setFormData(initialFormData);
}
setErrors({});
setActiveTab('basic');
}, [supplier]);
// Supplier type options
const supplierTypeOptions = [
{ value: 'INGREDIENTS', label: 'Ingredientes' },
{ value: 'PACKAGING', label: 'Embalaje' },
{ value: 'EQUIPMENT', label: 'Equipamiento' },
{ value: 'SERVICES', label: 'Servicios' },
{ value: 'UTILITIES', label: 'Utilidades' },
{ value: 'MULTI', label: 'Multi-categoría' }
];
// Payment terms options
const paymentTermsOptions = [
{ value: 'CASH_ON_DELIVERY', label: 'Contra Reembolso' },
{ value: 'NET_15', label: 'Neto 15 días' },
{ value: 'NET_30', label: 'Neto 30 días' },
{ value: 'NET_45', label: 'Neto 45 días' },
{ value: 'NET_60', label: 'Neto 60 días' },
{ value: 'PREPAID', label: 'Prepago' },
{ value: 'CREDIT_TERMS', label: 'Términos de Crédito' }
];
// Currency options
const currencyOptions = [
{ value: 'EUR', label: 'Euro (€)' },
{ value: 'USD', label: 'Dólar US ($)' },
{ value: 'GBP', label: 'Libra (£)' }
];
// Handle input change
const handleInputChange = (field: keyof FormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
// Validate form
const validateForm = (): boolean => {
const newErrors: Partial<FormData> = {};
// Required fields
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido';
}
if (!formData.supplier_type) {
newErrors.supplier_type = 'El tipo de proveedor es requerido';
}
// Email validation
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Email inválido';
}
// Phone validation (basic)
if (formData.phone && !/^[+]?[\d\s\-\(\)]+$/.test(formData.phone)) {
newErrors.phone = 'Teléfono inválido';
}
// Website validation
if (formData.website && !/^https?:\/\/.+\..+/.test(formData.website)) {
newErrors.website = 'URL del sitio web inválida';
}
// Numeric validations
if (formData.credit_limit && isNaN(parseFloat(formData.credit_limit))) {
newErrors.credit_limit = 'El límite de crédito debe ser un número';
}
if (formData.standard_lead_time && (isNaN(parseInt(formData.standard_lead_time)) || parseInt(formData.standard_lead_time) < 0)) {
newErrors.standard_lead_time = 'El tiempo de entrega debe ser un número positivo';
}
if (formData.minimum_order_amount && isNaN(parseFloat(formData.minimum_order_amount))) {
newErrors.minimum_order_amount = 'El monto mínimo debe ser un número';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
// Prepare submission data
const submissionData: CreateSupplierRequest | UpdateSupplierRequest = {
name: formData.name.trim(),
supplier_code: formData.supplier_code.trim() || undefined,
tax_id: formData.tax_id.trim() || undefined,
registration_number: formData.registration_number.trim() || undefined,
supplier_type: formData.supplier_type,
contact_person: formData.contact_person.trim() || undefined,
email: formData.email.trim() || undefined,
phone: formData.phone.trim() || undefined,
mobile: formData.mobile.trim() || undefined,
website: formData.website.trim() || undefined,
address_line1: formData.address_line1.trim() || undefined,
address_line2: formData.address_line2.trim() || undefined,
city: formData.city.trim() || undefined,
state_province: formData.state_province.trim() || undefined,
postal_code: formData.postal_code.trim() || undefined,
country: formData.country.trim() || undefined,
payment_terms: formData.payment_terms || undefined,
credit_limit: formData.credit_limit ? parseFloat(formData.credit_limit) : undefined,
currency: formData.currency || 'EUR',
standard_lead_time: formData.standard_lead_time ? parseInt(formData.standard_lead_time) : undefined,
minimum_order_amount: formData.minimum_order_amount ? parseFloat(formData.minimum_order_amount) : undefined,
delivery_area: formData.delivery_area.trim() || undefined,
notes: formData.notes.trim() || undefined
};
// Parse JSON fields if provided
try {
if (formData.certifications.trim()) {
submissionData.certifications = JSON.parse(formData.certifications);
}
} catch (e) {
setErrors(prev => ({ ...prev, certifications: 'JSON inválido' }));
return;
}
try {
if (formData.business_hours.trim()) {
submissionData.business_hours = JSON.parse(formData.business_hours);
}
} catch (e) {
setErrors(prev => ({ ...prev, business_hours: 'JSON inválido' }));
return;
}
try {
if (formData.specializations.trim()) {
submissionData.specializations = JSON.parse(formData.specializations);
}
} catch (e) {
setErrors(prev => ({ ...prev, specializations: 'JSON inválido' }));
return;
}
await onSubmit(submissionData);
};
if (!isOpen) return null;
const tabs = [
{ id: 'basic' as const, label: 'Información Básica', icon: Building },
{ id: 'contact' as const, label: 'Contacto y Dirección', icon: User },
{ id: 'business' as const, label: 'Términos Comerciales', icon: CreditCard },
{ id: 'additional' as const, label: 'Información Adicional', icon: FileText }
];
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
{supplier ? 'Editar Proveedor' : 'Nuevo Proveedor'}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center space-x-2 px-4 py-3 border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<tab.icon className="w-4 h-4" />
<span className="text-sm font-medium">{tab.label}</span>
</button>
))}
</div>
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-6">
{/* Basic Information Tab */}
{activeTab === 'basic' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre del Proveedor *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.name ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="Nombre de la empresa"
/>
{errors.name && <p className="text-red-600 text-sm mt-1">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Código de Proveedor
</label>
<input
type="text"
value={formData.supplier_code}
onChange={(e) => handleInputChange('supplier_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="SUP001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Proveedor *
</label>
<select
value={formData.supplier_type}
onChange={(e) => handleInputChange('supplier_type', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.supplier_type ? 'border-red-300' : 'border-gray-300'
}`}
>
{supplierTypeOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{errors.supplier_type && <p className="text-red-600 text-sm mt-1">{errors.supplier_type}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
NIF/CIF
</label>
<input
type="text"
value={formData.tax_id}
onChange={(e) => handleInputChange('tax_id', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="A12345678"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Número de Registro
</label>
<input
type="text"
value={formData.registration_number}
onChange={(e) => handleInputChange('registration_number', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Número de registro mercantil"
/>
</div>
</div>
</div>
)}
{/* Contact Information Tab */}
{activeTab === 'contact' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Información de Contacto</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Persona de Contacto
</label>
<input
type="text"
value={formData.contact_person}
onChange={(e) => handleInputChange('contact_person', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Nombre del contacto"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.email ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="contacto@proveedor.com"
/>
{errors.email && <p className="text-red-600 text-sm mt-1">{errors.email}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Teléfono
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.phone ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="+34 912 345 678"
/>
{errors.phone && <p className="text-red-600 text-sm mt-1">{errors.phone}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Móvil
</label>
<input
type="tel"
value={formData.mobile}
onChange={(e) => handleInputChange('mobile', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="+34 612 345 678"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Sitio Web
</label>
<input
type="url"
value={formData.website}
onChange={(e) => handleInputChange('website', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.website ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="https://www.proveedor.com"
/>
{errors.website && <p className="text-red-600 text-sm mt-1">{errors.website}</p>}
</div>
</div>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Dirección</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dirección Línea 1
</label>
<input
type="text"
value={formData.address_line1}
onChange={(e) => handleInputChange('address_line1', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Calle Principal 123"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dirección Línea 2
</label>
<input
type="text"
value={formData.address_line2}
onChange={(e) => handleInputChange('address_line2', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Piso, apartamento, etc."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ciudad
</label>
<input
type="text"
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Madrid"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Provincia/Estado
</label>
<input
type="text"
value={formData.state_province}
onChange={(e) => handleInputChange('state_province', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Madrid"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Código Postal
</label>
<input
type="text"
value={formData.postal_code}
onChange={(e) => handleInputChange('postal_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="28001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
País
</label>
<input
type="text"
value={formData.country}
onChange={(e) => handleInputChange('country', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="España"
/>
</div>
</div>
</div>
</div>
)}
{/* Business Terms Tab */}
{activeTab === 'business' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Términos de Pago
</label>
<select
value={formData.payment_terms}
onChange={(e) => handleInputChange('payment_terms', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{paymentTermsOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Moneda
</label>
<select
value={formData.currency}
onChange={(e) => handleInputChange('currency', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{currencyOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Límite de Crédito
</label>
<input
type="number"
step="0.01"
value={formData.credit_limit}
onChange={(e) => handleInputChange('credit_limit', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.credit_limit ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0.00"
/>
{errors.credit_limit && <p className="text-red-600 text-sm mt-1">{errors.credit_limit}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tiempo de Entrega Estándar (días)
</label>
<input
type="number"
value={formData.standard_lead_time}
onChange={(e) => handleInputChange('standard_lead_time', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.standard_lead_time ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="7"
/>
{errors.standard_lead_time && <p className="text-red-600 text-sm mt-1">{errors.standard_lead_time}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Monto Mínimo de Pedido
</label>
<input
type="number"
step="0.01"
value={formData.minimum_order_amount}
onChange={(e) => handleInputChange('minimum_order_amount', e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
errors.minimum_order_amount ? 'border-red-300' : 'border-gray-300'
}`}
placeholder="0.00"
/>
{errors.minimum_order_amount && <p className="text-red-600 text-sm mt-1">{errors.minimum_order_amount}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Área de Entrega
</label>
<input
type="text"
value={formData.delivery_area}
onChange={(e) => handleInputChange('delivery_area', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Nacional, Regional, Local"
/>
</div>
</div>
</div>
)}
{/* Additional Information Tab */}
{activeTab === 'additional' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notas
</label>
<textarea
value={formData.notes}
onChange={(e) => handleInputChange('notes', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Información adicional sobre el proveedor..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Certificaciones (JSON)
</label>
<textarea
value={formData.certifications}
onChange={(e) => handleInputChange('certifications', e.target.value)}
rows={3}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm ${
errors.certifications ? 'border-red-300' : 'border-gray-300'
}`}
placeholder='{"iso": "9001", "organic": true}'
/>
{errors.certifications && <p className="text-red-600 text-sm mt-1">{errors.certifications}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Horario de Atención (JSON)
</label>
<textarea
value={formData.business_hours}
onChange={(e) => handleInputChange('business_hours', e.target.value)}
rows={3}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm ${
errors.business_hours ? 'border-red-300' : 'border-gray-300'
}`}
placeholder='{"monday": "9:00-17:00", "tuesday": "9:00-17:00"}'
/>
{errors.business_hours && <p className="text-red-600 text-sm mt-1">{errors.business_hours}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Especializaciones (JSON)
</label>
<textarea
value={formData.specializations}
onChange={(e) => handleInputChange('specializations', e.target.value)}
rows={3}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm ${
errors.specializations ? 'border-red-300' : 'border-gray-300'
}`}
placeholder='{"organic": true, "gluten_free": true, "local": false}'
/>
{errors.specializations && <p className="text-red-600 text-sm mt-1">{errors.specializations}</p>}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end space-x-3 p-6 border-t bg-gray-50">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isCreating}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isCreating}
>
{isCreating ? (
<div className="flex items-center space-x-2">
<LoadingSpinner size="sm" />
<span>Guardando...</span>
</div>
) : (
<span>{supplier ? 'Actualizar Proveedor' : 'Crear Proveedor'}</span>
)}
</Button>
</div>
</form>
</div>
</div>
);
};
export default SupplierForm;

View File

@@ -0,0 +1,578 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
Search,
Filter,
Plus,
Download,
RefreshCw,
ChevronDown,
Building,
TrendingUp,
Users,
AlertCircle,
Package,
DollarSign,
Grid3X3,
List
} from 'lucide-react';
import {
useSuppliers,
SupplierSummary,
CreateSupplierRequest,
UpdateSupplierRequest
} from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import SupplierCard from './SupplierCard';
import SupplierForm from './SupplierForm';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface SupplierFilters {
search: string;
supplier_type: string;
status: string;
}
const SupplierManagementPage: React.FC = () => {
const { user } = useAuth();
const {
suppliers,
statistics,
activeSuppliers,
topSuppliers,
suppliersNeedingReview,
isLoading,
isCreating,
error,
pagination,
loadSuppliers,
loadStatistics,
loadActiveSuppliers,
loadTopSuppliers,
loadSuppliersNeedingReview,
createSupplier,
updateSupplier,
deleteSupplier,
approveSupplier,
clearError,
refresh,
setPage
} = useSuppliers();
const [filters, setFilters] = useState<SupplierFilters>({
search: '',
supplier_type: '',
status: ''
});
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
const [showSupplierForm, setShowSupplierForm] = useState(false);
const [selectedSupplier, setSelectedSupplier] = useState<SupplierSummary | null>(null);
// Load initial data
useEffect(() => {
if (user?.tenant_id) {
loadSuppliers();
loadStatistics();
loadActiveSuppliers();
loadTopSuppliers();
loadSuppliersNeedingReview();
}
}, [user?.tenant_id]);
// Apply filters
useEffect(() => {
const searchParams: any = {};
if (filters.search) {
searchParams.search_term = filters.search;
}
if (filters.supplier_type) {
searchParams.supplier_type = filters.supplier_type;
}
if (filters.status) {
searchParams.status = filters.status;
}
loadSuppliers(searchParams);
}, [filters]);
// Supplier type options
const supplierTypeOptions = [
{ value: '', label: 'Todos los tipos' },
{ value: 'INGREDIENTS', label: 'Ingredientes' },
{ value: 'PACKAGING', label: 'Embalaje' },
{ value: 'EQUIPMENT', label: 'Equipamiento' },
{ value: 'SERVICES', label: 'Servicios' },
{ value: 'UTILITIES', label: 'Utilidades' },
{ value: 'MULTI', label: 'Multi-categoría' }
];
// Status options
const statusOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'ACTIVE', label: 'Activos' },
{ value: 'INACTIVE', label: 'Inactivos' },
{ value: 'PENDING_APPROVAL', label: 'Pendiente Aprobación' },
{ value: 'SUSPENDED', label: 'Suspendidos' },
{ value: 'BLACKLISTED', label: 'Lista Negra' }
];
// Handle supplier creation
const handleCreateSupplier = async (supplierData: CreateSupplierRequest) => {
const supplier = await createSupplier(supplierData);
if (supplier) {
setShowSupplierForm(false);
// Refresh statistics and special lists
loadStatistics();
if (supplier.status === 'ACTIVE') loadActiveSuppliers();
if (supplier.status === 'PENDING_APPROVAL') loadSuppliersNeedingReview();
}
};
// Handle supplier update
const handleUpdateSupplier = async (supplierId: string, supplierData: UpdateSupplierRequest) => {
const supplier = await updateSupplier(supplierId, supplierData);
if (supplier) {
setShowSupplierForm(false);
setSelectedSupplier(null);
// Refresh special lists if status changed
loadActiveSuppliers();
loadSuppliersNeedingReview();
}
};
// Handle supplier approval
const handleApproveSupplier = async (
supplier: SupplierSummary,
action: 'approve' | 'reject',
notes?: string
) => {
const updatedSupplier = await approveSupplier(supplier.id, action, notes);
if (updatedSupplier) {
// Refresh relevant lists
loadActiveSuppliers();
loadSuppliersNeedingReview();
loadStatistics();
}
};
// Handle supplier deletion
const handleDeleteSupplier = async (supplier: SupplierSummary) => {
if (window.confirm(`¿Estás seguro de que quieres eliminar el proveedor "${supplier.name}"?`)) {
const success = await deleteSupplier(supplier.id);
if (success) {
loadActiveSuppliers();
loadStatistics();
}
}
};
// Handle clear filters
const handleClearFilters = () => {
setFilters({
search: '',
supplier_type: '',
status: ''
});
};
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
// Statistics cards data
const statsCards = useMemo(() => {
if (!statistics) return [];
return [
{
title: 'Total Proveedores',
value: statistics.total_suppliers.toString(),
icon: Building,
color: 'blue'
},
{
title: 'Proveedores Activos',
value: statistics.active_suppliers.toString(),
icon: Users,
color: 'green'
},
{
title: 'Pendientes Aprobación',
value: statistics.pending_suppliers.toString(),
icon: AlertCircle,
color: 'yellow'
},
{
title: 'Gasto Total',
value: formatCurrency(statistics.total_spend),
icon: DollarSign,
color: 'purple'
}
];
}, [statistics]);
if (isLoading && !suppliers.length) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Proveedores</h1>
<p className="text-gray-600">Administra tus proveedores y relaciones comerciales</p>
</div>
<div className="flex items-center space-x-3">
<Button
variant="outline"
onClick={refresh}
disabled={isLoading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button
onClick={() => setShowSupplierForm(true)}
disabled={isCreating}
>
<Plus className="w-4 h-4 mr-2" />
Nuevo Proveedor
</Button>
</div>
</div>
{/* Error display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-700">{error}</span>
</div>
<button
onClick={clearError}
className="text-red-500 hover:text-red-700"
>
</button>
</div>
)}
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map((stat, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{stat.title}</p>
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
stat.color === 'blue' ? 'bg-blue-100' :
stat.color === 'green' ? 'bg-green-100' :
stat.color === 'yellow' ? 'bg-yellow-100' :
'bg-purple-100'
}`}>
<stat.icon className={`w-6 h-6 ${
stat.color === 'blue' ? 'text-blue-600' :
stat.color === 'green' ? 'text-green-600' :
stat.color === 'yellow' ? 'text-yellow-600' :
'text-purple-600'
}`} />
</div>
</div>
</Card>
))}
</div>
{/* Quick Lists */}
{(suppliersNeedingReview.length > 0 || topSuppliers.length > 0) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Suppliers Needing Review */}
{suppliersNeedingReview.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<AlertCircle className="w-5 h-5 text-yellow-500 mr-2" />
Requieren Aprobación
</h3>
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-sm">
{suppliersNeedingReview.length}
</span>
</div>
<div className="space-y-3">
{suppliersNeedingReview.slice(0, 3).map(supplier => (
<SupplierCard
key={supplier.id}
supplier={supplier}
compact
onApprove={handleApproveSupplier}
onViewDetails={(supplier) => setSelectedSupplier(supplier)}
/>
))}
{suppliersNeedingReview.length > 3 && (
<button
onClick={() => setFilters(prev => ({ ...prev, status: 'PENDING_APPROVAL' }))}
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
Ver {suppliersNeedingReview.length - 3} más...
</button>
)}
</div>
</Card>
)}
{/* Top Suppliers */}
{topSuppliers.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<TrendingUp className="w-5 h-5 text-green-500 mr-2" />
Top Proveedores
</h3>
</div>
<div className="space-y-3">
{topSuppliers.slice(0, 3).map(supplier => (
<SupplierCard
key={supplier.id}
supplier={supplier}
compact
onViewDetails={(supplier) => setSelectedSupplier(supplier)}
/>
))}
</div>
</Card>
)}
</div>
)}
{/* Filters and Search */}
<Card>
<div className="flex flex-col lg:flex-row lg:items-center justify-between space-y-4 lg:space-y-0">
<div className="flex items-center space-x-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Buscar proveedores..."
value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-64"
/>
</div>
{/* Filter Toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center space-x-2 px-3 py-2 border rounded-lg transition-colors ${
showFilters ? 'bg-blue-50 border-blue-200 text-blue-700' : 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<Filter className="w-4 h-4" />
<span>Filtros</span>
<ChevronDown className={`w-4 h-4 transform transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{/* Active filters indicator */}
{(filters.supplier_type || filters.status) && (
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Filtros activos:</span>
{filters.supplier_type && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
{supplierTypeOptions.find(opt => opt.value === filters.supplier_type)?.label}
</span>
)}
{filters.status && (
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
{statusOptions.find(opt => opt.value === filters.status)?.label}
</span>
)}
<button
onClick={handleClearFilters}
className="text-xs text-red-600 hover:text-red-700"
>
Limpiar
</button>
</div>
)}
</div>
<div className="flex items-center space-x-3">
{/* View Mode Toggle */}
<div className="flex items-center space-x-1 border border-gray-300 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded ${viewMode === 'list' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</div>
{/* Expanded Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Proveedor
</label>
<select
value={filters.supplier_type}
onChange={(e) => setFilters(prev => ({ ...prev, supplier_type: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{supplierTypeOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<select
value={filters.status}
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
{statusOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
)}
</Card>
{/* Suppliers List */}
<div>
{suppliers.length === 0 ? (
<Card className="text-center py-12">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron proveedores</h3>
<p className="text-gray-600 mb-4">
{filters.search || filters.supplier_type || filters.status
? 'Intenta ajustar tus filtros de búsqueda'
: 'Comienza agregando tu primer proveedor'
}
</p>
{!(filters.search || filters.supplier_type || filters.status) && (
<Button onClick={() => setShowSupplierForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Agregar Proveedor
</Button>
)}
</Card>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'space-y-4'
}>
{suppliers.map(supplier => (
<SupplierCard
key={supplier.id}
supplier={supplier}
compact={viewMode === 'list'}
onEdit={(supplier) => {
setSelectedSupplier(supplier);
setShowSupplierForm(true);
}}
onDelete={handleDeleteSupplier}
onViewDetails={(supplier) => setSelectedSupplier(supplier)}
onApprove={handleApproveSupplier}
/>
))}
</div>
)}
{/* Pagination */}
{suppliers.length > 0 && pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="text-sm text-gray-700">
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
{pagination.total} proveedores
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setPage(pagination.page - 1)}
disabled={pagination.page === 1}
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Anterior
</button>
<span className="px-3 py-2 bg-blue-50 text-blue-600 rounded-lg">
{pagination.page}
</span>
<button
onClick={() => setPage(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
Siguiente
</button>
</div>
</div>
)}
</div>
{/* Supplier Form Modal */}
{showSupplierForm && (
<SupplierForm
supplier={selectedSupplier}
isOpen={showSupplierForm}
isCreating={isCreating}
onSubmit={selectedSupplier ?
(data) => handleUpdateSupplier(selectedSupplier.id, data) :
handleCreateSupplier
}
onClose={() => {
setShowSupplierForm(false);
setSelectedSupplier(null);
}}
/>
)}
</div>
);
};
export default SupplierManagementPage;

View File

@@ -0,0 +1,628 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
TrendingUp,
TrendingDown,
Calendar,
Clock,
Star,
Package,
DollarSign,
Truck,
AlertTriangle,
CheckCircle,
BarChart3,
PieChart,
Download,
Filter,
Building
} from 'lucide-react';
import {
useSuppliers,
usePurchaseOrders,
useDeliveries,
SupplierSummary
} from '../../api/hooks/useSuppliers';
import { useAuth } from '../../api/hooks/useAuth';
import Card from '../ui/Card';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
import SupplierCard from './SupplierCard';
interface ReportFilters {
period: 'last_30_days' | 'last_90_days' | 'last_year';
supplier_type?: string;
min_orders?: number;
}
interface SupplierPerformance extends SupplierSummary {
performance_score: number;
reliability_rating: number;
cost_efficiency: number;
response_time: number;
quality_consistency: number;
}
const SupplierPerformanceReport: React.FC = () => {
const { user } = useAuth();
const {
suppliers,
activeSuppliers,
statistics: supplierStats,
loadSuppliers,
loadActiveSuppliers,
loadStatistics: loadSupplierStats
} = useSuppliers();
const {
statistics: orderStats,
loadStatistics: loadOrderStats
} = usePurchaseOrders();
const {
performanceStats: deliveryStats,
loadPerformanceStats: loadDeliveryStats
} = useDeliveries();
const [filters, setFilters] = useState<ReportFilters>({
period: 'last_90_days',
min_orders: 1
});
const [isLoading, setIsLoading] = useState(true);
const [selectedSupplier, setSelectedSupplier] = useState<SupplierSummary | null>(null);
// Load data
useEffect(() => {
if (user?.tenant_id) {
loadReportData();
}
}, [user?.tenant_id, filters]);
const loadReportData = async () => {
setIsLoading(true);
try {
await Promise.all([
loadSuppliers(),
loadActiveSuppliers(),
loadSupplierStats(),
loadOrderStats(),
loadDeliveryStats(getPeriodDays(filters.period))
]);
} catch (error) {
console.error('Error loading report data:', error);
} finally {
setIsLoading(false);
}
};
// Convert period to days
const getPeriodDays = (period: string) => {
switch (period) {
case 'last_30_days': return 30;
case 'last_90_days': return 90;
case 'last_year': return 365;
default: return 90;
}
};
// Calculate enhanced supplier performance metrics
const enhancedSuppliers = useMemo(() => {
if (!activeSuppliers.length) return [];
return activeSuppliers
.filter(supplier => supplier.total_orders >= (filters.min_orders || 1))
.map(supplier => {
// Calculate performance score (0-100)
const qualityScore = (supplier.quality_rating || 0) * 20; // Convert 5-star to percentage
const deliveryScore = (supplier.delivery_rating || 0) * 20; // Convert 5-star to percentage
const volumeScore = Math.min((supplier.total_orders / 10) * 20, 20); // Orders factor
const valueScore = Math.min((supplier.total_amount / 10000) * 20, 20); // Value factor
const performance_score = (qualityScore + deliveryScore + volumeScore + valueScore) / 4;
// Calculate other metrics
const reliability_rating = supplier.delivery_rating || 0;
const cost_efficiency = supplier.total_orders > 0 ?
(supplier.total_amount / supplier.total_orders) / 100 : 0; // Simplified efficiency
const response_time = Math.random() * 24; // Mock response time in hours
const quality_consistency = supplier.quality_rating || 0;
return {
...supplier,
performance_score: Math.round(performance_score),
reliability_rating,
cost_efficiency,
response_time,
quality_consistency
} as SupplierPerformance;
})
.sort((a, b) => b.performance_score - a.performance_score);
}, [activeSuppliers, filters.min_orders]);
// Performance categories
const performanceCategories = useMemo(() => {
const excellent = enhancedSuppliers.filter(s => s.performance_score >= 80);
const good = enhancedSuppliers.filter(s => s.performance_score >= 60 && s.performance_score < 80);
const average = enhancedSuppliers.filter(s => s.performance_score >= 40 && s.performance_score < 60);
const poor = enhancedSuppliers.filter(s => s.performance_score < 40);
return { excellent, good, average, poor };
}, [enhancedSuppliers]);
// Supplier type distribution
const supplierTypeDistribution = useMemo(() => {
const distribution: Record<string, number> = {};
enhancedSuppliers.forEach(supplier => {
distribution[supplier.supplier_type] = (distribution[supplier.supplier_type] || 0) + 1;
});
return Object.entries(distribution).map(([type, count]) => ({
type,
count,
percentage: (count / enhancedSuppliers.length) * 100
}));
}, [enhancedSuppliers]);
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
};
// Format percentage
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`;
};
// Get performance color
const getPerformanceColor = (score: number) => {
if (score >= 80) return 'text-green-600';
if (score >= 60) return 'text-blue-600';
if (score >= 40) return 'text-yellow-600';
return 'text-red-600';
};
// Get performance badge color
const getPerformanceBadgeColor = (score: number) => {
if (score >= 80) return 'bg-green-100 text-green-800';
if (score >= 60) return 'bg-blue-100 text-blue-800';
if (score >= 40) return 'bg-yellow-100 text-yellow-800';
return 'bg-red-100 text-red-800';
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reporte de Rendimiento de Proveedores</h1>
<p className="text-gray-600">Análisis detallado del rendimiento y métricas de tus proveedores</p>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={filters.period}
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="last_30_days">Últimos 30 días</option>
<option value="last_90_days">Últimos 90 días</option>
<option value="last_year">Último año</option>
</select>
</div>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar Reporte
</Button>
</div>
</div>
{/* Performance Overview */}
<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">Proveedores Excelentes</p>
<p className="text-2xl font-bold text-green-600">{performanceCategories.excellent.length}</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<TrendingUp className="w-6 h-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">Rendimiento Bueno</p>
<p className="text-2xl font-bold text-blue-600">{performanceCategories.good.length}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<CheckCircle className="w-6 h-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">Necesita Mejora</p>
<p className="text-2xl font-bold text-yellow-600">{performanceCategories.average.length}</p>
</div>
<div className="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
<Clock className="w-6 h-6 text-yellow-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">Rendimiento Bajo</p>
<p className="text-2xl font-bold text-red-600">{performanceCategories.poor.length}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
</div>
</Card>
</div>
{/* Detailed Performance Analysis */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Performers */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Star className="w-5 h-5 text-yellow-500 mr-2" />
Top 5 Proveedores
</h3>
</div>
<div className="space-y-4">
{enhancedSuppliers.slice(0, 5).map((supplier, index) => (
<div
key={supplier.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => setSelectedSupplier(supplier)}
>
<div className="flex items-center space-x-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
index === 0 ? 'bg-yellow-200 text-yellow-800' :
index === 1 ? 'bg-gray-200 text-gray-800' :
index === 2 ? 'bg-orange-200 text-orange-800' :
'bg-blue-100 text-blue-800'
}`}>
{index + 1}
</div>
<div>
<p className="font-medium text-gray-900">{supplier.name}</p>
<p className="text-sm text-gray-500">{supplier.supplier_type}</p>
</div>
</div>
<div className="text-right">
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
getPerformanceBadgeColor(supplier.performance_score)
}`}>
{supplier.performance_score}%
</div>
<p className="text-xs text-gray-500 mt-1">
{supplier.total_orders} pedidos
</p>
</div>
</div>
))}
</div>
</div>
</Card>
{/* Supplier Type Distribution */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<PieChart className="w-5 h-5 text-purple-500 mr-2" />
Distribución por Tipo
</h3>
</div>
<div className="space-y-3">
{supplierTypeDistribution.map((item, index) => (
<div key={item.type} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className="w-4 h-4 rounded-full"
style={{
backgroundColor: [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'
][index % 6]
}}
/>
<span className="text-sm font-medium text-gray-700 capitalize">
{item.type.toLowerCase()}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-24 h-2 bg-gray-200 rounded-full">
<div
className="h-2 rounded-full"
style={{
width: `${item.percentage}%`,
backgroundColor: [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'
][index % 6]
}}
/>
</div>
<span className="text-sm text-gray-600">{item.count}</span>
</div>
</div>
))}
</div>
</div>
</Card>
</div>
{/* Detailed Performance Metrics Table */}
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<BarChart3 className="w-5 h-5 text-indigo-500 mr-2" />
Métricas Detalladas de Rendimiento
</h3>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Mín. pedidos:</span>
<input
type="number"
min="1"
value={filters.min_orders || 1}
onChange={(e) => setFilters(prev => ({
...prev,
min_orders: parseInt(e.target.value) || 1
}))}
className="w-16 px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</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">
Proveedor
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Puntuación
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Calidad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Puntualidad
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Pedidos
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Valor Total
</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">
{enhancedSuppliers.map((supplier) => (
<tr key={supplier.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<Building className="w-5 h-5 text-blue-600" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{supplier.name}
</div>
<div className="text-sm text-gray-500">
{supplier.supplier_type}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center space-x-2">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${
supplier.performance_score >= 80 ? 'bg-green-600' :
supplier.performance_score >= 60 ? 'bg-blue-600' :
supplier.performance_score >= 40 ? 'bg-yellow-600' :
'bg-red-600'
}`}
style={{ width: `${supplier.performance_score}%` }}
/>
</div>
<span className={`text-sm font-medium ${getPerformanceColor(supplier.performance_score)}`}>
{supplier.performance_score}%
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${
i < (supplier.quality_rating || 0)
? 'text-yellow-400 fill-current'
: 'text-gray-300'
}`}
/>
))}
<span className="ml-2 text-sm text-gray-600">
{supplier.quality_rating?.toFixed(1) || 'N/A'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${
i < (supplier.delivery_rating || 0)
? 'text-blue-400 fill-current'
: 'text-gray-300'
}`}
/>
))}
<span className="ml-2 text-sm text-gray-600">
{supplier.delivery_rating?.toFixed(1) || 'N/A'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{supplier.total_orders}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCurrency(supplier.total_amount)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => setSelectedSupplier(supplier)}
className="text-blue-600 hover:text-blue-900"
>
Ver Detalles
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Card>
{/* Supplier Detail Modal */}
{selectedSupplier && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden mx-4">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Detalles del Proveedor: {selectedSupplier.name}
</h2>
<button
onClick={() => setSelectedSupplier(null)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[70vh]">
<SupplierCard
supplier={selectedSupplier}
compact={false}
showActions={false}
/>
{/* Performance Details */}
<Card className="mt-6">
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Métricas de Rendimiento Detalladas
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-medium text-gray-900 mb-3">Puntuación General</h4>
<div className="relative pt-1">
<div className="flex mb-2 items-center justify-between">
<div>
<span className="text-xs font-semibold inline-block text-blue-600">
Rendimiento Global
</span>
</div>
<div className="text-right">
<span className={`text-xs font-semibold inline-block ${
getPerformanceColor((selectedSupplier as SupplierPerformance).performance_score)
}`}>
{(selectedSupplier as SupplierPerformance).performance_score}%
</span>
</div>
</div>
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-gray-200">
<div
className={`shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center ${
(selectedSupplier as SupplierPerformance).performance_score >= 80 ? 'bg-green-600' :
(selectedSupplier as SupplierPerformance).performance_score >= 60 ? 'bg-blue-600' :
(selectedSupplier as SupplierPerformance).performance_score >= 40 ? 'bg-yellow-600' :
'bg-red-600'
}`}
style={{ width: `${(selectedSupplier as SupplierPerformance).performance_score}%` }}
/>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-gray-900 mb-3">Indicadores Clave</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Calidad:</span>
<span className="text-sm font-medium">
{selectedSupplier.quality_rating?.toFixed(1)}/5.0
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Puntualidad:</span>
<span className="text-sm font-medium">
{selectedSupplier.delivery_rating?.toFixed(1)}/5.0
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Total Pedidos:</span>
<span className="text-sm font-medium">
{selectedSupplier.total_orders}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Valor Total:</span>
<span className="text-sm font-medium">
{formatCurrency(selectedSupplier.total_amount)}
</span>
</div>
</div>
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
)}
</div>
);
};
export default SupplierPerformanceReport;

View File

@@ -0,0 +1,20 @@
// Supplier Components Exports
export { default as SupplierCard } from './SupplierCard';
export { default as SupplierForm } from './SupplierForm';
export { default as SupplierManagementPage } from './SupplierManagementPage';
export { default as SupplierDashboardWidget } from './SupplierDashboardWidget';
// Purchase Order Components Exports
export { default as PurchaseOrderCard } from './PurchaseOrderCard';
export { default as PurchaseOrderForm } from './PurchaseOrderForm';
export { default as PurchaseOrderManagementPage } from './PurchaseOrderManagementPage';
// Delivery Tracking Components Exports
export { default as DeliveryCard } from './DeliveryCard';
export { default as DeliveryTrackingPage } from './DeliveryTrackingPage';
export { default as DeliveryDashboardWidget } from './DeliveryDashboardWidget';
// Supplier Analytics Components Exports
export { default as SupplierAnalyticsDashboard } from './SupplierAnalyticsDashboard';
export { default as SupplierPerformanceReport } from './SupplierPerformanceReport';
export { default as SupplierCostAnalysis } from './SupplierCostAnalysis';

View File

@@ -0,0 +1,542 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Search,
Plus,
Filter,
Download,
Upload,
Grid3X3,
List,
Package,
TrendingDown,
AlertTriangle,
Loader,
RefreshCw,
BarChart3,
Calendar
} from 'lucide-react';
import toast from 'react-hot-toast';
import { useInventory } from '../../api/hooks/useInventory';
import {
InventorySearchParams,
ProductType,
CreateInventoryItemRequest,
UpdateInventoryItemRequest,
StockAdjustmentRequest,
InventoryItem
} from '../../api/services/inventory.service';
import InventoryItemCard from '../../components/inventory/InventoryItemCard';
import StockAlertsPanel from '../../components/inventory/StockAlertsPanel';
type ViewMode = 'grid' | 'list';
interface FilterState {
search: string;
product_type?: ProductType;
category?: string;
is_active?: boolean;
low_stock_only?: boolean;
expiring_soon_only?: boolean;
sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at';
sort_order?: 'asc' | 'desc';
}
const InventoryPage: React.FC = () => {
const {
items,
stockLevels,
alerts,
dashboardData,
isLoading,
error,
pagination,
loadItems,
createItem,
updateItem,
deleteItem,
adjustStock,
acknowledgeAlert,
refresh,
clearError
} = useInventory();
// Local state
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [showAlerts, setShowAlerts] = useState(false);
const [filters, setFilters] = useState<FilterState>({
search: '',
sort_by: 'name',
sort_order: 'asc'
});
const [showFilters, setShowFilters] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// Load items when filters change
useEffect(() => {
const searchParams: InventorySearchParams = {
...filters,
page: 1,
limit: 20
};
// Remove empty values
Object.keys(searchParams).forEach(key => {
if (searchParams[key as keyof InventorySearchParams] === '' ||
searchParams[key as keyof InventorySearchParams] === undefined) {
delete searchParams[key as keyof InventorySearchParams];
}
});
loadItems(searchParams);
}, [filters, loadItems]);
// Handle search
const handleSearch = useCallback((value: string) => {
setFilters(prev => ({ ...prev, search: value }));
}, []);
// Handle filter changes
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
}, []);
// Clear all filters
const clearFilters = useCallback(() => {
setFilters({
search: '',
sort_by: 'name',
sort_order: 'asc'
});
}, []);
// Handle item selection
const toggleItemSelection = (itemId: string) => {
const newSelection = new Set(selectedItems);
if (newSelection.has(itemId)) {
newSelection.delete(itemId);
} else {
newSelection.add(itemId);
}
setSelectedItems(newSelection);
};
// Handle stock adjustment
const handleStockAdjust = async (item: InventoryItem, adjustment: StockAdjustmentRequest) => {
const result = await adjustStock(item.id, adjustment);
if (result) {
// Refresh data to get updated stock levels
refresh();
}
};
// Handle item edit
const handleItemEdit = (item: InventoryItem) => {
// TODO: Open edit modal
console.log('Edit item:', item);
};
// Handle item view details
const handleItemViewDetails = (item: InventoryItem) => {
// TODO: Open details modal or navigate to details page
console.log('View details:', item);
};
// Handle alert acknowledgment
const handleAcknowledgeAlert = async (alertId: string) => {
await acknowledgeAlert(alertId);
};
// Handle bulk acknowledge alerts
const handleBulkAcknowledgeAlerts = async (alertIds: string[]) => {
// TODO: Implement bulk acknowledge
for (const alertId of alertIds) {
await acknowledgeAlert(alertId);
}
};
// Get quick stats
const getQuickStats = () => {
const totalItems = items.length;
const lowStockItems = alerts.filter(a => a.alert_type === 'low_stock' && !a.is_acknowledged).length;
const expiringItems = alerts.filter(a => a.alert_type === 'expiring_soon' && !a.is_acknowledged).length;
const totalValue = dashboardData?.total_value || 0;
return { totalItems, lowStockItems, expiringItems, totalValue };
};
const stats = getQuickStats();
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Inventario</h1>
<p className="text-gray-600 mt-1">
Administra tus productos, stock y alertas
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => setShowAlerts(!showAlerts)}
className={`relative p-2 rounded-lg transition-colors ${
showAlerts ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<AlertTriangle className="w-5 h-5" />
{alerts.filter(a => !a.is_acknowledged).length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{alerts.filter(a => !a.is_acknowledged).length}
</span>
)}
</button>
<button
onClick={() => refresh()}
disabled={isLoading}
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<Plus className="w-4 h-4" />
<span>Nuevo Producto</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Main Content */}
<div className={`${showAlerts ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Package className="w-8 h-8 text-blue-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Total Productos</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalItems}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<TrendingDown className="w-8 h-8 text-yellow-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Stock Bajo</p>
<p className="text-2xl font-bold text-gray-900">{stats.lowStockItems}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Calendar className="w-8 h-8 text-red-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Por Vencer</p>
<p className="text-2xl font-bold text-gray-900">{stats.expiringItems}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<BarChart3 className="w-8 h-8 text-green-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Valor Total</p>
<p className="text-2xl font-bold text-gray-900">
{stats.totalValue.toLocaleString()}
</p>
</div>
</div>
</div>
</div>
{/* Filters and Search */}
<div className="bg-white rounded-lg border mb-6 p-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
{/* Search */}
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Buscar productos..."
value={filters.search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* View Controls */}
<div className="flex items-center space-x-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-lg transition-colors flex items-center space-x-1 ${
showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter className="w-4 h-4" />
<span>Filtros</span>
</button>
<div className="flex rounded-lg border">
<button
onClick={() => setViewMode('grid')}
className={`p-2 ${
viewMode === 'grid'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
{/* Product Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Producto
</label>
<select
value={filters.product_type || ''}
onChange={(e) => handleFilterChange('product_type', e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Todos</option>
<option value="ingredient">Ingredientes</option>
<option value="finished_product">Productos Finales</option>
</select>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<select
value={filters.is_active?.toString() || ''}
onChange={(e) => handleFilterChange('is_active',
e.target.value === '' ? undefined : e.target.value === 'true'
)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Todos</option>
<option value="true">Activos</option>
<option value="false">Inactivos</option>
</select>
</div>
{/* Stock Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stock
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={filters.low_stock_only || false}
onChange={(e) => handleFilterChange('low_stock_only', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Stock bajo</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={filters.expiring_soon_only || false}
onChange={(e) => handleFilterChange('expiring_soon_only', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Por vencer</span>
</label>
</div>
</div>
{/* Sort By */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ordenar por
</label>
<select
value={filters.sort_by || 'name'}
onChange={(e) => handleFilterChange('sort_by', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="name">Nombre</option>
<option value="category">Categoría</option>
<option value="stock_level">Nivel de Stock</option>
<option value="created_at">Fecha de Creación</option>
</select>
</div>
{/* Sort Order */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Orden
</label>
<select
value={filters.sort_order || 'asc'}
onChange={(e) => handleFilterChange('sort_order', e.target.value as 'asc' | 'desc')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="asc">Ascendente</option>
<option value="desc">Descendente</option>
</select>
</div>
</div>
<div className="mt-4 flex justify-end">
<button
onClick={clearFilters}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
>
Limpiar filtros
</button>
</div>
</div>
)}
</div>
{/* Items Grid/List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Cargando inventario...</span>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<AlertTriangle className="w-8 h-8 text-red-600 mx-auto mb-3" />
<h3 className="text-lg font-medium text-red-900 mb-2">Error al cargar inventario</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Reintentar
</button>
</div>
) : items.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{filters.search || Object.values(filters).some(v => v)
? 'No se encontraron productos'
: 'No tienes productos en tu inventario'
}
</h3>
<p className="text-gray-600 mb-6">
{filters.search || Object.values(filters).some(v => v)
? 'Prueba ajustando los filtros de búsqueda'
: 'Comienza agregando tu primer producto al inventario'
}
</p>
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Agregar Producto
</button>
</div>
) : (
<div>
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6'
: 'space-y-4'
}>
{items.map((item) => (
<InventoryItemCard
key={item.id}
item={item}
stockLevel={stockLevels[item.id]}
compact={viewMode === 'list'}
onEdit={handleItemEdit}
onViewDetails={handleItemViewDetails}
onStockAdjust={handleStockAdjust}
/>
))}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="mt-8 flex items-center justify-between">
<div className="text-sm text-gray-600">
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
{pagination.total} productos
</div>
<div className="flex space-x-2">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => {
const searchParams: InventorySearchParams = {
...filters,
page,
limit: pagination.limit
};
loadItems(searchParams);
}}
className={`px-3 py-2 rounded-lg ${
page === pagination.page
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
}`}
>
{page}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Alerts Panel */}
{showAlerts && (
<div className="lg:col-span-1">
<StockAlertsPanel
alerts={alerts}
onAcknowledge={handleAcknowledgeAlert}
onAcknowledgeAll={handleBulkAcknowledgeAlerts}
onViewItem={handleItemViewDetails}
/>
</div>
)}
</div>
</div>
</div>
);
};
export default InventoryPage;

View File

@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain
import toast from 'react-hot-toast';
import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
import SmartHistoricalDataImport from '../../components/onboarding/SmartHistoricalDataImport';
import {
useTenant,
@@ -50,6 +51,7 @@ const MADRID_PRODUCTS = [
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [useSmartImport, setUseSmartImport] = useState(true); // New state for smart import
const manualNavigation = useRef(false);
// Enhanced onboarding with progress tracking
@@ -477,6 +479,11 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}
return true;
case 2:
// Skip validation if using smart import (it handles its own validation)
if (useSmartImport) {
return true;
}
if (!bakeryData.csvFile) {
toast.error('Por favor, selecciona un archivo con tus datos históricos');
return false;
@@ -704,328 +711,373 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
);
case 2:
// If tenantId is not available, show loading or message
if (!tenantId) {
return (
<div className="text-center py-12">
<Loader className="h-8 w-8 animate-spin mx-auto mb-4 text-primary-500" />
<p className="text-gray-600">Preparando la importación inteligente...</p>
<p className="text-sm text-gray-500 mt-2">
Asegúrate de haber completado el paso anterior
</p>
</div>
);
}
// Use Smart Import by default, with option to switch to traditional
if (useSmartImport) {
return (
<div className="space-y-6">
{/* Header with import mode toggle */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-semibold text-gray-900">
Importación Inteligente de Datos 🧠
</h3>
<p className="text-gray-600 mt-1">
Nuestra IA creará automáticamente tu inventario desde tus datos históricos
</p>
</div>
<button
onClick={() => setUseSmartImport(false)}
className="flex items-center space-x-2 px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Upload className="w-4 h-4" />
<span>Importación tradicional</span>
</button>
</div>
{/* Smart Import Component */}
<SmartHistoricalDataImport
tenantId={tenantId}
onComplete={(result) => {
// Mark sales data as uploaded and proceed to training
completeStep('sales_data_uploaded', {
smart_import: true,
records_imported: result.successful_imports,
import_job_id: result.import_job_id,
tenant_id: tenantId,
user_id: user?.id
}).then(() => {
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
startTraining();
}).catch(() => {
// Continue even if step completion fails
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
startTraining();
});
}}
onBack={() => setUseSmartImport(false)}
/>
</div>
);
}
// Traditional import fallback
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Datos Históricos
</h3>
<p className="text-gray-600 mb-6">
Para obtener predicciones precisas, necesitamos tus datos históricos de ventas.
Puedes subir archivos en varios formatos.
</p>
{/* Header with import mode toggle */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Datos Históricos (Modo Tradicional)
</h3>
<p className="text-gray-600 mt-1">
Sube tus datos y configura tu inventario manualmente
</p>
</div>
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg mb-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">!</span>
</div>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-blue-900">
Formatos soportados y estructura de datos
</h4>
<div className="mt-2 text-sm text-blue-700">
<p className="mb-3"><strong>Formatos aceptados:</strong></p>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="font-medium">📊 Hojas de cálculo:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.xlsx (Excel moderno)</li>
<li>.xls (Excel clásico)</li>
</ul>
</div>
<div>
<p className="font-medium">📄 Datos estructurados:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.csv (Valores separados por comas)</li>
<li>.json (Formato JSON)</li>
</ul>
</div>
</div>
<p className="mb-2"><strong>Columnas requeridas (en cualquier idioma):</strong></p>
<ul className="list-disc list-inside text-xs space-y-1">
<li><strong>Fecha</strong>: fecha, date, datum (formato: YYYY-MM-DD, DD/MM/YYYY, etc.)</li>
<li><strong>Producto</strong>: producto, product, item, articulo, nombre</li>
<li><strong>Cantidad</strong>: cantidad, quantity, cantidad_vendida, qty</li>
</ul>
</div>
<button
onClick={() => setUseSmartImport(true)}
className="flex items-center space-x-2 px-4 py-2 text-sm bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg hover:from-blue-600 hover:to-purple-600 transition-colors"
>
<Brain className="w-4 h-4" />
<span>Activar IA</span>
</button>
</div>
<p className="text-gray-600 mb-6">
Para obtener predicciones precisas, necesitamos tus datos históricos de ventas.
Puedes subir archivos en varios formatos.
</p>
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg mb-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">!</span>
</div>
</div>
</div>
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-xl hover:border-primary-300 transition-colors">
<div className="text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<label htmlFor="sales-file-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-medium text-gray-900">
Subir archivo de datos históricos
</span>
<span className="mt-1 block text-sm text-gray-500">
Arrastra y suelta tu archivo aquí, o haz clic para seleccionar
</span>
<span className="mt-1 block text-xs text-gray-400">
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
</span>
</label>
<input
id="sales-file-upload"
type="file"
accept=".csv,.xlsx,.xls,.json"
required
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
toast.error('El archivo es demasiado grande. Máximo 10MB.');
return;
}
// Update bakery data with the selected file
setBakeryData(prev => ({
...prev,
csvFile: file,
hasHistoricalData: true
}));
toast.success(`Archivo ${file.name} seleccionado correctamente`);
// Auto-validate the file after upload if tenantId exists
if (tenantId) {
setValidationStatus({ status: 'validating' });
try {
const validationResult = await validateSalesData(tenantId, file);
if (validationResult.is_valid) {
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
} else {
// If no tenantId yet, set to idle and wait for manual validation
setValidationStatus({ status: 'idle' });
}
}
}}
className="hidden"
/>
</div>
</div>
{bakeryData.csvFile ? (
<div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
</div>
</div>
<button
onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
<div className="ml-3">
<h4 className="text-sm font-medium text-blue-900">
Formatos soportados y estructura de datos
</h4>
<div className="mt-2 text-sm text-blue-700">
<p className="mb-3"><strong>Formatos aceptados:</strong></p>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="font-medium">📊 Hojas de cálculo:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.xlsx (Excel moderno)</li>
<li>.xls (Excel clásico)</li>
</ul>
</div>
<div>
<p className="font-medium">📄 Datos estructurados:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.csv (Valores separados por comas)</li>
<li>.json (Formato JSON)</li>
</ul>
</div>
</div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-yellow-600 mr-2" />
<p className="text-sm text-yellow-800">
<strong>Archivo requerido:</strong> Selecciona un archivo con tus datos históricos de ventas
</p>
</div>
</div>
)}
</div>
{/* Sample formats examples */}
<div className="mt-6 space-y-4">
<h5 className="text-sm font-medium text-gray-900">
Ejemplos de formato:
</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* CSV Example */}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="text-sm font-medium text-gray-900">📄 CSV</span>
</div>
<div className="bg-white p-3 rounded border text-xs font-mono">
<div className="text-gray-600">fecha,producto,cantidad</div>
<div>2024-01-15,Croissants,45</div>
<div>2024-01-15,Pan de molde,32</div>
<div>2024-01-16,Baguettes,28</div>
</div>
</div>
{/* Excel Example */}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="text-sm font-medium text-gray-900">📊 Excel</span>
</div>
<div className="bg-white p-3 rounded border text-xs">
<table className="w-full">
<thead>
<tr className="bg-gray-100">
<th className="p-1 text-left">Fecha</th>
<th className="p-1 text-left">Producto</th>
<th className="p-1 text-left">Cantidad</th>
</tr>
</thead>
<tbody>
<tr><td className="p-1">15/01/2024</td><td className="p-1">Croissants</td><td className="p-1">45</td></tr>
<tr><td className="p-1">15/01/2024</td><td className="p-1">Pan molde</td><td className="p-1">32</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{/* JSON Example */}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="text-sm font-medium text-gray-900">🔧 JSON</span>
</div>
<div className="bg-white p-3 rounded border text-xs font-mono">
<div className="text-gray-600">[</div>
<div className="ml-2">{"{"}"fecha": "2024-01-15", "producto": "Croissants", "cantidad": 45{"}"},</div>
<div className="ml-2">{"{"}"fecha": "2024-01-15", "producto": "Pan de molde", "cantidad": 32{"}"}</div>
<div className="text-gray-600">]</div>
<p className="mb-2"><strong>Columnas requeridas (en cualquier idioma):</strong></p>
<ul className="list-disc list-inside text-xs space-y-1">
<li><strong>Fecha</strong>: fecha, date, datum (formato: YYYY-MM-DD, DD/MM/YYYY, etc.)</li>
<li><strong>Producto</strong>: producto, product, item, articulo, nombre</li>
<li><strong>Cantidad</strong>: cantidad, quantity, cantidad_vendida, qty</li>
</ul>
</div>
</div>
</div>
</div>
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-xl hover:border-primary-300 transition-colors">
<div className="text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<label htmlFor="sales-file-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-medium text-gray-900">
Subir archivo de datos históricos
</span>
<span className="mt-1 block text-sm text-gray-500">
Arrastra y suelta tu archivo aquí, o haz clic para seleccionar
</span>
<span className="mt-1 block text-xs text-gray-400">
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
</span>
</label>
<input
id="sales-file-upload"
type="file"
accept=".csv,.xlsx,.xls,.json"
required
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
toast.error('El archivo es demasiado grande. Máximo 10MB.');
return;
}
// Update bakery data with the selected file
setBakeryData(prev => ({
...prev,
csvFile: file,
hasHistoricalData: true
}));
toast.success(`Archivo ${file.name} seleccionado correctamente`);
// Auto-validate the file after upload if tenantId exists
if (tenantId) {
setValidationStatus({ status: 'validating' });
try {
const validationResult = await validateSalesData(tenantId, file);
if (validationResult.is_valid) {
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
} else {
// If no tenantId yet, set to idle and wait for manual validation
setValidationStatus({ status: 'idle' });
}
}
}}
className="hidden"
/>
</div>
</div>
{bakeryData.csvFile ? (
<div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
</div>
</div>
<button
onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
</div>
</div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-yellow-600 mr-2" />
<p className="text-sm text-yellow-800">
<strong>Archivo requerido:</strong> Selecciona un archivo con tus datos históricos de ventas
</p>
</div>
</div>
)}
</div>
{/* Show switch to smart import suggestion if traditional validation fails */}
{validationStatus.status === 'invalid' && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Brain className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900 mb-2">
💡 ¿Problemas con la validación?
</h4>
<p className="text-sm text-blue-700 mb-3">
Nuestra IA puede manejar archivos con formatos más flexibles y ayudarte a solucionar problemas automáticamente.
</p>
<button
onClick={() => setUseSmartImport(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Probar importación inteligente
</button>
</div>
</div>
</div>
)}
</div>
);
@@ -1316,8 +1368,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
{renderStep()}
</main>
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3) and completion (step 4) */}
{currentStep < 3 && (
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3), completion (step 4), and smart import */}
{currentStep < 3 && !(currentStep === 2 && useSmartImport) && (
<nav
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
role="navigation"

View File

@@ -0,0 +1,517 @@
// frontend/src/pages/recipes/RecipesPage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import {
Search,
Plus,
Filter,
Grid3X3,
List,
ChefHat,
TrendingUp,
AlertTriangle,
Loader,
RefreshCw,
BarChart3,
Star,
Calendar,
Download,
Upload
} from 'lucide-react';
import toast from 'react-hot-toast';
import { useRecipes } from '../../api/hooks/useRecipes';
import { Recipe, RecipeSearchParams } from '../../api/services/recipes.service';
import RecipeCard from '../../components/recipes/RecipeCard';
type ViewMode = 'grid' | 'list';
interface FilterState {
search: string;
status?: string;
category?: string;
is_seasonal?: boolean;
is_signature?: boolean;
difficulty_level?: number;
}
const RecipesPage: React.FC = () => {
const {
recipes,
categories,
statistics,
isLoading,
isCreating,
error,
pagination,
loadRecipes,
createRecipe,
updateRecipe,
deleteRecipe,
duplicateRecipe,
activateRecipe,
checkFeasibility,
loadStatistics,
clearError,
refresh,
setPage
} = useRecipes();
// Local state
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [filters, setFilters] = useState<FilterState>({
search: ''
});
const [showFilters, setShowFilters] = useState(false);
const [selectedRecipes, setSelectedRecipes] = useState<Set<string>>(new Set());
const [feasibilityResults, setFeasibilityResults] = useState<Map<string, any>>(new Map());
// Load recipes when filters change
useEffect(() => {
const searchParams: RecipeSearchParams = {
search_term: filters.search || undefined,
status: filters.status || undefined,
category: filters.category || undefined,
is_seasonal: filters.is_seasonal,
is_signature: filters.is_signature,
difficulty_level: filters.difficulty_level,
limit: 20,
offset: (pagination.page - 1) * 20
};
// Remove undefined values
Object.keys(searchParams).forEach(key => {
if (searchParams[key as keyof RecipeSearchParams] === undefined) {
delete searchParams[key as keyof RecipeSearchParams];
}
});
loadRecipes(searchParams);
}, [filters, pagination.page, loadRecipes]);
// Handle search
const handleSearch = useCallback((value: string) => {
setFilters(prev => ({ ...prev, search: value }));
setPage(1); // Reset to first page
}, [setPage]);
// Handle filter changes
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
setPage(1); // Reset to first page
}, [setPage]);
// Clear all filters
const clearFilters = useCallback(() => {
setFilters({ search: '' });
setPage(1);
}, [setPage]);
// Handle recipe selection
const toggleRecipeSelection = (recipeId: string) => {
const newSelection = new Set(selectedRecipes);
if (newSelection.has(recipeId)) {
newSelection.delete(recipeId);
} else {
newSelection.add(recipeId);
}
setSelectedRecipes(newSelection);
};
// Handle recipe actions
const handleViewRecipe = (recipe: Recipe) => {
// TODO: Navigate to recipe details page or open modal
console.log('View recipe:', recipe);
};
const handleEditRecipe = (recipe: Recipe) => {
// TODO: Navigate to recipe edit page or open modal
console.log('Edit recipe:', recipe);
};
const handleDuplicateRecipe = async (recipe: Recipe) => {
const newName = prompt(`Enter name for duplicated recipe:`, `${recipe.name} (Copy)`);
if (newName && newName.trim()) {
const result = await duplicateRecipe(recipe.id, newName.trim());
if (result) {
toast.success('Recipe duplicated successfully');
}
}
};
const handleActivateRecipe = async (recipe: Recipe) => {
if (confirm(`Are you sure you want to activate "${recipe.name}"?`)) {
const result = await activateRecipe(recipe.id);
if (result) {
toast.success('Recipe activated successfully');
}
}
};
const handleCheckFeasibility = async (recipe: Recipe) => {
const result = await checkFeasibility(recipe.id, 1.0);
if (result) {
setFeasibilityResults(prev => new Map(prev.set(recipe.id, result)));
if (result.feasible) {
toast.success('Recipe can be produced with current inventory');
} else {
toast.error('Recipe cannot be produced - missing ingredients');
}
}
};
const handleDeleteRecipe = async (recipe: Recipe) => {
if (confirm(`Are you sure you want to delete "${recipe.name}"? This action cannot be undone.`)) {
const success = await deleteRecipe(recipe.id);
if (success) {
toast.success('Recipe deleted successfully');
}
}
};
// Get quick stats
const getQuickStats = () => {
if (!statistics) {
return {
totalRecipes: recipes.length,
activeRecipes: recipes.filter(r => r.status === 'active').length,
signatureRecipes: recipes.filter(r => r.is_signature_item).length,
seasonalRecipes: recipes.filter(r => r.is_seasonal).length
};
}
return {
totalRecipes: statistics.total_recipes,
activeRecipes: statistics.active_recipes,
signatureRecipes: statistics.signature_recipes,
seasonalRecipes: statistics.seasonal_recipes
};
};
const stats = getQuickStats();
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Recipe Management</h1>
<p className="text-gray-600 mt-1">
Create and manage your bakery recipes
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => refresh()}
disabled={isLoading}
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<Plus className="w-4 h-4" />
<span>New Recipe</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<ChefHat className="w-8 h-8 text-blue-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Total Recipes</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalRecipes}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<TrendingUp className="w-8 h-8 text-green-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Active Recipes</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeRecipes}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Star className="w-8 h-8 text-yellow-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Signature Items</p>
<p className="text-2xl font-bold text-gray-900">{stats.signatureRecipes}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Calendar className="w-8 h-8 text-purple-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Seasonal Items</p>
<p className="text-2xl font-bold text-gray-900">{stats.seasonalRecipes}</p>
</div>
</div>
</div>
</div>
{/* Filters and Search */}
<div className="bg-white rounded-lg border mb-6 p-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
{/* Search */}
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search recipes..."
value={filters.search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* View Controls */}
<div className="flex items-center space-x-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-lg transition-colors flex items-center space-x-1 ${
showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter className="w-4 h-4" />
<span>Filters</span>
</button>
<div className="flex rounded-lg border">
<button
onClick={() => setViewMode('grid')}
className={`p-2 ${
viewMode === 'grid'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="testing">Testing</option>
<option value="archived">Archived</option>
<option value="discontinued">Discontinued</option>
</select>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
value={filters.category || ''}
onChange={(e) => handleFilterChange('category', e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Difficulty */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Difficulty
</label>
<select
value={filters.difficulty_level || ''}
onChange={(e) => handleFilterChange('difficulty_level',
e.target.value ? parseInt(e.target.value) : undefined
)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Levels</option>
<option value="1">Level 1 (Easy)</option>
<option value="2">Level 2</option>
<option value="3">Level 3</option>
<option value="4">Level 4</option>
<option value="5">Level 5 (Hard)</option>
</select>
</div>
{/* Special Types */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Special Types
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={filters.is_signature || false}
onChange={(e) => handleFilterChange('is_signature', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Signature items</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={filters.is_seasonal || false}
onChange={(e) => handleFilterChange('is_seasonal', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Seasonal items</span>
</label>
</div>
</div>
{/* Clear Filters */}
<div className="flex items-end">
<button
onClick={clearFilters}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Clear filters
</button>
</div>
</div>
</div>
)}
</div>
{/* Recipes Grid/List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading recipes...</span>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<AlertTriangle className="w-8 h-8 text-red-600 mx-auto mb-3" />
<h3 className="text-lg font-medium text-red-900 mb-2">Error loading recipes</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Try Again
</button>
</div>
) : recipes.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<ChefHat className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{Object.values(filters).some(v => v)
? 'No recipes found'
: 'No recipes yet'
}
</h3>
<p className="text-gray-600 mb-6">
{Object.values(filters).some(v => v)
? 'Try adjusting your search and filter criteria'
: 'Create your first recipe to get started'
}
</p>
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Create Recipe
</button>
</div>
) : (
<div>
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6'
: 'space-y-4'
}>
{recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
compact={viewMode === 'list'}
onView={handleViewRecipe}
onEdit={handleEditRecipe}
onDuplicate={handleDuplicateRecipe}
onActivate={handleActivateRecipe}
onCheckFeasibility={handleCheckFeasibility}
feasibility={feasibilityResults.get(recipe.id)}
/>
))}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="mt-8 flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing {((pagination.page - 1) * pagination.limit) + 1} to{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} of{' '}
{pagination.total} recipes
</div>
<div className="flex space-x-2">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setPage(page)}
className={`px-3 py-2 rounded-lg ${
page === pagination.page
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
}`}
>
{page}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default RecipesPage;

View File

@@ -0,0 +1,203 @@
import React, { useState } from 'react';
import {
BarChart3,
ShoppingCart,
TrendingUp,
Package
} from 'lucide-react';
import { SalesAnalyticsDashboard, SalesManagementPage } from '../../components/sales';
import Button from '../../components/ui/Button';
type SalesPageView = 'overview' | 'analytics' | 'management';
const SalesPage: React.FC = () => {
const [activeView, setActiveView] = useState<SalesPageView>('overview');
const renderContent = () => {
switch (activeView) {
case 'analytics':
return <SalesAnalyticsDashboard />;
case 'management':
return <SalesManagementPage />;
case 'overview':
default:
return (
<div className="space-y-6">
{/* Overview Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-xl p-8 text-white">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">Panel de Ventas</h1>
<p className="text-blue-100">
Gestiona, analiza y optimiza tus ventas con insights inteligentes
</p>
</div>
<div className="w-16 h-16 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
<ShoppingCart className="w-8 h-8 text-white" />
</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
onClick={() => setActiveView('analytics')}
className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group"
>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
<BarChart3 className="w-6 h-6 text-blue-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Análisis de Ventas
</h3>
<p className="text-gray-600 text-sm mb-4">
Explora métricas detalladas, tendencias y insights de rendimiento
</p>
<div className="flex items-center text-blue-600 text-sm font-medium">
Ver Analytics
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<div
onClick={() => setActiveView('management')}
className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group"
>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
<Package className="w-6 h-6 text-green-600" />
</div>
<ShoppingCart className="w-5 h-5 text-blue-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Gestión de Ventas
</h3>
<p className="text-gray-600 text-sm mb-4">
Administra, filtra y exporta todos tus registros de ventas
</p>
<div className="flex items-center text-green-600 text-sm font-medium">
Gestionar Ventas
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<div className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group">
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
<TrendingUp className="w-6 h-6 text-purple-600" />
</div>
<div className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded-full">
Próximamente
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Predicciones IA
</h3>
<p className="text-gray-600 text-sm mb-4">
Predicciones inteligentes y recomendaciones de ventas
</p>
<div className="flex items-center text-purple-600 text-sm font-medium opacity-50">
En Desarrollo
</div>
</div>
</div>
{/* Quick Insights */}
<div className="bg-white rounded-xl p-6 border border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Insights Rápidos</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<TrendingUp className="w-8 h-8 text-green-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">+12.5%</div>
<div className="text-sm text-gray-600">Crecimiento mensual</div>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
<ShoppingCart className="w-8 h-8 text-blue-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">247</div>
<div className="text-sm text-gray-600">Pedidos este mes</div>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Package className="w-8 h-8 text-purple-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">18.50</div>
<div className="text-sm text-gray-600">Valor promedio pedido</div>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-8 h-8 text-orange-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">4.2</div>
<div className="text-sm text-gray-600">Puntuación satisfacción</div>
</div>
</div>
</div>
{/* Getting Started */}
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl p-6 border border-indigo-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">¿Primera vez aquí?</h2>
<p className="text-gray-700 mb-6">
Comienza explorando tus análisis de ventas para descubrir insights valiosos
sobre el rendimiento de tu panadería.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={() => setActiveView('analytics')}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
<BarChart3 className="w-4 h-4 mr-2" />
Ver Analytics
</Button>
<Button
variant="outline"
onClick={() => setActiveView('management')}
>
<Package className="w-4 h-4 mr-2" />
Gestionar Ventas
</Button>
</div>
</div>
</div>
);
}
};
return (
<div className="p-4 md:p-6 space-y-6">
{/* Navigation */}
{activeView !== 'overview' && (
<div className="flex items-center justify-between mb-6">
<nav className="flex items-center space-x-4">
<button
onClick={() => setActiveView('overview')}
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
>
Volver al Panel
</button>
</nav>
</div>
)}
{/* Content */}
{renderContent()}
</div>
);
};
export default SalesPage;