Create new services: inventory, recipes, suppliers
This commit is contained in:
@@ -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';
|
||||
|
||||
510
frontend/src/api/hooks/useInventory.ts
Normal file
510
frontend/src/api/hooks/useInventory.ts
Normal 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
|
||||
};
|
||||
};
|
||||
682
frontend/src/api/hooks/useRecipes.ts
Normal file
682
frontend/src/api/hooks/useRecipes.ts
Normal 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
|
||||
};
|
||||
};
|
||||
890
frontend/src/api/hooks/useSuppliers.ts
Normal file
890
frontend/src/api/hooks/useSuppliers.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
474
frontend/src/api/services/inventory.service.ts
Normal file
474
frontend/src/api/services/inventory.service.ts
Normal 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();
|
||||
@@ -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();
|
||||
551
frontend/src/api/services/recipes.service.ts
Normal file
551
frontend/src/api/services/recipes.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
622
frontend/src/api/services/suppliers.service.ts
Normal file
622
frontend/src/api/services/suppliers.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
249
frontend/src/components/inventory/InventoryDashboardWidget.tsx
Normal file
249
frontend/src/components/inventory/InventoryDashboardWidget.tsx
Normal 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;
|
||||
424
frontend/src/components/inventory/InventoryItemCard.tsx
Normal file
424
frontend/src/components/inventory/InventoryItemCard.tsx
Normal 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;
|
||||
359
frontend/src/components/inventory/StockAlertsPanel.tsx
Normal file
359
frontend/src/components/inventory/StockAlertsPanel.tsx
Normal 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;
|
||||
727
frontend/src/components/onboarding/SmartHistoricalDataImport.tsx
Normal file
727
frontend/src/components/onboarding/SmartHistoricalDataImport.tsx
Normal 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;
|
||||
323
frontend/src/components/recipes/IngredientList.tsx
Normal file
323
frontend/src/components/recipes/IngredientList.tsx
Normal 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;
|
||||
547
frontend/src/components/recipes/ProductionBatchCard.tsx
Normal file
547
frontend/src/components/recipes/ProductionBatchCard.tsx
Normal 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;
|
||||
445
frontend/src/components/recipes/RecipeCard.tsx
Normal file
445
frontend/src/components/recipes/RecipeCard.tsx
Normal 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;
|
||||
487
frontend/src/components/sales/SalesAnalyticsDashboard.tsx
Normal file
487
frontend/src/components/sales/SalesAnalyticsDashboard.tsx
Normal 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;
|
||||
353
frontend/src/components/sales/SalesDashboardWidget.tsx
Normal file
353
frontend/src/components/sales/SalesDashboardWidget.tsx
Normal 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;
|
||||
315
frontend/src/components/sales/SalesDataCard.tsx
Normal file
315
frontend/src/components/sales/SalesDataCard.tsx
Normal 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;
|
||||
534
frontend/src/components/sales/SalesManagementPage.tsx
Normal file
534
frontend/src/components/sales/SalesManagementPage.tsx
Normal 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;
|
||||
484
frontend/src/components/sales/SalesPerformanceInsights.tsx
Normal file
484
frontend/src/components/sales/SalesPerformanceInsights.tsx
Normal 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;
|
||||
6
frontend/src/components/sales/index.ts
Normal file
6
frontend/src/components/sales/index.ts
Normal 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';
|
||||
611
frontend/src/components/suppliers/DeliveryCard.tsx
Normal file
611
frontend/src/components/suppliers/DeliveryCard.tsx
Normal 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;
|
||||
347
frontend/src/components/suppliers/DeliveryDashboardWidget.tsx
Normal file
347
frontend/src/components/suppliers/DeliveryDashboardWidget.tsx
Normal 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;
|
||||
651
frontend/src/components/suppliers/DeliveryTrackingPage.tsx
Normal file
651
frontend/src/components/suppliers/DeliveryTrackingPage.tsx
Normal 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;
|
||||
482
frontend/src/components/suppliers/PurchaseOrderCard.tsx
Normal file
482
frontend/src/components/suppliers/PurchaseOrderCard.tsx
Normal 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;
|
||||
848
frontend/src/components/suppliers/PurchaseOrderForm.tsx
Normal file
848
frontend/src/components/suppliers/PurchaseOrderForm.tsx
Normal 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;
|
||||
@@ -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;
|
||||
610
frontend/src/components/suppliers/SupplierAnalyticsDashboard.tsx
Normal file
610
frontend/src/components/suppliers/SupplierAnalyticsDashboard.tsx
Normal 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;
|
||||
391
frontend/src/components/suppliers/SupplierCard.tsx
Normal file
391
frontend/src/components/suppliers/SupplierCard.tsx
Normal 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;
|
||||
599
frontend/src/components/suppliers/SupplierCostAnalysis.tsx
Normal file
599
frontend/src/components/suppliers/SupplierCostAnalysis.tsx
Normal 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;
|
||||
314
frontend/src/components/suppliers/SupplierDashboardWidget.tsx
Normal file
314
frontend/src/components/suppliers/SupplierDashboardWidget.tsx
Normal 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;
|
||||
789
frontend/src/components/suppliers/SupplierForm.tsx
Normal file
789
frontend/src/components/suppliers/SupplierForm.tsx
Normal 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;
|
||||
578
frontend/src/components/suppliers/SupplierManagementPage.tsx
Normal file
578
frontend/src/components/suppliers/SupplierManagementPage.tsx
Normal 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;
|
||||
628
frontend/src/components/suppliers/SupplierPerformanceReport.tsx
Normal file
628
frontend/src/components/suppliers/SupplierPerformanceReport.tsx
Normal 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;
|
||||
20
frontend/src/components/suppliers/index.ts
Normal file
20
frontend/src/components/suppliers/index.ts
Normal 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';
|
||||
542
frontend/src/pages/inventory/InventoryPage.tsx
Normal file
542
frontend/src/pages/inventory/InventoryPage.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
517
frontend/src/pages/recipes/RecipesPage.tsx
Normal file
517
frontend/src/pages/recipes/RecipesPage.tsx
Normal 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;
|
||||
203
frontend/src/pages/sales/SalesPage.tsx
Normal file
203
frontend/src/pages/sales/SalesPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user