diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 966610e0..f8cf22c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { AppRouter } from './router/AppRouter'; import { ThemeProvider } from './contexts/ThemeContext'; import { AuthProvider } from './contexts/AuthContext'; import { SSEProvider } from './contexts/SSEContext'; +import { SubscriptionEventsProvider } from './contexts/SubscriptionEventsContext'; import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler'; import { CookieBanner } from './components/ui/CookieConsent'; import i18n from './i18n'; @@ -63,7 +64,9 @@ function App() { - + + + @@ -75,4 +78,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/api/hooks/dashboard.ts b/frontend/src/api/hooks/dashboard.ts index 3a676b8c..9e6d348f 100644 --- a/frontend/src/api/hooks/dashboard.ts +++ b/frontend/src/api/hooks/dashboard.ts @@ -10,7 +10,7 @@ import { inventoryService } from '../services/inventory'; import { getAlertAnalytics } from '../services/alert_analytics'; import { getSustainabilityWidgetData } from '../services/sustainability'; import { ApiError } from '../client/apiClient'; -import type { InventoryDashboardSummary } from '../types/dashboard'; +import type { InventoryDashboardSummary } from '../types/inventory'; import type { AlertAnalytics } from '../services/alert_analytics'; import type { SalesAnalytics } from '../types/sales'; import type { OrdersDashboardSummary } from '../types/orders'; @@ -106,25 +106,12 @@ function calculateTrend(current: number, previous: number): number { } /** - * Calculate today's sales from sales records + * Calculate today's sales from sales records (REMOVED - Professional/Enterprise tier feature) + * Basic tier users don't get sales analytics on dashboard */ -function calculateTodaySales(salesData?: SalesAnalytics): { amount: number; trend: number; productsSold: number; productsTrend: number } { - if (!salesData) { - return { amount: 0, trend: 0, productsSold: 0, productsTrend: 0 }; - } - - // Sales data should have today's revenue and comparison - const todayRevenue = salesData.total_revenue || 0; - const previousRevenue = salesData.previous_period_revenue || 0; - const todayUnits = salesData.total_units_sold || 0; - const previousUnits = salesData.previous_period_units_sold || 0; - - return { - amount: todayRevenue, - trend: calculateTrend(todayRevenue, previousRevenue), - productsSold: todayUnits, - productsTrend: calculateTrend(todayUnits, previousUnits), - }; +function calculateTodaySales(): { amount: number; trend: number; productsSold: number; productsTrend: number } { + // Return zero values - sales analytics not available for basic tier + return { amount: 0, trend: 0, productsSold: 0, productsTrend: 0 }; } /** @@ -135,27 +122,27 @@ function calculateOrdersMetrics(ordersData?: OrdersDashboardSummary): { pending: return { pending: 0, today: 0, trend: 0 }; } - const pendingCount = ordersData.pending_orders_count || 0; - const todayCount = ordersData.orders_today_count || 0; - const yesterdayCount = ordersData.orders_yesterday_count || 0; + const pendingCount = ordersData.pending_orders || 0; + const todayCount = ordersData.total_orders_today || 0; return { pending: pendingCount, today: todayCount, - trend: calculateTrend(todayCount, yesterdayCount), + trend: 0, // Trend calculation removed - needs historical data }; } /** * Aggregate dashboard data from all services + * NOTE: Sales analytics removed - Professional/Enterprise tier feature */ function aggregateDashboardStats(data: AggregatedDashboardData): DashboardStats { - const sales = calculateTodaySales(data.sales); + const sales = calculateTodaySales(); // Returns zeros for basic tier const orders = calculateOrdersMetrics(data.orders); const criticalStockCount = - (data.inventory?.low_stock_count || 0) + - (data.inventory?.out_of_stock_count || 0); + (data.inventory?.low_stock_items || 0) + + (data.inventory?.out_of_stock_items || 0); return { // Alerts @@ -167,20 +154,20 @@ function aggregateDashboardStats(data: AggregatedDashboardData): DashboardStats ordersToday: orders.today, ordersTrend: orders.trend, - // Sales - salesToday: sales.amount, - salesTrend: sales.trend, - salesCurrency: '€', // Default to EUR for bakery + // Sales (REMOVED - not available for basic tier) + salesToday: 0, + salesTrend: 0, + salesCurrency: '€', // Inventory criticalStock: criticalStockCount, - lowStockCount: data.inventory?.low_stock_count || 0, - outOfStockCount: data.inventory?.out_of_stock_count || 0, - expiringSoon: data.inventory?.expiring_soon_count || 0, + lowStockCount: data.inventory?.low_stock_items || 0, + outOfStockCount: data.inventory?.out_of_stock_items || 0, + expiringSoon: data.inventory?.expiring_soon_items || 0, - // Products - productsSoldToday: sales.productsSold, - productsSoldTrend: sales.productsTrend, + // Products (REMOVED - not available for basic tier) + productsSoldToday: 0, + productsSoldTrend: 0, // Sustainability wasteReductionPercentage: data.sustainability?.waste_reduction_percentage, @@ -209,17 +196,13 @@ export const useDashboardStats = ( return useQuery({ queryKey: dashboardKeys.stats(tenantId), queryFn: async () => { - // Fetch all data in parallel - const [alertsData, ordersData, salesData, inventoryData, sustainabilityData] = await Promise.allSettled([ + // Fetch all data in parallel (REMOVED sales analytics - Professional/Enterprise tier only) + const [alertsData, ordersData, inventoryData, sustainabilityData] = await Promise.allSettled([ getAlertAnalytics(tenantId, 7), // Note: OrdersService methods are static import('../services/orders').then(({ OrdersService }) => OrdersService.getDashboardSummary(tenantId) ), - // Fetch today's sales with comparison to yesterday - import('../services/sales').then(({ salesService }) => - salesService.getSalesAnalytics(tenantId, todayStr, todayStr) - ), inventoryService.getDashboardSummary(tenantId), getSustainabilityWidgetData(tenantId, 30), // 30 days for monthly savings ]); @@ -228,7 +211,7 @@ export const useDashboardStats = ( const aggregatedData: AggregatedDashboardData = { alerts: alertsData.status === 'fulfilled' ? alertsData.value : undefined, orders: ordersData.status === 'fulfilled' ? ordersData.value : undefined, - sales: salesData.status === 'fulfilled' ? salesData.value : undefined, + sales: undefined, // REMOVED - Professional/Enterprise tier only inventory: inventoryData.status === 'fulfilled' ? inventoryData.value : undefined, sustainability: sustainabilityData.status === 'fulfilled' ? sustainabilityData.value : undefined, }; @@ -240,9 +223,6 @@ export const useDashboardStats = ( if (ordersData.status === 'rejected') { console.warn('[Dashboard] Failed to fetch orders:', ordersData.reason); } - if (salesData.status === 'rejected') { - console.warn('[Dashboard] Failed to fetch sales:', salesData.reason); - } if (inventoryData.status === 'rejected') { console.warn('[Dashboard] Failed to fetch inventory:', inventoryData.reason); } diff --git a/frontend/src/api/hooks/equipment.ts b/frontend/src/api/hooks/equipment.ts index 4065f498..7acad5d9 100644 --- a/frontend/src/api/hooks/equipment.ts +++ b/frontend/src/api/hooks/equipment.ts @@ -6,7 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-hot-toast'; import { equipmentService } from '../services/equipment'; -import type { Equipment } from '../types/equipment'; +import type { Equipment, EquipmentDeletionSummary } from '../types/equipment'; // Query Keys export const equipmentKeys = { @@ -114,7 +114,7 @@ export function useUpdateEquipment(tenantId: string) { } /** - * Hook to delete equipment + * Hook to delete equipment (soft delete) */ export function useDeleteEquipment(tenantId: string) { const queryClient = useQueryClient(); @@ -139,3 +139,46 @@ export function useDeleteEquipment(tenantId: string) { }, }); } + +/** + * Hook to hard delete equipment (permanent deletion) + */ +export function useHardDeleteEquipment(tenantId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (equipmentId: string) => + equipmentService.hardDeleteEquipment(tenantId, equipmentId), + onSuccess: (_, equipmentId) => { + // Remove from cache + queryClient.removeQueries({ + queryKey: equipmentKeys.detail(tenantId, equipmentId) + }); + + // Invalidate lists to refresh + queryClient.invalidateQueries({ queryKey: equipmentKeys.lists() }); + + toast.success('Equipment permanently deleted'); + }, + onError: (error: any) => { + console.error('Error hard deleting equipment:', error); + toast.error(error.response?.data?.detail || 'Error permanently deleting equipment'); + }, + }); +} + +/** + * Hook to get equipment deletion summary + */ +export function useEquipmentDeletionSummary( + tenantId: string, + equipmentId: string, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: [...equipmentKeys.detail(tenantId, equipmentId), 'deletion-summary'], + queryFn: () => equipmentService.getEquipmentDeletionSummary(tenantId, equipmentId), + enabled: !!tenantId && !!equipmentId && (options?.enabled ?? true), + staleTime: 0, // Always fetch fresh data for dependency checks + }); +} diff --git a/frontend/src/api/hooks/subscription.ts b/frontend/src/api/hooks/subscription.ts index c85ab830..71358dc1 100644 --- a/frontend/src/api/hooks/subscription.ts +++ b/frontend/src/api/hooks/subscription.ts @@ -2,7 +2,7 @@ * Subscription hook for checking plan features and limits */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { subscriptionService } from '../services/subscription'; import { SUBSCRIPTION_TIERS, @@ -10,6 +10,7 @@ import { } from '../types/subscription'; import { useCurrentTenant } from '../../stores'; import { useAuthUser } from '../../stores/auth.store'; +import { useSubscriptionEvents } from '../../contexts/SubscriptionEventsContext'; export interface SubscriptionFeature { hasFeature: boolean; @@ -40,9 +41,10 @@ export const useSubscription = () => { loading: true, }); - const currentTenant = useCurrentTenant(); + const currentTenant = useCurrentTenant(); const user = useAuthUser(); const tenantId = currentTenant?.id || user?.tenant_id; + const { notifySubscriptionChanged } = useSubscriptionEvents(); // Load subscription data const loadSubscriptionData = useCallback(async () => { @@ -62,6 +64,9 @@ export const useSubscription = () => { features: usageSummary.usage || {}, loading: false, }); + + // Notify subscribers that subscription data has changed + notifySubscriptionChanged(); } catch (error) { console.error('Error loading subscription data:', error); setSubscriptionInfo(prev => ({ @@ -70,7 +75,7 @@ export const useSubscription = () => { error: 'Failed to load subscription data' })); } - }, [tenantId]); + }, [tenantId, notifySubscriptionChanged]); useEffect(() => { loadSubscriptionData(); @@ -177,4 +182,4 @@ export const useSubscription = () => { }; }; -export default useSubscription; \ No newline at end of file +export default useSubscription; diff --git a/frontend/src/api/hooks/suppliers.ts b/frontend/src/api/hooks/suppliers.ts index b6ab1838..0d4b887e 100644 --- a/frontend/src/api/hooks/suppliers.ts +++ b/frontend/src/api/hooks/suppliers.ts @@ -26,6 +26,9 @@ import type { DeliveryReceiptConfirmation, DeliverySearchParams, PerformanceMetric, + SupplierPriceListCreate, + SupplierPriceListUpdate, + SupplierPriceListResponse, } from '../types/suppliers'; // Query Keys Factory @@ -228,6 +231,37 @@ export const useDelivery = ( }); }; +// Supplier Price List Queries +export const useSupplierPriceLists = ( + tenantId: string, + supplierId: string, + isActive: boolean = true, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists', isActive], + queryFn: () => suppliersService.getSupplierPriceLists(tenantId, supplierId, isActive), + enabled: !!tenantId && !!supplierId, + staleTime: 5 * 60 * 1000, // 5 minutes + ...options, + }); +}; + +export const useSupplierPriceList = ( + tenantId: string, + supplierId: string, + priceListId: string, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', priceListId], + queryFn: () => suppliersService.getSupplierPriceList(tenantId, supplierId, priceListId), + enabled: !!tenantId && !!supplierId && !!priceListId, + staleTime: 5 * 60 * 1000, // 5 minutes + ...options, + }); +}; + // Performance Queries export const useSupplierPerformanceMetrics = ( tenantId: string, @@ -236,7 +270,7 @@ export const useSupplierPerformanceMetrics = ( ) => { return useQuery({ queryKey: suppliersKeys.performance.metrics(tenantId, supplierId), - queryFn: () => suppliersService.getPerformanceMetrics(tenantId, supplierId), + queryFn: () => suppliersService.getSupplierPerformanceMetrics(tenantId, supplierId), enabled: !!tenantId && !!supplierId, staleTime: 10 * 60 * 1000, // 10 minutes ...options, @@ -245,13 +279,13 @@ export const useSupplierPerformanceMetrics = ( export const usePerformanceAlerts = ( tenantId: string, - supplierId: string, + supplierId?: string, options?: Omit, 'queryKey' | 'queryFn'> ) => { return useQuery({ queryKey: suppliersKeys.performance.alerts(tenantId, supplierId), - queryFn: () => suppliersService.evaluatePerformanceAlerts(tenantId, supplierId), - enabled: !!tenantId && !!supplierId, + queryFn: () => suppliersService.getPerformanceAlerts(tenantId, supplierId), + enabled: !!tenantId, staleTime: 2 * 60 * 1000, // 2 minutes ...options, }); @@ -607,12 +641,108 @@ export const useConfirmDeliveryReceipt = ( }); }; +// Supplier Price List Mutations +export const useCreateSupplierPriceList = ( + options?: UseMutationOptions< + SupplierPriceListResponse, + ApiError, + { tenantId: string; supplierId: string; priceListData: SupplierPriceListCreate } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + SupplierPriceListResponse, + ApiError, + { tenantId: string; supplierId: string; priceListData: SupplierPriceListCreate } + >({ + mutationFn: ({ tenantId, supplierId, priceListData }) => + suppliersService.createSupplierPriceList(tenantId, supplierId, priceListData), + onSuccess: (data, { tenantId, supplierId }) => { + // Add to cache + queryClient.setQueryData( + [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', data.id], + data + ); + + // Invalidate price lists + queryClient.invalidateQueries({ + queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists'] + }); + }, + ...options, + }); +}; + +export const useUpdateSupplierPriceList = ( + options?: UseMutationOptions< + SupplierPriceListResponse, + ApiError, + { tenantId: string; supplierId: string; priceListId: string; priceListData: SupplierPriceListUpdate } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + SupplierPriceListResponse, + ApiError, + { tenantId: string; supplierId: string; priceListId: string; priceListData: SupplierPriceListUpdate } + >({ + mutationFn: ({ tenantId, supplierId, priceListId, priceListData }) => + suppliersService.updateSupplierPriceList(tenantId, supplierId, priceListId, priceListData), + onSuccess: (data, { tenantId, supplierId, priceListId }) => { + // Update cache + queryClient.setQueryData( + [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', priceListId], + data + ); + + // Invalidate price lists + queryClient.invalidateQueries({ + queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists'] + }); + }, + ...options, + }); +}; + +export const useDeleteSupplierPriceList = ( + options?: UseMutationOptions< + { message: string }, + ApiError, + { tenantId: string; supplierId: string; priceListId: string } + > +) => { + const queryClient = useQueryClient(); + + return useMutation< + { message: string }, + ApiError, + { tenantId: string; supplierId: string; priceListId: string } + >({ + mutationFn: ({ tenantId, supplierId, priceListId }) => + suppliersService.deleteSupplierPriceList(tenantId, supplierId, priceListId), + onSuccess: (_, { tenantId, supplierId, priceListId }) => { + // Remove from cache + queryClient.removeQueries({ + queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-list', priceListId] + }); + + // Invalidate price lists + queryClient.invalidateQueries({ + queryKey: [...suppliersKeys.suppliers.detail(tenantId, supplierId), 'price-lists'] + }); + }, + ...options, + }); +}; + // Performance Mutations export const useCalculateSupplierPerformance = ( options?: UseMutationOptions< { message: string; calculation_id: string }, ApiError, - { tenantId: string; supplierId: string; request?: PerformanceCalculationRequest } + { tenantId: string; supplierId: string; request?: any } > ) => { const queryClient = useQueryClient(); @@ -620,7 +750,7 @@ export const useCalculateSupplierPerformance = ( return useMutation< { message: string; calculation_id: string }, ApiError, - { tenantId: string; supplierId: string; request?: PerformanceCalculationRequest } + { tenantId: string; supplierId: string; request?: any } >({ mutationFn: ({ tenantId, supplierId, request }) => suppliersService.calculateSupplierPerformance(tenantId, supplierId, request), @@ -641,7 +771,7 @@ export const useEvaluatePerformanceAlerts = ( options?: UseMutationOptions< { alerts_generated: number; message: string }, ApiError, - { tenantId: string } + { tenantId: string; supplierId?: string } > ) => { const queryClient = useQueryClient(); @@ -649,7 +779,7 @@ export const useEvaluatePerformanceAlerts = ( return useMutation< { alerts_generated: number; message: string }, ApiError, - { tenantId: string } + { tenantId: string; supplierId?: string } >({ mutationFn: ({ tenantId, supplierId }) => suppliersService.evaluatePerformanceAlerts(tenantId, supplierId), onSuccess: (_, { tenantId }) => { @@ -677,11 +807,7 @@ export const useActiveSuppliersCount = (tenantId: string) => { return statistics?.active_suppliers || 0; }; -export const usePendingOrdersCount = (supplierId?: string) => { - const { data: orders } = usePurchaseOrders({ - supplier_id: supplierId, - status: 'pending_approval', - limit: 1000 - }); - return orders?.data?.length || 0; -}; \ No newline at end of file +export const usePendingOrdersCount = (queryParams?: PurchaseOrderSearchParams) => { + const { data: orders } = usePurchaseOrders('', queryParams); + return orders?.length || 0; +}; diff --git a/frontend/src/api/services/equipment.ts b/frontend/src/api/services/equipment.ts index 5a31fc30..d001d1dd 100644 --- a/frontend/src/api/services/equipment.ts +++ b/frontend/src/api/services/equipment.ts @@ -9,7 +9,8 @@ import type { EquipmentCreate, EquipmentUpdate, EquipmentResponse, - EquipmentListResponse + EquipmentListResponse, + EquipmentDeletionSummary } from '../types/equipment'; class EquipmentService { @@ -163,7 +164,7 @@ class EquipmentService { } /** - * Delete an equipment item + * Delete an equipment item (soft delete) */ async deleteEquipment(tenantId: string, equipmentId: string): Promise { await apiClient.delete( @@ -173,6 +174,34 @@ class EquipmentService { } ); } + + /** + * Permanently delete an equipment item (hard delete) + */ + async hardDeleteEquipment(tenantId: string, equipmentId: string): Promise { + await apiClient.delete( + `${this.baseURL}/${tenantId}/production/equipment/${equipmentId}?permanent=true`, + { + headers: { 'X-Tenant-ID': tenantId } + } + ); + } + + /** + * Get deletion summary for an equipment item + */ + async getEquipmentDeletionSummary( + tenantId: string, + equipmentId: string + ): Promise { + const data: EquipmentDeletionSummary = await apiClient.get( + `${this.baseURL}/${tenantId}/production/equipment/${equipmentId}/deletion-summary`, + { + headers: { 'X-Tenant-ID': tenantId } + } + ); + return data; + } } export const equipmentService = new EquipmentService(); diff --git a/frontend/src/api/services/suppliers.ts b/frontend/src/api/services/suppliers.ts index 871b8821..38ee5b46 100644 --- a/frontend/src/api/services/suppliers.ts +++ b/frontend/src/api/services/suppliers.ts @@ -20,25 +20,25 @@ import type { SupplierResponse, SupplierSummary, SupplierApproval, - SupplierQueryParams, + SupplierSearchParams, SupplierStatistics, SupplierDeletionSummary, - TopSuppliersResponse, + SupplierResponse as SupplierResponse_, PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderResponse, PurchaseOrderApproval, - PurchaseOrderQueryParams, + PurchaseOrderSearchParams, DeliveryCreate, DeliveryUpdate, DeliveryResponse, DeliveryReceiptConfirmation, - DeliveryQueryParams, - PerformanceCalculationRequest, - PerformanceMetrics, + DeliverySearchParams, + PerformanceMetric, PerformanceAlert, - PaginatedResponse, - ApiResponse, + SupplierPriceListCreate, + SupplierPriceListUpdate, + SupplierPriceListResponse } from '../types/suppliers'; class SuppliersService { @@ -59,10 +59,71 @@ class SuppliersService { ); } + // =================================================================== + // ATOMIC: Supplier Price Lists CRUD + // Backend: services/suppliers/app/api/suppliers.py (price list endpoints) + // =================================================================== + + async getSupplierPriceLists( + tenantId: string, + supplierId: string, + isActive: boolean = true + ): Promise { + const params = new URLSearchParams(); + params.append('is_active', isActive.toString()); + + return apiClient.get( + `${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists?${params.toString()}` + ); + } + + async getSupplierPriceList( + tenantId: string, + supplierId: string, + priceListId: string + ): Promise { + return apiClient.get( + `${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}` + ); + } + + async createSupplierPriceList( + tenantId: string, + supplierId: string, + priceListData: SupplierPriceListCreate + ): Promise { + return apiClient.post( + `${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists`, + priceListData + ); + } + + async updateSupplierPriceList( + tenantId: string, + supplierId: string, + priceListId: string, + priceListData: SupplierPriceListUpdate + ): Promise { + return apiClient.put( + `${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`, + priceListData + ); + } + + async deleteSupplierPriceList( + tenantId: string, + supplierId: string, + priceListId: string + ): Promise<{ message: string }> { + return apiClient.delete<{ message: string }>( + `${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}` + ); + } + async getSuppliers( tenantId: string, - queryParams?: SupplierQueryParams - ): Promise> { + queryParams?: SupplierSearchParams + ): Promise { const params = new URLSearchParams(); if (queryParams?.search_term) params.append('search_term', queryParams.search_term); @@ -70,11 +131,9 @@ class SuppliersService { if (queryParams?.status) params.append('status', queryParams.status); if (queryParams?.limit) params.append('limit', queryParams.limit.toString()); if (queryParams?.offset) params.append('offset', queryParams.offset.toString()); - if (queryParams?.sort_by) params.append('sort_by', queryParams.sort_by); - if (queryParams?.sort_order) params.append('sort_order', queryParams.sort_order); const queryString = params.toString() ? `?${params.toString()}` : ''; - return apiClient.get>( + return apiClient.get( `${this.baseUrl}/${tenantId}/suppliers${queryString}` ); } @@ -142,10 +201,10 @@ class SuppliersService { ); } - async getPurchaseOrders( + async getPurchaseOrders( tenantId: string, - queryParams?: PurchaseOrderQueryParams - ): Promise> { + queryParams?: PurchaseOrderSearchParams + ): Promise { const params = new URLSearchParams(); if (queryParams?.supplier_id) params.append('supplier_id', queryParams.supplier_id); @@ -155,11 +214,9 @@ class SuppliersService { if (queryParams?.date_to) params.append('date_to', queryParams.date_to); if (queryParams?.limit) params.append('limit', queryParams.limit.toString()); if (queryParams?.offset) params.append('offset', queryParams.offset.toString()); - if (queryParams?.sort_by) params.append('sort_by', queryParams.sort_by); - if (queryParams?.sort_order) params.append('sort_order', queryParams.sort_order); const queryString = params.toString() ? `?${params.toString()}` : ''; - return apiClient.get>( + return apiClient.get( `${this.baseUrl}/${tenantId}/suppliers/purchase-orders${queryString}` ); } @@ -209,8 +266,8 @@ class SuppliersService { async getDeliveries( tenantId: string, - queryParams?: DeliveryQueryParams - ): Promise> { + queryParams?: DeliverySearchParams + ): Promise { const params = new URLSearchParams(); if (queryParams?.supplier_id) params.append('supplier_id', queryParams.supplier_id); @@ -226,11 +283,9 @@ class SuppliersService { } if (queryParams?.limit) params.append('limit', queryParams.limit.toString()); if (queryParams?.offset) params.append('offset', queryParams.offset.toString()); - if (queryParams?.sort_by) params.append('sort_by', queryParams.sort_by); - if (queryParams?.sort_order) params.append('sort_order', queryParams.sort_order); const queryString = params.toString() ? `?${params.toString()}` : ''; - return apiClient.get>( + return apiClient.get( `${this.baseUrl}/${tenantId}/suppliers/deliveries${queryString}` ); } @@ -276,8 +331,8 @@ class SuppliersService { async getActiveSuppliers( tenantId: string, - queryParams?: Omit - ): Promise> { + queryParams?: SupplierSearchParams + ): Promise { const params = new URLSearchParams(); if (queryParams?.search_term) params.append('search_term', queryParams.search_term); if (queryParams?.supplier_type) params.append('supplier_type', queryParams.supplier_type); @@ -285,10 +340,10 @@ class SuppliersService { if (queryParams?.offset) params.append('offset', queryParams.offset.toString()); const queryString = params.toString() ? `?${params.toString()}` : ''; - return apiClient.get>( + return apiClient.get( `${this.baseUrl}/${tenantId}/suppliers/operations/active${queryString}` ); - } + } async getTopSuppliers(tenantId: string): Promise { return apiClient.get( @@ -356,11 +411,11 @@ class SuppliersService { async getSupplierPerformanceMetrics( tenantId: string, supplierId: string - ): Promise { - return apiClient.get( + ): Promise { + return apiClient.get( `${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/metrics` ); - } + } async evaluatePerformanceAlerts( tenantId: string diff --git a/frontend/src/api/types/equipment.ts b/frontend/src/api/types/equipment.ts index 4ff6ee90..5c2ecc65 100644 --- a/frontend/src/api/types/equipment.ts +++ b/frontend/src/api/types/equipment.ts @@ -138,4 +138,15 @@ export interface EquipmentListResponse { total_count: number; page: number; page_size: number; +} + +export interface EquipmentDeletionSummary { + can_delete: boolean; + warnings: string[]; + production_batches_count: number; + maintenance_records_count: number; + temperature_logs_count: number; + equipment_name?: string; + equipment_type?: string; + equipment_location?: string; } \ No newline at end of file diff --git a/frontend/src/api/types/suppliers.ts b/frontend/src/api/types/suppliers.ts index 6dbb1027..dcc016f9 100644 --- a/frontend/src/api/types/suppliers.ts +++ b/frontend/src/api/types/suppliers.ts @@ -22,7 +22,7 @@ export enum SupplierType { export enum SupplierStatus { ACTIVE = 'active', - INACTIVE = 'inactive', + INACTIVE = 'inactive', PENDING_APPROVAL = 'pending_approval', SUSPENDED = 'suspended', BLACKLISTED = 'blacklisted' @@ -114,7 +114,7 @@ export enum AlertType { export enum AlertStatus { ACTIVE = 'ACTIVE', - ACKNOWLEDGED = 'ACKNOWLEDGED', + ACKNOWLEDGED = 'ACKNOWLEDGED', IN_PROGRESS = 'IN_PROGRESS', RESOLVED = 'RESOLVED', DISMISSED = 'DISMISSED' @@ -139,6 +139,71 @@ export enum PerformancePeriod { YEARLY = 'YEARLY' } +// ===== SUPPLIER PRICE LIST SCHEMAS ===== +export interface SupplierPriceListCreate { + inventory_product_id: string; + product_code?: string | null; // max_length=100 + unit_price: number; // gt=0 + unit_of_measure: string; // max_length=20 + minimum_order_quantity?: number | null; // ge=1 + price_per_unit: number; // gt=0 + tier_pricing?: Record | null; // [{quantity: 100, price: 2.50}, ...] + effective_date?: string; // Default: now() + expiry_date?: string | null; + is_active?: boolean; // Default: true + brand?: string | null; // max_length=100 + packaging_size?: string | null; // max_length=50 + origin_country?: string | null; // max_length=100 + shelf_life_days?: number | null; + storage_requirements?: string | null; + quality_specs?: Record | null; + allergens?: Record | null; +} + +export interface SupplierPriceListUpdate { + unit_price?: number | null; // gt=0 + unit_of_measure?: string | null; // max_length=20 + minimum_order_quantity?: number | null; // ge=1 + tier_pricing?: Record | null; + effective_date?: string | null; + expiry_date?: string | null; + is_active?: boolean | null; + brand?: string | null; + packaging_size?: string | null; + origin_country?: string | null; + shelf_life_days?: number | null; + storage_requirements?: string | null; + quality_specs?: Record | null; + allergens?: Record | null; +} + +export interface SupplierPriceListResponse { + id: string; + tenant_id: string; + supplier_id: string; + inventory_product_id: string; + product_code: string | null; + unit_price: number; + unit_of_measure: string; + minimum_order_quantity: number | null; + price_per_unit: number; + tier_pricing: Record | null; + effective_date: string; + expiry_date: string | null; + is_active: boolean; + brand: string | null; + packaging_size: string | null; + origin_country: string | null; + shelf_life_days: number | null; + storage_requirements: string | null; + quality_specs: Record | null; + allergens: Record | null; + created_at: string; + updated_at: string; + created_by: string; + updated_by: string; +} + // ===== SUPPLIER SCHEMAS ===== // Mirror: SupplierCreate from suppliers.py:23 @@ -222,7 +287,7 @@ export interface SupplierApproval { // Mirror: SupplierResponse from suppliers.py:102 export interface SupplierResponse { - id: string; + id: string; tenant_id: string; name: string; supplier_code: string | null; @@ -245,7 +310,7 @@ export interface SupplierResponse { country: string | null; // Business terms - payment_terms: PaymentTerms; + payment_terms: PaymentTerms; credit_limit: number | null; currency: string; standard_lead_time: number; @@ -253,7 +318,7 @@ export interface SupplierResponse { delivery_area: string | null; // Performance metrics - quality_rating: number | null; + quality_rating: number | null; delivery_rating: number | null; total_orders: number; total_amount: number; @@ -264,7 +329,7 @@ export interface SupplierResponse { rejection_reason: string | null; // Additional information - notes: string | null; + notes: string | null; certifications: Record | null; business_hours: Record | null; specializations: Record | null; @@ -346,12 +411,12 @@ export interface PurchaseOrderItemResponse { // Mirror: PurchaseOrderCreate from suppliers.py (inferred) export interface PurchaseOrderCreate { supplier_id: string; - items: PurchaseOrderItemCreate[]; // min_items=1 + items: PurchaseOrderItemCreate[]; // min_items=1 // Order details reference_number?: string | null; // max_length=100 priority?: string; // Default: "normal", max_length=20 - required_delivery_date?: string | null; + required_delivery_date?: string | null; // Delivery info delivery_address?: string | null; @@ -360,12 +425,12 @@ export interface PurchaseOrderCreate { delivery_phone?: string | null; // max_length=30 // Financial (all default=0, ge=0) - tax_amount?: number; + tax_amount?: number; shipping_cost?: number; discount_amount?: number; // Additional - notes?: string | null; + notes?: string | null; internal_notes?: string | null; terms_and_conditions?: string | null; } @@ -376,7 +441,7 @@ export interface PurchaseOrderUpdate { priority?: string | null; required_delivery_date?: string | null; estimated_delivery_date?: string | null; - supplier_reference?: string | null; // max_length=100 + supplier_reference?: string | null; // max_length=100 delivery_address?: string | null; delivery_instructions?: string | null; delivery_contact?: string | null; @@ -411,25 +476,25 @@ export interface PurchaseOrderResponse { order_date: string; reference_number: string | null; priority: string; - required_delivery_date: string | null; + required_delivery_date: string | null; estimated_delivery_date: string | null; // Financial subtotal: number; - tax_amount: number; + tax_amount: number; shipping_cost: number; discount_amount: number; total_amount: number; currency: string; // Delivery - delivery_address: string | null; + delivery_address: string | null; delivery_instructions: string | null; delivery_contact: string | null; delivery_phone: string | null; // Approval - requires_approval: boolean; + requires_approval: boolean; approved_by: string | null; approved_at: string | null; rejection_reason: string | null; @@ -440,12 +505,12 @@ export interface PurchaseOrderResponse { supplier_reference: string | null; // Additional - notes: string | null; + notes: string | null; internal_notes: string | null; terms_and_conditions: string | null; // Audit - created_at: string; + created_at: string; updated_at: string; created_by: string; updated_by: string; @@ -516,7 +581,7 @@ export interface DeliveryCreate { delivery_phone?: string | null; // max_length=30 // Tracking - carrier_name?: string | null; // max_length=200 + carrier_name?: string | null; // max_length=200 tracking_number?: string | null; // max_length=100 // Additional @@ -525,7 +590,7 @@ export interface DeliveryCreate { // Mirror: DeliveryUpdate from suppliers.py (inferred) export interface DeliveryUpdate { - supplier_delivery_note?: string | null; + supplier_delivery_note?: string | null; scheduled_date?: string | null; estimated_arrival?: string | null; actual_arrival?: string | null; @@ -565,25 +630,25 @@ export interface DeliveryResponse { status: DeliveryStatus; // Timing - scheduled_date: string | null; + scheduled_date: string | null; estimated_arrival: string | null; - actual_arrival: string | null; + actual_arrival: string | null; completed_at: string | null; // Delivery info - supplier_delivery_note: string | null; + supplier_delivery_note: string | null; delivery_address: string | null; delivery_contact: string | null; delivery_phone: string | null; // Tracking - carrier_name: string | null; + carrier_name: string | null; tracking_number: string | null; // Quality inspection_passed: boolean | null; inspection_notes: string | null; - quality_issues: Record | null; + quality_issues: Record | null; // Receipt received_by: string | null; @@ -594,7 +659,7 @@ export interface DeliveryResponse { photos: Record | null; // Audit - created_at: string; + created_at: string; updated_at: string; created_by: string; @@ -624,7 +689,7 @@ export interface DeliverySummary { export interface PerformanceMetricCreate { supplier_id: string; - metric_type: PerformanceMetricType; + metric_type: PerformanceMetricType; period: PerformancePeriod; period_start: string; period_end: string; @@ -651,21 +716,21 @@ export interface PerformanceMetric extends PerformanceMetricCreate { tenant_id: string; previous_value: number | null; trend_direction: string | null; // improving, declining, stable - trend_percentage: number | null; + trend_percentage: number | null; calculated_at: string; } // Mirror: AlertCreate from performance.py export interface AlertCreate { supplier_id: string; - alert_type: AlertType; + alert_type: AlertType; severity: AlertSeverity; title: string; // max_length=255 message: string; description?: string | null; // Context - trigger_value?: number | null; + trigger_value?: number | null; threshold_value?: number | null; metric_type?: PerformanceMetricType | null; @@ -693,7 +758,7 @@ export interface Alert extends Omit { resolved_at: string | null; resolved_by: string | null; actions_taken: Array> | null; - resolution_notes: string | null; + resolution_notes: string | null; escalated: boolean; escalated_at: string | null; notification_sent: boolean; @@ -759,7 +824,7 @@ export interface SupplierStatistics { active_suppliers: number; pending_suppliers: number; avg_quality_rating: number; - avg_delivery_rating: number; + avg_delivery_rating: number; total_spend: number; } @@ -800,12 +865,12 @@ export interface PerformanceDashboardSummary { average_quality_rate: number; total_active_alerts: number; critical_alerts: number; - high_priority_alerts: number; + high_priority_alerts: number; recent_scorecards_generated: number; cost_savings_this_month: number; performance_trend: string; delivery_trend: string; - quality_trend: string; + quality_trend: string; detected_business_model: string; model_confidence: number; business_model_metrics: Record; @@ -958,7 +1023,7 @@ export interface ExportDataResponse { export interface SupplierDeletionSummary { supplier_name: string; deleted_price_lists: number; - deleted_quality_reviews: number; + deleted_quality_reviews: number; deleted_performance_metrics: number; deleted_alerts: number; deleted_scorecards: number; diff --git a/frontend/src/api/types/tenant.ts b/frontend/src/api/types/tenant.ts index 85693b60..b7d8ecc6 100644 --- a/frontend/src/api/types/tenant.ts +++ b/frontend/src/api/types/tenant.ts @@ -110,8 +110,8 @@ export interface TenantSubscriptionUpdate { // ================================================================ /** - * Tenant response schema - FIXED VERSION with owner_id - * Backend: TenantResponse in schemas/tenants.py (lines 55-82) + * Tenant response schema - Updated with subscription_plan + * Backend: TenantResponse in schemas/tenants.py (lines 59-87) */ export interface TenantResponse { id: string; @@ -124,11 +124,15 @@ export interface TenantResponse { postal_code: string; phone?: string | null; is_active: boolean; - subscription_tier: string; + subscription_plan?: string | null; // Populated from subscription relationship ml_model_trained: boolean; last_training_date?: string | null; // ISO datetime string - owner_id: string; // ✅ REQUIRED field - fixes type error + owner_id: string; // ✅ REQUIRED field created_at: string; // ISO datetime string + + // Backward compatibility + /** @deprecated Use subscription_plan instead */ + subscription_tier?: string; } /** diff --git a/frontend/src/components/domain/equipment/DeleteEquipmentModal.tsx b/frontend/src/components/domain/equipment/DeleteEquipmentModal.tsx new file mode 100644 index 00000000..768db9f0 --- /dev/null +++ b/frontend/src/components/domain/equipment/DeleteEquipmentModal.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { BaseDeleteModal } from '../../ui'; +import { Equipment } from '../../../api/types/equipment'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { useEquipmentDeletionSummary } from '../../../api/hooks/equipment'; + +interface DeleteEquipmentModalProps { + isOpen: boolean; + onClose: () => void; + equipment: Equipment; + onSoftDelete: (equipmentId: string) => Promise; + onHardDelete: (equipmentId: string) => Promise; + isLoading?: boolean; +} + +/** + * Modal for equipment deletion with soft/hard delete options + * - Soft delete: Deactivate equipment (reversible) + * - Hard delete: Permanent deletion with dependency checking + */ +export const DeleteEquipmentModal: React.FC = ({ + isOpen, + onClose, + equipment, + onSoftDelete, + onHardDelete, + isLoading = false, +}) => { + const currentTenant = useCurrentTenant(); + + // Fetch deletion summary for dependency checking + const { data: deletionSummary, isLoading: summaryLoading } = useEquipmentDeletionSummary( + currentTenant?.id || '', + equipment?.id || '', + { + enabled: isOpen && !!equipment, + } + ); + + if (!equipment) return null; + + // Build dependency check warnings + const dependencyWarnings: string[] = []; + if (deletionSummary) { + if (deletionSummary.production_batches_count > 0) { + dependencyWarnings.push( + `${deletionSummary.production_batches_count} lote(s) de producción utilizan este equipo` + ); + } + if (deletionSummary.maintenance_records_count > 0) { + dependencyWarnings.push( + `${deletionSummary.maintenance_records_count} registro(s) de mantenimiento` + ); + } + if (deletionSummary.temperature_logs_count > 0) { + dependencyWarnings.push( + `${deletionSummary.temperature_logs_count} registro(s) de temperatura` + ); + } + if (deletionSummary.warnings && deletionSummary.warnings.length > 0) { + dependencyWarnings.push(...deletionSummary.warnings); + } + } + + // Build hard delete warning items + const hardDeleteItems = [ + 'El equipo y toda su información', + 'Todo el historial de mantenimiento', + 'Las alertas relacionadas', + ]; + + if (deletionSummary) { + if (deletionSummary.production_batches_count > 0) { + hardDeleteItems.push( + `Referencias en ${deletionSummary.production_batches_count} lote(s) de producción` + ); + } + } + + // Get equipment type label + const getEquipmentTypeLabel = (type: string): string => { + const typeLabels: Record = { + oven: 'Horno', + mixer: 'Batidora', + proofer: 'Fermentadora', + freezer: 'Congelador', + packaging: 'Empaquetado', + other: 'Otro', + }; + return typeLabels[type] || type; + }; + + return ( + eq.id} + getEntityDisplay={(eq) => ({ + primaryText: eq.name, + secondaryText: `Tipo: ${getEquipmentTypeLabel(eq.type)} • Ubicación: ${eq.location || 'No especificada'}`, + })} + softDeleteOption={{ + title: 'Desactivar (Recomendado)', + description: 'El equipo se marca como inactivo pero conserva todo su historial de mantenimiento. Ideal para equipos temporalmente fuera de servicio.', + benefits: '✓ Reversible • ✓ Conserva historial • ✓ Conserva alertas', + }} + hardDeleteOption={{ + title: 'Eliminar Permanentemente', + description: 'Elimina completamente el equipo y todos sus datos asociados. Use solo para datos erróneos o pruebas.', + benefits: '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos', + enabled: true, + }} + softDeleteWarning={{ + title: 'ℹ️ Esta acción desactivará el equipo:', + items: [ + 'El equipo se marcará como inactivo', + 'No aparecerá en listas activas', + 'Se conserva todo el historial de mantenimiento', + 'Se puede reactivar posteriormente', + ], + }} + hardDeleteWarning={{ + title: '⚠️ Esta acción eliminará permanentemente:', + items: hardDeleteItems, + footer: 'Esta acción NO se puede deshacer', + }} + dependencyCheck={{ + isLoading: summaryLoading, + canDelete: deletionSummary?.can_delete !== false, + warnings: dependencyWarnings, + }} + requireConfirmText={true} + confirmText="ELIMINAR" + showSuccessScreen={true} + successTitle="Equipo Procesado" + getSuccessMessage={(eq, mode) => + mode === 'hard' + ? `${eq.name} ha sido eliminado permanentemente` + : `${eq.name} ha sido desactivado` + } + autoCloseDelay={1500} + /> + ); +}; + +export default DeleteEquipmentModal; diff --git a/frontend/src/components/domain/equipment/EquipmentModal.tsx b/frontend/src/components/domain/equipment/EquipmentModal.tsx index 708c3715..503094df 100644 --- a/frontend/src/components/domain/equipment/EquipmentModal.tsx +++ b/frontend/src/components/domain/equipment/EquipmentModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit } from 'lucide-react'; +import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit, FileText } from 'lucide-react'; import { EditViewModal, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal'; import { Equipment } from '../../../api/types/equipment'; @@ -39,6 +39,9 @@ export const EquipmentModal: React.FC = ({ uptime: 100, energyUsage: 0, utilizationToday: 0, + temperature: 0, + targetTemperature: 0, + notes: '', alerts: [], maintenanceHistory: [], specifications: { @@ -95,7 +98,10 @@ export const EquipmentModal: React.FC = ({ [t('fields.specifications.weight')]: 'weight', [t('fields.specifications.width')]: 'width', [t('fields.specifications.height')]: 'height', - [t('fields.specifications.depth')]: 'depth' + [t('fields.specifications.depth')]: 'depth', + [t('fields.current_temperature')]: 'temperature', + [t('fields.target_temperature')]: 'targetTemperature', + [t('fields.notes')]: 'notes' }; const propertyName = fieldLabelKeyMap[field.label]; @@ -303,6 +309,39 @@ export const EquipmentModal: React.FC = ({ placeholder: '0' } ] + }, + { + title: t('sections.temperature_monitoring'), + icon: Thermometer, + fields: [ + { + label: t('fields.current_temperature'), + value: equipment.temperature || 0, + type: 'number', + editable: true, + placeholder: '0' + }, + { + label: t('fields.target_temperature'), + value: equipment.targetTemperature || 0, + type: 'number', + editable: true, + placeholder: '0' + } + ] + }, + { + title: t('sections.notes'), + icon: FileText, + fields: [ + { + label: t('fields.notes'), + value: equipment.notes || '', + type: 'textarea', + editable: true, + placeholder: t('placeholders.notes') + } + ] } ]; }; diff --git a/frontend/src/components/domain/equipment/MaintenanceHistoryModal.tsx b/frontend/src/components/domain/equipment/MaintenanceHistoryModal.tsx new file mode 100644 index 00000000..98701ee8 --- /dev/null +++ b/frontend/src/components/domain/equipment/MaintenanceHistoryModal.tsx @@ -0,0 +1,202 @@ +import React from 'react'; +import { Clock, Wrench, AlertTriangle, Zap } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { EditViewModal } from '../../ui/EditViewModal/EditViewModal'; +import { Equipment, MaintenanceHistory } from '../../../api/types/equipment'; +import { statusColors } from '../../../styles/colors'; + +interface MaintenanceHistoryModalProps { + isOpen: boolean; + onClose: () => void; + equipment: Equipment; + loading?: boolean; +} + +/** + * MaintenanceHistoryModal - Modal for viewing equipment maintenance history + * Shows maintenance records with color-coded types and detailed information + */ +export const MaintenanceHistoryModal: React.FC = ({ + isOpen, + onClose, + equipment, + loading = false +}) => { + const { t } = useTranslation(['equipment', 'common']); + + // Get maintenance type display info with colors and icons + const getMaintenanceTypeInfo = (type: MaintenanceHistory['type']) => { + switch (type) { + case 'preventive': + return { + label: t('maintenance.type.preventive', 'Preventivo'), + icon: Wrench, + color: statusColors.inProgress.primary, + bgColor: `${statusColors.inProgress.primary}15` + }; + case 'corrective': + return { + label: t('maintenance.type.corrective', 'Correctivo'), + icon: AlertTriangle, + color: statusColors.pending.primary, + bgColor: `${statusColors.pending.primary}15` + }; + case 'emergency': + return { + label: t('maintenance.type.emergency', 'Emergencia'), + icon: Zap, + color: statusColors.out.primary, + bgColor: `${statusColors.out.primary}15` + }; + default: + return { + label: type, + icon: Wrench, + color: statusColors.normal.primary, + bgColor: `${statusColors.normal.primary}15` + }; + } + }; + + // Process maintenance history for display + const maintenanceRecords = equipment.maintenanceHistory || []; + const sortedRecords = [...maintenanceRecords].sort((a, b) => + new Date(b.date).getTime() - new Date(a.date).getTime() + ); + + const statusConfig = { + color: statusColors.inProgress.primary, + text: `${maintenanceRecords.length} ${t('maintenance.records', 'registros')}`, + icon: Clock + }; + + // Create maintenance list display + const maintenanceList = sortedRecords.length > 0 ? ( +
+ {sortedRecords.map((record) => { + const typeInfo = getMaintenanceTypeInfo(record.type); + const MaintenanceIcon = typeInfo.icon; + + return ( +
+ {/* Icon and type */} +
+ +
+ + {/* Main content */} +
+
+ + {record.description} + + + {typeInfo.label} + +
+ +
+
+ {t('fields.date', 'Fecha')}: + + {new Date(record.date).toLocaleDateString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + +
+
+ {t('fields.technician', 'Técnico')}: + {record.technician} +
+
+ {t('common:actions.cost', 'Coste')}: + €{record.cost.toFixed(2)} +
+
+ {t('fields.downtime', 'Parada')}: + {record.downtime}h +
+
+ + {record.partsUsed && record.partsUsed.length > 0 && ( +
+ + {t('fields.parts', 'Repuestos')}: + +
+ {record.partsUsed.map((part, index) => ( + + {part} + + ))} +
+
+ )} +
+
+ ); + })} +
+ ) : ( +
+ +

+ {t('maintenance.no_history', 'No hay historial de mantenimiento')} +

+

+ {t('maintenance.no_history_description', 'Los registros de mantenimiento aparecerán aquí cuando se realicen operaciones')} +

+
+ ); + + const sections = [ + { + title: t('maintenance.history', 'Historial de Mantenimiento'), + icon: Clock, + fields: [ + { + label: '', + value: maintenanceList, + span: 2 as const + } + ] + } + ]; + + return ( + + ); +}; + +export default MaintenanceHistoryModal; diff --git a/frontend/src/components/domain/equipment/ScheduleMaintenanceModal.tsx b/frontend/src/components/domain/equipment/ScheduleMaintenanceModal.tsx new file mode 100644 index 00000000..50b9039c --- /dev/null +++ b/frontend/src/components/domain/equipment/ScheduleMaintenanceModal.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { Calendar, Wrench } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { AddModal, AddModalSection } from '../../ui/AddModal/AddModal'; +import { Equipment } from '../../../api/types/equipment'; +import { statusColors } from '../../../styles/colors'; + +interface ScheduleMaintenanceModalProps { + isOpen: boolean; + onClose: () => void; + equipment: Equipment; + onSchedule: (equipmentId: string, maintenanceData: MaintenanceScheduleData) => Promise; + isLoading?: boolean; +} + +export interface MaintenanceScheduleData { + type: 'preventive' | 'corrective' | 'emergency'; + scheduledDate: string; + scheduledTime: string; + estimatedDuration: number; + technician: string; + partsNeeded: string; + priority: 'low' | 'medium' | 'high' | 'urgent'; + description: string; +} + +/** + * ScheduleMaintenanceModal - Modal for scheduling equipment maintenance + * Uses AddModal component for consistent UX across the application + */ +export const ScheduleMaintenanceModal: React.FC = ({ + isOpen, + onClose, + equipment, + onSchedule, + isLoading = false +}) => { + const { t } = useTranslation(['equipment', 'common']); + + const handleSave = async (formData: Record) => { + const maintenanceData: MaintenanceScheduleData = { + type: formData.type as MaintenanceScheduleData['type'], + scheduledDate: formData.scheduledDate, + scheduledTime: formData.scheduledTime, + estimatedDuration: Number(formData.estimatedDuration), + technician: formData.technician, + partsNeeded: formData.partsNeeded || '', + priority: formData.priority as MaintenanceScheduleData['priority'], + description: formData.description + }; + + await onSchedule(equipment.id, maintenanceData); + }; + + const sections: AddModalSection[] = [ + { + title: t('sections.maintenance_info', 'Información de Mantenimiento'), + icon: Wrench, + columns: 2, + fields: [ + { + label: t('fields.maintenance_type', 'Tipo de Mantenimiento'), + name: 'type', + type: 'select', + required: true, + defaultValue: 'preventive', + options: [ + { label: t('maintenance.type.preventive', 'Preventivo'), value: 'preventive' }, + { label: t('maintenance.type.corrective', 'Correctivo'), value: 'corrective' }, + { label: t('maintenance.type.emergency', 'Emergencia'), value: 'emergency' } + ] + }, + { + label: t('fields.priority', 'Prioridad'), + name: 'priority', + type: 'select', + required: true, + defaultValue: 'medium', + options: [ + { label: t('priority.low', 'Baja'), value: 'low' }, + { label: t('priority.medium', 'Media'), value: 'medium' }, + { label: t('priority.high', 'Alta'), value: 'high' }, + { label: t('priority.urgent', 'Urgente'), value: 'urgent' } + ] + } + ] + }, + { + title: t('sections.scheduling', 'Programación'), + icon: Calendar, + columns: 2, + fields: [ + { + label: t('fields.scheduled_date', 'Fecha Programada'), + name: 'scheduledDate', + type: 'date', + required: true, + defaultValue: new Date().toISOString().split('T')[0] + }, + { + label: t('fields.time', 'Hora'), + name: 'scheduledTime', + type: 'text', + required: false, + defaultValue: '09:00', + placeholder: 'HH:MM' + }, + { + label: t('fields.technician', 'Técnico Asignado'), + name: 'technician', + type: 'text', + required: true, + placeholder: t('placeholders.technician', 'Nombre del técnico'), + span: 2 + }, + { + label: t('fields.duration', 'Duración (horas)'), + name: 'estimatedDuration', + type: 'number', + required: true, + defaultValue: 2, + validation: (value: number) => { + if (value <= 0) { + return t('validation.must_be_positive', 'Debe ser mayor que 0'); + } + return null; + } + } + ] + }, + { + title: t('sections.details', 'Detalles'), + icon: Wrench, + columns: 1, + fields: [ + { + label: t('fields.description', 'Descripción'), + name: 'description', + type: 'textarea', + required: true, + placeholder: t('placeholders.maintenance_description', 'Descripción del trabajo a realizar'), + span: 2 + }, + { + label: t('fields.parts_needed', 'Repuestos Necesarios'), + name: 'partsNeeded', + type: 'textarea', + required: false, + placeholder: t('placeholders.parts_needed', 'Lista de repuestos y materiales necesarios'), + span: 2 + } + ] + } + ]; + + return ( + + ); +}; + +export default ScheduleMaintenanceModal; diff --git a/frontend/src/components/domain/forecasting/RetrainModelModal.tsx b/frontend/src/components/domain/forecasting/RetrainModelModal.tsx new file mode 100644 index 00000000..c77ac1a6 --- /dev/null +++ b/frontend/src/components/domain/forecasting/RetrainModelModal.tsx @@ -0,0 +1,384 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + RotateCcw, + Sparkles, + Calendar, + Settings, + TrendingUp, + Sun, + Box, + Zap, + Info +} from 'lucide-react'; +import { EditViewModal, EditViewModalAction, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal'; +import { Button } from '../../ui/Button'; +import { IngredientResponse } from '../../../api/types/inventory'; +import { TrainedModelResponse, SingleProductTrainingRequest } from '../../../api/types/training'; + +interface RetrainModelModalProps { + isOpen: boolean; + onClose: () => void; + ingredient: IngredientResponse; + currentModel?: TrainedModelResponse | null; + onRetrain: (settings: SingleProductTrainingRequest) => Promise; + isLoading?: boolean; +} + +type RetrainMode = 'quick' | 'preset' | 'advanced'; + +interface TrainingPreset { + id: string; + name: string; + description: string; + icon: typeof Sparkles; + settings: Partial; + recommended?: boolean; +} + +export const RetrainModelModal: React.FC = ({ + isOpen, + onClose, + ingredient, + currentModel, + onRetrain, + isLoading = false +}) => { + const { t } = useTranslation(['models', 'common']); + const [mode, setMode] = useState('quick'); + const [selectedPreset, setSelectedPreset] = useState('standard'); + const [advancedSettings, setAdvancedSettings] = useState({ + seasonality_mode: 'additive', + daily_seasonality: true, + weekly_seasonality: true, + yearly_seasonality: false, + }); + + // Define training presets - memoized to prevent recreation + const presets: TrainingPreset[] = React.useMemo(() => [ + { + id: 'standard', + name: t('models:presets.standard.name', 'Panadería Estándar'), + description: t('models:presets.standard.description', 'Recomendado para productos con patrones semanales y ciclos diarios. Ideal para pan y productos horneados diarios.'), + icon: TrendingUp, + settings: { + seasonality_mode: 'additive', + daily_seasonality: true, + weekly_seasonality: true, + yearly_seasonality: false, + }, + recommended: true + }, + { + id: 'seasonal', + name: t('models:presets.seasonal.name', 'Productos Estacionales'), + description: t('models:presets.seasonal.description', 'Para productos con demanda estacional o de temporada. Incluye patrones anuales para festividades y eventos especiales.'), + icon: Sun, + settings: { + seasonality_mode: 'additive', + daily_seasonality: true, + weekly_seasonality: true, + yearly_seasonality: true, + } + }, + { + id: 'stable', + name: t('models:presets.stable.name', 'Demanda Estable'), + description: t('models:presets.stable.description', 'Para ingredientes básicos con demanda constante. Mínima estacionalidad.'), + icon: Box, + settings: { + seasonality_mode: 'additive', + daily_seasonality: false, + weekly_seasonality: true, + yearly_seasonality: false, + } + }, + { + id: 'custom', + name: t('models:presets.custom.name', 'Personalizado'), + description: t('models:presets.custom.description', 'Configuración avanzada con control total sobre los parámetros.'), + icon: Settings, + settings: advancedSettings + } + ], [t, advancedSettings]); + + const handleRetrain = async () => { + let settings: SingleProductTrainingRequest; + + switch (mode) { + case 'quick': + // Use existing model's hyperparameters if available + settings = currentModel?.hyperparameters || { + seasonality_mode: 'additive', + daily_seasonality: true, + weekly_seasonality: true, + yearly_seasonality: false, + }; + break; + case 'preset': + const preset = presets.find(p => p.id === selectedPreset); + settings = preset?.settings || presets[0].settings; + break; + case 'advanced': + settings = advancedSettings; + break; + default: + settings = advancedSettings; + } + + await onRetrain(settings); + }; + + // Build sections based on current mode - memoized to prevent recreation + const sections = React.useMemo((): EditViewModalSection[] => { + const result: EditViewModalSection[] = []; + + if (mode === 'quick') { + result.push({ + title: t('models:retrain.quick.title', 'Reentrenamiento Rápido'), + icon: Zap, + fields: [ + { + label: t('models:retrain.quick.ingredient', 'Ingrediente'), + value: ingredient.name, + type: 'text', + highlight: true + }, + { + label: t('models:retrain.quick.current_accuracy', 'Precisión Actual'), + value: currentModel?.training_metrics?.mape + ? `${(100 - currentModel.training_metrics.mape).toFixed(1)}%` + : t('common:not_available', 'N/A'), + type: 'text' + }, + { + label: t('models:retrain.quick.last_training', 'Último Entrenamiento'), + value: currentModel?.created_at || t('common:not_available', 'N/A'), + type: 'date' + }, + { + label: t('models:retrain.quick.description', 'Descripción'), + value: t('models:retrain.quick.description_text', 'El reentrenamiento rápido utiliza la misma configuración del modelo actual pero con los datos más recientes. Esto mantiene la precisión del modelo actualizada sin cambiar su comportamiento.'), + type: 'text', + span: 2 + } + ] + }); + } + + if (mode === 'preset') { + // Show preset selection + result.push({ + title: t('models:retrain.preset.title', 'Seleccionar Configuración'), + icon: Sparkles, + fields: [ + { + label: t('models:retrain.preset.ingredient', 'Ingrediente'), + value: ingredient.name, + type: 'text', + highlight: true + }, + { + label: t('models:retrain.preset.select', 'Tipo de Producto'), + value: selectedPreset, + type: 'select', + editable: true, + options: presets.map(p => ({ label: p.name, value: p.id })), + span: 2 + } + ] + }); + + // Show description of selected preset + const currentPreset = presets.find(p => p.id === selectedPreset); + if (currentPreset) { + result.push({ + title: currentPreset.name, + icon: currentPreset.icon, + fields: [ + { + label: t('models:retrain.preset.description', 'Descripción'), + value: currentPreset.description, + type: 'text', + span: 2 + }, + { + label: t('models:retrain.preset.seasonality_mode', 'Modo de Estacionalidad'), + value: currentPreset.settings.seasonality_mode === 'additive' + ? t('models:seasonality.additive', 'Aditivo') + : t('models:seasonality.multiplicative', 'Multiplicativo'), + type: 'text' + }, + { + label: t('models:retrain.preset.daily', 'Estacionalidad Diaria'), + value: currentPreset.settings.daily_seasonality + ? t('common:yes', 'Sí') + : t('common:no', 'No'), + type: 'text' + }, + { + label: t('models:retrain.preset.weekly', 'Estacionalidad Semanal'), + value: currentPreset.settings.weekly_seasonality + ? t('common:yes', 'Sí') + : t('common:no', 'No'), + type: 'text' + }, + { + label: t('models:retrain.preset.yearly', 'Estacionalidad Anual'), + value: currentPreset.settings.yearly_seasonality + ? t('common:yes', 'Sí') + : t('common:no', 'No'), + type: 'text' + } + ] + }); + } + } + + if (mode === 'advanced') { + result.push({ + title: t('models:retrain.advanced.title', 'Configuración Avanzada'), + icon: Settings, + fields: [ + { + label: t('models:retrain.advanced.ingredient', 'Ingrediente'), + value: ingredient.name, + type: 'text', + highlight: true, + span: 2 + }, + { + label: t('models:retrain.advanced.start_date', 'Fecha de Inicio'), + value: advancedSettings.start_date || '', + type: 'date', + editable: true, + helpText: t('models:retrain.advanced.start_date_help', 'Dejar vacío para usar todos los datos disponibles') + }, + { + label: t('models:retrain.advanced.end_date', 'Fecha de Fin'), + value: advancedSettings.end_date || '', + type: 'date', + editable: true, + helpText: t('models:retrain.advanced.end_date_help', 'Dejar vacío para usar hasta la fecha actual') + }, + { + label: t('models:retrain.advanced.seasonality_mode', 'Modo de Estacionalidad'), + value: advancedSettings.seasonality_mode || 'additive', + type: 'select', + editable: true, + options: [ + { label: t('models:seasonality.additive', 'Aditivo'), value: 'additive' }, + { label: t('models:seasonality.multiplicative', 'Multiplicativo'), value: 'multiplicative' } + ], + helpText: t('models:retrain.advanced.seasonality_mode_help', 'Aditivo: cambios constantes. Multiplicativo: cambios proporcionales.') + } + ] + }); + + // Seasonality options + result.push({ + title: t('models:retrain.advanced.seasonality_patterns', 'Patrones Estacionales'), + icon: Calendar, + fields: [ + { + label: t('models:retrain.advanced.daily_seasonality', 'Estacionalidad Diaria'), + value: advancedSettings.daily_seasonality ? t('common:enabled', 'Activado') : t('common:disabled', 'Desactivado'), + type: 'text', + helpText: t('models:retrain.advanced.daily_seasonality_help', 'Patrones que se repiten cada día') + }, + { + label: t('models:retrain.advanced.weekly_seasonality', 'Estacionalidad Semanal'), + value: advancedSettings.weekly_seasonality ? t('common:enabled', 'Activado') : t('common:disabled', 'Desactivado'), + type: 'text', + helpText: t('models:retrain.advanced.weekly_seasonality_help', 'Patrones que se repiten cada semana') + }, + { + label: t('models:retrain.advanced.yearly_seasonality', 'Estacionalidad Anual'), + value: advancedSettings.yearly_seasonality ? t('common:enabled', 'Activado') : t('common:disabled', 'Desactivado'), + type: 'text', + helpText: t('models:retrain.advanced.yearly_seasonality_help', 'Patrones que se repiten cada año (festividades, temporadas)') + } + ] + }); + } + + return result; + }, [mode, t, ingredient, currentModel, presets, selectedPreset, advancedSettings]); + + const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => { + const field = sections[sectionIndex]?.fields[fieldIndex]; + + if (!field) return; + + // Handle preset selection + if (mode === 'preset' && field.label === t('models:retrain.preset.select', 'Tipo de Producto')) { + setSelectedPreset(value as string); + return; + } + + // Handle advanced settings + if (mode === 'advanced') { + const label = field.label; + + if (label === t('models:retrain.advanced.start_date', 'Fecha de Inicio')) { + setAdvancedSettings(prev => ({ ...prev, start_date: value as string || null })); + } else if (label === t('models:retrain.advanced.end_date', 'Fecha de Fin')) { + setAdvancedSettings(prev => ({ ...prev, end_date: value as string || null })); + } else if (label === t('models:retrain.advanced.seasonality_mode', 'Modo de Estacionalidad')) { + setAdvancedSettings(prev => ({ ...prev, seasonality_mode: value as string })); + } + } + }; + + // Define tab-style actions for header navigation - memoized + const actions: EditViewModalAction[] = React.useMemo(() => [ + { + label: t('models:retrain.modes.quick', 'Rápido'), + icon: Zap, + onClick: () => setMode('quick'), + variant: 'outline', + disabled: mode === 'quick' + }, + { + label: t('models:retrain.modes.preset', 'Preconfigurado'), + icon: Sparkles, + onClick: () => setMode('preset'), + variant: 'outline', + disabled: mode === 'preset' + }, + { + label: t('models:retrain.modes.advanced', 'Avanzado'), + icon: Settings, + onClick: () => setMode('advanced'), + variant: 'outline', + disabled: mode === 'advanced' + } + ], [t, mode]); + + return ( + + ); +}; + +export default RetrainModelModal; diff --git a/frontend/src/components/domain/forecasting/index.ts b/frontend/src/components/domain/forecasting/index.ts index 32f3dc0e..47c441e6 100644 --- a/frontend/src/components/domain/forecasting/index.ts +++ b/frontend/src/components/domain/forecasting/index.ts @@ -4,6 +4,7 @@ export { default as ForecastTable } from './ForecastTable'; export { default as SeasonalityIndicator } from './SeasonalityIndicator'; export { default as AlertsPanel } from './AlertsPanel'; export { default as ModelDetailsModal } from './ModelDetailsModal'; +export { default as RetrainModelModal } from './RetrainModelModal'; // Export component props for type checking export type { DemandChartProps } from './DemandChart'; diff --git a/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx b/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx index c20d0961..efc4cca3 100644 --- a/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx +++ b/frontend/src/components/domain/inventory/DeleteIngredientModal.tsx @@ -1,10 +1,7 @@ -import React, { useState } from 'react'; -import { Trash2, AlertTriangle, Info, X } from 'lucide-react'; -import { Modal, Button } from '../../ui'; +import React from 'react'; +import { BaseDeleteModal } from '../../ui'; import { IngredientResponse, DeletionSummary } from '../../../api/types/inventory'; -type DeleteMode = 'soft' | 'hard'; - interface DeleteIngredientModalProps { isOpen: boolean; onClose: () => void; @@ -25,307 +22,62 @@ export const DeleteIngredientModal: React.FC = ({ onHardDelete, isLoading = false, }) => { - const [selectedMode, setSelectedMode] = useState('soft'); - const [showConfirmation, setShowConfirmation] = useState(false); - const [confirmText, setConfirmText] = useState(''); - const [deletionResult, setDeletionResult] = useState(null); - - const handleDeleteModeSelect = (mode: DeleteMode) => { - setSelectedMode(mode); - setShowConfirmation(true); - setConfirmText(''); - }; - - const handleConfirmDelete = async () => { - try { - if (selectedMode === 'hard') { - const result = await onHardDelete(ingredient.id); - setDeletionResult(result); - // Close modal immediately after successful hard delete - onClose(); - } else { - await onSoftDelete(ingredient.id); - // Close modal immediately after successful soft delete - onClose(); - } - } catch (error) { - console.error('Error deleting ingredient:', error); - // Handle error (could show a toast or error message) - } - }; - - const handleClose = () => { - setShowConfirmation(false); - setSelectedMode('soft'); - setConfirmText(''); - setDeletionResult(null); - onClose(); - }; - - const isConfirmDisabled = - selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR'; - - // Show deletion result for hard delete - if (deletionResult) { - return ( - -
-
-
-
- - - -
-
-
-

- Eliminación Completada -

-

- El artículo {deletionResult.ingredient_name} ha sido eliminado permanentemente -

-
-
- -
-

Resumen de eliminación:

-
-
- Lotes de stock eliminados: - {deletionResult.deleted_stock_entries} -
-
- Movimientos eliminados: - {deletionResult.deleted_stock_movements} -
-
- Alertas eliminadas: - {deletionResult.deleted_stock_alerts} -
-
-
- -
- -
-
-
- ); - } - - // Show confirmation step - if (showConfirmation) { - const isHardDelete = selectedMode === 'hard'; - - return ( - -
-
-
- {isHardDelete ? ( - - ) : ( - - )} -
- -
-

- {isHardDelete ? 'Confirmación de Eliminación Permanente' : 'Confirmación de Desactivación'} -

- -
-
-

{ingredient.name}

-

- Categoría: {ingredient.category} • Stock actual: {ingredient.current_stock || 0} -

-
- - {isHardDelete ? ( -
-

⚠️ Esta acción eliminará permanentemente:

-
    -
  • • El artículo y toda su información
  • -
  • • Todos los lotes de stock asociados
  • -
  • • Todo el historial de movimientos
  • -
  • • Las alertas relacionadas
  • -
-

- Esta acción NO se puede deshacer -

-
- ) : ( -
-

ℹ️ Esta acción desactivará el artículo:

-
    -
  • • El artículo se marcará como inactivo
  • -
  • • No aparecerá en listas activas
  • -
  • • Se conserva todo el historial y stock
  • -
  • • Se puede reactivar posteriormente
  • -
-
- )} -
- - {isHardDelete && ( -
- - setConfirmText(e.target.value)} - className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]" - placeholder="Escriba ELIMINAR" - autoComplete="off" - /> -
- )} -
-
- -
- - -
-
-
- ); - } - - // Initial mode selection return ( - -
-
-

- Eliminar Artículo -

-
- -
-
-

{ingredient.name}

-

- Categoría: {ingredient.category} • Stock actual: {ingredient.current_stock || 0} -

-
-
- -
-

- Elija el tipo de eliminación que desea realizar: -

- -
- {/* Soft Delete Option */} -
setSelectedMode('soft')} - > -
-
-
- {selectedMode === 'soft' && ( -
- )} -
-
-
-

- Desactivar (Recomendado) -

-

- El artículo se marca como inactivo pero conserva todo su historial. - Ideal para artículos temporalmente fuera del catálogo. -

-
- ✓ Reversible • ✓ Conserva historial • ✓ Conserva stock -
-
-
-
- - {/* Hard Delete Option */} -
setSelectedMode('hard')} - > -
-
-
- {selectedMode === 'hard' && ( -
- )} -
-
-
-

- Eliminar Permanentemente - -

-

- Elimina completamente el artículo y todos sus datos asociados. - Use solo para datos erróneos o pruebas. -

-
- ⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina stock -
-
-
-
-
-
- -
- - -
-
- + + isOpen={isOpen} + onClose={onClose} + entity={ingredient} + onSoftDelete={onSoftDelete} + onHardDelete={onHardDelete} + isLoading={isLoading} + title="Eliminar Artículo" + getEntityId={(ing) => ing.id} + getEntityDisplay={(ing) => ({ + primaryText: ing.name, + secondaryText: `Categoría: ${ing.category} • Stock actual: ${ing.current_stock || 0}`, + })} + softDeleteOption={{ + title: 'Desactivar (Recomendado)', + description: 'El artículo se marca como inactivo pero conserva todo su historial. Ideal para artículos temporalmente fuera del catálogo.', + benefits: '✓ Reversible • ✓ Conserva historial • ✓ Conserva stock', + }} + hardDeleteOption={{ + title: 'Eliminar Permanentemente', + description: 'Elimina completamente el artículo y todos sus datos asociados. Use solo para datos erróneos o pruebas.', + benefits: '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina stock', + enabled: true, + }} + softDeleteWarning={{ + title: 'ℹ️ Esta acción desactivará el artículo:', + items: [ + 'El artículo se marcará como inactivo', + 'No aparecerá en listas activas', + 'Se conserva todo el historial y stock', + 'Se puede reactivar posteriormente', + ], + }} + hardDeleteWarning={{ + title: '⚠️ Esta acción eliminará permanentemente:', + items: [ + 'El artículo y toda su información', + 'Todos los lotes de stock asociados', + 'Todo el historial de movimientos', + 'Las alertas relacionadas', + ], + footer: 'Esta acción NO se puede deshacer', + }} + requireConfirmText={true} + confirmText="ELIMINAR" + showSuccessScreen={false} + showDeletionSummary={true} + deletionSummaryTitle="Eliminación Completada" + formatDeletionSummary={(summary) => ({ + 'Lotes de stock eliminados': summary.deleted_stock_entries, + 'Movimientos eliminados': summary.deleted_stock_movements, + 'Alertas eliminadas': summary.deleted_stock_alerts, + })} + /> ); }; -export default DeleteIngredientModal; \ No newline at end of file +export default DeleteIngredientModal; diff --git a/frontend/src/components/domain/production/CreateQualityTemplateModal.tsx b/frontend/src/components/domain/production/CreateQualityTemplateModal.tsx index 426327b5..4f85b2e7 100644 --- a/frontend/src/components/domain/production/CreateQualityTemplateModal.tsx +++ b/frontend/src/components/domain/production/CreateQualityTemplateModal.tsx @@ -1,12 +1,14 @@ import React, { useState, useEffect } from 'react'; import { Plus, Settings, ClipboardCheck, Target, Cog } from 'lucide-react'; import { AddModal } from '../../ui/AddModal/AddModal'; +import { Select } from '../../ui/Select'; import { QualityCheckType, ProcessStage, type QualityCheckTemplateCreate } from '../../../api/types/qualityTemplates'; import { useCurrentTenant } from '../../../stores/tenant.store'; +import { useAuthUser } from '../../../stores/auth.store'; import { useQuery } from '@tanstack/react-query'; import { recipesService } from '../../../api/services/recipes'; import type { RecipeResponse } from '../../../api/types/recipes'; @@ -33,7 +35,8 @@ const QUALITY_CHECK_TYPE_OPTIONS = [ { value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' }, { value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' }, { value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' }, - { value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' } + { value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }, + { value: QualityCheckType.CHECKLIST, label: 'Checklist - Lista de verificación' } ]; const PROCESS_STAGE_OPTIONS = [ @@ -72,17 +75,19 @@ export const CreateQualityTemplateModal: React.FC { - const { t } = useTranslation(); + const { t } = useTranslation(['production', 'common']); const currentTenant = useCurrentTenant(); + const user = useAuthUser(); const [loading, setLoading] = useState(false); const [selectedStages, setSelectedStages] = useState([]); + const [selectedCheckType, setSelectedCheckType] = useState(null); // Helper function to get translated category label const getCategoryLabel = (category: string | null | undefined): string => { - if (!category) return 'Sin categoría'; - const translationKey = `production.quality.categories.${category}`; - const translated = t(translationKey); - return translated === translationKey ? category : translated; + if (!category) return t('production:quality.categories.no_category', 'Sin categoría'); + const translationKey = `quality.categories.${category}`; + const translated = t(`production:${translationKey}`); + return translated === `production:${translationKey}` ? category : translated; }; // Build category options with translations @@ -109,6 +114,22 @@ export const CreateQualityTemplateModal: React.FC | undefined; + if (formData.check_type === QualityCheckType.VISUAL) { + scoringCriteria = { + excellent: { + min: formData.scoring_excellent_min ? Number(formData.scoring_excellent_min) : undefined, + max: formData.scoring_excellent_max ? Number(formData.scoring_excellent_max) : undefined + }, + good: { + min: formData.scoring_good_min ? Number(formData.scoring_good_min) : undefined, + max: formData.scoring_good_max ? Number(formData.scoring_good_max) : undefined + }, + acceptable: { + min: formData.scoring_acceptable_min ? Number(formData.scoring_acceptable_min) : undefined, + max: formData.scoring_acceptable_max ? Number(formData.scoring_acceptable_max) : undefined + }, + fail: { + below: formData.scoring_fail_below ? Number(formData.scoring_fail_below) : undefined, + above: formData.scoring_fail_above ? Number(formData.scoring_fail_above) : undefined + } + }; + } + const templateData: QualityCheckTemplateCreate = { name: formData.name, template_code: formData.template_code || '', @@ -128,13 +172,15 @@ export const CreateQualityTemplateModal: React.FC 0 ? applicableStages as ProcessStage[] : undefined, - created_by: currentTenant?.id || '', + created_by: user?.id || '', // Measurement fields min_value: formData.min_value ? Number(formData.min_value) : undefined, max_value: formData.max_value ? Number(formData.max_value) : undefined, target_value: formData.target_value ? Number(formData.target_value) : undefined, - unit: formData.unit || undefined, - tolerance_percentage: formData.tolerance_percentage ? Number(formData.tolerance_percentage) : undefined + unit: formData.unit && formData.unit.trim() ? formData.unit.trim() : undefined, + tolerance_percentage: formData.tolerance_percentage ? Number(formData.tolerance_percentage) : undefined, + // Scoring criteria (for visual checks) + scoring_criteria: scoringCriteria }; // Handle recipe associations if provided @@ -170,15 +216,16 @@ export const CreateQualityTemplateModal: React.FC [ - QualityCheckType.MEASUREMENT, - QualityCheckType.TEMPERATURE, - QualityCheckType.WEIGHT - ].includes(checkType as QualityCheckType); + // Handler for field changes to track check_type selection + const handleFieldChange = (fieldName: string, value: any) => { + if (fieldName === 'check_type') { + setSelectedCheckType(value as QualityCheckType); + } + }; - const sections = [ - { + // Function to build sections dynamically based on selected check type + const getSections = () => { + const basicInfoSection = { title: 'Información Básica', icon: ClipboardCheck, fields: [ @@ -204,8 +251,9 @@ export const CreateQualityTemplateModal: React.FC @@ -365,15 +507,8 @@ export const CreateQualityTemplateModal: React.FC - - {/* TODO: Stage selection would need a custom component or enhanced AddModal field types */} - {isOpen && ( -
-

Nota: La selección de etapas del proceso requiere un componente personalizado no implementado en esta versión simplificada.

-

Las etapas actualmente se manejan mediante un campo de texto que debería ser reemplazado por un selector múltiple.

-
- )} ); }; diff --git a/frontend/src/components/domain/production/DeleteQualityTemplateModal.tsx b/frontend/src/components/domain/production/DeleteQualityTemplateModal.tsx new file mode 100644 index 00000000..0235084b --- /dev/null +++ b/frontend/src/components/domain/production/DeleteQualityTemplateModal.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { BaseDeleteModal } from '../../ui'; +import { QualityCheckTemplate } from '../../../api/types/qualityTemplates'; +import { useTranslation } from 'react-i18next'; + +interface DeleteQualityTemplateModalProps { + isOpen: boolean; + onClose: () => void; + template: QualityCheckTemplate | null; + onSoftDelete: (templateId: string) => Promise; + onHardDelete: (templateId: string) => Promise; + isLoading?: boolean; +} + +/** + * Modal for quality template deletion with soft/hard delete options + * - Soft delete: Mark as inactive (reversible) + * - Hard delete: Permanent deletion with dependency checking + */ +export const DeleteQualityTemplateModal: React.FC = ({ + isOpen, + onClose, + template, + onSoftDelete, + onHardDelete, + isLoading = false, +}) => { + const { t } = useTranslation(['production', 'common']); + + if (!template) return null; + + return ( + + isOpen={isOpen} + onClose={onClose} + entity={template} + onSoftDelete={onSoftDelete} + onHardDelete={onHardDelete} + isLoading={isLoading} + title={t('production:quality.delete.title', 'Eliminar Plantilla de Calidad')} + getEntityId={(temp) => temp.id} + getEntityDisplay={(temp) => ({ + primaryText: temp.name, + secondaryText: `${t('production:quality.delete.template_code', 'Código')}: ${temp.template_code || 'N/A'} • ${t('production:quality.delete.check_type', 'Tipo')}: ${temp.check_type}`, + })} + softDeleteOption={{ + title: t('production:quality.delete.soft_delete', 'Desactivar (Recomendado)'), + description: t('production:quality.delete.soft_explanation', 'La plantilla se marca como inactiva pero conserva todo su historial. Ideal para plantillas temporalmente fuera de uso.'), + benefits: t('production:quality.delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos'), + }} + hardDeleteOption={{ + title: t('production:quality.delete.hard_delete', 'Eliminar Permanentemente'), + description: t('production:quality.delete.hard_explanation', 'Elimina completamente la plantilla y todos sus datos asociados. Use solo para datos erróneos o pruebas.'), + benefits: t('production:quality.delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos'), + enabled: true, + }} + softDeleteWarning={{ + title: t('production:quality.delete.soft_info_title', 'ℹ️ Esta acción desactivará la plantilla:'), + items: [ + t('production:quality.delete.soft_info_1', 'La plantilla se marcará como inactiva'), + t('production:quality.delete.soft_info_2', 'No aparecerá en listas activas'), + t('production:quality.delete.soft_info_3', 'Se conserva todo el historial y datos'), + t('production:quality.delete.soft_info_4', 'Se puede reactivar posteriormente'), + ], + }} + hardDeleteWarning={{ + title: t('production:quality.delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:'), + items: [ + t('production:quality.delete.hard_warning_1', 'La plantilla y toda su información'), + t('production:quality.delete.hard_warning_2', 'Todas las configuraciones de calidad asociadas'), + t('production:quality.delete.hard_warning_3', 'Todo el historial de controles de calidad'), + t('production:quality.delete.hard_warning_4', 'Las alertas y métricas relacionadas'), + ], + footer: t('production:quality.delete.irreversible', 'Esta acción NO se puede deshacer'), + }} + requireConfirmText={true} + confirmText="ELIMINAR" + showSuccessScreen={true} + successTitle={t('production:quality.delete.success_soft_title', 'Plantilla Desactivada')} + getSuccessMessage={(temp, mode) => + mode === 'hard' + ? t('production:quality.delete.template_deleted', { name: temp.name }) + : t('production:quality.delete.template_deactivated', { name: temp.name }) + } + autoCloseDelay={1500} + /> + ); +}; + +export default DeleteQualityTemplateModal; diff --git a/frontend/src/components/domain/production/QualityTemplateManager.tsx b/frontend/src/components/domain/production/QualityTemplateManager.tsx index 87eb91c4..2163000d 100644 --- a/frontend/src/components/domain/production/QualityTemplateManager.tsx +++ b/frontend/src/components/domain/production/QualityTemplateManager.tsx @@ -3,8 +3,6 @@ import { Plus, Search, Filter, - Edit, - Copy, Trash2, Eye, CheckCircle, @@ -32,8 +30,7 @@ import { useQualityTemplates, useCreateQualityTemplate, useUpdateQualityTemplate, - useDeleteQualityTemplate, - useDuplicateQualityTemplate + useDeleteQualityTemplate } from '../../../api/hooks/qualityTemplates'; import { QualityCheckType, @@ -45,6 +42,7 @@ import { import { CreateQualityTemplateModal } from './CreateQualityTemplateModal'; import { EditQualityTemplateModal } from './EditQualityTemplateModal'; import { ViewQualityTemplateModal } from './ViewQualityTemplateModal'; +import { DeleteQualityTemplateModal } from './DeleteQualityTemplateModal'; import { useTranslation } from 'react-i18next'; interface QualityTemplateManagerProps { @@ -114,10 +112,12 @@ export const QualityTemplateManager: React.FC = ({ const [selectedCheckType, setSelectedCheckType] = useState(''); const [selectedStage, setSelectedStage] = useState(''); const [showActiveOnly, setShowActiveOnly] = useState(true); - const [showCreateModal, setShowCreateModal] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const [showViewModal, setShowViewModal] = useState(false); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [showViewModal, setShowViewModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(null); + const [templateToDelete, setTemplateToDelete] = useState(null); const currentTenant = useCurrentTenant(); const tenantId = currentTenant?.id || ''; @@ -146,7 +146,6 @@ export const QualityTemplateManager: React.FC = ({ const createTemplateMutation = useCreateQualityTemplate(tenantId); const updateTemplateMutation = useUpdateQualityTemplate(tenantId); const deleteTemplateMutation = useDeleteQualityTemplate(tenantId); - const duplicateTemplateMutation = useDuplicateQualityTemplate(tenantId); // Filtered templates const filteredTemplates = useMemo(() => { @@ -214,25 +213,25 @@ export const QualityTemplateManager: React.FC = ({ } }; - const handleDeleteTemplate = async (templateId: string) => { - if (!confirm('¿Estás seguro de que quieres eliminar esta plantilla?')) return; - + const handleSoftDelete = async (templateId: string) => { try { await deleteTemplateMutation.mutateAsync(templateId); } catch (error) { console.error('Error deleting template:', error); + throw error; } }; - const handleDuplicateTemplate = async (templateId: string) => { + const handleHardDelete = async (templateId: string) => { try { - await duplicateTemplateMutation.mutateAsync(templateId); + await deleteTemplateMutation.mutateAsync(templateId); } catch (error) { - console.error('Error duplicating template:', error); + console.error('Error deleting template:', error); + throw error; } }; - const getTemplateStatusConfig = (template: QualityCheckTemplate) => { + const getTemplateStatusConfig = (template: QualityCheckTemplate) => { const typeConfigs = QUALITY_CHECK_TYPE_CONFIG(t); const typeConfig = typeConfigs[template.check_type]; @@ -241,7 +240,7 @@ export const QualityTemplateManager: React.FC = ({ text: typeConfig.label, icon: typeConfig.icon }; - }; + }; if (isLoading) { return ( @@ -406,27 +405,15 @@ export const QualityTemplateManager: React.FC = ({ setShowViewModal(true); } }, - { - label: 'Editar', - icon: Edit, - priority: 'secondary', - onClick: () => { - setSelectedTemplate(template); - setShowEditModal(true); - } - }, - { - label: 'Duplicar', - icon: Copy, - priority: 'secondary', - onClick: () => handleDuplicateTemplate(template.id) - }, { label: 'Eliminar', icon: Trash2, destructive: true, priority: 'secondary', - onClick: () => handleDeleteTemplate(template.id) + onClick: () => { + setTemplateToDelete(template); + setShowDeleteModal(true); + } } ]} /> @@ -491,6 +478,21 @@ export const QualityTemplateManager: React.FC = ({ }} /> )} + + {/* Delete Template Modal */} + {templateToDelete && ( + { + setShowDeleteModal(false); + setTemplateToDelete(null); + }} + template={templateToDelete} + onSoftDelete={handleSoftDelete} + onHardDelete={handleHardDelete} + isLoading={deleteTemplateMutation.isPending} + /> + )}
); }; diff --git a/frontend/src/components/domain/recipes/DeleteRecipeModal.tsx b/frontend/src/components/domain/recipes/DeleteRecipeModal.tsx index c8effbc6..b114123a 100644 --- a/frontend/src/components/domain/recipes/DeleteRecipeModal.tsx +++ b/frontend/src/components/domain/recipes/DeleteRecipeModal.tsx @@ -1,13 +1,10 @@ -import React, { useState, useEffect } from 'react'; -import { Trash2, AlertTriangle, Info } from 'lucide-react'; -import { Modal, Button } from '../../ui'; -import { RecipeResponse, RecipeDeletionSummary } from '../../../api/types/recipes'; +import React from 'react'; +import { BaseDeleteModal } from '../../ui'; +import { RecipeResponse } from '../../../api/types/recipes'; import { useTranslation } from 'react-i18next'; import { useCurrentTenant } from '../../../stores/tenant.store'; import { useRecipeDeletionSummary } from '../../../api/hooks/recipes'; -type DeleteMode = 'soft' | 'hard'; - interface DeleteRecipeModalProps { isOpen: boolean; onClose: () => void; @@ -32,345 +29,121 @@ export const DeleteRecipeModal: React.FC = ({ }) => { const { t } = useTranslation(['recipes', 'common']); const currentTenant = useCurrentTenant(); - const [selectedMode, setSelectedMode] = useState('soft'); - const [showConfirmation, setShowConfirmation] = useState(false); - const [confirmText, setConfirmText] = useState(''); - const [deletionComplete, setDeletionComplete] = useState(false); - // Fetch deletion summary when modal opens for hard delete + // Fetch deletion summary for dependency checking const { data: deletionSummary, isLoading: summaryLoading } = useRecipeDeletionSummary( currentTenant?.id || '', recipe?.id || '', { - enabled: isOpen && !!recipe && selectedMode === 'hard' && showConfirmation, + enabled: isOpen && !!recipe, } ); - useEffect(() => { - if (!isOpen) { - // Reset state when modal closes - setShowConfirmation(false); - setSelectedMode('soft'); - setConfirmText(''); - setDeletionComplete(false); - } - }, [isOpen]); - if (!recipe) return null; - const handleDeleteModeSelect = (mode: DeleteMode) => { - setSelectedMode(mode); - setShowConfirmation(true); - setConfirmText(''); - }; - - const handleConfirmDelete = async () => { - try { - if (selectedMode === 'hard') { - await onHardDelete(recipe.id); - } else { - await onSoftDelete(recipe.id); - } - setDeletionComplete(true); - // Auto-close after 1.5 seconds - setTimeout(() => { - handleClose(); - }, 1500); - } catch (error) { - console.error('Error deleting recipe:', error); - // Error handling is done by parent component + // Build dependency check warnings + const dependencyWarnings: string[] = []; + if (deletionSummary) { + if (deletionSummary.production_batches_count > 0) { + dependencyWarnings.push( + t('recipes:delete.batches_affected', + { count: deletionSummary.production_batches_count }, + `${deletionSummary.production_batches_count} lotes de producción afectados` + ) + ); + } + if (deletionSummary.affected_orders_count > 0) { + dependencyWarnings.push( + t('recipes:delete.orders_affected', + { count: deletionSummary.affected_orders_count }, + `${deletionSummary.affected_orders_count} pedidos afectados` + ) + ); + } + if (deletionSummary.warnings && deletionSummary.warnings.length > 0) { + dependencyWarnings.push(...deletionSummary.warnings); } - }; - - const handleClose = () => { - setShowConfirmation(false); - setSelectedMode('soft'); - setConfirmText(''); - setDeletionComplete(false); - onClose(); - }; - - const isConfirmDisabled = - selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR'; - - // Show deletion success - if (deletionComplete) { - return ( - -
-
-
-
- - - -
-
-
-

- {selectedMode === 'hard' - ? t('recipes:delete.success_hard_title', 'Receta Eliminada') - : t('recipes:delete.success_soft_title', 'Receta Archivada')} -

-

- {selectedMode === 'hard' - ? t('recipes:delete.recipe_deleted', { name: recipe.name }) - : t('recipes:delete.recipe_archived', { name: recipe.name })} -

-
-
-
-
- ); } - // Show confirmation step - if (showConfirmation) { - const isHardDelete = selectedMode === 'hard'; - const canDelete = !isHardDelete || (deletionSummary?.can_delete !== false); + // Build hard delete warning items + const hardDeleteItems = [ + t('recipes:delete.hard_warning_1', 'La receta y toda su información'), + t('recipes:delete.hard_warning_2', 'Todos los ingredientes asociados'), + ]; - return ( - -
-
-
- {isHardDelete ? ( - - ) : ( - - )} -
- -
-

- {isHardDelete - ? t('recipes:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente') - : t('recipes:delete.confirm_soft_title', 'Confirmación de Archivo')} -

- -
-
-

{recipe.name}

-

- {t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category} -

-
- - {isHardDelete ? ( - <> - {summaryLoading ? ( -
-
-

- {t('recipes:delete.checking_dependencies', 'Verificando dependencias...')} -

-
- ) : deletionSummary && !canDelete ? ( -
-

- {t('recipes:delete.cannot_delete', '⚠️ No se puede eliminar esta receta')} -

-
    - {deletionSummary.warnings.map((warning, idx) => ( -
  • • {warning}
  • - ))} -
-
- ) : ( -
-

- {t('recipes:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')} -

-
    -
  • {t('recipes:delete.hard_warning_1', '• La receta y toda su información')}
  • -
  • {t('recipes:delete.hard_warning_2', '• Todos los ingredientes asociados')}
  • - {deletionSummary && ( - <> - {deletionSummary.production_batches_count > 0 && ( -
  • {t('recipes:delete.batches_affected', { count: deletionSummary.production_batches_count }, `• ${deletionSummary.production_batches_count} lotes de producción`)}
  • - )} - {deletionSummary.affected_orders_count > 0 && ( -
  • {t('recipes:delete.orders_affected', { count: deletionSummary.affected_orders_count }, `• ${deletionSummary.affected_orders_count} pedidos afectados`)}
  • - )} - - )} -
-

- {t('recipes:delete.irreversible', 'Esta acción NO se puede deshacer')} -

-
- )} - - ) : ( -
-

- {t('recipes:delete.soft_info_title', 'ℹ️ Esta acción archivará la receta:')} -

-
    -
  • {t('recipes:delete.soft_info_1', '• La receta cambiará a estado ARCHIVADO')}
  • -
  • {t('recipes:delete.soft_info_2', '• No aparecerá en listas activas')}
  • -
  • {t('recipes:delete.soft_info_3', '• Se conserva todo el historial y datos')}
  • -
  • {t('recipes:delete.soft_info_4', '• Se puede reactivar posteriormente')}
  • -
-
- )} -
- - {isHardDelete && canDelete && ( -
- - setConfirmText(e.target.value)} - className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]" - placeholder={t('recipes:delete.type_placeholder', 'Escriba ELIMINAR')} - autoComplete="off" - /> -
- )} -
-
- -
- - {(!isHardDelete || canDelete) && ( - - )} -
-
-
- ); + if (deletionSummary) { + if (deletionSummary.production_batches_count > 0) { + hardDeleteItems.push( + t('recipes:delete.batches_affected', + { count: deletionSummary.production_batches_count }, + `${deletionSummary.production_batches_count} lotes de producción` + ) + ); + } + if (deletionSummary.affected_orders_count > 0) { + hardDeleteItems.push( + t('recipes:delete.orders_affected', + { count: deletionSummary.affected_orders_count }, + `${deletionSummary.affected_orders_count} pedidos afectados` + ) + ); + } } - // Initial mode selection return ( - -
-
-

- {t('recipes:delete.title', 'Eliminar Receta')} -

-
- -
-
-

{recipe.name}

-

- {t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category} -

-
-
- -
-

- {t('recipes:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')} -

- -
- {/* Soft Delete Option */} -
setSelectedMode('soft')} - > -
-
-
- {selectedMode === 'soft' && ( -
- )} -
-
-
-

- {t('recipes:delete.soft_delete', 'Archivar (Recomendado)')} -

-

- {t('recipes:delete.soft_explanation', 'La receta se marca como archivada pero conserva todo su historial. Ideal para recetas fuera de uso temporal.')} -

-
- {t('recipes:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')} -
-
-
-
- - {/* Hard Delete Option */} -
setSelectedMode('hard')} - > -
-
-
- {selectedMode === 'hard' && ( -
- )} -
-
-
-

- {t('recipes:delete.hard_delete', 'Eliminar Permanentemente')} - -

-

- {t('recipes:delete.hard_explanation', 'Elimina completamente la receta y todos sus datos asociados. Use solo para datos erróneos o pruebas.')} -

-
- {t('recipes:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')} -
-
-
-
-
-
- -
- - -
-
- + rec.id} + getEntityDisplay={(rec) => ({ + primaryText: rec.name, + secondaryText: `${t('recipes:delete.recipe_code', 'Código')}: ${rec.recipe_code || 'N/A'} • ${t('recipes:delete.recipe_category', 'Categoría')}: ${rec.category}`, + })} + softDeleteOption={{ + title: t('recipes:delete.soft_delete', 'Archivar (Recomendado)'), + description: t('recipes:delete.soft_explanation', 'La receta se marca como archivada pero conserva todo su historial. Ideal para recetas fuera de uso temporal.'), + benefits: t('recipes:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos'), + }} + hardDeleteOption={{ + title: t('recipes:delete.hard_delete', 'Eliminar Permanentemente'), + description: t('recipes:delete.hard_explanation', 'Elimina completamente la receta y todos sus datos asociados. Use solo para datos erróneos o pruebas.'), + benefits: t('recipes:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos'), + enabled: true, + }} + softDeleteWarning={{ + title: t('recipes:delete.soft_info_title', 'ℹ️ Esta acción archivará la receta:'), + items: [ + t('recipes:delete.soft_info_1', 'La receta cambiará a estado ARCHIVADO'), + t('recipes:delete.soft_info_2', 'No aparecerá en listas activas'), + t('recipes:delete.soft_info_3', 'Se conserva todo el historial y datos'), + t('recipes:delete.soft_info_4', 'Se puede reactivar posteriormente'), + ], + }} + hardDeleteWarning={{ + title: t('recipes:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:'), + items: hardDeleteItems, + footer: t('recipes:delete.irreversible', 'Esta acción NO se puede deshacer'), + }} + dependencyCheck={{ + isLoading: summaryLoading, + canDelete: deletionSummary?.can_delete !== false, + warnings: dependencyWarnings, + }} + requireConfirmText={true} + confirmText="ELIMINAR" + showSuccessScreen={true} + successTitle={t('recipes:delete.success_soft_title', 'Receta Archivada')} + getSuccessMessage={(rec, mode) => + mode === 'hard' + ? t('recipes:delete.recipe_deleted', { name: rec.name }) + : t('recipes:delete.recipe_archived', { name: rec.name }) + } + autoCloseDelay={1500} + /> ); }; diff --git a/frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx b/frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx index a610c2ce..27d3a740 100644 --- a/frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx +++ b/frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx @@ -1,11 +1,8 @@ -import React, { useState } from 'react'; -import { Trash2, AlertTriangle, Info } from 'lucide-react'; -import { Modal, Button } from '../../ui'; +import React from 'react'; +import { BaseDeleteModal } from '../../ui'; import { SupplierResponse, SupplierDeletionSummary } from '../../../api/types/suppliers'; import { useTranslation } from 'react-i18next'; -type DeleteMode = 'soft' | 'hard'; - interface DeleteSupplierModalProps { isOpen: boolean; onClose: () => void; @@ -29,323 +26,65 @@ export const DeleteSupplierModal: React.FC = ({ isLoading = false, }) => { const { t } = useTranslation(['suppliers', 'common']); - const [selectedMode, setSelectedMode] = useState('soft'); - const [showConfirmation, setShowConfirmation] = useState(false); - const [confirmText, setConfirmText] = useState(''); - const [deletionResult, setDeletionResult] = useState(null); if (!supplier) return null; - const handleDeleteModeSelect = (mode: DeleteMode) => { - setSelectedMode(mode); - setShowConfirmation(true); - setConfirmText(''); - }; - - const handleConfirmDelete = async () => { - try { - if (selectedMode === 'hard') { - const result = await onHardDelete(supplier.id); - setDeletionResult(result); - // Close modal immediately after successful hard delete - onClose(); - } else { - await onSoftDelete(supplier.id); - // Close modal immediately after soft delete - onClose(); - } - } catch (error) { - console.error('Error deleting supplier:', error); - // Error handling could show a toast notification - } - }; - - const handleClose = () => { - setShowConfirmation(false); - setSelectedMode('soft'); - setConfirmText(''); - setDeletionResult(null); - onClose(); - }; - - const isConfirmDisabled = - selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR'; - - // Show deletion result for hard delete - if (deletionResult) { - return ( - -
-
-
-
- - - -
-
-
-

- {t('suppliers:delete.summary_title')} -

-

- {t('suppliers:delete.supplier_deleted', { name: deletionResult.supplier_name })} -

-
-
- -
-

- {t('suppliers:delete.deletion_summary')}: -

-
-
- {t('suppliers:delete.deleted_price_lists')}: - {deletionResult.deleted_price_lists} -
-
- {t('suppliers:delete.deleted_quality_reviews')}: - {deletionResult.deleted_quality_reviews} -
-
- {t('suppliers:delete.deleted_performance_metrics')}: - {deletionResult.deleted_performance_metrics} -
-
- {t('suppliers:delete.deleted_alerts')}: - {deletionResult.deleted_alerts} -
-
- {t('suppliers:delete.deleted_scorecards')}: - {deletionResult.deleted_scorecards} -
-
-
- -
- -
-
-
- ); - } - - // Show confirmation step - if (showConfirmation) { - const isHardDelete = selectedMode === 'hard'; - - return ( - -
-
-
- {isHardDelete ? ( - - ) : ( - - )} -
- -
-

- {isHardDelete - ? t('suppliers:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente') - : t('suppliers:delete.confirm_soft_title', 'Confirmación de Desactivación')} -

- -
-
-

{supplier.name}

-

- {t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type} -

-
- - {isHardDelete ? ( -
-

- {t('suppliers:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')} -

-
    -
  • {t('suppliers:delete.hard_warning_1', '• El proveedor y toda su información')}
  • -
  • {t('suppliers:delete.hard_warning_2', '• Todas las listas de precios asociadas')}
  • -
  • {t('suppliers:delete.hard_warning_3', '• Todo el historial de calidad y rendimiento')}
  • -
  • {t('suppliers:delete.hard_warning_4', '• Las alertas y scorecards relacionados')}
  • -
-

- {t('suppliers:delete.irreversible', 'Esta acción NO se puede deshacer')} -

-
- ) : ( -
-

- {t('suppliers:delete.soft_info_title', 'ℹ️ Esta acción desactivará el proveedor:')} -

-
    -
  • {t('suppliers:delete.soft_info_1', '• El proveedor se marcará como inactivo')}
  • -
  • {t('suppliers:delete.soft_info_2', '• No aparecerá en listas activas')}
  • -
  • {t('suppliers:delete.soft_info_3', '• Se conserva todo el historial y datos')}
  • -
  • {t('suppliers:delete.soft_info_4', '• Se puede reactivar posteriormente')}
  • -
-
- )} -
- - {isHardDelete && ( -
- - setConfirmText(e.target.value)} - className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]" - placeholder={t('suppliers:delete.type_placeholder', 'Escriba ELIMINAR')} - autoComplete="off" - /> -
- )} -
-
- -
- - -
-
-
- ); - } - - // Initial mode selection return ( - -
-
-

- {t('suppliers:delete.title', 'Eliminar Proveedor')} -

-
- -
-
-

{supplier.name}

-

- {t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type} -

-
-
- -
-

- {t('suppliers:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')} -

- -
- {/* Soft Delete Option */} -
setSelectedMode('soft')} - > -
-
-
- {selectedMode === 'soft' && ( -
- )} -
-
-
-

- {t('suppliers:delete.soft_delete', 'Desactivar (Recomendado)')} -

-

- {t('suppliers:delete.soft_explanation', 'El proveedor se marca como inactivo pero conserva todo su historial. Ideal para proveedores temporalmente fuera del catálogo.')} -

-
- {t('suppliers:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')} -
-
-
-
- - {/* Hard Delete Option */} -
setSelectedMode('hard')} - > -
-
-
- {selectedMode === 'hard' && ( -
- )} -
-
-
-

- {t('suppliers:delete.hard_delete', 'Eliminar Permanentemente')} - -

-

- {t('suppliers:delete.hard_explanation', 'Elimina completamente el proveedor y todos sus datos asociados. Use solo para datos erróneos o pruebas.')} -

-
- {t('suppliers:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')} -
-
-
-
-
-
- -
- - -
-
- + + isOpen={isOpen} + onClose={onClose} + entity={supplier} + onSoftDelete={onSoftDelete} + onHardDelete={onHardDelete} + isLoading={isLoading} + title={t('suppliers:delete.title', 'Eliminar Proveedor')} + getEntityId={(sup) => sup.id} + getEntityDisplay={(sup) => ({ + primaryText: sup.name, + secondaryText: `${t('suppliers:delete.supplier_code', 'Código')}: ${sup.supplier_code || 'N/A'} • ${t('suppliers:delete.supplier_type', 'Tipo')}: ${sup.supplier_type}`, + })} + softDeleteOption={{ + title: t('suppliers:delete.soft_delete', 'Desactivar (Recomendado)'), + description: t('suppliers:delete.soft_explanation', 'El proveedor se marca como inactivo pero conserva todo su historial. Ideal para proveedores temporalmente fuera del catálogo.'), + benefits: t('suppliers:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos'), + }} + hardDeleteOption={{ + title: t('suppliers:delete.hard_delete', 'Eliminar Permanentemente'), + description: t('suppliers:delete.hard_explanation', 'Elimina completamente el proveedor y todos sus datos asociados. Use solo para datos erróneos o pruebas.'), + benefits: t('suppliers:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos'), + enabled: true, + }} + softDeleteWarning={{ + title: t('suppliers:delete.soft_info_title', 'ℹ️ Esta acción desactivará el proveedor:'), + items: [ + t('suppliers:delete.soft_info_1', 'El proveedor se marcará como inactivo'), + t('suppliers:delete.soft_info_2', 'No aparecerá en listas activas'), + t('suppliers:delete.soft_info_3', 'Se conserva todo el historial y datos'), + t('suppliers:delete.soft_info_4', 'Se puede reactivar posteriormente'), + ], + }} + hardDeleteWarning={{ + title: t('suppliers:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:'), + items: [ + t('suppliers:delete.hard_warning_1', 'El proveedor y toda su información'), + t('suppliers:delete.hard_warning_2', 'Todas las listas de precios asociadas'), + t('suppliers:delete.hard_warning_3', 'Todo el historial de calidad y rendimiento'), + t('suppliers:delete.hard_warning_4', 'Las alertas y scorecards relacionados'), + ], + footer: t('suppliers:delete.irreversible', 'Esta acción NO se puede deshacer'), + }} + requireConfirmText={true} + confirmText="ELIMINAR" + showSuccessScreen={false} + showDeletionSummary={true} + deletionSummaryTitle={t('suppliers:delete.summary_title', 'Eliminación Completada')} + formatDeletionSummary={(summary) => ({ + [t('suppliers:delete.deleted_price_lists', 'Listas de precios eliminadas')]: summary.deleted_price_lists, + [t('suppliers:delete.deleted_quality_reviews', 'Revisiones de calidad eliminadas')]: summary.deleted_quality_reviews, + [t('suppliers:delete.deleted_performance_metrics', 'Métricas de rendimiento eliminadas')]: summary.deleted_performance_metrics, + [t('suppliers:delete.deleted_alerts', 'Alertas eliminadas')]: summary.deleted_alerts, + [t('suppliers:delete.deleted_scorecards', 'Scorecards eliminados')]: summary.deleted_scorecards, + })} + /> ); }; diff --git a/frontend/src/components/domain/suppliers/PriceListModal.tsx b/frontend/src/components/domain/suppliers/PriceListModal.tsx new file mode 100644 index 00000000..cfece61a --- /dev/null +++ b/frontend/src/components/domain/suppliers/PriceListModal.tsx @@ -0,0 +1,328 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DollarSign, Package, Calendar, Info } from 'lucide-react'; +import { AddModal, AddModalSection } from '../../ui/AddModal/AddModal'; +import { ProductSelector } from './ProductSelector'; +import { + SupplierPriceListCreate, + SupplierPriceListUpdate, + SupplierPriceListResponse, +} from '../../../api/types/suppliers'; +import { IngredientResponse, UnitOfMeasure } from '../../../api/types/inventory'; + +interface PriceListModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (priceListData: SupplierPriceListCreate | SupplierPriceListUpdate) => Promise; + mode: 'create' | 'edit'; + initialData?: SupplierPriceListResponse; + loading?: boolean; + excludeProductIds?: string[]; + // Wait-for-refetch support + waitForRefetch?: boolean; + isRefetching?: boolean; + onSaveComplete?: () => Promise; +} + +export const PriceListModal: React.FC = ({ + isOpen, + onClose, + onSave, + mode, + initialData, + loading = false, + excludeProductIds = [], + waitForRefetch, + isRefetching, + onSaveComplete, +}) => { + const { t } = useTranslation(['suppliers', 'common']); + const [selectedProduct, setSelectedProduct] = useState(); + const [formData, setFormData] = useState>({}); + + // Initialize form data when modal opens or initialData changes + useEffect(() => { + if (isOpen) { + if (mode === 'edit' && initialData) { + setFormData({ + inventory_product_id: initialData.inventory_product_id, + product_code: initialData.product_code || '', + unit_price: initialData.unit_price, + unit_of_measure: initialData.unit_of_measure, + minimum_order_quantity: initialData.minimum_order_quantity || '', + price_per_unit: initialData.price_per_unit, + effective_date: initialData.effective_date?.split('T')[0] || new Date().toISOString().split('T')[0], + expiry_date: initialData.expiry_date?.split('T')[0] || '', + is_active: initialData.is_active ? 'true' : 'false', + brand: initialData.brand || '', + packaging_size: initialData.packaging_size || '', + origin_country: initialData.origin_country || '', + shelf_life_days: initialData.shelf_life_days || '', + storage_requirements: initialData.storage_requirements || '', + }); + } else { + // Reset form for create mode + setFormData({ + inventory_product_id: '', + product_code: '', + unit_price: '', + unit_of_measure: 'kg', + minimum_order_quantity: '', + price_per_unit: '', + effective_date: new Date().toISOString().split('T')[0], + expiry_date: '', + is_active: 'true', + brand: '', + packaging_size: '', + origin_country: '', + shelf_life_days: '', + storage_requirements: '', + }); + setSelectedProduct(undefined); + } + } + }, [isOpen, mode, initialData]); + + const handleProductChange = (productId: string, product?: IngredientResponse) => { + setSelectedProduct(product); + setFormData(prev => ({ + ...prev, + inventory_product_id: productId, + // Auto-fill some fields from product if available + unit_of_measure: product?.unit_of_measure || prev.unit_of_measure, + brand: product?.brand || prev.brand, + })); + }; + + const handleFieldChange = (fieldName: string, value: any) => { + setFormData(prev => ({ + ...prev, + [fieldName]: value, + })); + }; + + const handleSave = async (data: Record) => { + // Clean and prepare the data + const priceListData: SupplierPriceListCreate | SupplierPriceListUpdate = { + inventory_product_id: data.inventory_product_id, + product_code: data.product_code || null, + unit_price: parseFloat(data.unit_price), + unit_of_measure: data.unit_of_measure, + minimum_order_quantity: data.minimum_order_quantity ? parseFloat(data.minimum_order_quantity) : null, + price_per_unit: parseFloat(data.price_per_unit), + effective_date: data.effective_date || undefined, + expiry_date: data.expiry_date || null, + is_active: data.is_active === 'true' || data.is_active === true, + brand: data.brand || null, + packaging_size: data.packaging_size || null, + origin_country: data.origin_country || null, + shelf_life_days: data.shelf_life_days ? parseInt(data.shelf_life_days) : null, + storage_requirements: data.storage_requirements || null, + }; + + await onSave(priceListData); + }; + + const unitOptions = [ + { label: t('common:units.kg'), value: UnitOfMeasure.KILOGRAMS }, + { label: t('common:units.g'), value: UnitOfMeasure.GRAMS }, + { label: t('common:units.l'), value: UnitOfMeasure.LITERS }, + { label: t('common:units.ml'), value: UnitOfMeasure.MILLILITERS }, + { label: t('common:units.units'), value: UnitOfMeasure.UNITS }, + { label: t('common:units.pieces'), value: UnitOfMeasure.PIECES }, + { label: t('common:units.packages'), value: UnitOfMeasure.PACKAGES }, + { label: t('common:units.bags'), value: UnitOfMeasure.BAGS }, + { label: t('common:units.boxes'), value: UnitOfMeasure.BOXES }, + ]; + + const sections: AddModalSection[] = [ + { + title: t('price_list.sections.product_selection'), + icon: Package, + columns: 1, + fields: [ + { + name: 'inventory_product_id', + label: t('price_list.fields.product'), + type: 'component', + required: true, + component: ProductSelector, + componentProps: { + value: formData.inventory_product_id, + onChange: handleProductChange, + excludeIds: mode === 'create' ? excludeProductIds : [], + disabled: mode === 'edit', // Can't change product in edit mode + isRequired: true, + }, + helpText: mode === 'edit' + ? t('price_list.help.product_locked') + : t('price_list.help.select_product'), + }, + { + name: 'product_code', + label: t('price_list.fields.product_code'), + type: 'text', + required: false, + placeholder: t('price_list.placeholders.product_code'), + helpText: t('price_list.help.product_code'), + }, + ], + }, + { + title: t('price_list.sections.pricing'), + icon: DollarSign, + columns: 2, + fields: [ + { + name: 'unit_price', + label: t('price_list.fields.unit_price'), + type: 'number', + required: true, + placeholder: '0.00', + validation: (value) => { + const num = parseFloat(value as string); + if (isNaN(num) || num <= 0) { + return t('price_list.validation.price_positive'); + } + return null; + }, + helpText: t('price_list.help.unit_price'), + }, + { + name: 'price_per_unit', + label: t('price_list.fields.price_per_unit'), + type: 'number', + required: true, + placeholder: '0.00', + validation: (value) => { + const num = parseFloat(value as string); + if (isNaN(num) || num <= 0) { + return t('price_list.validation.price_positive'); + } + return null; + }, + helpText: t('price_list.help.price_per_unit'), + }, + { + name: 'unit_of_measure', + label: t('price_list.fields.unit_of_measure'), + type: 'select', + required: true, + options: unitOptions, + helpText: t('price_list.help.unit_of_measure'), + }, + { + name: 'minimum_order_quantity', + label: t('price_list.fields.minimum_order'), + type: 'number', + required: false, + placeholder: '0', + helpText: t('price_list.help.minimum_order'), + }, + ], + }, + { + title: t('price_list.sections.validity'), + icon: Calendar, + columns: 2, + fields: [ + { + name: 'effective_date', + label: t('price_list.fields.effective_date'), + type: 'date', + required: false, + defaultValue: new Date().toISOString().split('T')[0], + helpText: t('price_list.help.effective_date'), + }, + { + name: 'expiry_date', + label: t('price_list.fields.expiry_date'), + type: 'date', + required: false, + helpText: t('price_list.help.expiry_date'), + }, + { + name: 'is_active', + label: t('price_list.fields.is_active'), + type: 'select', + required: false, + defaultValue: 'true', + options: [ + { label: t('common:yes'), value: 'true' }, + { label: t('common:no'), value: 'false' } + ], + helpText: t('price_list.help.is_active'), + }, + ], + }, + { + title: t('price_list.sections.product_details'), + icon: Info, + columns: 2, + fields: [ + { + name: 'brand', + label: t('price_list.fields.brand'), + type: 'text', + required: false, + placeholder: t('price_list.placeholders.brand'), + }, + { + name: 'packaging_size', + label: t('price_list.fields.packaging_size'), + type: 'text', + required: false, + placeholder: t('price_list.placeholders.packaging_size'), + helpText: t('price_list.help.packaging_size'), + }, + { + name: 'origin_country', + label: t('price_list.fields.origin_country'), + type: 'text', + required: false, + placeholder: t('price_list.placeholders.origin_country'), + }, + { + name: 'shelf_life_days', + label: t('price_list.fields.shelf_life_days'), + type: 'number', + required: false, + placeholder: '0', + helpText: t('price_list.help.shelf_life_days'), + }, + { + name: 'storage_requirements', + label: t('price_list.fields.storage_requirements'), + type: 'textarea', + required: false, + placeholder: t('price_list.placeholders.storage_requirements'), + span: 2, + }, + ], + }, + ]; + + return ( + + ); +}; diff --git a/frontend/src/components/domain/suppliers/ProductSelector.tsx b/frontend/src/components/domain/suppliers/ProductSelector.tsx new file mode 100644 index 00000000..6c2fcded --- /dev/null +++ b/frontend/src/components/domain/suppliers/ProductSelector.tsx @@ -0,0 +1,85 @@ +import React, { useMemo } from 'react'; +import { Select, SelectOption } from '../../ui/Select'; +import { useIngredients } from '../../../api/hooks/inventory'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { IngredientResponse } from '../../../api/types/inventory'; + +interface ProductSelectorProps { + value?: string; + onChange: (productId: string, product?: IngredientResponse) => void; + placeholder?: string; + error?: string; + disabled?: boolean; + excludeIds?: string[]; + label?: string; + isRequired?: boolean; +} + +export function ProductSelector({ + value, + onChange, + placeholder = 'Select product...', + error, + disabled = false, + excludeIds = [], + label = 'Product', + isRequired = false, +}: ProductSelectorProps) { + const currentTenant = useCurrentTenant(); + + // Fetch all active ingredients + const { data: ingredients, isLoading } = useIngredients( + currentTenant?.id || '', + { is_active: true }, + { enabled: !!currentTenant?.id } + ); + + // Convert ingredients to select options + const productOptions: SelectOption[] = useMemo(() => { + if (!ingredients) return []; + + return ingredients + .filter(ingredient => !excludeIds.includes(ingredient.id)) + .map(ingredient => ({ + value: ingredient.id, + label: ingredient.name, + description: ingredient.category + ? `${ingredient.category}${ingredient.subcategory ? ` - ${ingredient.subcategory}` : ''}` + : undefined, + group: ingredient.category || 'Other', + })) + .sort((a, b) => { + // Sort by group first, then by label + const groupCompare = (a.group || '').localeCompare(b.group || ''); + return groupCompare !== 0 ? groupCompare : a.label.localeCompare(b.label); + }); + }, [ingredients, excludeIds]); + + const handleChange = (selectedValue: string | number | Array) => { + if (typeof selectedValue === 'string') { + const selectedProduct = ingredients?.find(ing => ing.id === selectedValue); + onChange(selectedValue, selectedProduct); + } + }; + + return ( + setEditData(prev => ({ ...prev, unit_price: Number(e.target.value) }))} + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {formatters.currency(price.unit_price)} +
+ )} +
+ +
+ +
+ {formatters.currency(price.price_per_unit)} +
+
+ +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, unit_of_measure: e.target.value }))} + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.unit_of_measure} +
+ )} +
+ +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, minimum_order_quantity: Number(e.target.value) || null }))} + placeholder="Opcional" + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.minimum_order_quantity || 'N/A'} +
+ )} +
+
+ + {/* Product Details Section */} + {(price.product_code || price.brand || price.packaging_size) && ( +
+
+ Detalles del Producto +
+
+ {price.product_code && ( +
+ +
+ {price.product_code} +
+
+ )} + +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, brand: e.target.value || null }))} + placeholder="Marca" + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.brand || 'N/A'} +
+ )} +
+ +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, packaging_size: e.target.value || null }))} + placeholder="Ej: 25kg" + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.packaging_size || 'N/A'} +
+ )} +
+ + {price.origin_country && ( +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, origin_country: e.target.value || null }))} + placeholder="País" + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.origin_country} +
+ )} +
+ )} +
+
+ )} + + {/* Validity Section */} +
+
+ Vigencia +
+
+
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, effective_date: e.target.value }))} + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.effective_date + ? new Date(price.effective_date).toLocaleDateString('es-ES') + : 'N/A' + } +
+ )} +
+ +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, expiry_date: e.target.value || null }))} + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.expiry_date + ? new Date(price.expiry_date).toLocaleDateString('es-ES') + : 'Sin vencimiento' + } +
+ )} +
+ +
+ + {isEditing ? ( + + ) : ( +
+ {price.is_active ? 'Activo' : 'Inactivo'} +
+ )} +
+
+
+ + {/* Storage Section */} + {(price.shelf_life_days || price.storage_requirements) && ( +
+
+ Almacenamiento +
+
+ {price.shelf_life_days && ( +
+ + {isEditing ? ( + setEditData(prev => ({ ...prev, shelf_life_days: Number(e.target.value) || null }))} + placeholder="Días" + className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ) : ( +
+ {price.shelf_life_days} días +
+ )} +
+ )} + + {(price.storage_requirements || isEditing) && ( +
+ + {isEditing ? ( +