Improve the frontend 2

This commit is contained in:
Urtzi Alfaro
2025-10-29 06:58:05 +01:00
parent 858d985c92
commit 36217a2729
98 changed files with 6652 additions and 4230 deletions

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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
});
}

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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();

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
/**

View File

@@ -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;

View File

@@ -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')
}
]
}
];
};

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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>
)}
</>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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,
})}
/>
);
};

View 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}
/>
);
};

View 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"
/>
);
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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';

View 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;

View File

@@ -0,0 +1,9 @@
export { BaseDeleteModal } from './BaseDeleteModal';
export type {
DeleteMode,
EntityDisplayInfo,
DeleteModeOption,
DeleteWarning,
DeletionSummaryData,
BaseDeleteModalProps,
} from './BaseDeleteModal';

View File

@@ -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" />

View File

@@ -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';

View 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;
};

View File

@@ -66,4 +66,4 @@ export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
subscriptionInfo,
isLoading: subscriptionInfo.loading
};
};
};

View File

@@ -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",

View 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"
}
}

View File

@@ -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}?",

View File

@@ -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",

View 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"
}
}

View File

@@ -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}?",

View File

@@ -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",

View 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"
}
}

View File

@@ -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}?",

View File

@@ -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"
/>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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) {

View File

@@ -1 +1 @@
export { default as ProcurementPage } from './ProcurementPage';
export { default as ProcurementPage } from './ProcurementPage';

View File

@@ -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>
);
};

View File

@@ -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>
);