Improve the frontend 2
This commit is contained in:
@@ -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() {
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<SSEProvider>
|
||||
<AppContent />
|
||||
<SubscriptionEventsProvider>
|
||||
<AppContent />
|
||||
</SubscriptionEventsProvider>
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
@@ -75,4 +78,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -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<DashboardStats, ApiError>({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
export default useSubscription;
|
||||
|
||||
@@ -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<UseQueryOptions<SupplierPriceListResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<SupplierPriceListResponse[], ApiError>({
|
||||
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<UseQueryOptions<SupplierPriceListResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<SupplierPriceListResponse, ApiError>({
|
||||
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<PerformanceMetric[], ApiError>({
|
||||
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<UseQueryOptions<any[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<any[], ApiError>({
|
||||
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;
|
||||
};
|
||||
export const usePendingOrdersCount = (queryParams?: PurchaseOrderSearchParams) => {
|
||||
const { data: orders } = usePurchaseOrders('', queryParams);
|
||||
return orders?.length || 0;
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
await apiClient.delete(
|
||||
@@ -173,6 +174,34 @@ class EquipmentService {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an equipment item (hard delete)
|
||||
*/
|
||||
async hardDeleteEquipment(tenantId: string, equipmentId: string): Promise<void> {
|
||||
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<EquipmentDeletionSummary> {
|
||||
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();
|
||||
|
||||
@@ -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<SupplierPriceListResponse[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('is_active', isActive.toString());
|
||||
|
||||
return apiClient.get<SupplierPriceListResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async getSupplierPriceList(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
priceListId: string
|
||||
): Promise<SupplierPriceListResponse> {
|
||||
return apiClient.get<SupplierPriceListResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists/${priceListId}`
|
||||
);
|
||||
}
|
||||
|
||||
async createSupplierPriceList(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
priceListData: SupplierPriceListCreate
|
||||
): Promise<SupplierPriceListResponse> {
|
||||
return apiClient.post<SupplierPriceListResponse>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/${supplierId}/price-lists`,
|
||||
priceListData
|
||||
);
|
||||
}
|
||||
|
||||
async updateSupplierPriceList(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
priceListId: string,
|
||||
priceListData: SupplierPriceListUpdate
|
||||
): Promise<SupplierPriceListResponse> {
|
||||
return apiClient.put<SupplierPriceListResponse>(
|
||||
`${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<PaginatedResponse<SupplierSummary>> {
|
||||
queryParams?: SupplierSearchParams
|
||||
): Promise<SupplierSummary[]> {
|
||||
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<PaginatedResponse<SupplierSummary>>(
|
||||
return apiClient.get<SupplierSummary[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers${queryString}`
|
||||
);
|
||||
}
|
||||
@@ -142,10 +201,10 @@ class SuppliersService {
|
||||
);
|
||||
}
|
||||
|
||||
async getPurchaseOrders(
|
||||
async getPurchaseOrders(
|
||||
tenantId: string,
|
||||
queryParams?: PurchaseOrderQueryParams
|
||||
): Promise<PaginatedResponse<PurchaseOrderResponse>> {
|
||||
queryParams?: PurchaseOrderSearchParams
|
||||
): Promise<PurchaseOrderResponse[]> {
|
||||
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<PaginatedResponse<PurchaseOrderResponse>>(
|
||||
return apiClient.get<PurchaseOrderResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/purchase-orders${queryString}`
|
||||
);
|
||||
}
|
||||
@@ -209,8 +266,8 @@ class SuppliersService {
|
||||
|
||||
async getDeliveries(
|
||||
tenantId: string,
|
||||
queryParams?: DeliveryQueryParams
|
||||
): Promise<PaginatedResponse<DeliveryResponse>> {
|
||||
queryParams?: DeliverySearchParams
|
||||
): Promise<DeliveryResponse[]> {
|
||||
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<PaginatedResponse<DeliveryResponse>>(
|
||||
return apiClient.get<DeliveryResponse[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/deliveries${queryString}`
|
||||
);
|
||||
}
|
||||
@@ -276,8 +331,8 @@ class SuppliersService {
|
||||
|
||||
async getActiveSuppliers(
|
||||
tenantId: string,
|
||||
queryParams?: Omit<SupplierQueryParams, 'status'>
|
||||
): Promise<PaginatedResponse<SupplierSummary>> {
|
||||
queryParams?: SupplierSearchParams
|
||||
): Promise<SupplierSummary[]> {
|
||||
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<PaginatedResponse<SupplierSummary>>(
|
||||
return apiClient.get<SupplierSummary[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/operations/active${queryString}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getTopSuppliers(tenantId: string): Promise<TopSuppliersResponse> {
|
||||
return apiClient.get<TopSuppliersResponse>(
|
||||
@@ -356,11 +411,11 @@ class SuppliersService {
|
||||
async getSupplierPerformanceMetrics(
|
||||
tenantId: string,
|
||||
supplierId: string
|
||||
): Promise<PerformanceMetrics> {
|
||||
return apiClient.get<PerformanceMetrics>(
|
||||
): Promise<PerformanceMetric[]> {
|
||||
return apiClient.get<PerformanceMetric[]>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/analytics/performance/${supplierId}/metrics`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async evaluatePerformanceAlerts(
|
||||
tenantId: string
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, any> | 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<string, any> | null;
|
||||
allergens?: Record<string, any> | 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<string, any> | 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<string, any> | null;
|
||||
allergens?: Record<string, any> | 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<string, any> | 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<string, any> | null;
|
||||
allergens: Record<string, any> | 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<string, any> | null;
|
||||
business_hours: Record<string, any> | null;
|
||||
specializations: Record<string, any> | 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<string, any> | null;
|
||||
quality_issues: Record<string, any> | null;
|
||||
|
||||
// Receipt
|
||||
received_by: string | null;
|
||||
@@ -594,7 +659,7 @@ export interface DeliveryResponse {
|
||||
photos: Record<string, any> | 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<AlertCreate, 'auto_resolve'> {
|
||||
resolved_at: string | null;
|
||||
resolved_by: string | null;
|
||||
actions_taken: Array<Record<string, any>> | 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<string, any>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void>;
|
||||
onHardDelete: (equipmentId: string) => Promise<void>;
|
||||
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<DeleteEquipmentModalProps> = ({
|
||||
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<string, string> = {
|
||||
oven: 'Horno',
|
||||
mixer: 'Batidora',
|
||||
proofer: 'Fermentadora',
|
||||
freezer: 'Congelador',
|
||||
packaging: 'Empaquetado',
|
||||
other: 'Otro',
|
||||
};
|
||||
return typeLabels[type] || type;
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseDeleteModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
entity={equipment}
|
||||
onSoftDelete={onSoftDelete}
|
||||
onHardDelete={onHardDelete}
|
||||
isLoading={isLoading}
|
||||
title="Eliminar Equipo"
|
||||
getEntityId={(eq) => 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;
|
||||
@@ -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<EquipmentModalProps> = ({
|
||||
uptime: 100,
|
||||
energyUsage: 0,
|
||||
utilizationToday: 0,
|
||||
temperature: 0,
|
||||
targetTemperature: 0,
|
||||
notes: '',
|
||||
alerts: [],
|
||||
maintenanceHistory: [],
|
||||
specifications: {
|
||||
@@ -95,7 +98,10 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
[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<EquipmentModalProps> = ({
|
||||
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')
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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<MaintenanceHistoryModalProps> = ({
|
||||
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 ? (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{sortedRecords.map((record) => {
|
||||
const typeInfo = getMaintenanceTypeInfo(record.type);
|
||||
const MaintenanceIcon = typeInfo.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-start gap-3 p-4 bg-[var(--surface-secondary)] rounded-lg hover:bg-[var(--surface-tertiary)] transition-colors border border-[var(--border-primary)]"
|
||||
>
|
||||
{/* Icon and type */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: typeInfo.bgColor }}
|
||||
>
|
||||
<MaintenanceIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: typeInfo.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{record.description}
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-1 text-xs font-medium rounded"
|
||||
style={{
|
||||
backgroundColor: typeInfo.bgColor,
|
||||
color: typeInfo.color
|
||||
}}
|
||||
>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-[var(--text-secondary)] mb-2">
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">{t('fields.date', 'Fecha')}:</span>
|
||||
<span className="ml-1">
|
||||
{new Date(record.date).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">{t('fields.technician', 'Técnico')}:</span>
|
||||
<span className="ml-1">{record.technician}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">{t('common:actions.cost', 'Coste')}:</span>
|
||||
<span className="ml-1 font-medium">€{record.cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">{t('fields.downtime', 'Parada')}:</span>
|
||||
<span className="ml-1 font-medium">{record.downtime}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{record.partsUsed && record.partsUsed.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-[var(--text-tertiary)]">
|
||||
{t('fields.parts', 'Repuestos')}:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{record.partsUsed.map((part, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 bg-[var(--bg-tertiary)] text-xs rounded border border-[var(--border-primary)]"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-[var(--text-secondary)]">
|
||||
<Wrench className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('maintenance.no_history', 'No hay historial de mantenimiento')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('maintenance.no_history_description', 'Los registros de mantenimiento aparecerán aquí cuando se realicen operaciones')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: t('maintenance.history', 'Historial de Mantenimiento'),
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: '',
|
||||
value: maintenanceList,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={equipment.name}
|
||||
subtitle={`${equipment.model || equipment.type} • ${maintenanceRecords.length} ${t('maintenance.records', 'registros')}`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceHistoryModal;
|
||||
@@ -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<void>;
|
||||
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<ScheduleMaintenanceModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
equipment,
|
||||
onSchedule,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const { t } = useTranslation(['equipment', 'common']);
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
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 (
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('actions.schedule_maintenance', 'Programar Mantenimiento')}
|
||||
subtitle={`${equipment.name} • ${equipment.model || equipment.type}`}
|
||||
statusIndicator={{
|
||||
color: statusColors.inProgress.primary,
|
||||
text: t('maintenance.scheduled', 'Programado'),
|
||||
icon: Calendar,
|
||||
isHighlight: true
|
||||
}}
|
||||
sections={sections}
|
||||
onSave={handleSave}
|
||||
size="lg"
|
||||
loading={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleMaintenanceModal;
|
||||
384
frontend/src/components/domain/forecasting/RetrainModelModal.tsx
Normal file
384
frontend/src/components/domain/forecasting/RetrainModelModal.tsx
Normal file
@@ -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<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
type RetrainMode = 'quick' | 'preset' | 'advanced';
|
||||
|
||||
interface TrainingPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: typeof Sparkles;
|
||||
settings: Partial<SingleProductTrainingRequest>;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export const RetrainModelModal: React.FC<RetrainModelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
currentModel,
|
||||
onRetrain,
|
||||
isLoading = false
|
||||
}) => {
|
||||
const { t } = useTranslation(['models', 'common']);
|
||||
const [mode, setMode] = useState<RetrainMode>('quick');
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('standard');
|
||||
const [advancedSettings, setAdvancedSettings] = useState<SingleProductTrainingRequest>({
|
||||
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 (
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
title={t('models:retrain.title', 'Reentrenar Modelo')}
|
||||
subtitle={ingredient.name}
|
||||
statusIndicator={{
|
||||
color: '#F59E0B',
|
||||
text: t('models:status.retraining', 'Reentrenamiento'),
|
||||
icon: RotateCcw,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
}}
|
||||
size="lg"
|
||||
sections={sections}
|
||||
actions={actions}
|
||||
actionsPosition="header"
|
||||
showDefaultActions={true}
|
||||
onSave={handleRetrain}
|
||||
onFieldChange={handleFieldChange}
|
||||
loading={isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RetrainModelModal;
|
||||
@@ -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';
|
||||
|
||||
@@ -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<DeleteIngredientModalProps> = ({
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionResult, setDeletionResult] = useState<DeletionSummary | null>(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 (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Eliminación Completada
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
El artículo {deletionResult.ingredient_name} ha sido eliminado permanentemente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--background-secondary)] rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3">Resumen de eliminación:</h4>
|
||||
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>Lotes de stock eliminados:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_stock_entries}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Movimientos eliminados:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_stock_movements}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Alertas eliminadas:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_stock_alerts}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
Entendido
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete ? 'Confirmación de Eliminación Permanente' : 'Confirmación de Desactivación'}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{ingredient.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Categoría: {ingredient.category} • Stock actual: {ingredient.current_stock || 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">⚠️ Esta acción eliminará permanentemente:</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>• El artículo y toda su información</li>
|
||||
<li>• Todos los lotes de stock asociados</li>
|
||||
<li>• Todo el historial de movimientos</li>
|
||||
<li>• Las alertas relacionadas</li>
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
Esta acción NO se puede deshacer
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">ℹ️ Esta acción desactivará el artículo:</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>• El artículo se marcará como inactivo</li>
|
||||
<li>• No aparecerá en listas activas</li>
|
||||
<li>• Se conserva todo el historial y stock</li>
|
||||
<li>• Se puede reactivar posteriormente</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Para confirmar, escriba <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete ? 'Eliminar Permanentemente' : 'Desactivar Artículo'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Eliminar Artículo
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{ingredient.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Categoría: {ingredient.category} • Stock actual: {ingredient.current_stock || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
Elija el tipo de eliminación que desea realizar:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
Desactivar (Recomendado)
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
El artículo se marca como inactivo pero conserva todo su historial.
|
||||
Ideal para artículos temporalmente fuera del catálogo.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
✓ Reversible • ✓ Conserva historial • ✓ Conserva stock
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
Eliminar Permanentemente
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Elimina completamente el artículo y todos sus datos asociados.
|
||||
Use solo para datos erróneos o pruebas.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina stock
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Continuar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<BaseDeleteModal<IngredientResponse, DeletionSummary>
|
||||
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;
|
||||
export default DeleteIngredientModal;
|
||||
|
||||
@@ -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<CreateQualityTemplateModalProp
|
||||
isLoading: externalLoading = false,
|
||||
initialRecipe
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation(['production', 'common']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>([]);
|
||||
const [selectedCheckType, setSelectedCheckType] = useState<QualityCheckType | null>(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<CreateQualityTemplateModalProp
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Type-specific validation
|
||||
if (formData.check_type === QualityCheckType.VISUAL) {
|
||||
const hasAnyScoring = formData.scoring_excellent_min || formData.scoring_excellent_max ||
|
||||
formData.scoring_good_min || formData.scoring_good_max ||
|
||||
formData.scoring_acceptable_min || formData.scoring_acceptable_max;
|
||||
if (!hasAnyScoring) {
|
||||
throw new Error('Los criterios de puntuación son requeridos para controles visuales');
|
||||
}
|
||||
}
|
||||
|
||||
if ([QualityCheckType.MEASUREMENT, QualityCheckType.TEMPERATURE, QualityCheckType.WEIGHT].includes(formData.check_type)) {
|
||||
if (!formData.unit?.trim()) {
|
||||
throw new Error('La unidad es requerida para controles de medición, temperatura y peso');
|
||||
}
|
||||
}
|
||||
|
||||
// Process applicable stages - convert string back to array
|
||||
const applicableStages = formData.applicable_stages
|
||||
? (typeof formData.applicable_stages === 'string'
|
||||
@@ -116,6 +137,29 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
: formData.applicable_stages)
|
||||
: [];
|
||||
|
||||
// Build scoring criteria for visual checks
|
||||
let scoringCriteria: Record<string, any> | 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<CreateQualityTemplateModalProp
|
||||
is_critical: formData.is_critical || false,
|
||||
weight: Number(formData.weight) || 1.0,
|
||||
applicable_stages: applicableStages.length > 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<CreateQualityTemplateModalProp
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
// Determine if measurement fields should be shown based on check type
|
||||
const showMeasurementFields = (checkType: string) => [
|
||||
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<CreateQualityTemplateModalProp
|
||||
name: 'check_type',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
defaultValue: QualityCheckType.VISUAL,
|
||||
options: QUALITY_CHECK_TYPE_OPTIONS
|
||||
placeholder: 'Selecciona un tipo de control...',
|
||||
options: QUALITY_CHECK_TYPE_OPTIONS,
|
||||
helpText: 'Los campos de configuración cambiarán según el tipo seleccionado'
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
@@ -230,8 +278,9 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
helpText: 'Pasos específicos que debe seguir el operario'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
};
|
||||
|
||||
const measurementSection = {
|
||||
title: 'Configuración de Medición',
|
||||
icon: Target,
|
||||
fields: [
|
||||
@@ -257,11 +306,12 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
helpText: 'Valor ideal que se busca alcanzar'
|
||||
},
|
||||
{
|
||||
label: 'Unidad',
|
||||
label: 'Unidad *',
|
||||
name: 'unit',
|
||||
type: 'text' as const,
|
||||
required: true,
|
||||
placeholder: '°C / g / cm',
|
||||
helpText: 'Unidad de medida (ej: °C para temperatura)'
|
||||
helpText: 'REQUERIDO para este tipo de control (ej: °C, g, cm)'
|
||||
},
|
||||
{
|
||||
label: 'Tolerancia (%)',
|
||||
@@ -271,22 +321,93 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
helpText: 'Porcentaje de tolerancia permitido'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
};
|
||||
|
||||
const scoringSection = {
|
||||
title: 'Criterios de Puntuación (Controles Visuales)',
|
||||
icon: Target,
|
||||
fields: [
|
||||
{
|
||||
label: 'Excelente - Mínimo',
|
||||
name: 'scoring_excellent_min',
|
||||
type: 'number' as const,
|
||||
placeholder: '9.0',
|
||||
helpText: 'Puntuación mínima para nivel excelente'
|
||||
},
|
||||
{
|
||||
label: 'Excelente - Máximo',
|
||||
name: 'scoring_excellent_max',
|
||||
type: 'number' as const,
|
||||
placeholder: '10.0',
|
||||
helpText: 'Puntuación máxima para nivel excelente'
|
||||
},
|
||||
{
|
||||
label: 'Bueno - Mínimo',
|
||||
name: 'scoring_good_min',
|
||||
type: 'number' as const,
|
||||
placeholder: '7.0',
|
||||
helpText: 'Puntuación mínima para nivel bueno'
|
||||
},
|
||||
{
|
||||
label: 'Bueno - Máximo',
|
||||
name: 'scoring_good_max',
|
||||
type: 'number' as const,
|
||||
placeholder: '8.9',
|
||||
helpText: 'Puntuación máxima para nivel bueno'
|
||||
},
|
||||
{
|
||||
label: 'Aceptable - Mínimo',
|
||||
name: 'scoring_acceptable_min',
|
||||
type: 'number' as const,
|
||||
placeholder: '5.0',
|
||||
helpText: 'Puntuación mínima para nivel aceptable'
|
||||
},
|
||||
{
|
||||
label: 'Aceptable - Máximo',
|
||||
name: 'scoring_acceptable_max',
|
||||
type: 'number' as const,
|
||||
placeholder: '6.9',
|
||||
helpText: 'Puntuación máxima para nivel aceptable'
|
||||
},
|
||||
{
|
||||
label: 'Fallo - Por Debajo',
|
||||
name: 'scoring_fail_below',
|
||||
type: 'number' as const,
|
||||
placeholder: '5.0',
|
||||
helpText: 'Valor por debajo del cual se considera fallo'
|
||||
},
|
||||
{
|
||||
label: 'Fallo - Por Encima',
|
||||
name: 'scoring_fail_above',
|
||||
type: 'number' as const,
|
||||
placeholder: '10.0',
|
||||
helpText: 'Valor por encima del cual se considera fallo (opcional)'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const stagesSection = {
|
||||
title: 'Etapas del Proceso',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: 'Etapas Aplicables',
|
||||
name: 'applicable_stages',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Se seleccionarán las etapas donde aplicar',
|
||||
helpText: 'Las etapas se configuran mediante la selección múltiple',
|
||||
type: 'component' as const,
|
||||
component: Select,
|
||||
componentProps: {
|
||||
options: PROCESS_STAGE_OPTIONS,
|
||||
multiple: true,
|
||||
placeholder: 'Seleccionar etapas del proceso',
|
||||
searchable: true
|
||||
},
|
||||
helpText: 'Selecciona las etapas donde se aplicará este control de calidad',
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
};
|
||||
|
||||
const recipesSection = {
|
||||
title: 'Asociación con Recetas',
|
||||
icon: Plus,
|
||||
fields: [
|
||||
@@ -300,8 +421,9 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
};
|
||||
|
||||
const advancedSection = {
|
||||
title: 'Configuración Avanzada',
|
||||
icon: Cog,
|
||||
fields: [
|
||||
@@ -350,8 +472,28 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
helpText: 'Si es crítico, bloquea la producción si falla'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Build sections array based on selected check type
|
||||
const sections = [basicInfoSection];
|
||||
|
||||
// Add type-specific configuration sections
|
||||
if (selectedCheckType === QualityCheckType.VISUAL) {
|
||||
sections.push(scoringSection);
|
||||
} else if ([QualityCheckType.MEASUREMENT, QualityCheckType.TEMPERATURE, QualityCheckType.WEIGHT].includes(selectedCheckType as QualityCheckType)) {
|
||||
sections.push(measurementSection);
|
||||
}
|
||||
];
|
||||
// For BOOLEAN, TIMING, CHECKLIST - no special configuration sections yet
|
||||
|
||||
// Always add these sections
|
||||
sections.push(stagesSection);
|
||||
sections.push(recipesSection);
|
||||
sections.push(advancedSection);
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
const sections = getSections();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -365,15 +507,8 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
size="xl"
|
||||
loading={loading || externalLoading}
|
||||
onSave={handleSave}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{/* TODO: Stage selection would need a custom component or enhanced AddModal field types */}
|
||||
{isOpen && (
|
||||
<div style={{ display: 'none' }}>
|
||||
<p>Nota: La selección de etapas del proceso requiere un componente personalizado no implementado en esta versión simplificada.</p>
|
||||
<p>Las etapas actualmente se manejan mediante un campo de texto que debería ser reemplazado por un selector múltiple.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<void>;
|
||||
onHardDelete: (templateId: string) => Promise<void>;
|
||||
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<DeleteQualityTemplateModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
template,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['production', 'common']);
|
||||
|
||||
if (!template) return null;
|
||||
|
||||
return (
|
||||
<BaseDeleteModal<QualityCheckTemplate>
|
||||
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;
|
||||
@@ -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<QualityTemplateManagerProps> = ({
|
||||
const [selectedCheckType, setSelectedCheckType] = useState<QualityCheckType | ''>('');
|
||||
const [selectedStage, setSelectedStage] = useState<ProcessStage | ''>('');
|
||||
const [showActiveOnly, setShowActiveOnly] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState<boolean>(false);
|
||||
const [showEditModal, setShowEditModal] = useState<boolean>(false);
|
||||
const [showViewModal, setShowViewModal] = useState<boolean>(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<QualityCheckTemplate | null>(null);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
@@ -146,7 +146,6 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
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<QualityTemplateManagerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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<QualityTemplateManagerProps> = ({
|
||||
text: typeConfig.label,
|
||||
icon: typeConfig.icon
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -406,27 +405,15 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
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<QualityTemplateManagerProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Template Modal */}
|
||||
{templateToDelete && (
|
||||
<DeleteQualityTemplateModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTemplateToDelete(null);
|
||||
}}
|
||||
template={templateToDelete}
|
||||
onSoftDelete={handleSoftDelete}
|
||||
onHardDelete={handleHardDelete}
|
||||
isLoading={deleteTemplateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<DeleteRecipeModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation(['recipes', 'common']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('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 (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{selectedMode === 'hard'
|
||||
? t('recipes:delete.success_hard_title', 'Receta Eliminada')
|
||||
: t('recipes:delete.success_soft_title', 'Receta Archivada')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{selectedMode === 'hard'
|
||||
? t('recipes:delete.recipe_deleted', { name: recipe.name })
|
||||
: t('recipes:delete.recipe_archived', { name: recipe.name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete
|
||||
? t('recipes:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente')
|
||||
: t('recipes:delete.confirm_soft_title', 'Confirmación de Archivo')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<>
|
||||
{summaryLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--text-primary)]"></div>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.checking_dependencies', 'Verificando dependencias...')}
|
||||
</p>
|
||||
</div>
|
||||
) : deletionSummary && !canDelete ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||
<p className="font-medium text-red-700 dark:text-red-400 mb-2">
|
||||
{t('recipes:delete.cannot_delete', '⚠️ No se puede eliminar esta receta')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 text-red-600 dark:text-red-300">
|
||||
{deletionSummary.warnings.map((warning, idx) => (
|
||||
<li key={idx}>• {warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('recipes:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('recipes:delete.hard_warning_1', '• La receta y toda su información')}</li>
|
||||
<li>{t('recipes:delete.hard_warning_2', '• Todos los ingredientes asociados')}</li>
|
||||
{deletionSummary && (
|
||||
<>
|
||||
{deletionSummary.production_batches_count > 0 && (
|
||||
<li>{t('recipes:delete.batches_affected', { count: deletionSummary.production_batches_count }, `• ${deletionSummary.production_batches_count} lotes de producción`)}</li>
|
||||
)}
|
||||
{deletionSummary.affected_orders_count > 0 && (
|
||||
<li>{t('recipes:delete.orders_affected', { count: deletionSummary.affected_orders_count }, `• ${deletionSummary.affected_orders_count} pedidos afectados`)}</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{t('recipes:delete.irreversible', 'Esta acción NO se puede deshacer')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('recipes:delete.soft_info_title', 'ℹ️ Esta acción archivará la receta:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('recipes:delete.soft_info_1', '• La receta cambiará a estado ARCHIVADO')}</li>
|
||||
<li>{t('recipes:delete.soft_info_2', '• No aparecerá en listas activas')}</li>
|
||||
<li>{t('recipes:delete.soft_info_3', '• Se conserva todo el historial y datos')}</li>
|
||||
<li>{t('recipes:delete.soft_info_4', '• Se puede reactivar posteriormente')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && canDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('recipes:delete.type_to_confirm_label', 'Para confirmar, escriba')} <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common:back', 'Volver')}
|
||||
</Button>
|
||||
{(!isHardDelete || canDelete) && (
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading || summaryLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete
|
||||
? t('recipes:delete.confirm_hard', 'Eliminar Permanentemente')
|
||||
: t('recipes:delete.confirm_soft', 'Archivar Receta')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{t('recipes:delete.title', 'Eliminar Receta')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
{t('recipes:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('recipes:delete.soft_delete', 'Archivar (Recomendado)')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.soft_explanation', 'La receta se marca como archivada pero conserva todo su historial. Ideal para recetas fuera de uso temporal.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{t('recipes:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
{t('recipes:delete.hard_delete', 'Eliminar Permanentemente')}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.hard_explanation', 'Elimina completamente la receta y todos sus datos asociados. Use solo para datos erróneos o pruebas.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{t('recipes:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('common:continue', 'Continuar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<BaseDeleteModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
entity={recipe}
|
||||
onSoftDelete={onSoftDelete}
|
||||
onHardDelete={onHardDelete}
|
||||
isLoading={isLoading}
|
||||
title={t('recipes:delete.title', 'Eliminar Receta')}
|
||||
getEntityId={(rec) => 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<DeleteSupplierModalProps> = ({
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionResult, setDeletionResult] = useState<SupplierDeletionSummary | null>(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 (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('suppliers:delete.summary_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_deleted', { name: deletionResult.supplier_name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--background-secondary)] rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('suppliers:delete.deletion_summary')}:
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_price_lists')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_price_lists}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_quality_reviews')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_quality_reviews}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_performance_metrics')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_performance_metrics}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_alerts')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_alerts}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_scorecards')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_scorecards}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
{t('common:close', 'Entendido')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete
|
||||
? t('suppliers:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente')
|
||||
: t('suppliers:delete.confirm_soft_title', 'Confirmación de Desactivación')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{supplier.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('suppliers:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('suppliers:delete.hard_warning_1', '• El proveedor y toda su información')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_2', '• Todas las listas de precios asociadas')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_3', '• Todo el historial de calidad y rendimiento')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_4', '• Las alertas y scorecards relacionados')}</li>
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{t('suppliers:delete.irreversible', 'Esta acción NO se puede deshacer')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('suppliers:delete.soft_info_title', 'ℹ️ Esta acción desactivará el proveedor:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('suppliers:delete.soft_info_1', '• El proveedor se marcará como inactivo')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_2', '• No aparecerá en listas activas')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_3', '• Se conserva todo el historial y datos')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_4', '• Se puede reactivar posteriormente')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('suppliers:delete.type_to_confirm_label', 'Para confirmar, escriba')} <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common:back', 'Volver')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete
|
||||
? t('suppliers:delete.confirm_hard', 'Eliminar Permanentemente')
|
||||
: t('suppliers:delete.confirm_soft', 'Desactivar Proveedor')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{t('suppliers:delete.title', 'Eliminar Proveedor')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{supplier.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
{t('suppliers:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('suppliers:delete.soft_delete', 'Desactivar (Recomendado)')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.soft_explanation', 'El proveedor se marca como inactivo pero conserva todo su historial. Ideal para proveedores temporalmente fuera del catálogo.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{t('suppliers:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
{t('suppliers:delete.hard_delete', 'Eliminar Permanentemente')}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.hard_explanation', 'Elimina completamente el proveedor y todos sus datos asociados. Use solo para datos erróneos o pruebas.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{t('suppliers:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('common:continue', 'Continuar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<BaseDeleteModal<SupplierResponse, SupplierDeletionSummary>
|
||||
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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
328
frontend/src/components/domain/suppliers/PriceListModal.tsx
Normal file
328
frontend/src/components/domain/suppliers/PriceListModal.tsx
Normal file
@@ -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<void>;
|
||||
mode: 'create' | 'edit';
|
||||
initialData?: SupplierPriceListResponse;
|
||||
loading?: boolean;
|
||||
excludeProductIds?: string[];
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch?: boolean;
|
||||
isRefetching?: boolean;
|
||||
onSaveComplete?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const PriceListModal: React.FC<PriceListModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
mode,
|
||||
initialData,
|
||||
loading = false,
|
||||
excludeProductIds = [],
|
||||
waitForRefetch,
|
||||
isRefetching,
|
||||
onSaveComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const [selectedProduct, setSelectedProduct] = useState<IngredientResponse | undefined>();
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 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<string, any>) => {
|
||||
// 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 (
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === 'create'
|
||||
? t('price_list.modal.title_create')
|
||||
: t('price_list.modal.title_edit')
|
||||
}
|
||||
subtitle={mode === 'create'
|
||||
? t('price_list.modal.subtitle_create')
|
||||
: t('price_list.modal.subtitle_edit')
|
||||
}
|
||||
sections={sections}
|
||||
onSave={handleSave}
|
||||
onCancel={onClose}
|
||||
size="xl"
|
||||
loading={loading || isRefetching}
|
||||
initialData={formData}
|
||||
onFieldChange={handleFieldChange}
|
||||
waitForRefetch={waitForRefetch}
|
||||
showSuccessState={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
85
frontend/src/components/domain/suppliers/ProductSelector.tsx
Normal file
85
frontend/src/components/domain/suppliers/ProductSelector.tsx
Normal file
@@ -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<string | number>) => {
|
||||
if (typeof selectedValue === 'string') {
|
||||
const selectedProduct = ingredients?.find(ing => ing.id === selectedValue);
|
||||
onChange(selectedValue, selectedProduct);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={productOptions}
|
||||
placeholder={placeholder}
|
||||
error={error}
|
||||
disabled={disabled || isLoading}
|
||||
loading={isLoading}
|
||||
searchable
|
||||
clearable
|
||||
isRequired={isRequired}
|
||||
isInvalid={!!error}
|
||||
loadingMessage="Loading products..."
|
||||
noOptionsMessage="No products available"
|
||||
size="md"
|
||||
variant="outline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, DollarSign, Calendar, Info, Plus, Edit, Trash2, CheckCircle, X, Save, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { SupplierResponse, SupplierPriceListResponse, SupplierPriceListUpdate } from '../../../api/types/suppliers';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
|
||||
interface SupplierPriceListViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
supplier: SupplierResponse;
|
||||
priceLists: SupplierPriceListResponse[];
|
||||
loading?: boolean;
|
||||
tenantId: string;
|
||||
onAddPrice?: () => void;
|
||||
onEditPrice?: (priceId: string, updateData: SupplierPriceListUpdate) => Promise<void>;
|
||||
onDeletePrice?: (priceId: string) => Promise<void>;
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch?: boolean;
|
||||
isRefetching?: boolean;
|
||||
onSaveComplete?: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* SupplierPriceListViewModal - Card-based price list management modal
|
||||
* Follows the same UI/UX pattern as BatchModal for inventory stock management
|
||||
*/
|
||||
export const SupplierPriceListViewModal: React.FC<SupplierPriceListViewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
supplier,
|
||||
priceLists = [],
|
||||
loading = false,
|
||||
tenantId,
|
||||
onAddPrice,
|
||||
onEditPrice,
|
||||
onDeletePrice,
|
||||
waitForRefetch,
|
||||
isRefetching,
|
||||
onSaveComplete
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const [editingPrice, setEditingPrice] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState<SupplierPriceListUpdate>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
|
||||
|
||||
// Collapsible state - start with all price entries collapsed for better UX
|
||||
const [collapsedPrices, setCollapsedPrices] = useState<Set<string>>(new Set());
|
||||
|
||||
// Initialize all prices as collapsed when prices change or modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && priceLists.length > 0) {
|
||||
setCollapsedPrices(new Set(priceLists.map(p => p.id)));
|
||||
}
|
||||
}, [isOpen, priceLists]);
|
||||
|
||||
// Fetch ingredients for product name display
|
||||
const { data: ingredientsData } = useIngredients(
|
||||
tenantId,
|
||||
{},
|
||||
{ enabled: !!tenantId && isOpen }
|
||||
);
|
||||
const ingredients = ingredientsData || [];
|
||||
|
||||
// Helper to get product name by ID
|
||||
const getProductName = (productId: string): string => {
|
||||
const product = ingredients.find(ing => ing.id === productId);
|
||||
return product?.name || 'Producto desconocido';
|
||||
};
|
||||
|
||||
// Toggle price entry collapse state
|
||||
const togglePriceCollapse = (priceId: string) => {
|
||||
setCollapsedPrices(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(priceId)) {
|
||||
next.delete(priceId);
|
||||
} else {
|
||||
next.add(priceId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Get price status based on validity dates and active state
|
||||
const getPriceStatus = (price: SupplierPriceListResponse) => {
|
||||
if (!price.is_active) {
|
||||
return {
|
||||
label: 'Inactivo',
|
||||
color: statusColors.cancelled.primary,
|
||||
icon: X,
|
||||
isCritical: false
|
||||
};
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const effectiveDate = price.effective_date ? new Date(price.effective_date) : null;
|
||||
const expiryDate = price.expiry_date ? new Date(price.expiry_date) : null;
|
||||
|
||||
// Check if not yet effective
|
||||
if (effectiveDate && effectiveDate > today) {
|
||||
return {
|
||||
label: 'Programado',
|
||||
color: statusColors.inProgress.primary,
|
||||
icon: Calendar,
|
||||
isCritical: false
|
||||
};
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (expiryDate && expiryDate < today) {
|
||||
return {
|
||||
label: 'Vencido',
|
||||
color: statusColors.expired.primary,
|
||||
icon: AlertTriangle,
|
||||
isCritical: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if expiring soon (within 30 days)
|
||||
if (expiryDate) {
|
||||
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
if (daysUntilExpiry <= 30) {
|
||||
return {
|
||||
label: 'Próximo a Vencer',
|
||||
color: statusColors.pending.primary,
|
||||
icon: AlertTriangle,
|
||||
isCritical: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Activo',
|
||||
color: statusColors.completed.primary,
|
||||
icon: CheckCircle,
|
||||
isCritical: false
|
||||
};
|
||||
};
|
||||
|
||||
const handleEditStart = (price: SupplierPriceListResponse) => {
|
||||
setEditingPrice(price.id);
|
||||
// Auto-expand when editing
|
||||
setCollapsedPrices(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(price.id);
|
||||
return next;
|
||||
});
|
||||
setEditData({
|
||||
unit_price: price.unit_price,
|
||||
unit_of_measure: price.unit_of_measure,
|
||||
minimum_order_quantity: price.minimum_order_quantity,
|
||||
effective_date: price.effective_date?.split('T')[0],
|
||||
expiry_date: price.expiry_date?.split('T')[0] || undefined,
|
||||
is_active: price.is_active,
|
||||
brand: price.brand || undefined,
|
||||
packaging_size: price.packaging_size || undefined,
|
||||
origin_country: price.origin_country || undefined,
|
||||
shelf_life_days: price.shelf_life_days || undefined,
|
||||
storage_requirements: price.storage_requirements || undefined
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingPrice(null);
|
||||
setEditData({});
|
||||
};
|
||||
|
||||
const handleEditSave = async (priceId: string) => {
|
||||
if (!onEditPrice) return;
|
||||
|
||||
// CRITICAL: Capture editData IMMEDIATELY before any async operations
|
||||
const dataToSave = { ...editData };
|
||||
|
||||
// Validate we have data to save
|
||||
if (Object.keys(dataToSave).length === 0) {
|
||||
console.error('SupplierPriceListViewModal: No edit data to save for price', priceId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('SupplierPriceListViewModal: Saving price data:', dataToSave);
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Execute the update mutation
|
||||
await onEditPrice(priceId, dataToSave);
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become false (with timeout)
|
||||
const startTime = Date.now();
|
||||
const refetchTimeout = 3000;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached for price update');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
|
||||
// Clear editing state after save (and optional refetch) completes
|
||||
setEditingPrice(null);
|
||||
setEditData({});
|
||||
} catch (error) {
|
||||
console.error('Error updating price:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (priceId: string) => {
|
||||
if (!onDeletePrice) return;
|
||||
|
||||
const confirmed = window.confirm('¿Está seguro que desea eliminar este precio? Esta acción no se puede deshacer.');
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onDeletePrice(priceId);
|
||||
} catch (error) {
|
||||
console.error('Error deleting price:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: `${priceLists.length} precios`,
|
||||
icon: DollarSign
|
||||
};
|
||||
|
||||
// Create card-based price list
|
||||
const priceCards = priceLists.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{priceLists.map((price) => {
|
||||
const status = getPriceStatus(price);
|
||||
const StatusIcon = status.icon;
|
||||
const isEditing = editingPrice === price.id;
|
||||
const productName = getProductName(price.inventory_product_id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={price.id}
|
||||
className="bg-[var(--surface-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden"
|
||||
style={{
|
||||
borderColor: status.isCritical ? `${status.color}40` : undefined,
|
||||
backgroundColor: status.isCritical ? `${status.color}05` : undefined
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[var(--border-secondary)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Left side: clickable area for collapse/expand */}
|
||||
<button
|
||||
onClick={() => !isEditing && togglePriceCollapse(price.id)}
|
||||
disabled={isEditing}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity disabled:cursor-default disabled:hover:opacity-100"
|
||||
aria-expanded={!collapsedPrices.has(price.id)}
|
||||
aria-label={`${collapsedPrices.has(price.id) ? 'Expandir' : 'Colapsar'} precio de ${productName}`}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${status.color}15` }}
|
||||
>
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: status.color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||
{productName}
|
||||
</h3>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{status.label}
|
||||
</div>
|
||||
{/* Inline summary when collapsed */}
|
||||
{collapsedPrices.has(price.id) && (
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{formatters.currency(price.unit_price)} / {price.unit_of_measure}
|
||||
{price.expiry_date && (
|
||||
<> • Vence: {new Date(price.expiry_date).toLocaleDateString('es-ES')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Right side: action buttons */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Collapse/Expand chevron */}
|
||||
{!isEditing && (
|
||||
<button
|
||||
onClick={() => togglePriceCollapse(price.id)}
|
||||
className="p-2 rounded-md hover:bg-[var(--surface-tertiary)] transition-colors"
|
||||
aria-label={collapsedPrices.has(price.id) ? 'Expandir' : 'Colapsar'}
|
||||
>
|
||||
{collapsedPrices.has(price.id) ? (
|
||||
<ChevronDown className="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditStart(price)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(price.id)}
|
||||
disabled={isSubmitting}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleEditSave(price.id)}
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - Only show when expanded */}
|
||||
{!collapsedPrices.has(price.id) && (
|
||||
<div className="p-4 space-y-4 transition-all duration-200 ease-in-out">
|
||||
{/* Pricing Information Section */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Precio Unitario
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editData.unit_price || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{formatters.currency(price.unit_price)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Precio por Unidad
|
||||
</label>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{formatters.currency(price.price_per_unit)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Unidad de Medida
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.unit_of_measure || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.unit_of_measure}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Cantidad Mínima de Pedido
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.minimum_order_quantity || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.minimum_order_quantity || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Details Section */}
|
||||
{(price.product_code || price.brand || price.packaging_size) && (
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
|
||||
Detalles del Producto
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{price.product_code && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Código de Producto
|
||||
</label>
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.product_code}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Marca
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.brand || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.brand || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Tamaño del Empaque
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.packaging_size || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.packaging_size || 'N/A'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{price.origin_country && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
País de Origen
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.origin_country || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.origin_country}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validity Section */}
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
|
||||
Vigencia
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Fecha de Vigencia
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="date"
|
||||
value={editData.effective_date ? new Date(editData.effective_date).toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.effective_date
|
||||
? new Date(price.effective_date).toLocaleDateString('es-ES')
|
||||
: 'N/A'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Fecha de Vencimiento
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="date"
|
||||
value={editData.expiry_date ? new Date(editData.expiry_date).toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.expiry_date
|
||||
? new Date(price.expiry_date).toLocaleDateString('es-ES')
|
||||
: 'Sin vencimiento'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Estado
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editData.is_active ? 'active' : 'inactive'}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, is_active: e.target.value === 'active' }))}
|
||||
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"
|
||||
>
|
||||
<option value="active">Activo</option>
|
||||
<option value="inactive">Inactivo</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.is_active ? 'Activo' : 'Inactivo'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Section */}
|
||||
{(price.shelf_life_days || price.storage_requirements) && (
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
|
||||
Almacenamiento
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{price.shelf_life_days && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Vida Útil (días)
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.shelf_life_days || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{price.shelf_life_days} días
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(price.storage_requirements || isEditing) && (
|
||||
<div className={price.shelf_life_days ? '' : 'col-span-2'}>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Requisitos de Almacenamiento
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
value={editData.storage_requirements || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_requirements: e.target.value || null }))}
|
||||
placeholder="Requisitos especiales..."
|
||||
rows={2}
|
||||
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.storage_requirements ? (
|
||||
<div className="p-2 bg-[var(--surface-tertiary)] rounded-md">
|
||||
<div className="text-xs text-[var(--text-secondary)] italic">
|
||||
"{price.storage_requirements}"
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
N/A
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-[var(--text-secondary)]">
|
||||
<DollarSign className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No hay precios registrados
|
||||
</h3>
|
||||
<p className="text-sm mb-6">
|
||||
Agregue precios para los productos que suministra este proveedor
|
||||
</p>
|
||||
{onAddPrice && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAddPrice}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar Primer Precio
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Lista de Precios',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: '',
|
||||
value: priceCards,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const actions = [];
|
||||
|
||||
// Only show "Agregar Precio" button when there are existing prices
|
||||
if (onAddPrice && priceLists.length > 0) {
|
||||
actions.push({
|
||||
label: 'Agregar Precio',
|
||||
icon: Plus,
|
||||
variant: 'primary' as const,
|
||||
onClick: onAddPrice
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={`Lista de Precios - ${supplier.name}`}
|
||||
subtitle={`${priceLists.length} precios registrados`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading || isSubmitting || isWaitingForRefetch}
|
||||
showDefaultActions={false}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierPriceListViewModal;
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Supplier Domain Components
|
||||
* Export all supplier-related components
|
||||
*/
|
||||
|
||||
export { CreateSupplierForm } from './CreateSupplierForm';
|
||||
export { DeleteSupplierModal } from './DeleteSupplierModal';
|
||||
export { PriceListModal } from './PriceListModal';
|
||||
export { ProductSelector } from './ProductSelector';
|
||||
export { SupplierPriceListViewModal } from './SupplierPriceListViewModal';
|
||||
|
||||
// Export types
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
||||
import React, { useState, useCallback, forwardRef, useMemo, useEffect } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,6 +7,7 @@ import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { useSubscriptionEvents } from '../../../contexts/SubscriptionEventsContext';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Tooltip } from '../../ui';
|
||||
@@ -161,11 +162,18 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
const { subscriptionVersion } = useSubscriptionEvents();
|
||||
|
||||
// Get subscription-aware navigation routes
|
||||
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
||||
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||
|
||||
// Force re-render when subscription changes
|
||||
useEffect(() => {
|
||||
// The subscriptionVersion change will trigger a re-render
|
||||
// This ensures the sidebar picks up new route filtering based on updated subscription
|
||||
}, [subscriptionVersion]);
|
||||
|
||||
// Map route paths to translation keys
|
||||
const getTranslationKey = (routePath: string): string => {
|
||||
const pathMappings: Record<string, string> = {
|
||||
@@ -1079,4 +1087,4 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
);
|
||||
});
|
||||
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
|
||||
485
frontend/src/components/ui/BaseDeleteModal/BaseDeleteModal.tsx
Normal file
485
frontend/src/components/ui/BaseDeleteModal/BaseDeleteModal.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trash2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Modal, Button } from '../index';
|
||||
|
||||
export type DeleteMode = 'soft' | 'hard';
|
||||
|
||||
export interface EntityDisplayInfo {
|
||||
primaryText: string;
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
export interface DeleteModeOption {
|
||||
mode: DeleteMode;
|
||||
title: string;
|
||||
description: string;
|
||||
benefits: string;
|
||||
enabled: boolean;
|
||||
disabledMessage?: string;
|
||||
}
|
||||
|
||||
export interface DeleteWarning {
|
||||
title: string;
|
||||
items: string[];
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
export interface DeletionSummaryData {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface BaseDeleteModalProps<TEntity, TSummary = DeletionSummaryData> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
entity: TEntity | null;
|
||||
onSoftDelete: (entityId: string) => Promise<void | TSummary>;
|
||||
onHardDelete?: (entityId: string) => Promise<void | TSummary>;
|
||||
isLoading?: boolean;
|
||||
|
||||
// Configuration
|
||||
title: string;
|
||||
getEntityId: (entity: TEntity) => string;
|
||||
getEntityDisplay: (entity: TEntity) => EntityDisplayInfo;
|
||||
|
||||
// Mode configuration
|
||||
softDeleteOption: Omit<DeleteModeOption, 'mode' | 'enabled'>;
|
||||
hardDeleteOption?: Omit<DeleteModeOption, 'mode'>;
|
||||
|
||||
// Warnings
|
||||
softDeleteWarning: DeleteWarning;
|
||||
hardDeleteWarning: DeleteWarning;
|
||||
|
||||
// Optional features
|
||||
requireConfirmText?: boolean;
|
||||
confirmText?: string;
|
||||
showSuccessScreen?: boolean;
|
||||
successTitle?: string;
|
||||
getSuccessMessage?: (entity: TEntity, mode: DeleteMode) => string;
|
||||
|
||||
// Deletion summary
|
||||
showDeletionSummary?: boolean;
|
||||
formatDeletionSummary?: (summary: TSummary) => DeletionSummaryData;
|
||||
deletionSummaryTitle?: string;
|
||||
|
||||
// Dependency checking (for hard delete)
|
||||
dependencyCheck?: {
|
||||
isLoading: boolean;
|
||||
canDelete: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
// Auto-close timing
|
||||
autoCloseDelay?: number;
|
||||
}
|
||||
|
||||
export function BaseDeleteModal<TEntity, TSummary = DeletionSummaryData>({
|
||||
isOpen,
|
||||
onClose,
|
||||
entity,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
title,
|
||||
getEntityId,
|
||||
getEntityDisplay,
|
||||
softDeleteOption,
|
||||
hardDeleteOption,
|
||||
softDeleteWarning,
|
||||
hardDeleteWarning,
|
||||
requireConfirmText = true,
|
||||
confirmText: customConfirmText = 'ELIMINAR',
|
||||
showSuccessScreen = false,
|
||||
successTitle,
|
||||
getSuccessMessage,
|
||||
showDeletionSummary = false,
|
||||
formatDeletionSummary,
|
||||
deletionSummaryTitle,
|
||||
dependencyCheck,
|
||||
autoCloseDelay,
|
||||
}: BaseDeleteModalProps<TEntity, TSummary>) {
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionResult, setDeletionResult] = useState<TSummary | null>(null);
|
||||
const [deletionComplete, setDeletionComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionResult(null);
|
||||
setDeletionComplete(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!entity) return null;
|
||||
|
||||
const entityDisplay = getEntityDisplay(entity);
|
||||
const entityId = getEntityId(entity);
|
||||
const isHardDeleteEnabled = hardDeleteOption?.enabled !== false;
|
||||
|
||||
const handleDeleteModeSelect = (mode: DeleteMode) => {
|
||||
setSelectedMode(mode);
|
||||
setShowConfirmation(true);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (selectedMode === 'hard' && onHardDelete) {
|
||||
const result = await onHardDelete(entityId);
|
||||
if (result && showDeletionSummary) {
|
||||
setDeletionResult(result as TSummary);
|
||||
}
|
||||
} else {
|
||||
const result = await onSoftDelete(entityId);
|
||||
if (result && showDeletionSummary) {
|
||||
setDeletionResult(result as TSummary);
|
||||
}
|
||||
}
|
||||
|
||||
if (showSuccessScreen) {
|
||||
setDeletionComplete(true);
|
||||
if (autoCloseDelay) {
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, autoCloseDelay);
|
||||
}
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting entity:', error);
|
||||
// Error handling is done by parent component
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionResult(null);
|
||||
setDeletionComplete(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedMode === 'hard' &&
|
||||
requireConfirmText &&
|
||||
confirmText.toUpperCase() !== customConfirmText.toUpperCase();
|
||||
|
||||
// Show deletion result/summary
|
||||
if (deletionResult && showDeletionSummary && formatDeletionSummary) {
|
||||
const formattedSummary = formatDeletionSummary(deletionResult);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{deletionSummaryTitle || 'Eliminación Completada'}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{entityDisplay.primaryText} ha sido eliminado
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--background-secondary)] rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3">Resumen de eliminación:</h4>
|
||||
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
{Object.entries(formattedSummary).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span>{key}:</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
Entendido
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show deletion success
|
||||
if (deletionComplete && showSuccessScreen) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{successTitle || (selectedMode === 'hard' ? 'Eliminado' : 'Desactivado')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{getSuccessMessage?.(entity, selectedMode) || `${entityDisplay.primaryText} procesado correctamente`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
const canDelete = !isHardDelete || !dependencyCheck || dependencyCheck.canDelete !== false;
|
||||
const warning = isHardDelete ? hardDeleteWarning : softDeleteWarning;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete ? 'Confirmación de Eliminación Permanente' : 'Confirmación de Desactivación'}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{entityDisplay.primaryText}</p>
|
||||
{entityDisplay.secondaryText && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{entityDisplay.secondaryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && dependencyCheck?.isLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--text-primary)]"></div>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
Verificando dependencias...
|
||||
</p>
|
||||
</div>
|
||||
) : isHardDelete && !canDelete ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||
<p className="font-medium text-red-700 dark:text-red-400 mb-2">
|
||||
⚠️ No se puede eliminar este elemento
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 text-red-600 dark:text-red-300">
|
||||
{dependencyCheck?.warnings.map((warn, idx) => (
|
||||
<li key={idx}>• {warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className={isHardDelete ? 'text-red-600 dark:text-red-400 mb-4' : 'text-orange-600 dark:text-orange-400 mb-4'}>
|
||||
<p className="font-medium mb-2">{warning.title}</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
{warning.items.map((item, idx) => (
|
||||
<li key={idx}>• {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
{warning.footer && (
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{warning.footer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && canDelete && requireConfirmText && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Para confirmar, escriba <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">{customConfirmText}</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => 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 ${customConfirmText}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading || dependencyCheck?.isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete ? 'Eliminar Permanentemente' : 'Desactivar'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{entityDisplay.primaryText}</p>
|
||||
{entityDisplay.secondaryText && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{entityDisplay.secondaryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
Elija el tipo de eliminación que desea realizar:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{softDeleteOption.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{softDeleteOption.description}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{softDeleteOption.benefits}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
{hardDeleteOption && (
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 transition-colors ${
|
||||
!isHardDeleteEnabled
|
||||
? 'opacity-50 cursor-not-allowed border-[var(--border-color)] bg-[var(--background-tertiary)]'
|
||||
: `cursor-pointer ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`
|
||||
}`}
|
||||
onClick={() => isHardDeleteEnabled && setSelectedMode('hard')}
|
||||
title={!isHardDeleteEnabled ? hardDeleteOption.disabledMessage : undefined}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard' && isHardDeleteEnabled
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && isHardDeleteEnabled && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
{hardDeleteOption.title}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{hardDeleteOption.description}
|
||||
</p>
|
||||
<div className={`mt-2 text-xs ${
|
||||
isHardDeleteEnabled
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-[var(--text-tertiary)]'
|
||||
}`}>
|
||||
{isHardDeleteEnabled ? hardDeleteOption.benefits : `ℹ️ ${hardDeleteOption.disabledMessage}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' && isHardDeleteEnabled ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Continuar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default BaseDeleteModal;
|
||||
9
frontend/src/components/ui/BaseDeleteModal/index.ts
Normal file
9
frontend/src/components/ui/BaseDeleteModal/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { BaseDeleteModal } from './BaseDeleteModal';
|
||||
export type {
|
||||
DeleteMode,
|
||||
EntityDisplayInfo,
|
||||
DeleteModeOption,
|
||||
DeleteWarning,
|
||||
DeletionSummaryData,
|
||||
BaseDeleteModalProps,
|
||||
} from './BaseDeleteModal';
|
||||
@@ -144,11 +144,7 @@ const renderEditableField = (
|
||||
onChange?: (value: string | number) => void,
|
||||
validationError?: string
|
||||
): React.ReactNode => {
|
||||
if (!isEditMode || !field.editable) {
|
||||
return formatFieldValue(field.value, field.type);
|
||||
}
|
||||
|
||||
// Handle custom components
|
||||
// Handle custom components FIRST - they work in both view and edit modes
|
||||
if (field.type === 'component' && field.component) {
|
||||
const Component = field.component;
|
||||
return (
|
||||
@@ -160,6 +156,11 @@ const renderEditableField = (
|
||||
);
|
||||
}
|
||||
|
||||
// Then check if we should render as view or edit
|
||||
if (!isEditMode || !field.editable) {
|
||||
return formatFieldValue(field.value, field.type);
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value;
|
||||
onChange?.(value);
|
||||
@@ -355,6 +356,16 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
const StatusIcon = statusIndicator?.icon;
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = React.useState(false);
|
||||
const [collapsedSections, setCollapsedSections] = React.useState<Record<number, boolean>>({});
|
||||
|
||||
// Initialize collapsed states when sections change
|
||||
React.useEffect(() => {
|
||||
const initialCollapsed: Record<number, boolean> = {};
|
||||
sections.forEach((section, index) => {
|
||||
initialCollapsed[index] = section.collapsed || false;
|
||||
});
|
||||
setCollapsedSections(initialCollapsed);
|
||||
}, [sections]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onModeChange) {
|
||||
@@ -616,7 +627,7 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
|
||||
const isCollapsed = collapsedSections[sectionIndex] || false;
|
||||
const sectionColumns = section.columns || (mobileOptimized ? 1 : 2);
|
||||
|
||||
// Determine grid classes based on mobile optimization and section columns
|
||||
@@ -642,7 +653,12 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
className={`flex items-start gap-3 pb-3 border-b border-[var(--border-primary)] ${
|
||||
section.collapsible ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={section.collapsible ? () => setIsCollapsed(!isCollapsed) : undefined}
|
||||
onClick={section.collapsible ? () => {
|
||||
setCollapsedSections(prev => ({
|
||||
...prev,
|
||||
[sectionIndex]: !isCollapsed
|
||||
}));
|
||||
} : undefined}
|
||||
>
|
||||
{section.icon && (
|
||||
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
|
||||
|
||||
@@ -27,6 +27,7 @@ export { LoadingSpinner } from './LoadingSpinner';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { ResponsiveText } from './ResponsiveText';
|
||||
export { SearchAndFilter } from './SearchAndFilter';
|
||||
export { BaseDeleteModal } from './BaseDeleteModal';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
@@ -54,4 +55,5 @@ export type { DialogModalProps, DialogModalAction } from './DialogModal';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ResponsiveTextProps } from './ResponsiveText';
|
||||
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
|
||||
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
|
||||
export type { BaseDeleteModalProps, DeleteMode, EntityDisplayInfo, DeleteModeOption, DeleteWarning, DeletionSummaryData } from './BaseDeleteModal';
|
||||
64
frontend/src/contexts/SubscriptionEventsContext.tsx
Normal file
64
frontend/src/contexts/SubscriptionEventsContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
|
||||
interface SubscriptionEventsContextType {
|
||||
subscriptionVersion: number;
|
||||
notifySubscriptionChanged: () => void;
|
||||
subscribeToChanges: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
const SubscriptionEventsContext = createContext<SubscriptionEventsContextType | undefined>(undefined);
|
||||
|
||||
export const SubscriptionEventsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [subscriptionVersion, setSubscriptionVersion] = useState(0);
|
||||
const [subscribers, setSubscribers] = useState<Set<() => void>>(new Set());
|
||||
|
||||
const notifySubscriptionChanged = useCallback(() => {
|
||||
setSubscriptionVersion(prev => prev + 1);
|
||||
|
||||
// Notify all subscribers
|
||||
subscribers.forEach(callback => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.warn('Error notifying subscription change subscriber:', error);
|
||||
}
|
||||
});
|
||||
}, [subscribers]);
|
||||
|
||||
const subscribeToChanges = useCallback((callback: () => void) => {
|
||||
setSubscribers(prev => {
|
||||
const newSubscribers = new Set(prev);
|
||||
newSubscribers.add(callback);
|
||||
return newSubscribers;
|
||||
});
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
setSubscribers(prev => {
|
||||
const newSubscribers = new Set(prev);
|
||||
newSubscribers.delete(callback);
|
||||
return newSubscribers;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
subscriptionVersion,
|
||||
notifySubscriptionChanged,
|
||||
subscribeToChanges
|
||||
};
|
||||
|
||||
return (
|
||||
<SubscriptionEventsContext.Provider value={value}>
|
||||
{children}
|
||||
</SubscriptionEventsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSubscriptionEvents = () => {
|
||||
const context = useContext(SubscriptionEventsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSubscriptionEvents must be used within a SubscriptionEventsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -66,4 +66,4 @@ export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
|
||||
subscriptionInfo,
|
||||
isLoading: subscriptionInfo.loading
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,12 +31,24 @@
|
||||
"energy_usage": "Energy Usage",
|
||||
"temperature": "Temperature",
|
||||
"target_temperature": "Target Temperature",
|
||||
"current_temperature": "Current Temperature",
|
||||
"power": "Power",
|
||||
"capacity": "Capacity",
|
||||
"weight": "Weight",
|
||||
"parts": "Parts",
|
||||
"utilization_today": "Utilization Today",
|
||||
"edit": "Edit",
|
||||
"notes": "Notes",
|
||||
"date": "Date",
|
||||
"technician": "Technician",
|
||||
"downtime": "Downtime",
|
||||
"maintenance_type": "Maintenance Type",
|
||||
"priority": "Priority",
|
||||
"scheduled_date": "Scheduled Date",
|
||||
"time": "Time",
|
||||
"duration": "Duration (hours)",
|
||||
"parts_needed": "Parts Needed",
|
||||
"description": "Description",
|
||||
"specifications": {
|
||||
"power": "Power",
|
||||
"capacity": "Capacity",
|
||||
@@ -50,13 +62,16 @@
|
||||
"add_equipment": "Add Equipment",
|
||||
"edit_equipment": "Edit Equipment",
|
||||
"delete_equipment": "Delete Equipment",
|
||||
"delete": "Delete",
|
||||
"schedule_maintenance": "Schedule Maintenance",
|
||||
"schedule": "Schedule",
|
||||
"view_maintenance_history": "View Maintenance History",
|
||||
"acknowledge_alert": "Acknowledge Alert",
|
||||
"view_details": "View Details",
|
||||
"view_history": "View History",
|
||||
"close": "Close",
|
||||
"cost": "Cost"
|
||||
"cost": "Cost",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"labels": {
|
||||
"total_equipment": "Total Equipment",
|
||||
@@ -74,14 +89,23 @@
|
||||
"equipment_info": "Equipment Information",
|
||||
"performance": "Performance",
|
||||
"maintenance": "Maintenance Information",
|
||||
"maintenance_info": "Maintenance Information",
|
||||
"specifications": "Specifications",
|
||||
"temperature_monitoring": "Temperature Monitoring",
|
||||
"notes": "Notes",
|
||||
"scheduling": "Scheduling",
|
||||
"details": "Details",
|
||||
"create_equipment_subtitle": "Fill in the details for the new equipment"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "Enter equipment name",
|
||||
"model": "Enter equipment model",
|
||||
"serial_number": "Enter serial number",
|
||||
"location": "Enter location"
|
||||
"location": "Enter location",
|
||||
"notes": "Additional notes and observations",
|
||||
"technician": "Assigned technician name",
|
||||
"parts_needed": "List of required parts and materials",
|
||||
"maintenance_description": "Description of the maintenance work to be performed"
|
||||
},
|
||||
"descriptions": {
|
||||
"equipment_efficiency": "Current equipment efficiency percentage",
|
||||
@@ -97,12 +121,24 @@
|
||||
"records": "records",
|
||||
"overdue": "Overdue",
|
||||
"scheduled": "Scheduled",
|
||||
"no_history": "No maintenance history",
|
||||
"no_history_description": "Maintenance records will appear here when operations are performed",
|
||||
"type": {
|
||||
"preventive": "Preventive",
|
||||
"corrective": "Corrective",
|
||||
"emergency": "Emergency"
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
},
|
||||
"validation": {
|
||||
"required": "This field is required",
|
||||
"must_be_positive": "Must be greater than 0"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Alerts",
|
||||
"unread_alerts": "unread alerts",
|
||||
|
||||
100
frontend/src/locales/en/models.json
Normal file
100
frontend/src/locales/en/models.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"page_title": "AI Models Configuration",
|
||||
"page_description": "Manage training and configuration of prediction models for each ingredient",
|
||||
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"no_model": "No Model",
|
||||
"training": "Training",
|
||||
"retraining": "Retraining",
|
||||
"error": "Error"
|
||||
},
|
||||
|
||||
"retrain": {
|
||||
"title": "Retrain Model",
|
||||
"subtitle": "Update the prediction model with recent data",
|
||||
|
||||
"modes": {
|
||||
"quick": "Quick",
|
||||
"preset": "Preset",
|
||||
"advanced": "Advanced"
|
||||
},
|
||||
|
||||
"quick": {
|
||||
"title": "Quick Retrain",
|
||||
"ingredient": "Ingredient",
|
||||
"current_accuracy": "Current Accuracy",
|
||||
"last_training": "Last Training",
|
||||
"description": "Description",
|
||||
"description_text": "Quick retraining uses the same configuration as the current model but with the most recent data. This keeps the model accuracy up to date without changing its behavior."
|
||||
},
|
||||
|
||||
"preset": {
|
||||
"title": "Select Configuration",
|
||||
"ingredient": "Ingredient",
|
||||
"select": "Product Type",
|
||||
"description": "Description",
|
||||
"seasonality_mode": "Seasonality Mode",
|
||||
"daily": "Daily Seasonality",
|
||||
"weekly": "Weekly Seasonality",
|
||||
"yearly": "Yearly Seasonality"
|
||||
},
|
||||
|
||||
"advanced": {
|
||||
"title": "Advanced Configuration",
|
||||
"ingredient": "Ingredient",
|
||||
"start_date": "Start Date",
|
||||
"start_date_help": "Leave empty to use all available data",
|
||||
"end_date": "End Date",
|
||||
"end_date_help": "Leave empty to use up to current date",
|
||||
"seasonality_mode": "Seasonality Mode",
|
||||
"seasonality_mode_help": "Additive: constant changes. Multiplicative: proportional changes.",
|
||||
"seasonality_patterns": "Seasonal Patterns",
|
||||
"daily_seasonality": "Daily Seasonality",
|
||||
"daily_seasonality_help": "Patterns that repeat every day",
|
||||
"weekly_seasonality": "Weekly Seasonality",
|
||||
"weekly_seasonality_help": "Patterns that repeat every week",
|
||||
"yearly_seasonality": "Yearly Seasonality",
|
||||
"yearly_seasonality_help": "Patterns that repeat every year (holidays, seasons)"
|
||||
}
|
||||
},
|
||||
|
||||
"presets": {
|
||||
"standard": {
|
||||
"name": "Standard Bakery",
|
||||
"description": "Recommended for products with weekly patterns and daily cycles. Ideal for bread and daily baked goods."
|
||||
},
|
||||
"seasonal": {
|
||||
"name": "Seasonal Products",
|
||||
"description": "For products with seasonal or seasonal demand. Includes annual patterns for holidays and special events."
|
||||
},
|
||||
"stable": {
|
||||
"name": "Stable Demand",
|
||||
"description": "For basic ingredients with constant demand. Minimal seasonality."
|
||||
},
|
||||
"custom": {
|
||||
"name": "Custom",
|
||||
"description": "Advanced configuration with full control over parameters."
|
||||
}
|
||||
},
|
||||
|
||||
"seasonality": {
|
||||
"additive": "Additive",
|
||||
"multiplicative": "Multiplicative"
|
||||
},
|
||||
|
||||
"actions": {
|
||||
"train": "Train",
|
||||
"retrain": "Retrain",
|
||||
"view_details": "View Details",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
|
||||
"messages": {
|
||||
"training_started": "Training started for {{name}}",
|
||||
"training_error": "Error starting training",
|
||||
"retraining_started": "Retraining started for {{name}}",
|
||||
"retraining_error": "Error retraining model"
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@
|
||||
"address_info": "Address Information",
|
||||
"commercial_info": "Commercial Information",
|
||||
"additional_info": "Additional Information",
|
||||
"price_list": "Price List",
|
||||
"performance": "Performance and Statistics",
|
||||
"notes": "Notes"
|
||||
},
|
||||
@@ -129,12 +130,92 @@
|
||||
"actions": {
|
||||
"approve": "Approve Supplier",
|
||||
"reject": "Reject Supplier",
|
||||
"delete": "Delete Supplier"
|
||||
"delete": "Delete Supplier",
|
||||
"manage_products": "Manage Products"
|
||||
},
|
||||
"confirm": {
|
||||
"approve": "Are you sure you want to approve this supplier? This will activate the supplier for use.",
|
||||
"reject": "Are you sure you want to reject this supplier? This action can be undone later."
|
||||
},
|
||||
"price_list": {
|
||||
"title": "Product Price List",
|
||||
"subtitle": "{{count}} products available from this supplier",
|
||||
"modal": {
|
||||
"title_create": "Add Product to Supplier",
|
||||
"title_edit": "Edit Product Price",
|
||||
"subtitle_create": "Add a new product that this supplier can provide",
|
||||
"subtitle_edit": "Update product pricing and details"
|
||||
},
|
||||
"sections": {
|
||||
"product_selection": "Product Selection",
|
||||
"pricing": "Pricing Information",
|
||||
"validity": "Price Validity",
|
||||
"product_details": "Product Details"
|
||||
},
|
||||
"fields": {
|
||||
"product": "Product",
|
||||
"product_code": "Supplier Product Code",
|
||||
"unit_price": "Unit Price",
|
||||
"price_per_unit": "Price per Unit",
|
||||
"unit_of_measure": "Unit of Measure",
|
||||
"minimum_order": "Minimum Order Quantity",
|
||||
"effective_date": "Effective Date",
|
||||
"expiry_date": "Expiry Date",
|
||||
"is_active": "Active",
|
||||
"brand": "Brand",
|
||||
"packaging_size": "Packaging Size",
|
||||
"origin_country": "Country of Origin",
|
||||
"shelf_life_days": "Shelf Life (days)",
|
||||
"storage_requirements": "Storage Requirements"
|
||||
},
|
||||
"placeholders": {
|
||||
"product_code": "e.g., SUP-FLOUR-001",
|
||||
"brand": "Brand name",
|
||||
"packaging_size": "e.g., 25kg bags, 1L bottles",
|
||||
"origin_country": "e.g., Spain, France",
|
||||
"storage_requirements": "e.g., Store in cool, dry place"
|
||||
},
|
||||
"help": {
|
||||
"product_locked": "Product cannot be changed after creation",
|
||||
"select_product": "Select a product from your inventory",
|
||||
"product_code": "Supplier's internal code for this product",
|
||||
"unit_price": "Base price per package/unit",
|
||||
"price_per_unit": "Calculated price per unit of measure",
|
||||
"unit_of_measure": "Unit used for pricing and ordering",
|
||||
"minimum_order": "Minimum quantity required for ordering",
|
||||
"effective_date": "Date when this price becomes valid",
|
||||
"expiry_date": "Optional expiration date for this price",
|
||||
"is_active": "Enable or disable this price list item",
|
||||
"packaging_size": "e.g., 25kg bags, 1L bottles, 100 units per box",
|
||||
"shelf_life_days": "Number of days product remains fresh"
|
||||
},
|
||||
"columns": {
|
||||
"product": "Product",
|
||||
"price": "Price",
|
||||
"min_order": "Min. Order",
|
||||
"validity": "Validity Period",
|
||||
"brand": "Brand",
|
||||
"status": "Status"
|
||||
},
|
||||
"actions": {
|
||||
"add_product": "Add Product",
|
||||
"add_first_product": "Add First Product"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No Products Yet",
|
||||
"description": "Add products that this supplier can provide with their prices"
|
||||
},
|
||||
"errors": {
|
||||
"load_failed": "Failed to load price list"
|
||||
},
|
||||
"validation": {
|
||||
"price_positive": "Price must be greater than 0"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Remove Product from Supplier",
|
||||
"description": "Are you sure you want to remove {{product}} from this supplier's price list?"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete Supplier",
|
||||
"subtitle": "How would you like to delete {name}?",
|
||||
|
||||
@@ -31,11 +31,24 @@
|
||||
"energy_usage": "Consumo Energético",
|
||||
"temperature": "Temperatura",
|
||||
"target_temperature": "Temperatura Objetivo",
|
||||
"current_temperature": "Temperatura Actual",
|
||||
"power": "Potencia",
|
||||
"capacity": "Capacidad",
|
||||
"weight": "Peso",
|
||||
"parts": "Repuestos",
|
||||
"utilization_today": "Utilización Hoy",
|
||||
"edit": "Editar",
|
||||
"notes": "Notas",
|
||||
"date": "Fecha",
|
||||
"technician": "Técnico",
|
||||
"downtime": "Parada",
|
||||
"maintenance_type": "Tipo de Mantenimiento",
|
||||
"priority": "Prioridad",
|
||||
"scheduled_date": "Fecha Programada",
|
||||
"time": "Hora",
|
||||
"duration": "Duración (horas)",
|
||||
"parts_needed": "Repuestos Necesarios",
|
||||
"description": "Descripción",
|
||||
"specifications": {
|
||||
"power": "Potencia",
|
||||
"capacity": "Capacidad",
|
||||
@@ -49,13 +62,16 @@
|
||||
"add_equipment": "Agregar Equipo",
|
||||
"edit_equipment": "Editar Equipo",
|
||||
"delete_equipment": "Eliminar Equipo",
|
||||
"delete": "Eliminar",
|
||||
"schedule_maintenance": "Programar Mantenimiento",
|
||||
"schedule": "Programar",
|
||||
"view_maintenance_history": "Ver Historial de Mantenimiento",
|
||||
"acknowledge_alert": "Reconocer Alerta",
|
||||
"view_details": "Ver Detalles",
|
||||
"view_history": "Ver Historial",
|
||||
"close": "Cerrar",
|
||||
"cost": "Costo"
|
||||
"cost": "Costo",
|
||||
"edit": "Editar"
|
||||
},
|
||||
"labels": {
|
||||
"total_equipment": "Total de Equipos",
|
||||
@@ -73,14 +89,23 @@
|
||||
"equipment_info": "Información de Equipo",
|
||||
"performance": "Rendimiento",
|
||||
"maintenance": "Información de Mantenimiento",
|
||||
"maintenance_info": "Información de Mantenimiento",
|
||||
"specifications": "Especificaciones",
|
||||
"temperature_monitoring": "Monitoreo de Temperatura",
|
||||
"notes": "Notas",
|
||||
"scheduling": "Programación",
|
||||
"details": "Detalles",
|
||||
"create_equipment_subtitle": "Completa los detalles del nuevo equipo"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "Introduce el nombre del equipo",
|
||||
"model": "Introduce el modelo del equipo",
|
||||
"serial_number": "Introduce el número de serie",
|
||||
"location": "Introduce la ubicación"
|
||||
"location": "Introduce la ubicación",
|
||||
"notes": "Notas y observaciones adicionales",
|
||||
"technician": "Nombre del técnico asignado",
|
||||
"parts_needed": "Lista de repuestos y materiales necesarios",
|
||||
"maintenance_description": "Descripción del trabajo a realizar"
|
||||
},
|
||||
"descriptions": {
|
||||
"equipment_efficiency": "Porcentaje de eficiencia actual de los equipos",
|
||||
@@ -96,12 +121,24 @@
|
||||
"records": "registros",
|
||||
"overdue": "Atrasado",
|
||||
"scheduled": "Programado",
|
||||
"no_history": "No hay historial de mantenimiento",
|
||||
"no_history_description": "Los registros de mantenimiento aparecerán aquí cuando se realicen operaciones",
|
||||
"type": {
|
||||
"preventive": "Preventivo",
|
||||
"corrective": "Correctivo",
|
||||
"emergency": "Emergencia"
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta",
|
||||
"urgent": "Urgente"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Este campo es requerido",
|
||||
"must_be_positive": "Debe ser mayor que 0"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Alertas",
|
||||
"unread_alerts": "alertas no leídas",
|
||||
|
||||
100
frontend/src/locales/es/models.json
Normal file
100
frontend/src/locales/es/models.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"page_title": "Configuración de Modelos IA",
|
||||
"page_description": "Gestiona el entrenamiento y configuración de modelos de predicción para cada ingrediente",
|
||||
|
||||
"status": {
|
||||
"active": "Activo",
|
||||
"no_model": "Sin Modelo",
|
||||
"training": "Entrenando",
|
||||
"retraining": "Reentrenamiento",
|
||||
"error": "Error"
|
||||
},
|
||||
|
||||
"retrain": {
|
||||
"title": "Reentrenar Modelo",
|
||||
"subtitle": "Actualiza el modelo de predicción con datos recientes",
|
||||
|
||||
"modes": {
|
||||
"quick": "Rápido",
|
||||
"preset": "Preconfigurado",
|
||||
"advanced": "Avanzado"
|
||||
},
|
||||
|
||||
"quick": {
|
||||
"title": "Reentrenamiento Rápido",
|
||||
"ingredient": "Ingrediente",
|
||||
"current_accuracy": "Precisión Actual",
|
||||
"last_training": "Último Entrenamiento",
|
||||
"description": "Descripción",
|
||||
"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."
|
||||
},
|
||||
|
||||
"preset": {
|
||||
"title": "Seleccionar Configuración",
|
||||
"ingredient": "Ingrediente",
|
||||
"select": "Tipo de Producto",
|
||||
"description": "Descripción",
|
||||
"seasonality_mode": "Modo de Estacionalidad",
|
||||
"daily": "Estacionalidad Diaria",
|
||||
"weekly": "Estacionalidad Semanal",
|
||||
"yearly": "Estacionalidad Anual"
|
||||
},
|
||||
|
||||
"advanced": {
|
||||
"title": "Configuración Avanzada",
|
||||
"ingredient": "Ingrediente",
|
||||
"start_date": "Fecha de Inicio",
|
||||
"start_date_help": "Dejar vacío para usar todos los datos disponibles",
|
||||
"end_date": "Fecha de Fin",
|
||||
"end_date_help": "Dejar vacío para usar hasta la fecha actual",
|
||||
"seasonality_mode": "Modo de Estacionalidad",
|
||||
"seasonality_mode_help": "Aditivo: cambios constantes. Multiplicativo: cambios proporcionales.",
|
||||
"seasonality_patterns": "Patrones Estacionales",
|
||||
"daily_seasonality": "Estacionalidad Diaria",
|
||||
"daily_seasonality_help": "Patrones que se repiten cada día",
|
||||
"weekly_seasonality": "Estacionalidad Semanal",
|
||||
"weekly_seasonality_help": "Patrones que se repiten cada semana",
|
||||
"yearly_seasonality": "Estacionalidad Anual",
|
||||
"yearly_seasonality_help": "Patrones que se repiten cada año (festividades, temporadas)"
|
||||
}
|
||||
},
|
||||
|
||||
"presets": {
|
||||
"standard": {
|
||||
"name": "Panadería Estándar",
|
||||
"description": "Recomendado para productos con patrones semanales y ciclos diarios. Ideal para pan y productos horneados diarios."
|
||||
},
|
||||
"seasonal": {
|
||||
"name": "Productos Estacionales",
|
||||
"description": "Para productos con demanda estacional o de temporada. Incluye patrones anuales para festividades y eventos especiales."
|
||||
},
|
||||
"stable": {
|
||||
"name": "Demanda Estable",
|
||||
"description": "Para ingredientes básicos con demanda constante. Mínima estacionalidad."
|
||||
},
|
||||
"custom": {
|
||||
"name": "Personalizado",
|
||||
"description": "Configuración avanzada con control total sobre los parámetros."
|
||||
}
|
||||
},
|
||||
|
||||
"seasonality": {
|
||||
"additive": "Aditivo",
|
||||
"multiplicative": "Multiplicativo"
|
||||
},
|
||||
|
||||
"actions": {
|
||||
"train": "Entrenar",
|
||||
"retrain": "Reentrenar",
|
||||
"view_details": "Ver Detalles",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar"
|
||||
},
|
||||
|
||||
"messages": {
|
||||
"training_started": "Entrenamiento iniciado para {{name}}",
|
||||
"training_error": "Error al iniciar el entrenamiento",
|
||||
"retraining_started": "Reentrenamiento iniciado para {{name}}",
|
||||
"retraining_error": "Error al reentrenar el modelo"
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@
|
||||
"address_info": "Información de Dirección",
|
||||
"commercial_info": "Información Comercial",
|
||||
"additional_info": "Información Adicional",
|
||||
"price_list": "Lista de Precios",
|
||||
"performance": "Rendimiento y Estadísticas",
|
||||
"notes": "Notas"
|
||||
},
|
||||
@@ -129,12 +130,92 @@
|
||||
"actions": {
|
||||
"approve": "Aprobar Proveedor",
|
||||
"reject": "Rechazar Proveedor",
|
||||
"delete": "Eliminar Proveedor"
|
||||
"delete": "Eliminar Proveedor",
|
||||
"manage_products": "Gestionar Productos"
|
||||
},
|
||||
"confirm": {
|
||||
"approve": "¿Estás seguro de que quieres aprobar este proveedor? Esto activará el proveedor para su uso.",
|
||||
"reject": "¿Estás seguro de que quieres rechazar este proveedor? Esta acción se puede deshacer más tarde."
|
||||
},
|
||||
"price_list": {
|
||||
"title": "Lista de Precios de Productos",
|
||||
"subtitle": "{{count}} productos disponibles de este proveedor",
|
||||
"modal": {
|
||||
"title_create": "Añadir Producto al Proveedor",
|
||||
"title_edit": "Editar Precio de Producto",
|
||||
"subtitle_create": "Añadir un nuevo producto que este proveedor puede suministrar",
|
||||
"subtitle_edit": "Actualizar precios y detalles del producto"
|
||||
},
|
||||
"sections": {
|
||||
"product_selection": "Selección de Producto",
|
||||
"pricing": "Información de Precios",
|
||||
"validity": "Validez del Precio",
|
||||
"product_details": "Detalles del Producto"
|
||||
},
|
||||
"fields": {
|
||||
"product": "Producto",
|
||||
"product_code": "Código de Producto del Proveedor",
|
||||
"unit_price": "Precio Unitario",
|
||||
"price_per_unit": "Precio por Unidad",
|
||||
"unit_of_measure": "Unidad de Medida",
|
||||
"minimum_order": "Cantidad Mínima de Pedido",
|
||||
"effective_date": "Fecha de Vigencia",
|
||||
"expiry_date": "Fecha de Vencimiento",
|
||||
"is_active": "Activo",
|
||||
"brand": "Marca",
|
||||
"packaging_size": "Tamaño del Envase",
|
||||
"origin_country": "País de Origen",
|
||||
"shelf_life_days": "Vida Útil (días)",
|
||||
"storage_requirements": "Requisitos de Almacenamiento"
|
||||
},
|
||||
"placeholders": {
|
||||
"product_code": "ej., PROV-HARINA-001",
|
||||
"brand": "Nombre de la marca",
|
||||
"packaging_size": "ej., Sacos de 25kg, Botellas de 1L",
|
||||
"origin_country": "ej., España, Francia",
|
||||
"storage_requirements": "ej., Almacenar en lugar fresco y seco"
|
||||
},
|
||||
"help": {
|
||||
"product_locked": "El producto no se puede cambiar después de la creación",
|
||||
"select_product": "Selecciona un producto de tu inventario",
|
||||
"product_code": "Código interno del proveedor para este producto",
|
||||
"unit_price": "Precio base por paquete/unidad",
|
||||
"price_per_unit": "Precio calculado por unidad de medida",
|
||||
"unit_of_measure": "Unidad utilizada para precios y pedidos",
|
||||
"minimum_order": "Cantidad mínima requerida para realizar pedidos",
|
||||
"effective_date": "Fecha en que este precio entra en vigencia",
|
||||
"expiry_date": "Fecha de vencimiento opcional para este precio",
|
||||
"is_active": "Activar o desactivar este elemento de la lista de precios",
|
||||
"packaging_size": "ej., Sacos de 25kg, Botellas de 1L, 100 unidades por caja",
|
||||
"shelf_life_days": "Número de días que el producto permanece fresco"
|
||||
},
|
||||
"columns": {
|
||||
"product": "Producto",
|
||||
"price": "Precio",
|
||||
"min_order": "Pedido Mín.",
|
||||
"validity": "Período de Validez",
|
||||
"brand": "Marca",
|
||||
"status": "Estado"
|
||||
},
|
||||
"actions": {
|
||||
"add_product": "Añadir Producto",
|
||||
"add_first_product": "Añadir Primer Producto"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aún No Hay Productos",
|
||||
"description": "Añade productos que este proveedor puede suministrar con sus precios"
|
||||
},
|
||||
"errors": {
|
||||
"load_failed": "Error al cargar la lista de precios"
|
||||
},
|
||||
"validation": {
|
||||
"price_positive": "El precio debe ser mayor que 0"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar Producto del Proveedor",
|
||||
"description": "¿Estás seguro de que quieres eliminar {{product}} de la lista de precios de este proveedor?"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"title": "Eliminar Proveedor",
|
||||
"subtitle": "¿Cómo te gustaría eliminar {name}?",
|
||||
|
||||
@@ -31,11 +31,24 @@
|
||||
"energy_usage": "Energia-kontsumoa",
|
||||
"temperature": "Tenperatura",
|
||||
"target_temperature": "Helburuko tenperatura",
|
||||
"current_temperature": "Uneko tenperatura",
|
||||
"power": "Potentzia",
|
||||
"capacity": "Edukiera",
|
||||
"weight": "Pisua",
|
||||
"parts": "Piezak",
|
||||
"utilization_today": "Gaurko erabilera",
|
||||
"edit": "Editatu",
|
||||
"notes": "Oharrak",
|
||||
"date": "Data",
|
||||
"technician": "Teknikaria",
|
||||
"downtime": "Geldialdia",
|
||||
"maintenance_type": "Mantentze mota",
|
||||
"priority": "Lehentasuna",
|
||||
"scheduled_date": "Programatutako data",
|
||||
"time": "Ordua",
|
||||
"duration": "Iraupena (orduak)",
|
||||
"parts_needed": "Behar diren piezak",
|
||||
"description": "Deskribapena",
|
||||
"specifications": {
|
||||
"power": "Potentzia",
|
||||
"capacity": "Edukiera",
|
||||
@@ -49,13 +62,16 @@
|
||||
"add_equipment": "Gehitu makina",
|
||||
"edit_equipment": "Editatu makina",
|
||||
"delete_equipment": "Ezabatu makina",
|
||||
"delete": "Ezabatu",
|
||||
"schedule_maintenance": "Antolatu mantentzea",
|
||||
"schedule": "Antolatu",
|
||||
"view_maintenance_history": "Ikusi mantentze-historia",
|
||||
"acknowledge_alert": "Berretsi alerta",
|
||||
"view_details": "Ikusi xehetasunak",
|
||||
"view_history": "Ikusi historia",
|
||||
"close": "Itxi",
|
||||
"cost": "Kostua"
|
||||
"cost": "Kostua",
|
||||
"edit": "Editatu"
|
||||
},
|
||||
"labels": {
|
||||
"total_equipment": "Makina guztira",
|
||||
@@ -70,14 +86,23 @@
|
||||
"equipment_info": "Makinaren informazioa",
|
||||
"performance": "Errendimendua",
|
||||
"maintenance": "Mantentze informazioa",
|
||||
"maintenance_info": "Mantentze informazioa",
|
||||
"specifications": "Zehaztapenak",
|
||||
"temperature_monitoring": "Tenperatura-jarraipena",
|
||||
"notes": "Oharrak",
|
||||
"scheduling": "Programazioa",
|
||||
"details": "Xehetasunak",
|
||||
"create_equipment_subtitle": "Bete makinaren xehetasunak"
|
||||
},
|
||||
"placeholders": {
|
||||
"name": "Sartu makinaren izena",
|
||||
"model": "Sartu makinaren modeloa",
|
||||
"serial_number": "Sartu serie-zenbakia",
|
||||
"location": "Sartu kokapena"
|
||||
"location": "Sartu kokapena",
|
||||
"notes": "Ohar eta behaketa gehigarriak",
|
||||
"technician": "Esleitutako teknikariaren izena",
|
||||
"parts_needed": "Beharrezko piezen eta materialen zerrenda",
|
||||
"maintenance_description": "Egingo den lanaren deskribapena"
|
||||
},
|
||||
"descriptions": {
|
||||
"equipment_efficiency": "Uneko makinaren eraginkortasun-ehunekoa",
|
||||
@@ -93,12 +118,24 @@
|
||||
"records": "erregistro",
|
||||
"overdue": "Atzeratuta",
|
||||
"scheduled": "Antolatuta",
|
||||
"no_history": "Ez dago mantentze-historiarik",
|
||||
"no_history_description": "Mantentze-erregistroak hemen agertuko dira eragiketak egiten direnean",
|
||||
"type": {
|
||||
"preventive": "Prebentiboa",
|
||||
"corrective": "Zuzentzailea",
|
||||
"emergency": "Larria"
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"low": "Baxua",
|
||||
"medium": "Ertaina",
|
||||
"high": "Altua",
|
||||
"urgent": "Presazkoa"
|
||||
},
|
||||
"validation": {
|
||||
"required": "Eremu hau beharrezkoa da",
|
||||
"must_be_positive": "0 baino handiagoa izan behar du"
|
||||
},
|
||||
"alerts": {
|
||||
"title": "Alertak",
|
||||
"unread_alerts": "irakurri gabeko alertak",
|
||||
|
||||
100
frontend/src/locales/eu/models.json
Normal file
100
frontend/src/locales/eu/models.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"page_title": "IA Ereduen Konfigurazioa",
|
||||
"page_description": "Kudeatu osagai bakoitzaren iragarpen-ereduen prestakuntza eta konfigurazioa",
|
||||
|
||||
"status": {
|
||||
"active": "Aktiboa",
|
||||
"no_model": "Eredurik Ez",
|
||||
"training": "Entrenatzen",
|
||||
"retraining": "Berrentrenatzea",
|
||||
"error": "Errorea"
|
||||
},
|
||||
|
||||
"retrain": {
|
||||
"title": "Eredua Berrentrenatu",
|
||||
"subtitle": "Eguneratu iragarpen-eredua datu berriekin",
|
||||
|
||||
"modes": {
|
||||
"quick": "Azkarra",
|
||||
"preset": "Aurrekonfiguratua",
|
||||
"advanced": "Aurreratua"
|
||||
},
|
||||
|
||||
"quick": {
|
||||
"title": "Berrentrenamendu Azkarra",
|
||||
"ingredient": "Osagaia",
|
||||
"current_accuracy": "Uneko Zehaztasuna",
|
||||
"last_training": "Azken Entrenamentua",
|
||||
"description": "Deskribapena",
|
||||
"description_text": "Berrentrenamendu azkarrak uneko ereduaren konfigurazio bera erabiltzen du baina datu berrienekin. Honek ereduaren zehaztasuna eguneratuta mantentzen du bere portaera aldatu gabe."
|
||||
},
|
||||
|
||||
"preset": {
|
||||
"title": "Hautatu Konfigurazioa",
|
||||
"ingredient": "Osagaia",
|
||||
"select": "Produktu Mota",
|
||||
"description": "Deskribapena",
|
||||
"seasonality_mode": "Denboraldiko Modua",
|
||||
"daily": "Eguneroko Denboraldia",
|
||||
"weekly": "Asteko Denboraldia",
|
||||
"yearly": "Urteko Denboraldia"
|
||||
},
|
||||
|
||||
"advanced": {
|
||||
"title": "Konfigurazio Aurreratua",
|
||||
"ingredient": "Osagaia",
|
||||
"start_date": "Hasiera Data",
|
||||
"start_date_help": "Hutsik utzi datu guztiak erabiltzeko",
|
||||
"end_date": "Amaiera Data",
|
||||
"end_date_help": "Hutsik utzi gaur arte erabiltzeko",
|
||||
"seasonality_mode": "Denboraldiko Modua",
|
||||
"seasonality_mode_help": "Gehigarria: aldaketa konstanteak. Biderkatzailea: aldaketa proportzionalak.",
|
||||
"seasonality_patterns": "Denboraldi Ereduak",
|
||||
"daily_seasonality": "Eguneroko Denboraldia",
|
||||
"daily_seasonality_help": "Egunero errepikatzen diren ereduak",
|
||||
"weekly_seasonality": "Asteko Denboraldia",
|
||||
"weekly_seasonality_help": "Astero errepikatzen diren ereduak",
|
||||
"yearly_seasonality": "Urteko Denboraldia",
|
||||
"yearly_seasonality_help": "Urtero errepikatzen diren ereduak (jaiak, denboraldiak)"
|
||||
}
|
||||
},
|
||||
|
||||
"presets": {
|
||||
"standard": {
|
||||
"name": "Okindegi Estandarra",
|
||||
"description": "Gomendatua asteko ereduak eta eguneroko zikloak dituzten produktuentzat. Egokia ogia eta egunero labe-produktuentzat."
|
||||
},
|
||||
"seasonal": {
|
||||
"name": "Denboraldiko Produktuak",
|
||||
"description": "Denboraldiko eskaria duten produktuentzat. Urteko ereduak barne hartzen ditu jaietarako eta ekitaldi berezietarako."
|
||||
},
|
||||
"stable": {
|
||||
"name": "Eskari Egonkorra",
|
||||
"description": "Eskari konstantea duten oinarrizko osagaientzat. Denboraldia gutxienekoa."
|
||||
},
|
||||
"custom": {
|
||||
"name": "Pertsonalizatua",
|
||||
"description": "Konfigurazio aurreratua parametroen kontrol osoarekin."
|
||||
}
|
||||
},
|
||||
|
||||
"seasonality": {
|
||||
"additive": "Gehigarria",
|
||||
"multiplicative": "Biderkatzailea"
|
||||
},
|
||||
|
||||
"actions": {
|
||||
"train": "Entrenatu",
|
||||
"retrain": "Berrentrenatu",
|
||||
"view_details": "Ikusi Xehetasunak",
|
||||
"cancel": "Ezeztatu",
|
||||
"save": "Gorde"
|
||||
},
|
||||
|
||||
"messages": {
|
||||
"training_started": "Entrenamentua hasi da {{name}}rako",
|
||||
"training_error": "Errorea entrenamentua hastean",
|
||||
"retraining_started": "Berrentrenamendua hasi da {{name}}rako",
|
||||
"retraining_error": "Errorea eredua berrentrenatzean"
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@
|
||||
"address_info": "Helbide informazioa",
|
||||
"commercial_info": "Informazio komertziala",
|
||||
"additional_info": "Informazio gehigarria",
|
||||
"price_list": "Prezioen Zerrenda",
|
||||
"performance": "Errendimendua eta estatistikak",
|
||||
"notes": "Oharrak"
|
||||
},
|
||||
@@ -129,12 +130,92 @@
|
||||
"actions": {
|
||||
"approve": "Hornitzailea Onartu",
|
||||
"reject": "Hornitzailea Baztertu",
|
||||
"delete": "Hornitzailea Ezabatu"
|
||||
"delete": "Hornitzailea Ezabatu",
|
||||
"manage_products": "Produktuak Kudeatu"
|
||||
},
|
||||
"confirm": {
|
||||
"approve": "Ziur zaude hornitzaile hau onartu nahi duzula? Honek hornitzailea erabiltzeko aktibatuko du.",
|
||||
"reject": "Ziur zaude hornitzaile hau baztertu nahi duzula? Ekintza hau geroago desegin daiteke."
|
||||
},
|
||||
"price_list": {
|
||||
"title": "Produktuen Prezioen Zerrenda",
|
||||
"subtitle": "{{count}} produktu hornitzaile honetatik eskuragarri",
|
||||
"modal": {
|
||||
"title_create": "Produktua Gehitu Hornitzaileari",
|
||||
"title_edit": "Produktuaren Prezioa Editatu",
|
||||
"subtitle_create": "Gehitu hornitzaile honek hornitu dezakeen produktu berri bat",
|
||||
"subtitle_edit": "Eguneratu produktuaren prezioak eta xehetasunak"
|
||||
},
|
||||
"sections": {
|
||||
"product_selection": "Produktu Hautapena",
|
||||
"pricing": "Prezio Informazioa",
|
||||
"validity": "Prezioaren Baliozkotasuna",
|
||||
"product_details": "Produktuaren Xehetasunak"
|
||||
},
|
||||
"fields": {
|
||||
"product": "Produktua",
|
||||
"product_code": "Hornitzailearen Produktu Kodea",
|
||||
"unit_price": "Unitate Prezioa",
|
||||
"price_per_unit": "Unitateko Prezioa",
|
||||
"unit_of_measure": "Neurri Unitatea",
|
||||
"minimum_order": "Gutxieneko Eskaera Kantitatea",
|
||||
"effective_date": "Indarrean Sartzeko Data",
|
||||
"expiry_date": "Iraungitze Data",
|
||||
"is_active": "Aktiboa",
|
||||
"brand": "Marka",
|
||||
"packaging_size": "Ontziaren Tamaina",
|
||||
"origin_country": "Jatorri Herrialdea",
|
||||
"shelf_life_days": "Iraupen Eguna (egunak)",
|
||||
"storage_requirements": "Biltegiratzeko Baldintzak"
|
||||
},
|
||||
"placeholders": {
|
||||
"product_code": "adib., HORN-IRINA-001",
|
||||
"brand": "Markaren izena",
|
||||
"packaging_size": "adib., 25kg zakuak, 1L botilak",
|
||||
"origin_country": "adib., Espainia, Frantzia",
|
||||
"storage_requirements": "adib., Gorde leku fresko eta lehor batean"
|
||||
},
|
||||
"help": {
|
||||
"product_locked": "Produktua ezin da aldatu sortu ondoren",
|
||||
"select_product": "Hautatu produktu bat zure inbentariotik",
|
||||
"product_code": "Hornitzailearen barne kodea produktu honetarako",
|
||||
"unit_price": "Oinarrizko prezioa pakete/unitateko",
|
||||
"price_per_unit": "Kalkulatutako prezioa neurri unitateko",
|
||||
"unit_of_measure": "Prezioak eta eskaeretan erabilitako unitatea",
|
||||
"minimum_order": "Eskaera egiteko beharrezko gutxieneko kantitatea",
|
||||
"effective_date": "Prezio hau indarrean sartzen den data",
|
||||
"expiry_date": "Prezio honentzako aukerako iraungitze data",
|
||||
"is_active": "Aktibatu edo desaktibatu prezio zerrenda elementu hau",
|
||||
"packaging_size": "adib., 25kg zakuak, 1L botilak, 100 unitate kaxako",
|
||||
"shelf_life_days": "Produktua freskoa mantentzen den egun kopurua"
|
||||
},
|
||||
"columns": {
|
||||
"product": "Produktua",
|
||||
"price": "Prezioa",
|
||||
"min_order": "Gutx. Eskaera",
|
||||
"validity": "Baliozkotasun Aldia",
|
||||
"brand": "Marka",
|
||||
"status": "Egoera"
|
||||
},
|
||||
"actions": {
|
||||
"add_product": "Produktua Gehitu",
|
||||
"add_first_product": "Lehen Produktua Gehitu"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Oraindik Ez Dago Produkturik",
|
||||
"description": "Gehitu hornitzaile honek bere prezioekin hornitu ditzakeen produktuak"
|
||||
},
|
||||
"errors": {
|
||||
"load_failed": "Errorea prezioen zerrenda kargatzean"
|
||||
},
|
||||
"validation": {
|
||||
"price_positive": "Prezioa 0 baino handiagoa izan behar da"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Produktua Kendu Hornitzailetik",
|
||||
"description": "Ziur zaude {{product}} hornitzaile honen prezioen zerrendatik kendu nahi duzula?"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"title": "Hornitzailea Ezabatu",
|
||||
"subtitle": "Nola ezabatu nahi duzu {name}?",
|
||||
|
||||
@@ -177,21 +177,13 @@ const DashboardPage: React.FC = () => {
|
||||
navigate('/app/operations/procurement');
|
||||
};
|
||||
|
||||
// Build stats from real API data
|
||||
// Build stats from real API data (Sales analytics removed - Professional/Enterprise tier only)
|
||||
const criticalStats = React.useMemo(() => {
|
||||
if (!dashboardStats) {
|
||||
// Return loading/empty state
|
||||
return [];
|
||||
}
|
||||
|
||||
// Format currency values
|
||||
const formatCurrency = (value: number): string => {
|
||||
return `${dashboardStats.salesCurrency}${value.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
// Determine trend direction
|
||||
const getTrendDirection = (value: number): 'up' | 'down' | 'neutral' => {
|
||||
if (value > 0) return 'up';
|
||||
@@ -199,33 +191,7 @@ const DashboardPage: React.FC = () => {
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
// Build subtitle for sales
|
||||
const salesChange = dashboardStats.salesToday * (dashboardStats.salesTrend / 100);
|
||||
const salesSubtitle = salesChange > 0
|
||||
? `+${formatCurrency(salesChange)} ${t('dashboard:messages.more_than_yesterday', 'more than yesterday')}`
|
||||
: salesChange < 0
|
||||
? `${formatCurrency(Math.abs(salesChange))} ${t('dashboard:messages.less_than_yesterday', 'less than yesterday')}`
|
||||
: t('dashboard:messages.same_as_yesterday', 'Same as yesterday');
|
||||
|
||||
// Build subtitle for products
|
||||
const productsChange = Math.round(dashboardStats.productsSoldToday * (dashboardStats.productsSoldTrend / 100));
|
||||
const productsSubtitle = productsChange !== 0
|
||||
? `${productsChange > 0 ? '+' : ''}${productsChange} ${t('dashboard:messages.more_units', 'units')}`
|
||||
: t('dashboard:messages.same_as_yesterday', 'Same as yesterday');
|
||||
|
||||
return [
|
||||
{
|
||||
title: t('dashboard:stats.sales_today', 'Sales Today'),
|
||||
value: formatCurrency(dashboardStats.salesToday),
|
||||
icon: Euro,
|
||||
variant: 'success' as const,
|
||||
trend: {
|
||||
value: Math.abs(dashboardStats.salesTrend),
|
||||
direction: getTrendDirection(dashboardStats.salesTrend),
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
},
|
||||
subtitle: salesSubtitle
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.pending_orders', 'Pending Orders'),
|
||||
value: dashboardStats.pendingOrders.toString(),
|
||||
@@ -240,18 +206,6 @@ const DashboardPage: React.FC = () => {
|
||||
? t('dashboard:messages.require_attention', 'Require attention')
|
||||
: t('dashboard:messages.all_caught_up', 'All caught up!')
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.products_sold', 'Products Sold'),
|
||||
value: dashboardStats.productsSoldToday.toString(),
|
||||
icon: Package,
|
||||
variant: 'info' as const,
|
||||
trend: dashboardStats.productsSoldTrend !== 0 ? {
|
||||
value: Math.abs(dashboardStats.productsSoldTrend),
|
||||
direction: getTrendDirection(dashboardStats.productsSoldTrend),
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
} : undefined,
|
||||
subtitle: productsSubtitle
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.stock_alerts', 'Critical Stock'),
|
||||
value: dashboardStats.criticalStock.toString(),
|
||||
@@ -406,8 +360,8 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Critical Metrics using StatsGrid */}
|
||||
<div data-tour="dashboard-stats">
|
||||
{isLoadingStats ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-32 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg animate-pulse"
|
||||
@@ -423,7 +377,7 @@ const DashboardPage: React.FC = () => {
|
||||
) : (
|
||||
<StatsGrid
|
||||
stats={criticalStats}
|
||||
columns={6}
|
||||
columns={4}
|
||||
gap="lg"
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useModelPerformance,
|
||||
useTenantTrainingStatistics
|
||||
} from '../../../../api/hooks/training';
|
||||
import { ModelDetailsModal } from '../../../../components/domain/forecasting';
|
||||
import { ModelDetailsModal, RetrainModelModal } from '../../../../components/domain/forecasting';
|
||||
import type { IngredientResponse } from '../../../../api/types/inventory';
|
||||
import type { TrainedModelResponse, SingleProductTrainingRequest } from '../../../../api/types/training';
|
||||
|
||||
@@ -47,6 +47,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
const [selectedIngredient, setSelectedIngredient] = useState<IngredientResponse | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<TrainedModelResponse | null>(null);
|
||||
const [showTrainingModal, setShowTrainingModal] = useState(false);
|
||||
const [showRetrainModal, setShowRetrainModal] = useState(false);
|
||||
const [showModelDetailsModal, setShowModelDetailsModal] = useState(false);
|
||||
const [trainingSettings, setTrainingSettings] = useState<Partial<SingleProductTrainingRequest>>({
|
||||
seasonality_mode: 'additive',
|
||||
@@ -183,9 +184,38 @@ const ModelsConfigPage: React.FC = () => {
|
||||
setShowTrainingModal(true);
|
||||
};
|
||||
|
||||
|
||||
const handleStartRetraining = (ingredient: IngredientResponse) => {
|
||||
setSelectedIngredient(ingredient);
|
||||
|
||||
// Find and set the model for this ingredient
|
||||
const model = modelStatuses.find(status => status.ingredient.id === ingredient.id)?.model;
|
||||
if (model) {
|
||||
setSelectedModel(model);
|
||||
}
|
||||
|
||||
setShowRetrainModal(true);
|
||||
};
|
||||
|
||||
const handleRetrain = async (settings: SingleProductTrainingRequest) => {
|
||||
if (!selectedIngredient) return;
|
||||
|
||||
try {
|
||||
await trainMutation.mutateAsync({
|
||||
tenantId,
|
||||
inventoryProductId: selectedIngredient.id,
|
||||
request: settings
|
||||
});
|
||||
|
||||
addToast(`Reentrenamiento iniciado para ${selectedIngredient.name}`, { type: 'success' });
|
||||
setShowRetrainModal(false);
|
||||
setSelectedIngredient(null);
|
||||
setSelectedModel(null);
|
||||
} catch (error) {
|
||||
addToast('Error al reentrenar el modelo', { type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
if (ingredientsLoading || modelsLoading) {
|
||||
return (
|
||||
@@ -238,7 +268,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: 'Precisión Promedio',
|
||||
value: statsError ? 'N/A' : (statistics?.average_accuracy ? `${Number(statistics.average_accuracy).toFixed(1)}%` : 'N/A'),
|
||||
value: statsError ? 'N/A' : (statistics?.models?.average_accuracy !== undefined && statistics?.models?.average_accuracy !== null ? `${Number(statistics.models.average_accuracy).toFixed(1)}%` : 'N/A'),
|
||||
icon: TrendingUp,
|
||||
variant: 'success',
|
||||
},
|
||||
@@ -354,7 +384,7 @@ const ModelsConfigPage: React.FC = () => {
|
||||
...(status.hasModel ? [{
|
||||
label: 'Reentrenar',
|
||||
icon: RotateCcw,
|
||||
onClick: () => handleStartTraining(status.ingredient),
|
||||
onClick: () => handleStartRetraining(status.ingredient),
|
||||
priority: 'secondary' as const
|
||||
}] : [])
|
||||
]}
|
||||
@@ -451,6 +481,22 @@ const ModelsConfigPage: React.FC = () => {
|
||||
model={selectedModel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Retrain Model Modal */}
|
||||
{selectedIngredient && (
|
||||
<RetrainModelModal
|
||||
isOpen={showRetrainModal}
|
||||
onClose={() => {
|
||||
setShowRetrainModal(false);
|
||||
setSelectedIngredient(null);
|
||||
setSelectedModel(null);
|
||||
}}
|
||||
ingredient={selectedIngredient}
|
||||
currentModel={selectedModel}
|
||||
onRetrain={handleRetrain}
|
||||
isLoading={trainMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
Calendar,
|
||||
Download,
|
||||
FileText,
|
||||
Info,
|
||||
HelpCircle
|
||||
Info
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { StatsGrid, Button, Card, Tooltip } from '../../../../components/ui';
|
||||
import { StatsGrid, Button, Card } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { useSustainabilityMetrics } from '../../../../api/hooks/sustainability';
|
||||
@@ -146,6 +145,76 @@ const SustainabilityPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we have insufficient data
|
||||
if (metrics.data_sufficient === false) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title={t('sustainability:page.title', 'Sostenibilidad')}
|
||||
description={t('sustainability:page.description', 'Seguimiento de impacto ambiental y cumplimiento SDG 12.3')}
|
||||
/>
|
||||
<Card className="p-8">
|
||||
<div className="text-center py-12 max-w-2xl mx-auto">
|
||||
<div className="mb-6 inline-flex items-center justify-center w-20 h-20 bg-blue-500/10 rounded-full">
|
||||
<Info className="w-10 h-10 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('sustainability:insufficient_data.title', 'Collecting Sustainability Data')}
|
||||
</h3>
|
||||
<p className="text-base text-[var(--text-secondary)] mb-6">
|
||||
{t('sustainability:insufficient_data.description',
|
||||
'Start producing batches to see your sustainability metrics and SDG compliance status.'
|
||||
)}
|
||||
</p>
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 mb-6">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('sustainability:insufficient_data.requirements_title', 'Minimum Requirements')}
|
||||
</h4>
|
||||
<ul className="text-sm text-[var(--text-secondary)] space-y-2 text-left max-w-md mx-auto">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.req_production',
|
||||
'At least 50kg of production over the analysis period'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.req_baseline',
|
||||
'90 days of production history for accurate baseline calculation'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 mt-0.5">•</span>
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.req_tracking',
|
||||
'Production batches with waste tracking enabled'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-[var(--text-tertiary)]">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>
|
||||
{t('sustainability:insufficient_data.current_production',
|
||||
'Current production: {{production}}kg of {{required}}kg minimum',
|
||||
{
|
||||
production: metrics.current_production_kg?.toFixed(1) || '0.0',
|
||||
required: metrics.minimum_production_required_kg || 50
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
{/* Page Header */}
|
||||
@@ -180,14 +249,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.waste_analytics', 'Análisis de Residuos')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.waste_analytics', 'Información detallada sobre los residuos generados en la producción')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.waste_analytics', 'Análisis de Residuos')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.waste_subtitle', 'Desglose de residuos por tipo')}
|
||||
</p>
|
||||
@@ -254,14 +318,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.environmental_impact', 'Impacto Ambiental')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.environmental_impact', 'Métricas de huella ambiental y su equivalencia en términos cotidianos')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.environmental_impact', 'Impacto Ambiental')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.environmental_subtitle', 'Métricas de huella ambiental')}
|
||||
</p>
|
||||
@@ -334,14 +393,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.sdg_compliance', 'Cumplimiento SDG 12.3')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.sdg_compliance', 'Progreso hacia el objetivo de desarrollo sostenible de la ONU para reducir residuos alimentarios')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.sdg_compliance', 'Cumplimiento SDG 12.3')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.sdg_subtitle', 'Progreso hacia objetivo ONU')}
|
||||
</p>
|
||||
@@ -413,14 +467,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.grant_readiness', 'Subvenciones Disponibles')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.grant_readiness', 'Programas de financiación disponibles para empresas españolas según la Ley 1/2025 de prevención de residuos')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.grant_readiness', 'Subvenciones Disponibles')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.grant_subtitle', 'Programas de financiación elegibles')}
|
||||
</p>
|
||||
@@ -508,14 +557,9 @@ const SustainabilityPage: React.FC = () => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.financial_impact', 'Impacto Financiero')}
|
||||
</h3>
|
||||
<Tooltip content={t('sustainability:tooltips.financial_impact', 'Costes asociados a residuos y ahorros potenciales mediante la reducción de desperdicio')}>
|
||||
<HelpCircle className="w-4 h-4 text-[var(--text-tertiary)] cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('sustainability:sections.financial_impact', 'Impacto Financiero')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('sustainability:sections.financial_subtitle', 'Costes y ahorros de sostenibilidad')}
|
||||
</p>
|
||||
|
||||
@@ -8,19 +8,26 @@ import { PageHeader } from '../../../../components/layout';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Equipment } from '../../../../api/types/equipment';
|
||||
import { EquipmentModal } from '../../../../components/domain/equipment/EquipmentModal';
|
||||
import { useEquipment, useCreateEquipment, useUpdateEquipment } from '../../../../api/hooks/equipment';
|
||||
import { DeleteEquipmentModal } from '../../../../components/domain/equipment/DeleteEquipmentModal';
|
||||
import { MaintenanceHistoryModal } from '../../../../components/domain/equipment/MaintenanceHistoryModal';
|
||||
import { ScheduleMaintenanceModal, type MaintenanceScheduleData } from '../../../../components/domain/equipment/ScheduleMaintenanceModal';
|
||||
import { useEquipment, useCreateEquipment, useUpdateEquipment, useDeleteEquipment, useHardDeleteEquipment } from '../../../../api/hooks/equipment';
|
||||
|
||||
const MaquinariaPage: React.FC = () => {
|
||||
const { t } = useTranslation(['equipment', 'common']);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [selectedItem, setSelectedItem] = useState<Equipment | null>(null);
|
||||
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||
const [showEquipmentModal, setShowEquipmentModal] = useState(false);
|
||||
const [equipmentModalMode, setEquipmentModalMode] = useState<'view' | 'edit' | 'create'>('create');
|
||||
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null);
|
||||
|
||||
// New modal states
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [equipmentForAction, setEquipmentForAction] = useState<Equipment | null>(null);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
@@ -29,9 +36,11 @@ const MaquinariaPage: React.FC = () => {
|
||||
is_active: true
|
||||
});
|
||||
|
||||
// Mutations for create and update
|
||||
// Mutations for create, update, and delete
|
||||
const createEquipmentMutation = useCreateEquipment(tenantId);
|
||||
const updateEquipmentMutation = useUpdateEquipment(tenantId);
|
||||
const deleteEquipmentMutation = useDeleteEquipment(tenantId);
|
||||
const hardDeleteEquipmentMutation = useHardDeleteEquipment(tenantId);
|
||||
|
||||
const handleCreateEquipment = () => {
|
||||
setSelectedEquipment({
|
||||
@@ -73,19 +82,58 @@ const MaquinariaPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleMaintenance = (equipmentId: string) => {
|
||||
console.log('Schedule maintenance for equipment:', equipmentId);
|
||||
// Implementation would go here
|
||||
const handleScheduleMaintenance = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowScheduleModal(true);
|
||||
};
|
||||
|
||||
const handleAcknowledgeAlert = (equipmentId: string, alertId: string) => {
|
||||
console.log('Acknowledge alert:', alertId, 'for equipment:', equipmentId);
|
||||
// Implementation would go here
|
||||
const handleScheduleMaintenanceSubmit = async (equipmentId: string, maintenanceData: MaintenanceScheduleData) => {
|
||||
try {
|
||||
// Update next maintenance date based on scheduled date
|
||||
await updateEquipmentMutation.mutateAsync({
|
||||
equipmentId: equipmentId,
|
||||
equipmentData: {
|
||||
nextMaintenance: maintenanceData.scheduledDate
|
||||
} as Partial<Equipment>
|
||||
});
|
||||
setShowScheduleModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error scheduling maintenance:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMaintenanceHistory = (equipmentId: string) => {
|
||||
console.log('View maintenance history for equipment:', equipmentId);
|
||||
// Implementation would go here
|
||||
const handleViewMaintenanceHistory = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowHistoryModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteEquipment = (equipment: Equipment) => {
|
||||
setEquipmentForAction(equipment);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleSoftDelete = async (equipmentId: string) => {
|
||||
try {
|
||||
await deleteEquipmentMutation.mutateAsync(equipmentId);
|
||||
setShowDeleteModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting equipment:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleHardDelete = async (equipmentId: string) => {
|
||||
try {
|
||||
await hardDeleteEquipmentMutation.mutateAsync(equipmentId);
|
||||
setShowDeleteModal(false);
|
||||
setEquipmentForAction(null);
|
||||
} catch (error) {
|
||||
console.error('Error hard deleting equipment:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEquipment = async (equipmentData: Equipment) => {
|
||||
@@ -200,13 +248,9 @@ const MaquinariaPage: React.FC = () => {
|
||||
];
|
||||
|
||||
const handleShowMaintenanceDetails = (equipment: Equipment) => {
|
||||
setSelectedItem(equipment);
|
||||
setShowMaintenanceModal(true);
|
||||
};
|
||||
|
||||
const handleCloseMaintenanceModal = () => {
|
||||
setShowMaintenanceModal(false);
|
||||
setSelectedItem(null);
|
||||
setSelectedEquipment(equipment);
|
||||
setEquipmentModalMode('view');
|
||||
setShowEquipmentModal(true);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
@@ -336,23 +380,25 @@ const MaquinariaPage: React.FC = () => {
|
||||
priority: 'primary',
|
||||
onClick: () => handleShowMaintenanceDetails(equipment)
|
||||
},
|
||||
{
|
||||
label: t('actions.edit'),
|
||||
icon: Edit,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleEditEquipment(equipment.id)
|
||||
},
|
||||
{
|
||||
label: t('actions.view_history'),
|
||||
icon: History,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleViewMaintenanceHistory(equipment.id)
|
||||
onClick: () => handleViewMaintenanceHistory(equipment)
|
||||
},
|
||||
{
|
||||
label: t('actions.schedule_maintenance'),
|
||||
icon: Wrench,
|
||||
priority: 'secondary',
|
||||
onClick: () => handleScheduleMaintenance(equipment.id)
|
||||
highlighted: true,
|
||||
onClick: () => handleScheduleMaintenance(equipment)
|
||||
},
|
||||
{
|
||||
label: t('actions.delete'),
|
||||
icon: Trash2,
|
||||
priority: 'secondary',
|
||||
destructive: true,
|
||||
onClick: () => handleDeleteEquipment(equipment)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -372,183 +418,7 @@ const MaquinariaPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Maintenance Details Modal */}
|
||||
{selectedItem && showMaintenanceModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto my-8">
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-[var(--text-primary)]">
|
||||
{selectedItem.name}
|
||||
</h2>
|
||||
<p className="text-[var(--text-secondary)] text-sm">
|
||||
{selectedItem.model} - {selectedItem.serialNumber}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseMaintenanceModal}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Equipment Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-2 text-sm sm:text-base">{t('fields.status')}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-2 h-2 sm:w-3 sm:h-3 rounded-full"
|
||||
style={{ backgroundColor: getStatusConfig(selectedItem.status).color }}
|
||||
/>
|
||||
<span className="text-[var(--text-primary)] text-sm sm:text-base">
|
||||
{t(`equipment_status.${selectedItem.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-2 text-sm sm:text-base">{t('fields.efficiency')}</h3>
|
||||
<div className="text-lg sm:text-2xl font-bold text-[var(--text-primary)]">
|
||||
{selectedItem.efficiency}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Information */}
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('maintenance.title')}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.last')}</p>
|
||||
<p className="text-[var(--text-primary)] text-sm sm:text-base">
|
||||
{new Date(selectedItem.lastMaintenance).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.next')}</p>
|
||||
<p className={`font-medium ${(new Date(selectedItem.nextMaintenance).getTime() < new Date().getTime()) ? 'text-red-500' : 'text-[var(--text-primary)]'} text-sm sm:text-base`}>
|
||||
{new Date(selectedItem.nextMaintenance).toLocaleDateString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.interval')}</p>
|
||||
<p className="text-[var(--text-primary)] text-sm sm:text-base">
|
||||
{selectedItem.maintenanceInterval} {t('common:units.days')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{new Date(selectedItem.nextMaintenance).getTime() < new Date().getTime() && (
|
||||
<div className="mt-3 p-2 bg-red-50 dark:bg-red-900/20 rounded border-l-2 border-red-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-xs sm:text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{t('maintenance.overdue')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Alerts */}
|
||||
{selectedItem.alerts.filter(a => !a.acknowledged).length > 0 && (
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('alerts.title')}</h3>
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{selectedItem.alerts.filter(a => !a.acknowledged).map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`p-2 sm:p-3 rounded border-l-2 ${
|
||||
alert.type === 'critical' ? 'bg-red-50 border-red-500 dark:bg-red-900/20' :
|
||||
alert.type === 'warning' ? 'bg-orange-50 border-orange-500 dark:bg-orange-900/20' :
|
||||
'bg-blue-50 border-blue-500 dark:bg-blue-900/20'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertTriangle className={`w-3 h-3 sm:w-4 sm:h-4 ${
|
||||
alert.type === 'critical' ? 'text-red-500' :
|
||||
alert.type === 'warning' ? 'text-orange-500' : 'text-blue-500'
|
||||
}`} />
|
||||
<span className="font-medium text-[var(--text-primary)] text-xs sm:text-sm">
|
||||
{alert.message}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--text-secondary)] hidden sm:block">
|
||||
{new Date(alert.timestamp).toLocaleString('es-ES')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Maintenance History */}
|
||||
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('maintenance.history')}</h3>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{selectedItem.maintenanceHistory.map((history) => (
|
||||
<div key={history.id} className="border-b border-[var(--border-primary)] pb-2 sm:pb-3 last:border-0 last:pb-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)] text-sm">{history.description}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{new Date(history.date).toLocaleDateString('es-ES')} - {history.technician}
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-1.5 py-0.5 sm:px-2 sm:py-1 bg-[var(--bg-tertiary)] text-xs rounded">
|
||||
{t(`maintenance.type.${history.type}`)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 sm:mt-2 flex flex-wrap gap-2">
|
||||
<span className="text-xs">
|
||||
<span className="text-[var(--text-secondary)]">{t('common:actions.cost')}:</span>
|
||||
<span className="font-medium text-[var(--text-primary)]"> €{history.cost}</span>
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
<span className="text-[var(--text-secondary)]">{t('fields.uptime')}:</span>
|
||||
<span className="font-medium text-[var(--text-primary)]"> {history.downtime}h</span>
|
||||
</span>
|
||||
</div>
|
||||
{history.partsUsed.length > 0 && (
|
||||
<div className="mt-1 sm:mt-2">
|
||||
<span className="text-xs text-[var(--text-secondary)]">{t('fields.parts')}:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{history.partsUsed.map((part, index) => (
|
||||
<span key={index} className="px-1.5 py-0.5 sm:px-2 sm:py-1 bg-[var(--bg-tertiary)] text-xs rounded">
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 sm:space-x-3 mt-4 sm:mt-6">
|
||||
<Button variant="outline" size="sm" onClick={handleCloseMaintenanceModal}>
|
||||
{t('common:actions.close')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => selectedItem && handleScheduleMaintenance(selectedItem.id)}>
|
||||
{t('actions.schedule_maintenance')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Equipment Modal */}
|
||||
{/* Equipment Modal - Used for View Details, Edit, and Create */}
|
||||
{showEquipmentModal && (
|
||||
<EquipmentModal
|
||||
isOpen={showEquipmentModal}
|
||||
@@ -561,6 +431,47 @@ const MaquinariaPage: React.FC = () => {
|
||||
mode={equipmentModalMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Equipment Modal */}
|
||||
{showDeleteModal && equipmentForAction && (
|
||||
<DeleteEquipmentModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
onSoftDelete={handleSoftDelete}
|
||||
onHardDelete={handleHardDelete}
|
||||
isLoading={deleteEquipmentMutation.isPending || hardDeleteEquipmentMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Maintenance History Modal */}
|
||||
{showHistoryModal && equipmentForAction && (
|
||||
<MaintenanceHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => {
|
||||
setShowHistoryModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Schedule Maintenance Modal */}
|
||||
{showScheduleModal && equipmentForAction && (
|
||||
<ScheduleMaintenanceModal
|
||||
isOpen={showScheduleModal}
|
||||
onClose={() => {
|
||||
setShowScheduleModal(false);
|
||||
setEquipmentForAction(null);
|
||||
}}
|
||||
equipment={equipmentForAction}
|
||||
onSchedule={handleScheduleMaintenanceSubmit}
|
||||
isLoading={updateEquipmentMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ const ProcurementPage: React.FC = () => {
|
||||
|
||||
const handleTriggerScheduler = async () => {
|
||||
try {
|
||||
await triggerSchedulerMutation.mutateAsync({ tenantId });
|
||||
await triggerSchedulerMutation.mutateAsync(tenantId);
|
||||
toast.success('Scheduler ejecutado exitosamente');
|
||||
refetchPOs();
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
export { default as ProcurementPage } from './ProcurementPage';
|
||||
export { default as ProcurementPage } from './ProcurementPage';
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader, Trash2 } from 'lucide-react';
|
||||
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, AddModal, SearchAndFilter, DialogModal, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader, Trash2, DollarSign, Package } from 'lucide-react';
|
||||
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, AddModal, SearchAndFilter, DialogModal, Modal, ModalHeader, ModalBody, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
|
||||
import { useSuppliers, useSupplierStatistics, useCreateSupplier, useUpdateSupplier, useApproveSupplier, useDeleteSupplier, useHardDeleteSupplier } from '../../../../api/hooks/suppliers';
|
||||
import { useSuppliers, useSupplierStatistics, useCreateSupplier, useUpdateSupplier, useApproveSupplier, useDeleteSupplier, useHardDeleteSupplier, useSupplierPriceLists, useCreateSupplierPriceList, useUpdateSupplierPriceList, useDeleteSupplierPriceList } from '../../../../api/hooks/suppliers';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { statusColors } from '../../../../styles/colors';
|
||||
import { DeleteSupplierModal } from '../../../../components/domain/suppliers';
|
||||
import { DeleteSupplierModal, SupplierPriceListViewModal, PriceListModal } from '../../../../components/domain/suppliers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const SuppliersPage: React.FC = () => {
|
||||
const [activeTab] = useState('all');
|
||||
@@ -23,6 +24,9 @@ const SuppliersPage: React.FC = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [supplierToApprove, setSupplierToApprove] = useState<any>(null);
|
||||
const [showPriceListView, setShowPriceListView] = useState(false);
|
||||
const [showAddPrice, setShowAddPrice] = useState(false);
|
||||
const [priceListSupplier, setPriceListSupplier] = useState<any>(null);
|
||||
|
||||
// Get tenant ID from tenant store (preferred) or auth user (fallback)
|
||||
const currentTenant = useCurrentTenant();
|
||||
@@ -48,6 +52,7 @@ const SuppliersPage: React.FC = () => {
|
||||
|
||||
const suppliers = suppliersData || [];
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Mutation hooks
|
||||
const createSupplierMutation = useCreateSupplier();
|
||||
@@ -56,6 +61,21 @@ const SuppliersPage: React.FC = () => {
|
||||
const softDeleteMutation = useDeleteSupplier();
|
||||
const hardDeleteMutation = useHardDeleteSupplier();
|
||||
|
||||
// Price list hooks
|
||||
const {
|
||||
data: priceListsData,
|
||||
isLoading: priceListsLoading,
|
||||
isRefetching: isRefetchingPriceLists
|
||||
} = useSupplierPriceLists(
|
||||
tenantId,
|
||||
priceListSupplier?.id || '',
|
||||
!!priceListSupplier?.id && showPriceListView
|
||||
);
|
||||
|
||||
const createPriceListMutation = useCreateSupplierPriceList();
|
||||
const updatePriceListMutation = useUpdateSupplierPriceList();
|
||||
const deletePriceListMutation = useDeleteSupplierPriceList();
|
||||
|
||||
// Delete handlers
|
||||
const handleSoftDelete = async (supplierId: string) => {
|
||||
await softDeleteMutation.mutateAsync({ tenantId, supplierId });
|
||||
@@ -65,6 +85,27 @@ const SuppliersPage: React.FC = () => {
|
||||
return await hardDeleteMutation.mutateAsync({ tenantId, supplierId });
|
||||
};
|
||||
|
||||
// Price list handlers
|
||||
const handlePriceListSaveComplete = async () => {
|
||||
if (!tenantId || !priceListSupplier?.id) return;
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['supplier-price-lists', tenantId, priceListSupplier.id]
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddPriceSubmit = async (priceListData: any) => {
|
||||
if (!priceListSupplier) return;
|
||||
|
||||
await createPriceListMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: priceListSupplier.id,
|
||||
priceListData
|
||||
});
|
||||
|
||||
// Close the add modal
|
||||
setShowAddPrice(false);
|
||||
};
|
||||
|
||||
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||
const statusConfig = {
|
||||
[SupplierStatus.ACTIVE]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: CheckCircle },
|
||||
@@ -274,6 +315,18 @@ const SuppliersPage: React.FC = () => {
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
// Manage products action
|
||||
{
|
||||
label: t('suppliers:actions.manage_products'),
|
||||
icon: Package,
|
||||
variant: 'outline',
|
||||
priority: 'secondary',
|
||||
highlighted: true,
|
||||
onClick: () => {
|
||||
setPriceListSupplier(supplier);
|
||||
setShowPriceListView(true);
|
||||
}
|
||||
},
|
||||
// Approval action - Only show for pending suppliers + admin/super_admin
|
||||
...(supplier.status === SupplierStatus.PENDING_APPROVAL &&
|
||||
(user?.role === 'admin' || user?.role === 'super_admin')
|
||||
@@ -769,7 +822,7 @@ const SuppliersPage: React.FC = () => {
|
||||
placeholder: t('suppliers:placeholders.notes')
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
}] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -942,6 +995,55 @@ const SuppliersPage: React.FC = () => {
|
||||
}}
|
||||
loading={approveSupplierMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Price List View Modal */}
|
||||
{priceListSupplier && (
|
||||
<SupplierPriceListViewModal
|
||||
isOpen={showPriceListView}
|
||||
onClose={() => {
|
||||
setShowPriceListView(false);
|
||||
setPriceListSupplier(null);
|
||||
}}
|
||||
supplier={priceListSupplier}
|
||||
priceLists={priceListsData || []}
|
||||
loading={priceListsLoading}
|
||||
tenantId={tenantId}
|
||||
onAddPrice={() => setShowAddPrice(true)}
|
||||
onEditPrice={async (priceId, updateData) => {
|
||||
await updatePriceListMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: priceListSupplier.id,
|
||||
priceListId: priceId,
|
||||
priceListData: updateData
|
||||
});
|
||||
}}
|
||||
onDeletePrice={async (priceId) => {
|
||||
await deletePriceListMutation.mutateAsync({
|
||||
tenantId,
|
||||
supplierId: priceListSupplier.id,
|
||||
priceListId: priceId
|
||||
});
|
||||
}}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingPriceLists}
|
||||
onSaveComplete={handlePriceListSaveComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Add Price Modal */}
|
||||
{priceListSupplier && (
|
||||
<PriceListModal
|
||||
isOpen={showAddPrice}
|
||||
onClose={() => setShowAddPrice(false)}
|
||||
onSave={handleAddPriceSubmit}
|
||||
mode="create"
|
||||
loading={createPriceListMutation.isPending}
|
||||
excludeProductIds={priceListsData?.map(p => p.inventory_product_id) || []}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingPriceLists}
|
||||
onSaveComplete={handlePriceListSaveComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, CheckCircle, ArrowRight, Star, ExternalLink, Download, CreditCard, X, Activity, Database, Zap, HardDrive, ShoppingCart, ChefHat } from 'lucide-react';
|
||||
import { Button, Card, Badge, Modal } from '../../../../components/ui';
|
||||
import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { useToast } from '../../../../hooks/ui/useToast';
|
||||
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
|
||||
import { useSubscriptionEvents } from '../../../../contexts/SubscriptionEventsContext';
|
||||
import { SubscriptionPricingCards } from '../../../../components/subscription/SubscriptionPricingCards';
|
||||
|
||||
const SubscriptionPage: React.FC = () => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { addToast } = useToast();
|
||||
const { notifySubscriptionChanged } = useSubscriptionEvents();
|
||||
|
||||
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
|
||||
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
|
||||
@@ -154,6 +157,9 @@ const SubscriptionPage: React.FC = () => {
|
||||
if (result.success) {
|
||||
addToast(result.message, { type: 'success' });
|
||||
|
||||
// Broadcast subscription change event to refresh sidebar and other components
|
||||
notifySubscriptionChanged();
|
||||
|
||||
await loadSubscriptionData();
|
||||
setUpgradeDialogOpen(false);
|
||||
setSelectedPlan('');
|
||||
@@ -325,7 +331,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Usuarios</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
|
||||
{usageSummary.usage.users.current}/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,7 +339,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Ubicaciones</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
|
||||
{usageSummary.usage.locations.current}/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,7 +383,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
|
||||
@@ -398,7 +404,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
|
||||
@@ -425,7 +431,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
|
||||
@@ -446,7 +452,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.recipes.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.recipes.unlimited ? '∞' : usageSummary.usage.recipes.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.recipes.unlimited ? '∞' : usageSummary.usage.recipes.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.recipes.usage_percentage} />
|
||||
@@ -467,7 +473,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.suppliers.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.suppliers.unlimited ? '∞' : usageSummary.usage.suppliers.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.suppliers.unlimited ? '∞' : usageSummary.usage.suppliers.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.suppliers.usage_percentage} />
|
||||
@@ -494,7 +500,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.training_jobs_today.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.training_jobs_today.unlimited ? '∞' : usageSummary.usage.training_jobs_today.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.training_jobs_today.unlimited ? '∞' : usageSummary.usage.training_jobs_today.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.training_jobs_today.usage_percentage} />
|
||||
@@ -515,7 +521,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.forecasts_today.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.forecasts_today.unlimited ? '∞' : usageSummary.usage.forecasts_today.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.forecasts_today.unlimited ? '∞' : usageSummary.usage.forecasts_today.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.forecasts_today.usage_percentage} />
|
||||
@@ -542,7 +548,7 @@ const SubscriptionPage: React.FC = () => {
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{usageSummary.usage.api_calls_this_hour.current}<span className="text-[var(--text-tertiary)]">/</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.api_calls_this_hour.unlimited ? '∞' : usageSummary.usage.api_calls_this_hour.limit}</span>
|
||||
<span className="text-[var(--text-tertiary)]">{usageSummary.usage.api_calls_this_hour.unlimited ? '∞' : usageSummary.usage.api_calls_this_hour.limit ?? 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={usageSummary.usage.api_calls_this_hour.usage_percentage} />
|
||||
@@ -704,89 +710,61 @@ const SubscriptionPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Upgrade Modal */}
|
||||
{/* Upgrade Dialog */}
|
||||
{upgradeDialogOpen && selectedPlan && availablePlans && (
|
||||
<Modal
|
||||
<DialogModal
|
||||
isOpen={upgradeDialogOpen}
|
||||
onClose={() => setUpgradeDialogOpen(false)}
|
||||
title="Confirmar Cambio de Plan"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
¿Estás seguro de que quieres cambiar tu plan de suscripción?
|
||||
</p>
|
||||
{availablePlans.plans[selectedPlan] && usageSummary && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Plan actual:</span>
|
||||
<span>{usageSummary.plan}</span>
|
||||
message={
|
||||
<div className="space-y-3">
|
||||
<p>¿Estás seguro de que quieres cambiar tu plan de suscripción?</p>
|
||||
{availablePlans.plans[selectedPlan as keyof typeof availablePlans.plans] && usageSummary && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Plan actual:</span>
|
||||
<span>{usageSummary.plan}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Nuevo plan:</span>
|
||||
<span>{availablePlans.plans[selectedPlan as keyof typeof availablePlans.plans].name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>Nuevo precio:</span>
|
||||
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan as keyof typeof availablePlans.plans].monthly_price)}/mes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Nuevo plan:</span>
|
||||
<span>{availablePlans.plans[selectedPlan].name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-medium">
|
||||
<span>Nuevo precio:</span>
|
||||
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setUpgradeDialogOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpgradeConfirm}
|
||||
disabled={upgrading}
|
||||
className="flex-1"
|
||||
>
|
||||
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
type="confirm"
|
||||
onConfirm={handleUpgradeConfirm}
|
||||
onCancel={() => setUpgradeDialogOpen(false)}
|
||||
confirmLabel="Confirmar Cambio"
|
||||
cancelLabel="Cancelar"
|
||||
loading={upgrading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cancellation Modal */}
|
||||
{/* Cancellation Dialog */}
|
||||
{cancellationDialogOpen && (
|
||||
<Modal
|
||||
<DialogModal
|
||||
isOpen={cancellationDialogOpen}
|
||||
onClose={() => setCancellationDialogOpen(false)}
|
||||
title="Cancelar Suscripción"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
¿Estás seguro de que deseas cancelar tu suscripción? Esta acción no se puede deshacer.
|
||||
</p>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Perderás acceso a las funcionalidades premium al final del período de facturación actual.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancellationDialogOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelling}
|
||||
className="flex-1"
|
||||
>
|
||||
{cancelling ? 'Cancelando...' : 'Confirmar Cancelación'}
|
||||
</Button>
|
||||
message={
|
||||
<div className="space-y-3">
|
||||
<p>¿Estás seguro de que deseas cancelar tu suscripción? Esta acción no se puede deshacer.</p>
|
||||
<p>Perderás acceso a las funcionalidades premium al final del período de facturación actual.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
type="warning"
|
||||
onConfirm={handleCancelSubscription}
|
||||
onCancel={() => setCancellationDialogOpen(false)}
|
||||
confirmLabel="Confirmar Cancelación"
|
||||
cancelLabel="Volver"
|
||||
loading={cancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user