Improve the frontend
This commit is contained in:
246
frontend/src/api/hooks/dashboard.ts
Normal file
246
frontend/src/api/hooks/dashboard.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Dashboard React Query hooks
|
||||
* Aggregates data from multiple services for dashboard metrics
|
||||
*/
|
||||
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useSalesAnalytics } from './sales';
|
||||
import { useOrdersDashboard } from './orders';
|
||||
import { inventoryService } from '../services/inventory';
|
||||
import { getAlertAnalytics } from '../services/alert_analytics';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
import type { InventoryDashboardSummary } from '../types/dashboard';
|
||||
import type { AlertAnalytics } from '../services/alert_analytics';
|
||||
import type { SalesAnalytics } from '../types/sales';
|
||||
import type { OrdersDashboardSummary } from '../types/orders';
|
||||
|
||||
export interface DashboardStats {
|
||||
// Alert metrics
|
||||
activeAlerts: number;
|
||||
criticalAlerts: number;
|
||||
|
||||
// Order metrics
|
||||
pendingOrders: number;
|
||||
ordersToday: number;
|
||||
ordersTrend: number; // percentage change
|
||||
|
||||
// Sales metrics
|
||||
salesToday: number;
|
||||
salesTrend: number; // percentage change
|
||||
salesCurrency: string;
|
||||
|
||||
// Inventory metrics
|
||||
criticalStock: number;
|
||||
lowStockCount: number;
|
||||
outOfStockCount: number;
|
||||
expiringSoon: number;
|
||||
|
||||
// Production metrics
|
||||
productsSoldToday: number;
|
||||
productsSoldTrend: number;
|
||||
|
||||
// Data freshness
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface AggregatedDashboardData {
|
||||
alerts?: AlertAnalytics;
|
||||
orders?: OrdersDashboardSummary;
|
||||
sales?: SalesAnalytics;
|
||||
inventory?: InventoryDashboardSummary;
|
||||
}
|
||||
|
||||
// Query Keys
|
||||
export const dashboardKeys = {
|
||||
all: ['dashboard'] as const,
|
||||
stats: (tenantId: string) => [...dashboardKeys.all, 'stats', tenantId] as const,
|
||||
inventory: (tenantId: string) => [...dashboardKeys.all, 'inventory', tenantId] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fetch inventory dashboard summary
|
||||
*/
|
||||
export const useInventoryDashboard = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<InventoryDashboardSummary, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<InventoryDashboardSummary, ApiError>({
|
||||
queryKey: dashboardKeys.inventory(tenantId),
|
||||
queryFn: () => inventoryService.getDashboardSummary(tenantId),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch alert analytics
|
||||
*/
|
||||
export const useAlertAnalytics = (
|
||||
tenantId: string,
|
||||
days: number = 7,
|
||||
options?: Omit<UseQueryOptions<AlertAnalytics, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<AlertAnalytics, ApiError>({
|
||||
queryKey: ['alerts', 'analytics', tenantId, days],
|
||||
queryFn: () => getAlertAnalytics(tenantId, days),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate percentage change between two values
|
||||
*/
|
||||
function calculateTrend(current: number, previous: number): number {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate today's sales from sales records
|
||||
*/
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate orders metrics
|
||||
*/
|
||||
function calculateOrdersMetrics(ordersData?: OrdersDashboardSummary): { pending: number; today: number; trend: number } {
|
||||
if (!ordersData) {
|
||||
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;
|
||||
|
||||
return {
|
||||
pending: pendingCount,
|
||||
today: todayCount,
|
||||
trend: calculateTrend(todayCount, yesterdayCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate dashboard data from all services
|
||||
*/
|
||||
function aggregateDashboardStats(data: AggregatedDashboardData): DashboardStats {
|
||||
const sales = calculateTodaySales(data.sales);
|
||||
const orders = calculateOrdersMetrics(data.orders);
|
||||
|
||||
const criticalStockCount =
|
||||
(data.inventory?.low_stock_count || 0) +
|
||||
(data.inventory?.out_of_stock_count || 0);
|
||||
|
||||
return {
|
||||
// Alerts
|
||||
activeAlerts: data.alerts?.activeAlerts || 0,
|
||||
criticalAlerts: data.alerts?.totalAlerts || 0,
|
||||
|
||||
// Orders
|
||||
pendingOrders: orders.pending,
|
||||
ordersToday: orders.today,
|
||||
ordersTrend: orders.trend,
|
||||
|
||||
// Sales
|
||||
salesToday: sales.amount,
|
||||
salesTrend: sales.trend,
|
||||
salesCurrency: '€', // Default to EUR for bakery
|
||||
|
||||
// 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,
|
||||
|
||||
// Products
|
||||
productsSoldToday: sales.productsSold,
|
||||
productsSoldTrend: sales.productsTrend,
|
||||
|
||||
// Metadata
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook to fetch aggregated dashboard statistics
|
||||
* Combines data from multiple services into a single cohesive dashboard view
|
||||
*/
|
||||
export const useDashboardStats = (
|
||||
tenantId: string,
|
||||
options?: Omit<UseQueryOptions<DashboardStats, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
// Get today's date range for sales
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
||||
|
||||
return useQuery<DashboardStats, ApiError>({
|
||||
queryKey: dashboardKeys.stats(tenantId),
|
||||
queryFn: async () => {
|
||||
// Fetch all data in parallel
|
||||
const [alertsData, ordersData, salesData, inventoryData] = 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),
|
||||
]);
|
||||
|
||||
// Extract data or use undefined for failed requests
|
||||
const aggregatedData: AggregatedDashboardData = {
|
||||
alerts: alertsData.status === 'fulfilled' ? alertsData.value : undefined,
|
||||
orders: ordersData.status === 'fulfilled' ? ordersData.value : undefined,
|
||||
sales: salesData.status === 'fulfilled' ? salesData.value : undefined,
|
||||
inventory: inventoryData.status === 'fulfilled' ? inventoryData.value : undefined,
|
||||
};
|
||||
|
||||
// Log any failures for debugging
|
||||
if (alertsData.status === 'rejected') {
|
||||
console.warn('[Dashboard] Failed to fetch alerts:', alertsData.reason);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
return aggregateDashboardStats(aggregatedData);
|
||||
},
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
refetchInterval: 60 * 1000, // Auto-refresh every minute
|
||||
retry: 2, // Retry failed requests twice
|
||||
retryDelay: 1000, // Wait 1s between retries
|
||||
...options,
|
||||
});
|
||||
};
|
||||
259
frontend/src/api/hooks/purchase-orders.ts
Normal file
259
frontend/src/api/hooks/purchase-orders.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Purchase Orders React Query hooks
|
||||
* Handles data fetching and mutations for purchase orders
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { ApiError } from '../client/apiClient';
|
||||
import type {
|
||||
PurchaseOrderSummary,
|
||||
PurchaseOrderDetail,
|
||||
PurchaseOrderSearchParams,
|
||||
PurchaseOrderUpdateData,
|
||||
PurchaseOrderStatus
|
||||
} from '../services/purchase_orders';
|
||||
import {
|
||||
listPurchaseOrders,
|
||||
getPurchaseOrder,
|
||||
getPendingApprovalPurchaseOrders,
|
||||
getPurchaseOrdersByStatus,
|
||||
updatePurchaseOrder,
|
||||
approvePurchaseOrder,
|
||||
rejectPurchaseOrder,
|
||||
bulkApprovePurchaseOrders,
|
||||
deletePurchaseOrder
|
||||
} from '../services/purchase_orders';
|
||||
|
||||
// Query Keys
|
||||
export const purchaseOrderKeys = {
|
||||
all: ['purchase-orders'] as const,
|
||||
lists: () => [...purchaseOrderKeys.all, 'list'] as const,
|
||||
list: (tenantId: string, params?: PurchaseOrderSearchParams) =>
|
||||
[...purchaseOrderKeys.lists(), tenantId, params] as const,
|
||||
details: () => [...purchaseOrderKeys.all, 'detail'] as const,
|
||||
detail: (tenantId: string, poId: string) =>
|
||||
[...purchaseOrderKeys.details(), tenantId, poId] as const,
|
||||
byStatus: (tenantId: string, status: PurchaseOrderStatus) =>
|
||||
[...purchaseOrderKeys.lists(), tenantId, 'status', status] as const,
|
||||
pendingApproval: (tenantId: string) =>
|
||||
[...purchaseOrderKeys.lists(), tenantId, 'pending-approval'] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Hook to list purchase orders with optional filters
|
||||
*/
|
||||
export const usePurchaseOrders = (
|
||||
tenantId: string,
|
||||
params?: PurchaseOrderSearchParams,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderSummary[], ApiError>({
|
||||
queryKey: purchaseOrderKeys.list(tenantId, params),
|
||||
queryFn: () => listPurchaseOrders(tenantId, params),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get pending approval purchase orders
|
||||
*/
|
||||
export const usePendingApprovalPurchaseOrders = (
|
||||
tenantId: string,
|
||||
limit: number = 50,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderSummary[], ApiError>({
|
||||
queryKey: purchaseOrderKeys.pendingApproval(tenantId),
|
||||
queryFn: () => getPendingApprovalPurchaseOrders(tenantId, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 15 * 1000, // 15 seconds - more frequent for pending approvals
|
||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get purchase orders by status
|
||||
*/
|
||||
export const usePurchaseOrdersByStatus = (
|
||||
tenantId: string,
|
||||
status: PurchaseOrderStatus,
|
||||
limit: number = 50,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderSummary[], ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderSummary[], ApiError>({
|
||||
queryKey: purchaseOrderKeys.byStatus(tenantId, status),
|
||||
queryFn: () => getPurchaseOrdersByStatus(tenantId, status, limit),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get a single purchase order detail
|
||||
*/
|
||||
export const usePurchaseOrder = (
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
options?: Omit<UseQueryOptions<PurchaseOrderDetail, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) => {
|
||||
return useQuery<PurchaseOrderDetail, ApiError>({
|
||||
queryKey: purchaseOrderKeys.detail(tenantId, poId),
|
||||
queryFn: () => getPurchaseOrder(tenantId, poId),
|
||||
enabled: !!tenantId && !!poId,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a purchase order
|
||||
*/
|
||||
export const useUpdatePurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; data: PurchaseOrderUpdateData }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; data: PurchaseOrderUpdateData }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId, data }) => updatePurchaseOrder(tenantId, poId, data),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate and refetch related queries
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to approve a purchase order
|
||||
*/
|
||||
export const useApprovePurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; notes?: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; notes?: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId, notes }) => approvePurchaseOrder(tenantId, poId, notes),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate pending approvals list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId)
|
||||
});
|
||||
// Invalidate all lists
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
|
||||
// Invalidate detail
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reject a purchase order
|
||||
*/
|
||||
export const useRejectPurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; reason: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; reason: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId, reason }) => rejectPurchaseOrder(tenantId, poId, reason),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate pending approvals list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.pendingApproval(variables.tenantId)
|
||||
});
|
||||
// Invalidate all lists
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.lists() });
|
||||
// Invalidate detail
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to bulk approve purchase orders
|
||||
*/
|
||||
export const useBulkApprovePurchaseOrders = (
|
||||
options?: UseMutationOptions<
|
||||
PurchaseOrderDetail[],
|
||||
ApiError,
|
||||
{ tenantId: string; poIds: string[]; notes?: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
PurchaseOrderDetail[],
|
||||
ApiError,
|
||||
{ tenantId: string; poIds: string[]; notes?: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poIds, notes }) => bulkApprovePurchaseOrders(tenantId, poIds, notes),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate all PO queries
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a purchase order
|
||||
*/
|
||||
export const useDeletePurchaseOrder = (
|
||||
options?: UseMutationOptions<
|
||||
{ message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
{ message: string },
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId }) => deletePurchaseOrder(tenantId, poId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate all PO queries
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
125
frontend/src/api/services/alert_analytics.ts
Normal file
125
frontend/src/api/services/alert_analytics.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Alert Analytics API Client
|
||||
* Handles all API calls for alert analytics and interaction tracking
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export interface AlertTrendData {
|
||||
date: string;
|
||||
count: number;
|
||||
urgentCount: number;
|
||||
highCount: number;
|
||||
mediumCount: number;
|
||||
lowCount: number;
|
||||
}
|
||||
|
||||
export interface AlertCategory {
|
||||
category: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface AlertAnalytics {
|
||||
trends: AlertTrendData[];
|
||||
averageResponseTime: number;
|
||||
topCategories: AlertCategory[];
|
||||
totalAlerts: number;
|
||||
resolvedAlerts: number;
|
||||
activeAlerts: number;
|
||||
resolutionRate: number;
|
||||
predictedDailyAverage: number;
|
||||
busiestDay: string;
|
||||
}
|
||||
|
||||
export interface AlertInteraction {
|
||||
alert_id: string;
|
||||
interaction_type: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed';
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface InteractionResponse {
|
||||
id: string;
|
||||
alert_id: string;
|
||||
interaction_type: string;
|
||||
interacted_at: string;
|
||||
response_time_seconds: number;
|
||||
}
|
||||
|
||||
export interface BatchInteractionResponse {
|
||||
created_count: number;
|
||||
interactions: Array<{
|
||||
id: string;
|
||||
alert_id: string;
|
||||
interaction_type: string;
|
||||
interacted_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a single alert interaction
|
||||
*/
|
||||
export async function trackAlertInteraction(
|
||||
tenantId: string,
|
||||
alertId: string,
|
||||
interactionType: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed',
|
||||
metadata?: Record<string, any>
|
||||
): Promise<InteractionResponse> {
|
||||
return apiClient.post<InteractionResponse>(
|
||||
`/tenants/${tenantId}/alerts/${alertId}/interactions`,
|
||||
{
|
||||
alert_id: alertId,
|
||||
interaction_type: interactionType,
|
||||
metadata
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track multiple alert interactions in batch
|
||||
*/
|
||||
export async function trackAlertInteractionsBatch(
|
||||
tenantId: string,
|
||||
interactions: AlertInteraction[]
|
||||
): Promise<BatchInteractionResponse> {
|
||||
return apiClient.post<BatchInteractionResponse>(
|
||||
`/tenants/${tenantId}/alerts/interactions/batch`,
|
||||
{
|
||||
interactions
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive alert analytics
|
||||
*/
|
||||
export async function getAlertAnalytics(
|
||||
tenantId: string,
|
||||
days: number = 7
|
||||
): Promise<AlertAnalytics> {
|
||||
console.log('[getAlertAnalytics] Calling API:', `/tenants/${tenantId}/alerts/analytics`, 'with days:', days);
|
||||
const data = await apiClient.get<AlertAnalytics>(
|
||||
`/tenants/${tenantId}/alerts/analytics`,
|
||||
{
|
||||
params: { days }
|
||||
}
|
||||
);
|
||||
console.log('[getAlertAnalytics] Received data:', data);
|
||||
console.log('[getAlertAnalytics] Data type:', typeof data);
|
||||
return data; // apiClient.get() already returns data, not response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert trends only
|
||||
*/
|
||||
export async function getAlertTrends(
|
||||
tenantId: string,
|
||||
days: number = 7
|
||||
): Promise<AlertTrendData[]> {
|
||||
return apiClient.get<AlertTrendData[]>(
|
||||
`/tenants/${tenantId}/alerts/analytics/trends`,
|
||||
{
|
||||
params: { days }
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export class OrdersService {
|
||||
*/
|
||||
static async createOrder(orderData: OrderCreate): Promise<OrderResponse> {
|
||||
const { tenant_id, ...data } = orderData;
|
||||
return apiClient.post<OrderResponse>(`/tenants/${tenant_id}/orders/orders`, data);
|
||||
return apiClient.post<OrderResponse>(`/tenants/${tenant_id}/orders`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +92,7 @@ export class OrdersService {
|
||||
* GET /tenants/{tenant_id}/orders/{order_id}
|
||||
*/
|
||||
static async getOrder(tenantId: string, orderId: string): Promise<OrderResponse> {
|
||||
return apiClient.get<OrderResponse>(`/tenants/${tenantId}/orders/orders/${orderId}`);
|
||||
return apiClient.get<OrderResponse>(`/tenants/${tenantId}/orders/${orderId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +117,7 @@ export class OrdersService {
|
||||
queryParams.append('end_date', end_date);
|
||||
}
|
||||
|
||||
return apiClient.get<OrderResponse[]>(`/tenants/${tenant_id}/orders/orders?${queryParams.toString()}`);
|
||||
return apiClient.get<OrderResponse[]>(`/tenants/${tenant_id}/orders?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -381,7 +381,7 @@ export class ProductionService {
|
||||
}
|
||||
|
||||
async getProductionRequirements(tenantId: string, date: string): Promise<any> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/requirements/${date}`);
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/production/dashboard/requirements?date=${date}`);
|
||||
}
|
||||
|
||||
async getCapacityOverview(tenantId: string, date?: string): Promise<any> {
|
||||
|
||||
239
frontend/src/api/services/purchase_orders.ts
Normal file
239
frontend/src/api/services/purchase_orders.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Purchase Orders API Client
|
||||
* Handles all API calls for purchase orders in the suppliers service
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export type PurchaseOrderStatus =
|
||||
| 'DRAFT'
|
||||
| 'PENDING_APPROVAL'
|
||||
| 'APPROVED'
|
||||
| 'SENT_TO_SUPPLIER'
|
||||
| 'CONFIRMED'
|
||||
| 'RECEIVED'
|
||||
| 'COMPLETED'
|
||||
| 'CANCELLED'
|
||||
| 'DISPUTED';
|
||||
|
||||
export type PurchaseOrderPriority = 'urgent' | 'high' | 'normal' | 'low';
|
||||
|
||||
export interface PurchaseOrderItem {
|
||||
id: string;
|
||||
inventory_product_id: string;
|
||||
product_code?: string;
|
||||
ordered_quantity: number;
|
||||
unit_of_measure: string;
|
||||
unit_price: string; // Decimal as string
|
||||
line_total: string; // Decimal as string
|
||||
received_quantity: number;
|
||||
remaining_quantity: number;
|
||||
quality_requirements?: string;
|
||||
item_notes?: string;
|
||||
}
|
||||
|
||||
export interface SupplierSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
supplier_code: string;
|
||||
supplier_type: string;
|
||||
status: string;
|
||||
contact_person?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSummary {
|
||||
id: string;
|
||||
po_number: string;
|
||||
supplier_id: string;
|
||||
supplier_name?: string;
|
||||
status: PurchaseOrderStatus;
|
||||
priority: PurchaseOrderPriority;
|
||||
order_date: string;
|
||||
required_delivery_date?: string;
|
||||
total_amount: string; // Decimal as string
|
||||
currency: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderDetail extends PurchaseOrderSummary {
|
||||
reference_number?: string;
|
||||
estimated_delivery_date?: string;
|
||||
|
||||
// Financial information
|
||||
subtotal: string;
|
||||
tax_amount: string;
|
||||
shipping_cost: string;
|
||||
discount_amount: string;
|
||||
|
||||
// Delivery information
|
||||
delivery_address?: string;
|
||||
delivery_instructions?: string;
|
||||
delivery_contact?: string;
|
||||
delivery_phone?: string;
|
||||
|
||||
// Approval workflow
|
||||
requires_approval: boolean;
|
||||
approved_by?: string;
|
||||
approved_at?: string;
|
||||
rejection_reason?: string;
|
||||
|
||||
// Communication tracking
|
||||
sent_to_supplier_at?: string;
|
||||
supplier_confirmation_date?: string;
|
||||
supplier_reference?: string;
|
||||
|
||||
// Additional information
|
||||
notes?: string;
|
||||
internal_notes?: string;
|
||||
terms_and_conditions?: string;
|
||||
|
||||
// Audit fields
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
|
||||
// Related data
|
||||
supplier?: SupplierSummary;
|
||||
items?: PurchaseOrderItem[];
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSearchParams {
|
||||
supplier_id?: string;
|
||||
status?: PurchaseOrderStatus;
|
||||
priority?: PurchaseOrderPriority;
|
||||
date_from?: string; // YYYY-MM-DD
|
||||
date_to?: string; // YYYY-MM-DD
|
||||
search_term?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderUpdateData {
|
||||
status?: PurchaseOrderStatus;
|
||||
priority?: PurchaseOrderPriority;
|
||||
notes?: string;
|
||||
rejection_reason?: string;
|
||||
internal_notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of purchase orders with optional filters
|
||||
*/
|
||||
export async function listPurchaseOrders(
|
||||
tenantId: string,
|
||||
params?: PurchaseOrderSearchParams
|
||||
): Promise<PurchaseOrderSummary[]> {
|
||||
return apiClient.get<PurchaseOrderSummary[]>(
|
||||
`/tenants/${tenantId}/purchase-orders`,
|
||||
{ params }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get purchase orders by status
|
||||
*/
|
||||
export async function getPurchaseOrdersByStatus(
|
||||
tenantId: string,
|
||||
status: PurchaseOrderStatus,
|
||||
limit: number = 50
|
||||
): Promise<PurchaseOrderSummary[]> {
|
||||
return listPurchaseOrders(tenantId, { status, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending approval purchase orders
|
||||
*/
|
||||
export async function getPendingApprovalPurchaseOrders(
|
||||
tenantId: string,
|
||||
limit: number = 50
|
||||
): Promise<PurchaseOrderSummary[]> {
|
||||
return getPurchaseOrdersByStatus(tenantId, 'PENDING_APPROVAL', limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single purchase order by ID with full details
|
||||
*/
|
||||
export async function getPurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.get<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/purchase-orders/${poId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update purchase order
|
||||
*/
|
||||
export async function updatePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
data: PurchaseOrderUpdateData
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.put<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/purchase-orders/${poId}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a purchase order
|
||||
*/
|
||||
export async function approvePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
notes?: string
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.post<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/purchase-orders/${poId}/approve`,
|
||||
{
|
||||
action: 'approve',
|
||||
notes: notes || 'Approved from dashboard'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a purchase order
|
||||
*/
|
||||
export async function rejectPurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
reason: string
|
||||
): Promise<PurchaseOrderDetail> {
|
||||
return apiClient.post<PurchaseOrderDetail>(
|
||||
`/tenants/${tenantId}/purchase-orders/${poId}/approve`,
|
||||
{
|
||||
action: 'reject',
|
||||
notes: reason
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk approve purchase orders
|
||||
*/
|
||||
export async function bulkApprovePurchaseOrders(
|
||||
tenantId: string,
|
||||
poIds: string[],
|
||||
notes?: string
|
||||
): Promise<PurchaseOrderDetail[]> {
|
||||
const approvalPromises = poIds.map(poId =>
|
||||
approvePurchaseOrder(tenantId, poId, notes)
|
||||
);
|
||||
return Promise.all(approvalPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete purchase order
|
||||
*/
|
||||
export async function deletePurchaseOrder(
|
||||
tenantId: string,
|
||||
poId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`/tenants/${tenantId}/purchase-orders/${poId}`
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,19 @@ class SuppliersService {
|
||||
);
|
||||
}
|
||||
|
||||
async getSupplierProducts(
|
||||
tenantId: string,
|
||||
supplierId: string,
|
||||
isActive: boolean = true
|
||||
): Promise<Array<{ inventory_product_id: string }>> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('is_active', isActive.toString());
|
||||
|
||||
return apiClient.get<Array<{ inventory_product_id: string }>>(
|
||||
`${this.baseUrl}/${tenantId}/suppliers/suppliers/${supplierId}/products?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ATOMIC: Purchase Orders CRUD
|
||||
// Backend: services/suppliers/app/api/purchase_orders.py
|
||||
|
||||
126
frontend/src/components/domain/dashboard/AlertBulkActions.tsx
Normal file
126
frontend/src/components/domain/dashboard/AlertBulkActions.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Trash2, Clock, X } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import AlertSnoozeMenu from './AlertSnoozeMenu';
|
||||
|
||||
export interface AlertBulkActionsProps {
|
||||
selectedCount: number;
|
||||
onMarkAsRead: () => void;
|
||||
onRemove: () => void;
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onDeselectAll: () => void;
|
||||
onSelectAll: () => void;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const AlertBulkActions: React.FC<AlertBulkActionsProps> = ({
|
||||
selectedCount,
|
||||
onMarkAsRead,
|
||||
onRemove,
|
||||
onSnooze,
|
||||
onDeselectAll,
|
||||
onSelectAll,
|
||||
totalCount,
|
||||
}) => {
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
onSnooze(duration);
|
||||
setShowSnoozeMenu(false);
|
||||
};
|
||||
|
||||
const allSelected = selectedCount === totalCount;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white px-4 py-3 rounded-xl shadow-xl flex items-center justify-between gap-3 animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/20 backdrop-blur-sm rounded-lg">
|
||||
<span className="text-sm font-bold">{selectedCount}</span>
|
||||
<span className="text-xs font-medium opacity-90">
|
||||
{selectedCount === 1 ? 'seleccionado' : 'seleccionados'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!allSelected && totalCount > selectedCount && (
|
||||
<button
|
||||
onClick={onSelectAll}
|
||||
className="text-sm font-medium hover:underline opacity-90 hover:opacity-100 transition-opacity whitespace-nowrap"
|
||||
aria-label={`Select all ${totalCount} alerts`}
|
||||
>
|
||||
Seleccionar todos ({totalCount})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 relative flex-shrink-0">
|
||||
{/* Quick Actions */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onMarkAsRead}
|
||||
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Mark all selected as read"
|
||||
>
|
||||
<Check className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Marcar leídos</span>
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
|
||||
className="bg-white/15 text-white border-white/30 hover:bg-white/25 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Snooze selected alerts"
|
||||
>
|
||||
<Clock className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Posponer</span>
|
||||
</Button>
|
||||
|
||||
{showSnoozeMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowSnoozeMenu(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 z-30">
|
||||
<AlertSnoozeMenu
|
||||
onSnooze={handleSnooze}
|
||||
onCancel={() => setShowSnoozeMenu(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="bg-red-500/25 text-white border-red-300/40 hover:bg-red-500/40 backdrop-blur-sm h-9 px-3"
|
||||
aria-label="Delete selected alerts"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Eliminar</span>
|
||||
</Button>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDeselectAll}
|
||||
className="ml-1 p-2 hover:bg-white/15 rounded-lg transition-colors"
|
||||
aria-label="Deselect all"
|
||||
title="Cerrar selección"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertBulkActions;
|
||||
446
frontend/src/components/domain/dashboard/AlertCard.tsx
Normal file
446
frontend/src/components/domain/dashboard/AlertCard.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Check,
|
||||
Trash2,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import type { NotificationData } from '../../../hooks/useNotifications';
|
||||
import { getSnoozedTimeRemaining, categorizeAlert } from '../../../utils/alertHelpers';
|
||||
import AlertContextActions from './AlertContextActions';
|
||||
import AlertSnoozeMenu from './AlertSnoozeMenu';
|
||||
|
||||
export interface AlertCardProps {
|
||||
alert: NotificationData;
|
||||
isExpanded: boolean;
|
||||
isSelected: boolean;
|
||||
isSnoozed: boolean;
|
||||
snoozedUntil?: number;
|
||||
onToggleExpand: () => void;
|
||||
onToggleSelect: () => void;
|
||||
onMarkAsRead: () => void;
|
||||
onRemove: () => void;
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onUnsnooze: () => void;
|
||||
showCheckbox?: boolean;
|
||||
}
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return AlertTriangle;
|
||||
case 'high':
|
||||
return AlertCircle;
|
||||
case 'medium':
|
||||
return Info;
|
||||
case 'low':
|
||||
return CheckCircle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'var(--color-error)';
|
||||
case 'high':
|
||||
return 'var(--color-warning)';
|
||||
case 'medium':
|
||||
return 'var(--color-info)';
|
||||
case 'low':
|
||||
return 'var(--color-success)';
|
||||
default:
|
||||
return 'var(--color-info)';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity: string): 'error' | 'warning' | 'info' | 'success' => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'error';
|
||||
case 'high':
|
||||
return 'warning';
|
||||
case 'medium':
|
||||
return 'info';
|
||||
case 'low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string, t: any) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora');
|
||||
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
|
||||
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
|
||||
? t('dashboard:alerts.time.yesterday', 'Ayer')
|
||||
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
const AlertCard: React.FC<AlertCardProps> = ({
|
||||
alert,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
isSnoozed,
|
||||
snoozedUntil,
|
||||
onToggleExpand,
|
||||
onToggleSelect,
|
||||
onMarkAsRead,
|
||||
onRemove,
|
||||
onSnooze,
|
||||
onUnsnooze,
|
||||
showCheckbox = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const SeverityIcon = getSeverityIcon(alert.severity);
|
||||
const severityColor = getSeverityColor(alert.severity);
|
||||
|
||||
const handleSnooze = (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
onSnooze(duration);
|
||||
setShowSnoozeMenu(false);
|
||||
};
|
||||
|
||||
const category = categorizeAlert(alert);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-lg transition-all duration-200 relative overflow-hidden
|
||||
${isExpanded ? 'shadow-md' : 'hover:shadow-md'}
|
||||
${isSelected ? 'ring-2 ring-[var(--color-primary)] ring-offset-2' : ''}
|
||||
${isSnoozed ? 'opacity-75' : ''}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
...(isExpanded && {
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
}),
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Left severity accent border */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1"
|
||||
style={{ backgroundColor: severityColor }}
|
||||
/>
|
||||
|
||||
{/* Compact Card Header */}
|
||||
<div className="flex items-start gap-3 p-4 pl-5">
|
||||
{/* Checkbox for selection */}
|
||||
{showCheckbox && (
|
||||
<div className="flex-shrink-0 pt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect();
|
||||
}}
|
||||
className="w-4 h-4 rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)] focus:ring-offset-0 cursor-pointer"
|
||||
aria-label={`Select alert: ${alert.title}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Severity Icon */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg cursor-pointer hover:scale-105 transition-transform"
|
||||
style={{ backgroundColor: severityColor + '15' }}
|
||||
onClick={onToggleExpand}
|
||||
aria-label="Toggle alert details"
|
||||
>
|
||||
<SeverityIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: severityColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alert Content */}
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onToggleExpand}>
|
||||
{/* Title and Status */}
|
||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-base font-semibold leading-snug mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Single primary severity badge */}
|
||||
<Badge variant={getSeverityBadge(alert.severity)} size="sm" className="font-semibold px-2.5 py-1 min-h-[1.375rem]">
|
||||
{t(`dashboard:alerts.severity.${alert.severity}`, alert.severity.toUpperCase())}
|
||||
</Badge>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{!alert.read && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-blue-50 dark:bg-blue-900/20 min-h-[1.375rem]" style={{ color: 'var(--color-info)' }}>
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse flex-shrink-0" />
|
||||
{t('dashboard:alerts.status.new', 'Nuevo')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Snoozed indicator */}
|
||||
{isSnoozed && snoozedUntil && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md bg-gray-50 dark:bg-gray-800 min-h-[1.375rem]" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Clock className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">{getSnoozedTimeRemaining(alert.id, new Map([[alert.id, { alertId: alert.id, until: snoozedUntil }]]))}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-xs font-medium flex-shrink-0 pt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{formatTimestamp(alert.timestamp, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preview message when collapsed */}
|
||||
{!isExpanded && alert.message && (
|
||||
<p
|
||||
className="text-sm leading-relaxed mt-2 overflow-hidden"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{alert.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions - shown on hover or when expanded */}
|
||||
<div className={`flex-shrink-0 flex items-center gap-1 transition-opacity ${isHovered || isExpanded || showActions ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Quick action buttons */}
|
||||
{!alert.read && !isExpanded && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
title={t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
aria-label="Mark as read"
|
||||
>
|
||||
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions(!showActions);
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Menu - Better positioning */}
|
||||
{showActions && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowActions(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-3 top-16 z-30 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-1 min-w-[180px]">
|
||||
{!alert.read && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Check className="w-4 h-4" style={{ color: 'var(--color-success)' }} />
|
||||
Marcar como leído
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSnoozeMenu(true);
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
{isSnoozed ? 'Cambiar tiempo' : 'Posponer'}
|
||||
</button>
|
||||
{isSnoozed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnsnooze();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
Reactivar ahora
|
||||
</button>
|
||||
)}
|
||||
<div className="my-1 border-t border-[var(--border-primary)]" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Snooze Menu - Better positioning */}
|
||||
{showSnoozeMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setShowSnoozeMenu(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute right-3 top-16 z-30">
|
||||
<AlertSnoozeMenu
|
||||
onSnooze={handleSnooze}
|
||||
onCancel={() => setShowSnoozeMenu(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-5 pb-4 border-t pt-4" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
{/* Full Message */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
|
||||
<div className="mb-4 p-3 rounded-lg border" style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderColor: 'var(--border-primary)'
|
||||
}}>
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
|
||||
</p>
|
||||
<div className="text-sm space-y-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{Object.entries(alert.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between gap-4">
|
||||
<span className="font-medium capitalize text-[var(--text-primary)]">{key.replace(/_/g, ' ')}:</span>
|
||||
<span className="text-right">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contextual Actions */}
|
||||
<div className="mb-4">
|
||||
<AlertContextActions alert={alert} />
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{!alert.read && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead();
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSnoozeMenu(true);
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{isSnoozed ? 'Cambiar' : 'Posponer'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="h-9 px-4 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('dashboard:alerts.remove', 'Eliminar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertCard;
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import type { NotificationData } from '../../../hooks/useNotifications';
|
||||
import { useAlertActions } from '../../../hooks/useAlertActions';
|
||||
|
||||
export interface AlertContextActionsProps {
|
||||
alert: NotificationData;
|
||||
}
|
||||
|
||||
const AlertContextActions: React.FC<AlertContextActionsProps> = ({ alert }) => {
|
||||
const { getActions, executeAction } = useAlertActions();
|
||||
const actions = getActions(alert);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide text-[var(--text-primary)]">
|
||||
Acciones Recomendadas
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action, index) => {
|
||||
const variantMap: Record<string, 'primary' | 'secondary' | 'outline'> = {
|
||||
primary: 'primary',
|
||||
secondary: 'secondary',
|
||||
outline: 'outline',
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={variantMap[action.variant] || 'outline'}
|
||||
size="sm"
|
||||
onClick={() => executeAction(alert, action)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>{action.icon}</span>
|
||||
<span>{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertContextActions;
|
||||
306
frontend/src/components/domain/dashboard/AlertFilters.tsx
Normal file
306
frontend/src/components/domain/dashboard/AlertFilters.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, X, Filter, ChevronDown } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import type { AlertSeverity, AlertCategory, TimeGroup } from '../../../utils/alertHelpers';
|
||||
import { getCategoryName, getCategoryIcon } from '../../../utils/alertHelpers';
|
||||
|
||||
export interface AlertFiltersProps {
|
||||
selectedSeverities: AlertSeverity[];
|
||||
selectedCategories: AlertCategory[];
|
||||
selectedTimeRange: TimeGroup | 'all';
|
||||
searchQuery: string;
|
||||
showSnoozed: boolean;
|
||||
onToggleSeverity: (severity: AlertSeverity) => void;
|
||||
onToggleCategory: (category: AlertCategory) => void;
|
||||
onSetTimeRange: (range: TimeGroup | 'all') => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onToggleShowSnoozed: () => void;
|
||||
onClearFilters: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
activeFilterCount: number;
|
||||
}
|
||||
|
||||
const SEVERITY_CONFIG: Record<AlertSeverity, { label: string; color: string; variant: 'error' | 'warning' | 'info' | 'success' }> = {
|
||||
urgent: { label: 'Urgente', color: 'bg-red-500', variant: 'error' },
|
||||
high: { label: 'Alta', color: 'bg-orange-500', variant: 'warning' },
|
||||
medium: { label: 'Media', color: 'bg-blue-500', variant: 'info' },
|
||||
low: { label: 'Baja', color: 'bg-green-500', variant: 'success' },
|
||||
};
|
||||
|
||||
const TIME_RANGES: Array<{ value: TimeGroup | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'today', label: 'Hoy' },
|
||||
{ value: 'yesterday', label: 'Ayer' },
|
||||
{ value: 'this_week', label: 'Esta semana' },
|
||||
{ value: 'older', label: 'Anteriores' },
|
||||
];
|
||||
|
||||
const CATEGORIES: AlertCategory[] = ['inventory', 'production', 'orders', 'equipment', 'quality', 'suppliers'];
|
||||
|
||||
const AlertFilters: React.FC<AlertFiltersProps> = ({
|
||||
selectedSeverities,
|
||||
selectedCategories,
|
||||
selectedTimeRange,
|
||||
searchQuery,
|
||||
showSnoozed,
|
||||
onToggleSeverity,
|
||||
onToggleCategory,
|
||||
onSetTimeRange,
|
||||
onSearchChange,
|
||||
onToggleShowSnoozed,
|
||||
onClearFilters,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation(['dashboard']);
|
||||
// Start collapsed by default for cleaner UI
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Search and Filter Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('dashboard:alerts.filters.search_placeholder', 'Buscar alertas...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
leftIcon={<Search className="w-4 h-4" />}
|
||||
rightIcon={
|
||||
searchQuery ? (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="p-1 hover:bg-[var(--bg-secondary)] rounded-full transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
className="pr-8 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={showFilters || hasActiveFilters ? 'primary' : 'outline'}
|
||||
size="md"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 relative h-10 px-4"
|
||||
aria-expanded={showFilters}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline font-medium">Filtros</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-[var(--color-error)] text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearFilters}
|
||||
className="text-red-500 hover:text-red-600 h-10 px-3"
|
||||
title="Clear all filters"
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="sr-only">Limpiar filtros</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable Filters Panel - Animated */}
|
||||
{showFilters && (
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)] space-y-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
{/* Severity Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.severity', 'Severidad')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(SEVERITY_CONFIG) as AlertSeverity[]).map((severity) => {
|
||||
const config = SEVERITY_CONFIG[severity];
|
||||
const isSelected = selectedSeverities.includes(severity);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={severity}
|
||||
onClick={() => onToggleSeverity(severity)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSelected
|
||||
? 'ring-2 ring-[var(--color-primary)] ring-offset-2 ring-offset-[var(--bg-secondary)] scale-105'
|
||||
: 'opacity-70 hover:opacity-100 hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<Badge variant={config.variant} size="sm" className="pointer-events-none">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.category', 'Categoría')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((category) => {
|
||||
const isSelected = selectedCategories.includes(category);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onToggleCategory(category)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all border-2
|
||||
${isSelected
|
||||
? 'bg-[var(--color-primary)] text-white border-[var(--color-primary)] scale-105'
|
||||
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] border-[var(--border-primary)] hover:border-[var(--color-primary)] hover:text-[var(--text-primary)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="mr-1.5">{getCategoryIcon(category)}</span>
|
||||
{getCategoryName(category, i18n.language)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range Filters */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('dashboard:alerts.filters.time_range', 'Periodo')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{TIME_RANGES.map((range) => {
|
||||
const isSelected = selectedTimeRange === range.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => onSetTimeRange(range.value)}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${isSelected
|
||||
? 'bg-[var(--color-primary)] text-white shadow-md scale-105'
|
||||
: 'bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)] hover:scale-105'
|
||||
}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Snoozed Toggle */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border-primary)]">
|
||||
<label htmlFor="show-snoozed-toggle" className="text-sm font-medium text-[var(--text-primary)] cursor-pointer">
|
||||
{t('dashboard:alerts.filters.show_snoozed', 'Mostrar pospuestos')}
|
||||
</label>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
id="show-snoozed-toggle"
|
||||
type="checkbox"
|
||||
checked={showSnoozed}
|
||||
onChange={onToggleShowSnoozed}
|
||||
className="sr-only peer"
|
||||
aria-label="Toggle show snoozed alerts"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-300 dark:bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[var(--color-primary)]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Filters Summary - Chips */}
|
||||
{hasActiveFilters && !showFilters && (
|
||||
<div className="flex flex-wrap gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<span className="text-xs font-medium text-[var(--text-secondary)] self-center">
|
||||
Filtros activos:
|
||||
</span>
|
||||
|
||||
{selectedSeverities.map((severity) => (
|
||||
<button
|
||||
key={severity}
|
||||
onClick={() => onToggleSeverity(severity)}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label={`Remove ${severity} filter`}
|
||||
>
|
||||
<Badge
|
||||
variant={SEVERITY_CONFIG[severity].variant}
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{SEVERITY_CONFIG[severity].label}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedCategories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onToggleCategory(category)}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label={`Remove ${category} filter`}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{getCategoryIcon(category)} {getCategoryName(category, i18n.language)}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{selectedTimeRange !== 'all' && (
|
||||
<button
|
||||
onClick={() => onSetTimeRange('all')}
|
||||
className="group inline-flex items-center gap-1.5 transition-all hover:scale-105"
|
||||
aria-label="Remove time range filter"
|
||||
>
|
||||
<Badge
|
||||
variant="info"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 pr-1"
|
||||
>
|
||||
{TIME_RANGES.find(r => r.value === selectedTimeRange)?.label}
|
||||
<span className="p-0.5 rounded-full hover:bg-black/10 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertFilters;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import type { AlertGroup } from '../../../utils/alertHelpers';
|
||||
|
||||
export interface AlertGroupHeaderProps {
|
||||
group: AlertGroup;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
urgent: 'text-red-600 bg-red-50 border-red-200',
|
||||
high: 'text-orange-600 bg-orange-50 border-orange-200',
|
||||
medium: 'text-blue-600 bg-blue-50 border-blue-200',
|
||||
low: 'text-green-600 bg-green-50 border-green-200',
|
||||
};
|
||||
|
||||
const SEVERITY_BADGE_VARIANTS: Record<string, 'error' | 'warning' | 'info' | 'success'> = {
|
||||
urgent: 'error',
|
||||
high: 'warning',
|
||||
medium: 'info',
|
||||
low: 'success',
|
||||
};
|
||||
|
||||
const AlertGroupHeader: React.FC<AlertGroupHeaderProps> = ({
|
||||
group,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const severityConfig = SEVERITY_COLORS[group.severity] || SEVERITY_COLORS.low;
|
||||
const badgeVariant = SEVERITY_BADGE_VARIANTS[group.severity] || 'info';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={`
|
||||
w-full flex items-center justify-between p-3.5 rounded-lg border-2 transition-all
|
||||
${severityConfig}
|
||||
hover:shadow-md cursor-pointer hover:scale-[1.01]
|
||||
`}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={`${isCollapsed ? 'Expand' : 'Collapse'} ${group.title}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<h3 className="font-bold text-sm truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
{group.type === 'similarity' && group.count > 1 && (
|
||||
<p className="text-xs opacity-75 mt-0.5">
|
||||
{group.alerts.length} alertas similares
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
{group.count > 1 && (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-white/60 backdrop-blur-sm rounded-lg border border-current/20 min-h-[1.625rem]">
|
||||
<span className="text-xs font-bold leading-none">{group.count}</span>
|
||||
<span className="text-xs opacity-75 leading-none">alertas</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.severity && (
|
||||
<Badge variant={badgeVariant} size="sm" className="font-bold px-2.5 py-1 min-h-[1.625rem]">
|
||||
{group.severity.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertGroupHeader;
|
||||
118
frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx
Normal file
118
frontend/src/components/domain/dashboard/AlertSnoozeMenu.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Clock, X } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
|
||||
export interface AlertSnoozeMenuProps {
|
||||
onSnooze: (duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PRESET_DURATIONS = [
|
||||
{ value: '15min' as const, label: '15 minutos', icon: '⏰' },
|
||||
{ value: '1hr' as const, label: '1 hora', icon: '🕐' },
|
||||
{ value: '4hr' as const, label: '4 horas', icon: '🕓' },
|
||||
{ value: 'tomorrow' as const, label: 'Mañana (9 AM)', icon: '☀️' },
|
||||
];
|
||||
|
||||
const AlertSnoozeMenu: React.FC<AlertSnoozeMenuProps> = ({
|
||||
onSnooze,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [customHours, setCustomHours] = useState(1);
|
||||
|
||||
const handleCustomSnooze = () => {
|
||||
const milliseconds = customHours * 60 * 60 * 1000;
|
||||
onSnooze(milliseconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg p-3 min-w-[240px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
Posponer hasta
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 hover:bg-[var(--bg-secondary)] rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-[var(--text-secondary)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!showCustom ? (
|
||||
<>
|
||||
{/* Preset Options */}
|
||||
<div className="space-y-1 mb-2">
|
||||
{PRESET_DURATIONS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => onSnooze(preset.value)}
|
||||
className="w-full px-3 py-2 text-left text-sm rounded-lg hover:bg-[var(--bg-secondary)] transition-colors flex items-center gap-2 text-[var(--text-primary)]"
|
||||
>
|
||||
<span className="text-lg">{preset.icon}</span>
|
||||
<span>{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Option */}
|
||||
<button
|
||||
onClick={() => setShowCustom(true)}
|
||||
className="w-full px-3 py-2 text-left text-sm rounded-lg border border-dashed border-[var(--border-primary)] hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-secondary)]"
|
||||
>
|
||||
⚙️ Personalizado...
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Custom Time Input */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Número de horas
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Máximo 168 horas (7 días)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCustom(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Atrás
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCustomSnooze}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirmar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertSnoozeMenu;
|
||||
179
frontend/src/components/domain/dashboard/AlertTrends.tsx
Normal file
179
frontend/src/components/domain/dashboard/AlertTrends.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp, Clock, AlertTriangle, BarChart3 } from 'lucide-react';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import type { AlertAnalytics } from '../../../hooks/useAlertAnalytics';
|
||||
|
||||
export interface AlertTrendsProps {
|
||||
analytics: AlertAnalytics | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AlertTrends: React.FC<AlertTrendsProps> = ({ analytics, className }) => {
|
||||
// Debug logging
|
||||
console.log('[AlertTrends] Received analytics:', analytics);
|
||||
console.log('[AlertTrends] Has trends?', analytics?.trends);
|
||||
console.log('[AlertTrends] Is array?', Array.isArray(analytics?.trends));
|
||||
|
||||
// Safety check: handle undefined or missing analytics data
|
||||
if (!analytics || !analytics.trends || !Array.isArray(analytics.trends)) {
|
||||
console.log('[AlertTrends] Showing loading state');
|
||||
return (
|
||||
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
|
||||
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)]">
|
||||
Cargando analíticas...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[AlertTrends] Rendering analytics with', analytics.trends.length, 'trends');
|
||||
|
||||
// Ensure we have valid trend data
|
||||
const validTrends = analytics.trends.filter(t => t && typeof t.count === 'number');
|
||||
const maxCount = validTrends.length > 0 ? Math.max(...validTrends.map(t => t.count), 1) : 1;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Hoy';
|
||||
}
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Ayer';
|
||||
}
|
||||
return date.toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-[var(--bg-secondary)] rounded-lg p-4 border border-[var(--border-primary)] ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||
Tendencias (7 días)
|
||||
</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{analytics.totalAlerts} total
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-end justify-between gap-1 h-32">
|
||||
{analytics.trends.map((trend, index) => {
|
||||
const heightPercentage = maxCount > 0 ? (trend.count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trend.date}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '100px' }}>
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-[var(--color-primary)] to-[var(--color-primary)]/60 rounded-t transition-all hover:opacity-80 cursor-pointer relative group"
|
||||
style={{ height: `${heightPercentage}%`, minHeight: trend.count > 0 ? '4px' : '0' }}
|
||||
title={`${trend.count} alertas`}
|
||||
>
|
||||
{/* Tooltip on hover */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="bg-[var(--text-primary)] text-white text-xs rounded px-2 py-1 whitespace-nowrap">
|
||||
{trend.count} alertas
|
||||
<div className="text-[10px] opacity-75">
|
||||
🔴 {trend.urgentCount} • 🟠 {trend.highCount} • 🔵 {trend.mediumCount} • 🟢 {trend.lowCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-[10px] text-[var(--text-secondary)] text-center">
|
||||
{formatDate(trend.date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
{/* Average Response Time */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-blue-500/10 rounded">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Respuesta promedio</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.averageResponseTime > 0 ? `${analytics.averageResponseTime} min` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Average */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-purple-500/10 rounded">
|
||||
<TrendingUp className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Promedio diario</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.predictedDailyAverage} alertas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution Rate */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-green-500/10 rounded">
|
||||
<BarChart3 className="w-4 h-4 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Tasa de resolución</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.resolutionRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Busiest Day */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-orange-500/10 rounded">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">Día más activo</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{analytics.busiestDay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Categories */}
|
||||
{analytics.topCategories.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="text-xs font-semibold text-[var(--text-secondary)] mb-2">
|
||||
Categorías principales
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{analytics.topCategories.map((cat) => (
|
||||
<Badge key={cat.category} variant="secondary" size="sm">
|
||||
{cat.count} ({cat.percentage}%)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertTrends;
|
||||
425
frontend/src/components/domain/dashboard/PendingPOApprovals.tsx
Normal file
425
frontend/src/components/domain/dashboard/PendingPOApprovals.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { usePendingApprovalPurchaseOrders, useApprovePurchaseOrder, useRejectPurchaseOrder } from '../../../api/hooks/purchase-orders';
|
||||
import {
|
||||
ShoppingCart,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
Package,
|
||||
TruckIcon,
|
||||
Euro,
|
||||
FileCheck
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface PendingPOApprovalsProps {
|
||||
className?: string;
|
||||
maxPOs?: number;
|
||||
onApprovePO?: (poId: string) => void;
|
||||
onRejectPO?: (poId: string) => void;
|
||||
onViewDetails?: (poId: string) => void;
|
||||
onViewAllPOs?: () => void;
|
||||
}
|
||||
|
||||
const PendingPOApprovals: React.FC<PendingPOApprovalsProps> = ({
|
||||
className,
|
||||
maxPOs = 5,
|
||||
onApprovePO,
|
||||
onRejectPO,
|
||||
onViewDetails,
|
||||
onViewAllPOs
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const [approvingPO, setApprovingPO] = useState<string | null>(null);
|
||||
const [rejectingPO, setRejectingPO] = useState<string | null>(null);
|
||||
|
||||
// Fetch pending approval POs
|
||||
const { data: pendingPOs, isLoading, error } = usePendingApprovalPurchaseOrders(
|
||||
tenantId,
|
||||
50,
|
||||
{
|
||||
enabled: !!tenantId,
|
||||
}
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const approveMutation = useApprovePurchaseOrder({
|
||||
onSuccess: () => {
|
||||
setApprovingPO(null);
|
||||
if (approvingPO && onApprovePO) {
|
||||
onApprovePO(approvingPO);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to approve PO:', error);
|
||||
setApprovingPO(null);
|
||||
}
|
||||
});
|
||||
|
||||
const rejectMutation = useRejectPurchaseOrder({
|
||||
onSuccess: () => {
|
||||
setRejectingPO(null);
|
||||
if (rejectingPO && onRejectPO) {
|
||||
onRejectPO(rejectingPO);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to reject PO:', error);
|
||||
setRejectingPO(null);
|
||||
}
|
||||
});
|
||||
|
||||
const handleApprovePO = async (poId: string) => {
|
||||
setApprovingPO(poId);
|
||||
await approveMutation.mutateAsync({
|
||||
tenantId,
|
||||
poId,
|
||||
notes: 'Approved from dashboard'
|
||||
});
|
||||
};
|
||||
|
||||
const handleRejectPO = async (poId: string) => {
|
||||
setRejectingPO(poId);
|
||||
await rejectMutation.mutateAsync({
|
||||
tenantId,
|
||||
poId,
|
||||
reason: 'Rejected from dashboard - requires review'
|
||||
});
|
||||
};
|
||||
|
||||
const getPOPriorityConfig = (priority: string) => {
|
||||
switch (priority.toLowerCase()) {
|
||||
case 'urgent':
|
||||
return {
|
||||
color: 'var(--color-error)',
|
||||
text: 'Urgente',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true,
|
||||
isHighlight: false
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Alta',
|
||||
icon: Clock,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
case 'normal':
|
||||
return {
|
||||
color: 'var(--color-info)',
|
||||
text: 'Normal',
|
||||
icon: Package,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
};
|
||||
case 'low':
|
||||
return {
|
||||
color: 'var(--color-success)',
|
||||
text: 'Baja',
|
||||
icon: Clock,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'var(--color-info)',
|
||||
text: 'Normal',
|
||||
icon: Package,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: string, currency: string = 'EUR') => {
|
||||
const value = parseFloat(amount);
|
||||
if (currency === 'EUR') {
|
||||
return `€${value.toFixed(2)}`;
|
||||
}
|
||||
return `${value.toFixed(2)} ${currency}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) {
|
||||
return `Vencido hace ${Math.abs(diffDays)} días`;
|
||||
} else if (diffDays === 0) {
|
||||
return 'Hoy';
|
||||
} else if (diffDays === 1) {
|
||||
return 'Mañana';
|
||||
} else if (diffDays <= 7) {
|
||||
return `En ${diffDays} días`;
|
||||
}
|
||||
return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
// Process POs and sort by priority and delivery date
|
||||
const displayPOs = useMemo(() => {
|
||||
if (!pendingPOs || !Array.isArray(pendingPOs)) return [];
|
||||
|
||||
const pos = [...pendingPOs];
|
||||
|
||||
// Sort by priority and delivery date
|
||||
const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 };
|
||||
pos.sort((a, b) => {
|
||||
// First by priority
|
||||
const aPriority = priorityOrder[a.priority.toLowerCase() as keyof typeof priorityOrder] ?? 4;
|
||||
const bPriority = priorityOrder[b.priority.toLowerCase() as keyof typeof priorityOrder] ?? 4;
|
||||
|
||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||
|
||||
// Then by delivery date (earliest first)
|
||||
if (a.required_delivery_date && b.required_delivery_date) {
|
||||
const aDate = new Date(a.required_delivery_date).getTime();
|
||||
const bDate = new Date(b.required_delivery_date).getTime();
|
||||
return aDate - bDate;
|
||||
}
|
||||
|
||||
// Finally by created date (oldest first - longest waiting)
|
||||
const aCreated = new Date(a.created_at).getTime();
|
||||
const bCreated = new Date(b.created_at).getTime();
|
||||
return aCreated - bCreated;
|
||||
});
|
||||
|
||||
return pos.slice(0, maxPOs);
|
||||
}, [pendingPOs, maxPOs]);
|
||||
|
||||
const urgentPOs = pendingPOs?.filter(po => po.priority === 'urgent' || po.priority === 'high').length || 0;
|
||||
const totalPOs = pendingPOs?.length || 0;
|
||||
|
||||
// Calculate total amount pending approval
|
||||
const totalAmount = useMemo(() => {
|
||||
if (!displayPOs || displayPOs.length === 0) return '0.00';
|
||||
const sum = displayPOs.reduce((acc, po) => acc + parseFloat(po.total_amount || '0'), 0);
|
||||
return sum.toFixed(2);
|
||||
}, [displayPOs]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<ShoppingCart className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<ShoppingCart className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
|
||||
<p className="text-[var(--color-error)] text-sm">
|
||||
{t('dashboard:messages.error_loading', 'Error al cargar los datos')}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center justify-between w-full flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<ShoppingCart className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.pending_po_approvals', 'Órdenes de Compra Pendientes')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:po_approvals.title', '¿Qué órdenes debo aprobar?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{urgentPOs > 0 && (
|
||||
<Badge variant="error" size="sm">
|
||||
{urgentPOs} urgentes
|
||||
</Badge>
|
||||
)}
|
||||
{totalPOs > 0 && (
|
||||
<Badge variant="warning" size="sm">
|
||||
{totalPOs} pendientes
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-sm font-semibold text-[var(--text-primary)]">
|
||||
<Euro className="w-4 h-4" />
|
||||
<span>{formatCurrency(totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{displayPOs.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--color-success)/20' }}
|
||||
>
|
||||
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium mb-2 text-[var(--text-primary)]">
|
||||
{t('dashboard:po_approvals.empty', 'Sin órdenes pendientes de aprobación')}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Todas las órdenes de compra están aprobadas o en proceso
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{displayPOs.map((po) => {
|
||||
const priorityConfig = getPOPriorityConfig(po.priority);
|
||||
const deliveryDate = po.required_delivery_date
|
||||
? formatDate(po.required_delivery_date)
|
||||
: 'Sin fecha';
|
||||
|
||||
const isApproving = approvingPO === po.id;
|
||||
const isRejecting = rejectingPO === po.id;
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={po.id}
|
||||
id={po.id}
|
||||
statusIndicator={priorityConfig}
|
||||
title={po.supplier_name || 'Proveedor desconocido'}
|
||||
subtitle={`PO #${po.po_number}`}
|
||||
primaryValue={formatCurrency(po.total_amount, po.currency)}
|
||||
primaryValueLabel="MONTO TOTAL"
|
||||
secondaryInfo={{
|
||||
label: 'Entrega requerida',
|
||||
value: deliveryDate
|
||||
}}
|
||||
metadata={[
|
||||
`📦 Orden: ${po.po_number}`,
|
||||
`📅 Creada: ${new Date(po.created_at).toLocaleDateString('es-ES')}`,
|
||||
`🚚 Entrega: ${deliveryDate}`,
|
||||
...(po.priority === 'urgent' ? [`⚠️ URGENTE - Requiere aprobación inmediata`] : []),
|
||||
...(po.priority === 'high' ? [`⚡ ALTA PRIORIDAD`] : [])
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: isApproving ? 'Aprobando...' : 'Aprobar',
|
||||
icon: CheckCircle,
|
||||
variant: 'primary' as const,
|
||||
onClick: () => handleApprovePO(po.id),
|
||||
priority: 'primary' as const,
|
||||
disabled: isApproving || isRejecting
|
||||
},
|
||||
{
|
||||
label: isRejecting ? 'Rechazando...' : 'Rechazar',
|
||||
icon: XCircle,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => handleRejectPO(po.id),
|
||||
priority: 'secondary' as const,
|
||||
destructive: true,
|
||||
disabled: isApproving || isRejecting
|
||||
},
|
||||
{
|
||||
label: 'Ver Detalles',
|
||||
icon: ChevronRight,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onViewDetails?.(po.id),
|
||||
priority: 'secondary' as const
|
||||
}
|
||||
]}
|
||||
compact={true}
|
||||
className="border-l-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayPOs.length > 0 && (
|
||||
<div
|
||||
className="p-4 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{totalPOs} {t('dashboard:po_approvals.pos_pending', 'órdenes pendientes de aprobación')}
|
||||
</span>
|
||||
{urgentPOs > 0 && (
|
||||
<span className="ml-2 text-[var(--color-error)] font-semibold">
|
||||
• {urgentPOs} urgentes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onViewAllPOs && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAllPOs}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Ver Todas las Órdenes
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingPOApprovals;
|
||||
@@ -1,297 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
ShoppingCart,
|
||||
Clock,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
User,
|
||||
Euro,
|
||||
Truck
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ProcurementItem {
|
||||
id: string;
|
||||
ingredient: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
supplier: string;
|
||||
priority: 'urgent' | 'high' | 'medium' | 'low';
|
||||
estimatedCost: number;
|
||||
deliveryTime: string;
|
||||
currentStock: number;
|
||||
minStock: number;
|
||||
plannedFor: string;
|
||||
status: 'pending' | 'ordered' | 'in_transit' | 'delivered';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementPlansProps {
|
||||
className?: string;
|
||||
items?: ProcurementItem[];
|
||||
onOrderItem?: (itemId: string) => void;
|
||||
onViewDetails?: (itemId: string) => void;
|
||||
onViewAllPlans?: () => void;
|
||||
}
|
||||
|
||||
const ProcurementPlansToday: React.FC<ProcurementPlansProps> = ({
|
||||
className,
|
||||
items = [],
|
||||
onOrderItem,
|
||||
onViewDetails,
|
||||
onViewAllPlans
|
||||
}) => {
|
||||
const defaultItems: ProcurementItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
ingredient: 'Harina de Trigo',
|
||||
quantity: 50,
|
||||
unit: 'kg',
|
||||
supplier: 'Molinos San José',
|
||||
priority: 'urgent',
|
||||
estimatedCost: 87.50,
|
||||
deliveryTime: '10:00',
|
||||
currentStock: 3,
|
||||
minStock: 15,
|
||||
plannedFor: '09:00',
|
||||
status: 'pending',
|
||||
notes: 'Stock crítico - necesario para producción matutina'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
ingredient: 'Levadura Fresca',
|
||||
quantity: 5,
|
||||
unit: 'kg',
|
||||
supplier: 'Distribuidora Alba',
|
||||
priority: 'urgent',
|
||||
estimatedCost: 32.50,
|
||||
deliveryTime: '11:30',
|
||||
currentStock: 1,
|
||||
minStock: 3,
|
||||
plannedFor: '09:30',
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
ingredient: 'Mantequilla',
|
||||
quantity: 15,
|
||||
unit: 'kg',
|
||||
supplier: 'Lácteos Premium',
|
||||
priority: 'high',
|
||||
estimatedCost: 105.00,
|
||||
deliveryTime: '14:00',
|
||||
currentStock: 8,
|
||||
minStock: 12,
|
||||
plannedFor: '10:00',
|
||||
status: 'ordered'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
ingredient: 'Azúcar Blanco',
|
||||
quantity: 25,
|
||||
unit: 'kg',
|
||||
supplier: 'Azucarera Local',
|
||||
priority: 'medium',
|
||||
estimatedCost: 62.50,
|
||||
deliveryTime: '16:00',
|
||||
currentStock: 18,
|
||||
minStock: 20,
|
||||
plannedFor: '11:00',
|
||||
status: 'pending'
|
||||
}
|
||||
];
|
||||
|
||||
const displayItems = items.length > 0 ? items : defaultItems;
|
||||
|
||||
const getItemStatusConfig = (item: ProcurementItem) => {
|
||||
const baseConfig = {
|
||||
isCritical: item.priority === 'urgent',
|
||||
isHighlight: item.priority === 'high' || item.status === 'pending',
|
||||
};
|
||||
|
||||
switch (item.status) {
|
||||
case 'pending':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
case 'ordered':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-info)',
|
||||
text: 'Pedido',
|
||||
icon: CheckCircle
|
||||
};
|
||||
case 'in_transit':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-primary)',
|
||||
text: 'En Camino',
|
||||
icon: Truck
|
||||
};
|
||||
case 'delivered':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-success)',
|
||||
text: 'Entregado',
|
||||
icon: Package
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const urgentItems = displayItems.filter(item => item.priority === 'urgent').length;
|
||||
const pendingItems = displayItems.filter(item => item.status === 'pending').length;
|
||||
const totalValue = displayItems.reduce((sum, item) => sum + item.estimatedCost, 0);
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--color-primary)20' }}
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Planes de Compra - Hoy
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Gestiona los pedidos programados para hoy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{urgentItems > 0 && (
|
||||
<Badge variant="error" size="sm">
|
||||
{urgentItems} urgentes
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="info" size="sm">
|
||||
€{totalValue.toFixed(2)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{displayItems.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--color-success)20' }}
|
||||
>
|
||||
<Package className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
No hay compras programadas
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Todos los suministros están al día
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{displayItems.map((item) => {
|
||||
const statusConfig = getItemStatusConfig(item);
|
||||
const stockPercentage = Math.round((item.currentStock / item.minStock) * 100);
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={item.ingredient}
|
||||
subtitle={`${item.supplier} • ${item.quantity} ${item.unit}`}
|
||||
primaryValue={`€${item.estimatedCost.toFixed(2)}`}
|
||||
primaryValueLabel="COSTO"
|
||||
secondaryInfo={{
|
||||
label: 'Stock actual',
|
||||
value: `${item.currentStock}/${item.minStock} ${item.unit} (${stockPercentage}%)`
|
||||
}}
|
||||
progress={item.currentStock < item.minStock ? {
|
||||
label: `Stock: ${stockPercentage}% del mínimo`,
|
||||
percentage: stockPercentage,
|
||||
color: stockPercentage < 50 ? 'var(--color-error)' :
|
||||
stockPercentage < 80 ? 'var(--color-warning)' : 'var(--color-success)'
|
||||
} : undefined}
|
||||
metadata={[
|
||||
`📅 Pedido: ${item.plannedFor}`,
|
||||
`🚚 Llegada: ${item.deliveryTime}`,
|
||||
...(item.notes ? [`📋 ${item.notes}`] : [])
|
||||
]}
|
||||
actions={[
|
||||
...(item.status === 'pending' ? [{
|
||||
label: 'Realizar Pedido',
|
||||
icon: ShoppingCart,
|
||||
variant: 'primary' as const,
|
||||
onClick: () => onOrderItem?.(item.id),
|
||||
priority: 'primary' as const
|
||||
}] : []),
|
||||
{
|
||||
label: 'Ver Detalles',
|
||||
icon: ChevronRight,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onViewDetails?.(item.id),
|
||||
priority: 'secondary' as const
|
||||
}
|
||||
]}
|
||||
compact={true}
|
||||
className="border-l-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayItems.length > 0 && (
|
||||
<div
|
||||
className="p-4 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{pendingItems} pendientes de {displayItems.length} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAllPlans}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Ver Todos los Planes
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProcurementPlansToday;
|
||||
@@ -1,665 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
Factory,
|
||||
Clock,
|
||||
Users,
|
||||
Thermometer,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Timer,
|
||||
Package,
|
||||
Flame,
|
||||
ChefHat,
|
||||
Eye,
|
||||
Scale,
|
||||
FlaskRound,
|
||||
CircleDot,
|
||||
ArrowRight,
|
||||
CheckSquare,
|
||||
XSquare,
|
||||
Zap,
|
||||
Snowflake,
|
||||
Box
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface QualityCheckRequirement {
|
||||
id: string;
|
||||
name: string;
|
||||
stage: ProcessStage;
|
||||
isRequired: boolean;
|
||||
isCritical: boolean;
|
||||
status: 'pending' | 'completed' | 'failed' | 'skipped';
|
||||
checkType: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean';
|
||||
}
|
||||
|
||||
export interface ProcessStageInfo {
|
||||
current: ProcessStage;
|
||||
history: Array<{
|
||||
stage: ProcessStage;
|
||||
timestamp: string;
|
||||
duration?: number;
|
||||
}>;
|
||||
pendingQualityChecks: QualityCheckRequirement[];
|
||||
completedQualityChecks: QualityCheckRequirement[];
|
||||
}
|
||||
|
||||
export type ProcessStage = 'mixing' | 'proofing' | 'shaping' | 'baking' | 'cooling' | 'packaging' | 'finishing';
|
||||
|
||||
export interface ProductionOrder {
|
||||
id: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
priority: 'urgent' | 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'paused' | 'delayed';
|
||||
startTime: string;
|
||||
estimatedDuration: number; // in minutes
|
||||
assignedBaker: string;
|
||||
ovenNumber?: number;
|
||||
temperature?: number;
|
||||
progress: number; // 0-100
|
||||
notes?: string;
|
||||
recipe: string;
|
||||
ingredients: Array<{
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
available: boolean;
|
||||
}>;
|
||||
processStage?: ProcessStageInfo;
|
||||
}
|
||||
|
||||
export interface ProductionPlansProps {
|
||||
className?: string;
|
||||
orders?: ProductionOrder[];
|
||||
onStartOrder?: (orderId: string) => void;
|
||||
onPauseOrder?: (orderId: string) => void;
|
||||
onViewDetails?: (orderId: string) => void;
|
||||
onViewAllPlans?: () => void;
|
||||
}
|
||||
|
||||
const getProcessStageIcon = (stage: ProcessStage) => {
|
||||
switch (stage) {
|
||||
case 'mixing': return ChefHat;
|
||||
case 'proofing': return Timer;
|
||||
case 'shaping': return Package;
|
||||
case 'baking': return Flame;
|
||||
case 'cooling': return Snowflake;
|
||||
case 'packaging': return Box;
|
||||
case 'finishing': return CheckCircle;
|
||||
default: return CircleDot;
|
||||
}
|
||||
};
|
||||
|
||||
const getProcessStageColor = (stage: ProcessStage) => {
|
||||
switch (stage) {
|
||||
case 'mixing': return 'var(--color-info)';
|
||||
case 'proofing': return 'var(--color-warning)';
|
||||
case 'shaping': return 'var(--color-primary)';
|
||||
case 'baking': return 'var(--color-error)';
|
||||
case 'cooling': return 'var(--color-info)';
|
||||
case 'packaging': return 'var(--color-success)';
|
||||
case 'finishing': return 'var(--color-success)';
|
||||
default: return 'var(--color-gray)';
|
||||
}
|
||||
};
|
||||
|
||||
const getProcessStageLabel = (stage: ProcessStage) => {
|
||||
switch (stage) {
|
||||
case 'mixing': return 'Mezclado';
|
||||
case 'proofing': return 'Fermentado';
|
||||
case 'shaping': return 'Formado';
|
||||
case 'baking': return 'Horneado';
|
||||
case 'cooling': return 'Enfriado';
|
||||
case 'packaging': return 'Empaquetado';
|
||||
case 'finishing': return 'Acabado';
|
||||
default: return 'Sin etapa';
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityCheckIcon = (checkType: string) => {
|
||||
switch (checkType) {
|
||||
case 'visual': return Eye;
|
||||
case 'measurement': return Scale;
|
||||
case 'temperature': return Thermometer;
|
||||
case 'weight': return Scale;
|
||||
case 'boolean': return CheckSquare;
|
||||
default: return FlaskRound;
|
||||
}
|
||||
};
|
||||
|
||||
const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
|
||||
className,
|
||||
orders = [],
|
||||
onStartOrder,
|
||||
onPauseOrder,
|
||||
onViewDetails,
|
||||
onViewAllPlans
|
||||
}) => {
|
||||
const defaultOrders: ProductionOrder[] = [
|
||||
{
|
||||
id: '1',
|
||||
product: 'Pan de Molde Integral',
|
||||
quantity: 20,
|
||||
unit: 'unidades',
|
||||
priority: 'urgent',
|
||||
status: 'in_progress',
|
||||
startTime: '06:00',
|
||||
estimatedDuration: 180,
|
||||
assignedBaker: 'María González',
|
||||
ovenNumber: 1,
|
||||
temperature: 220,
|
||||
progress: 65,
|
||||
recipe: 'Receta Estándar Integral',
|
||||
ingredients: [
|
||||
{ name: 'Harina integral', quantity: 5, unit: 'kg', available: true },
|
||||
{ name: 'Levadura', quantity: 0.5, unit: 'kg', available: true },
|
||||
{ name: 'Sal', quantity: 0.2, unit: 'kg', available: true },
|
||||
{ name: 'Agua', quantity: 3, unit: 'L', available: true }
|
||||
],
|
||||
processStage: {
|
||||
current: 'baking',
|
||||
history: [
|
||||
{ stage: 'mixing', timestamp: '06:00', duration: 30 },
|
||||
{ stage: 'proofing', timestamp: '06:30', duration: 90 },
|
||||
{ stage: 'shaping', timestamp: '08:00', duration: 15 },
|
||||
{ stage: 'baking', timestamp: '08:15' }
|
||||
],
|
||||
pendingQualityChecks: [
|
||||
{
|
||||
id: 'qc1',
|
||||
name: 'Control de temperatura interna',
|
||||
stage: 'baking',
|
||||
isRequired: true,
|
||||
isCritical: true,
|
||||
status: 'pending',
|
||||
checkType: 'temperature'
|
||||
}
|
||||
],
|
||||
completedQualityChecks: [
|
||||
{
|
||||
id: 'qc2',
|
||||
name: 'Inspección visual de masa',
|
||||
stage: 'mixing',
|
||||
isRequired: true,
|
||||
isCritical: false,
|
||||
status: 'completed',
|
||||
checkType: 'visual'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
product: 'Croissants de Mantequilla',
|
||||
quantity: 50,
|
||||
unit: 'unidades',
|
||||
priority: 'high',
|
||||
status: 'pending',
|
||||
startTime: '07:30',
|
||||
estimatedDuration: 240,
|
||||
assignedBaker: 'Carlos Rodríguez',
|
||||
ovenNumber: 2,
|
||||
temperature: 200,
|
||||
progress: 0,
|
||||
recipe: 'Croissant Francés',
|
||||
notes: 'Masa preparada ayer, lista para horneado',
|
||||
ingredients: [
|
||||
{ name: 'Masa de croissant', quantity: 3, unit: 'kg', available: true },
|
||||
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
|
||||
{ name: 'Huevo', quantity: 6, unit: 'unidades', available: true }
|
||||
],
|
||||
processStage: {
|
||||
current: 'shaping',
|
||||
history: [
|
||||
{ stage: 'proofing', timestamp: '07:30', duration: 120 }
|
||||
],
|
||||
pendingQualityChecks: [
|
||||
{
|
||||
id: 'qc3',
|
||||
name: 'Verificar formado de hojaldre',
|
||||
stage: 'shaping',
|
||||
isRequired: true,
|
||||
isCritical: false,
|
||||
status: 'pending',
|
||||
checkType: 'visual'
|
||||
},
|
||||
{
|
||||
id: 'qc4',
|
||||
name: 'Control de peso individual',
|
||||
stage: 'shaping',
|
||||
isRequired: false,
|
||||
isCritical: false,
|
||||
status: 'pending',
|
||||
checkType: 'weight'
|
||||
}
|
||||
],
|
||||
completedQualityChecks: []
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
product: 'Baguettes Tradicionales',
|
||||
quantity: 30,
|
||||
unit: 'unidades',
|
||||
priority: 'medium',
|
||||
status: 'completed',
|
||||
startTime: '05:00',
|
||||
estimatedDuration: 240,
|
||||
assignedBaker: 'Ana Martín',
|
||||
ovenNumber: 3,
|
||||
temperature: 240,
|
||||
progress: 100,
|
||||
recipe: 'Baguette Francesa',
|
||||
ingredients: [
|
||||
{ name: 'Harina blanca', quantity: 4, unit: 'kg', available: true },
|
||||
{ name: 'Levadura', quantity: 0.3, unit: 'kg', available: true },
|
||||
{ name: 'Sal', quantity: 0.15, unit: 'kg', available: true },
|
||||
{ name: 'Agua', quantity: 2.5, unit: 'L', available: true }
|
||||
],
|
||||
processStage: {
|
||||
current: 'finishing',
|
||||
history: [
|
||||
{ stage: 'mixing', timestamp: '05:00', duration: 20 },
|
||||
{ stage: 'proofing', timestamp: '05:20', duration: 120 },
|
||||
{ stage: 'shaping', timestamp: '07:20', duration: 30 },
|
||||
{ stage: 'baking', timestamp: '07:50', duration: 45 },
|
||||
{ stage: 'cooling', timestamp: '08:35', duration: 30 },
|
||||
{ stage: 'finishing', timestamp: '09:05' }
|
||||
],
|
||||
pendingQualityChecks: [],
|
||||
completedQualityChecks: [
|
||||
{
|
||||
id: 'qc5',
|
||||
name: 'Inspección visual final',
|
||||
stage: 'finishing',
|
||||
isRequired: true,
|
||||
isCritical: false,
|
||||
status: 'completed',
|
||||
checkType: 'visual'
|
||||
},
|
||||
{
|
||||
id: 'qc6',
|
||||
name: 'Control de temperatura de cocción',
|
||||
stage: 'baking',
|
||||
isRequired: true,
|
||||
isCritical: true,
|
||||
status: 'completed',
|
||||
checkType: 'temperature'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
product: 'Magdalenas de Vainilla',
|
||||
quantity: 100,
|
||||
unit: 'unidades',
|
||||
priority: 'medium',
|
||||
status: 'delayed',
|
||||
startTime: '09:00',
|
||||
estimatedDuration: 90,
|
||||
assignedBaker: 'Luis Fernández',
|
||||
ovenNumber: 4,
|
||||
temperature: 180,
|
||||
progress: 0,
|
||||
recipe: 'Magdalenas Clásicas',
|
||||
notes: 'Retraso por falta de moldes',
|
||||
ingredients: [
|
||||
{ name: 'Harina', quantity: 2, unit: 'kg', available: true },
|
||||
{ name: 'Azúcar', quantity: 1.5, unit: 'kg', available: true },
|
||||
{ name: 'Huevos', quantity: 24, unit: 'unidades', available: true },
|
||||
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
|
||||
{ name: 'Vainilla', quantity: 50, unit: 'ml', available: true }
|
||||
],
|
||||
processStage: {
|
||||
current: 'mixing',
|
||||
history: [],
|
||||
pendingQualityChecks: [
|
||||
{
|
||||
id: 'qc7',
|
||||
name: 'Verificar consistencia de masa',
|
||||
stage: 'mixing',
|
||||
isRequired: true,
|
||||
isCritical: false,
|
||||
status: 'pending',
|
||||
checkType: 'visual'
|
||||
}
|
||||
],
|
||||
completedQualityChecks: []
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const displayOrders = orders.length > 0 ? orders : defaultOrders;
|
||||
|
||||
const getOrderStatusConfig = (order: ProductionOrder) => {
|
||||
const baseConfig = {
|
||||
isCritical: order.status === 'delayed' || order.priority === 'urgent',
|
||||
isHighlight: order.status === 'in_progress' || order.priority === 'high',
|
||||
};
|
||||
|
||||
switch (order.status) {
|
||||
case 'pending':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
case 'in_progress':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-info)',
|
||||
text: 'En Proceso',
|
||||
icon: Play
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-success)',
|
||||
text: 'Completado',
|
||||
icon: CheckCircle
|
||||
};
|
||||
case 'paused':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pausado',
|
||||
icon: Pause
|
||||
};
|
||||
case 'delayed':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-error)',
|
||||
text: 'Retrasado',
|
||||
icon: AlertTriangle
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const inProgressOrders = displayOrders.filter(order => order.status === 'in_progress').length;
|
||||
const completedOrders = displayOrders.filter(order => order.status === 'completed').length;
|
||||
const delayedOrders = displayOrders.filter(order => order.status === 'delayed').length;
|
||||
|
||||
// Cross-batch quality overview calculations
|
||||
const totalPendingQualityChecks = displayOrders.reduce((total, order) =>
|
||||
total + (order.processStage?.pendingQualityChecks.length || 0), 0);
|
||||
const criticalPendingQualityChecks = displayOrders.reduce((total, order) =>
|
||||
total + (order.processStage?.pendingQualityChecks.filter(qc => qc.isCritical).length || 0), 0);
|
||||
const ordersBlockedByQuality = displayOrders.filter(order =>
|
||||
order.processStage?.pendingQualityChecks.some(qc => qc.isCritical && qc.isRequired) || false).length;
|
||||
|
||||
// Helper function to create enhanced metadata with process stage info
|
||||
const createEnhancedMetadata = (order: ProductionOrder) => {
|
||||
const baseMetadata = [
|
||||
`⏰ Inicio: ${order.startTime}`,
|
||||
`⏱️ Duración: ${formatDuration(order.estimatedDuration)}`,
|
||||
...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : [])
|
||||
];
|
||||
|
||||
if (order.processStage) {
|
||||
const { current, pendingQualityChecks, completedQualityChecks } = order.processStage;
|
||||
const currentStageIcon = getProcessStageIcon(current);
|
||||
const currentStageLabel = getProcessStageLabel(current);
|
||||
|
||||
// Add current stage info
|
||||
baseMetadata.push(`🔄 Etapa: ${currentStageLabel}`);
|
||||
|
||||
// Add quality check info
|
||||
if (pendingQualityChecks.length > 0) {
|
||||
const criticalPending = pendingQualityChecks.filter(qc => qc.isCritical).length;
|
||||
const requiredPending = pendingQualityChecks.filter(qc => qc.isRequired).length;
|
||||
|
||||
if (criticalPending > 0) {
|
||||
baseMetadata.push(`🚨 ${criticalPending} controles críticos pendientes`);
|
||||
} else if (requiredPending > 0) {
|
||||
baseMetadata.push(`✅ ${requiredPending} controles requeridos pendientes`);
|
||||
} else {
|
||||
baseMetadata.push(`📋 ${pendingQualityChecks.length} controles opcionales pendientes`);
|
||||
}
|
||||
}
|
||||
|
||||
if (completedQualityChecks.length > 0) {
|
||||
baseMetadata.push(`✅ ${completedQualityChecks.length} controles completados`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add ingredients info
|
||||
const availableIngredients = order.ingredients.filter(ing => ing.available).length;
|
||||
const totalIngredients = order.ingredients.length;
|
||||
const ingredientsReady = availableIngredients === totalIngredients;
|
||||
baseMetadata.push(`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`);
|
||||
|
||||
// Add notes if any
|
||||
if (order.notes) {
|
||||
baseMetadata.push(`📝 ${order.notes}`);
|
||||
}
|
||||
|
||||
return baseMetadata;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--color-primary)20' }}
|
||||
>
|
||||
<Factory className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Planes de Producción - Hoy
|
||||
</h3>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Gestiona la producción programada para hoy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{ordersBlockedByQuality > 0 && (
|
||||
<Badge variant="error" size="sm">
|
||||
🚨 {ordersBlockedByQuality} bloqueadas por calidad
|
||||
</Badge>
|
||||
)}
|
||||
{criticalPendingQualityChecks > 0 && ordersBlockedByQuality === 0 && (
|
||||
<Badge variant="warning" size="sm">
|
||||
🔍 {criticalPendingQualityChecks} controles críticos
|
||||
</Badge>
|
||||
)}
|
||||
{totalPendingQualityChecks > 0 && criticalPendingQualityChecks === 0 && (
|
||||
<Badge variant="info" size="sm">
|
||||
📋 {totalPendingQualityChecks} controles pendientes
|
||||
</Badge>
|
||||
)}
|
||||
{delayedOrders > 0 && (
|
||||
<Badge variant="error" size="sm">
|
||||
{delayedOrders} retrasadas
|
||||
</Badge>
|
||||
)}
|
||||
{inProgressOrders > 0 && (
|
||||
<Badge variant="info" size="sm">
|
||||
{inProgressOrders} activas
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="success" size="sm">
|
||||
{completedOrders} completadas
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{displayOrders.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--color-success)20' }}
|
||||
>
|
||||
<Factory className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
No hay producción programada
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Día libre de producción
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{displayOrders.map((order) => {
|
||||
const statusConfig = getOrderStatusConfig(order);
|
||||
const enhancedMetadata = createEnhancedMetadata(order);
|
||||
|
||||
// Enhanced secondary info that includes stage information
|
||||
const getSecondaryInfo = () => {
|
||||
if (order.processStage) {
|
||||
const currentStageLabel = getProcessStageLabel(order.processStage.current);
|
||||
return {
|
||||
label: 'Etapa actual',
|
||||
value: `${currentStageLabel} • ${order.assignedBaker}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'Panadero asignado',
|
||||
value: order.assignedBaker
|
||||
};
|
||||
};
|
||||
|
||||
// Enhanced status indicator with process stage color for active orders
|
||||
const getEnhancedStatusConfig = () => {
|
||||
if (order.processStage && order.status === 'in_progress') {
|
||||
return {
|
||||
...statusConfig,
|
||||
color: getProcessStageColor(order.processStage.current)
|
||||
};
|
||||
}
|
||||
return statusConfig;
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={order.id}
|
||||
id={order.id}
|
||||
statusIndicator={getEnhancedStatusConfig()}
|
||||
title={order.product}
|
||||
subtitle={`${order.recipe} • ${order.quantity} ${order.unit}`}
|
||||
primaryValue={`${order.progress}%`}
|
||||
primaryValueLabel="PROGRESO"
|
||||
secondaryInfo={getSecondaryInfo()}
|
||||
progress={order.status !== 'pending' ? {
|
||||
label: `Progreso de producción`,
|
||||
percentage: order.progress,
|
||||
color: order.progress === 100 ? 'var(--color-success)' :
|
||||
order.progress > 70 ? 'var(--color-info)' :
|
||||
order.progress > 30 ? 'var(--color-warning)' : 'var(--color-error)'
|
||||
} : undefined}
|
||||
metadata={enhancedMetadata}
|
||||
actions={[
|
||||
...(order.status === 'pending' ? [{
|
||||
label: 'Iniciar',
|
||||
icon: Play,
|
||||
variant: 'primary' as const,
|
||||
onClick: () => onStartOrder?.(order.id),
|
||||
priority: 'primary' as const
|
||||
}] : []),
|
||||
...(order.status === 'in_progress' ? [{
|
||||
label: 'Pausar',
|
||||
icon: Pause,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onPauseOrder?.(order.id),
|
||||
priority: 'primary' as const,
|
||||
destructive: true
|
||||
}] : []),
|
||||
// Add quality check action if there are pending quality checks
|
||||
...(order.processStage?.pendingQualityChecks.length > 0 ? [{
|
||||
label: `${order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? '🚨 ' : ''}Controles Calidad`,
|
||||
icon: FlaskRound,
|
||||
variant: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'outline' as const,
|
||||
onClick: () => onViewDetails?.(order.id), // This would open the quality check modal
|
||||
priority: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'secondary' as const
|
||||
}] : []),
|
||||
// Add next stage action for orders that can progress
|
||||
...(order.status === 'in_progress' && order.processStage && order.processStage.pendingQualityChecks.length === 0 ? [{
|
||||
label: 'Siguiente Etapa',
|
||||
icon: ArrowRight,
|
||||
variant: 'primary' as const,
|
||||
onClick: () => console.log(`Advancing stage for order ${order.id}`),
|
||||
priority: 'primary' as const
|
||||
}] : []),
|
||||
{
|
||||
label: 'Ver Detalles',
|
||||
icon: ChevronRight,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onViewDetails?.(order.id),
|
||||
priority: 'secondary' as const
|
||||
}
|
||||
]}
|
||||
compact={true}
|
||||
className="border-l-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayOrders.length > 0 && (
|
||||
<div
|
||||
className="p-4 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{completedOrders} de {displayOrders.length} órdenes completadas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAllPlans}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Ver Todos los Planes
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPlansToday;
|
||||
@@ -1,210 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Truck,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
Euro,
|
||||
Calendar,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import { useProcurementDashboard } from '../../../api/hooks/orders';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
|
||||
const PurchaseOrdersTracking: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: dashboard, isLoading } = useProcurementDashboard(tenantId);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Clock className="w-4 h-4" />;
|
||||
case 'pending_approval':
|
||||
return <AlertCircle className="w-4 h-4" />;
|
||||
case 'approved':
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
case 'in_execution':
|
||||
return <Truck className="w-4 h-4" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'text-[var(--text-tertiary)] bg-[var(--bg-tertiary)]';
|
||||
case 'pending_approval':
|
||||
return 'text-yellow-700 bg-yellow-100';
|
||||
case 'approved':
|
||||
return 'text-green-700 bg-green-100';
|
||||
case 'in_execution':
|
||||
return 'text-blue-700 bg-blue-100';
|
||||
case 'completed':
|
||||
return 'text-green-700 bg-green-100';
|
||||
case 'cancelled':
|
||||
return 'text-red-700 bg-red-100';
|
||||
default:
|
||||
return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)]';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Borrador',
|
||||
pending_approval: 'Pendiente Aprobación',
|
||||
approved: 'Aprobado',
|
||||
in_execution: 'En Ejecución',
|
||||
completed: 'Completado',
|
||||
cancelled: 'Cancelado'
|
||||
};
|
||||
return labels[status] || status;
|
||||
};
|
||||
|
||||
const handleViewAllPOs = () => {
|
||||
navigate('/app/operations/procurement');
|
||||
};
|
||||
|
||||
const handleViewPODetails = (planId: string) => {
|
||||
navigate(`/app/operations/procurement?plan=${planId}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Órdenes de Compra</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">Seguimiento de órdenes de compra</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const recentPlans = dashboard?.recent_plans || [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Órdenes de Compra</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleViewAllPOs}
|
||||
className="text-[var(--color-primary)] hover:text-[var(--color-primary)]/80"
|
||||
>
|
||||
Ver Todas
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">Seguimiento de órdenes de compra</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{recentPlans.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-3" />
|
||||
<p className="text-[var(--text-secondary)]">No hay órdenes de compra recientes</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleViewAllPOs}
|
||||
className="mt-4"
|
||||
>
|
||||
Crear Plan de Compras
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentPlans.slice(0, 5).map((plan: any) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className="flex items-center justify-between p-4 bg-[var(--bg-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors cursor-pointer"
|
||||
onClick={() => handleViewPODetails(plan.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className={`p-2 rounded-lg ${getStatusColor(plan.status)}`}>
|
||||
{getStatusIcon(plan.status)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{plan.plan_number}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${getStatusColor(plan.status)}`}>
|
||||
{getStatusLabel(plan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{new Date(plan.plan_date).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="w-3.5 h-3.5" />
|
||||
<span>{plan.total_requirements} items</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Euro className="w-3.5 h-3.5" />
|
||||
<span>€{plan.total_estimated_cost?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Stats */}
|
||||
{dashboard?.stats && (
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{dashboard.stats.total_plans || 0}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">Total Planes</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-success)]">
|
||||
{dashboard.stats.approved_plans || 0}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">Aprobados</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-[var(--color-warning)]">
|
||||
{dashboard.stats.pending_plans || 0}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">Pendientes</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrdersTracking;
|
||||
@@ -1,165 +1,288 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { useAlertFilters } from '../../../hooks/useAlertFilters';
|
||||
import { useAlertGrouping, type GroupingMode } from '../../../hooks/useAlertGrouping';
|
||||
import { useAlertAnalytics, useAlertAnalyticsTracking } from '../../../hooks/useAlertAnalytics';
|
||||
import { useKeyboardNavigation } from '../../../hooks/useKeyboardNavigation';
|
||||
import { filterAlerts, getAlertStatistics, getTimeGroup } from '../../../utils/alertHelpers';
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
X,
|
||||
Bell,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Check,
|
||||
Trash2
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
item_type: 'alert' | 'recommendation';
|
||||
type: string;
|
||||
severity: 'urgent' | 'high' | 'medium' | 'low';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
actions?: string[];
|
||||
metadata?: any;
|
||||
status?: 'active' | 'resolved' | 'acknowledged';
|
||||
}
|
||||
import AlertFilters from './AlertFilters';
|
||||
import AlertGroupHeader from './AlertGroupHeader';
|
||||
import AlertCard from './AlertCard';
|
||||
import AlertTrends from './AlertTrends';
|
||||
import AlertBulkActions from './AlertBulkActions';
|
||||
|
||||
export interface RealTimeAlertsProps {
|
||||
className?: string;
|
||||
maxAlerts?: number;
|
||||
showAnalytics?: boolean;
|
||||
showGrouping?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RealTimeAlerts - Dashboard component for displaying today's active alerts
|
||||
*
|
||||
* IMPORTANT: This component shows ONLY TODAY'S alerts (from 00:00 UTC today onwards)
|
||||
* to prevent flooding the dashboard with historical data.
|
||||
*
|
||||
* For historical alert data, use the Analytics panel or API endpoints:
|
||||
* - showAnalytics=true: Shows AlertTrends component with historical data (7 days, 30 days, etc.)
|
||||
* - API: /api/v1/tenants/{tenant_id}/alerts/analytics for historical analytics
|
||||
*
|
||||
* Alert scopes across the application:
|
||||
* - Dashboard (this component): TODAY'S alerts only
|
||||
* - Notification Bell: Last 24 hours
|
||||
* - Analytics Panel: Historical data (configurable: 7 days, 30 days, etc.)
|
||||
* - localStorage: Auto-cleanup of alerts >24h old on load
|
||||
* - Redis cache (initial_items): TODAY'S alerts only
|
||||
*/
|
||||
const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
className,
|
||||
maxAlerts = 10
|
||||
maxAlerts = 50,
|
||||
showAnalytics = true,
|
||||
showGrouping = true,
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const [expandedAlert, setExpandedAlert] = useState<string | null>(null);
|
||||
const [expandedAlerts, setExpandedAlerts] = useState<Set<string>>(new Set());
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
|
||||
const [showBulkActions, setShowBulkActions] = useState(false);
|
||||
const [showAnalyticsPanel, setShowAnalyticsPanel] = useState(false);
|
||||
|
||||
const { notifications, isConnected, markAsRead, removeNotification } = useNotifications();
|
||||
const {
|
||||
notifications,
|
||||
isConnected,
|
||||
markAsRead,
|
||||
removeNotification,
|
||||
snoozeAlert,
|
||||
unsnoozeAlert,
|
||||
isAlertSnoozed,
|
||||
snoozedAlerts,
|
||||
markMultipleAsRead,
|
||||
removeMultiple,
|
||||
snoozeMultiple,
|
||||
} = useNotifications();
|
||||
|
||||
// Convert notifications to alerts format and limit them
|
||||
const alerts = notifications.slice(0, maxAlerts).map(notification => ({
|
||||
id: notification.id,
|
||||
item_type: notification.item_type,
|
||||
type: notification.item_type, // Use item_type as type
|
||||
severity: notification.severity,
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
timestamp: notification.timestamp,
|
||||
status: notification.read ? 'acknowledged' as const : 'active' as const,
|
||||
}));
|
||||
const {
|
||||
filters,
|
||||
toggleSeverity,
|
||||
toggleCategory,
|
||||
setTimeRange,
|
||||
setSearch,
|
||||
toggleShowSnoozed,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
} = useAlertFilters();
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return AlertTriangle;
|
||||
case 'high':
|
||||
return AlertCircle;
|
||||
case 'medium':
|
||||
return Info;
|
||||
case 'low':
|
||||
return CheckCircle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
};
|
||||
// Dashboard shows only TODAY's alerts
|
||||
// Analytics panel shows historical data (configured separately)
|
||||
const filteredNotifications = useMemo(() => {
|
||||
// Filter to today's alerts only for dashboard display
|
||||
// This prevents showing yesterday's or older alerts on the main dashboard
|
||||
const todayAlerts = notifications.filter(alert => {
|
||||
const timeGroup = getTimeGroup(alert.timestamp);
|
||||
return timeGroup === 'today';
|
||||
});
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'var(--color-error)';
|
||||
case 'high':
|
||||
return 'var(--color-warning)';
|
||||
case 'medium':
|
||||
return 'var(--color-info)';
|
||||
case 'low':
|
||||
return 'var(--color-success)';
|
||||
default:
|
||||
return 'var(--color-info)';
|
||||
}
|
||||
};
|
||||
return filterAlerts(todayAlerts, filters, snoozedAlerts).slice(0, maxAlerts);
|
||||
}, [notifications, filters, snoozedAlerts, maxAlerts]);
|
||||
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return 'error';
|
||||
case 'high':
|
||||
return 'warning';
|
||||
case 'medium':
|
||||
return 'info';
|
||||
case 'low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
const {
|
||||
groupedAlerts,
|
||||
groupingMode,
|
||||
setGroupingMode,
|
||||
toggleGroupCollapse,
|
||||
isGroupCollapsed,
|
||||
} = useAlertGrouping(filteredNotifications, 'time');
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const analytics = useAlertAnalytics(notifications);
|
||||
const { trackAcknowledgment, trackResolution } = useAlertAnalyticsTracking();
|
||||
|
||||
if (diffMins < 1) return t('dashboard:alerts.time.now', 'Ahora');
|
||||
if (diffMins < 60) return t('dashboard:alerts.time.minutes_ago', 'hace {{count}} min', { count: diffMins });
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return t('dashboard:alerts.time.hours_ago', 'hace {{count}} h', { count: diffHours });
|
||||
return date.toLocaleDateString() === new Date(now.getTime() - 24 * 60 * 60 * 1000).toLocaleDateString()
|
||||
? t('dashboard:alerts.time.yesterday', 'Ayer')
|
||||
: date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
const stats = useMemo(() => {
|
||||
return getAlertStatistics(filteredNotifications, snoozedAlerts);
|
||||
}, [filteredNotifications, snoozedAlerts]);
|
||||
|
||||
const toggleExpanded = (alertId: string) => {
|
||||
setExpandedAlert(prev => prev === alertId ? null : alertId);
|
||||
};
|
||||
const flatAlerts = useMemo(() => {
|
||||
return groupedAlerts.flatMap(group =>
|
||||
isGroupCollapsed(group.id) ? [] : group.alerts
|
||||
);
|
||||
}, [groupedAlerts, isGroupCollapsed]);
|
||||
|
||||
const handleMarkAsRead = (alertId: string) => {
|
||||
const { focusedIndex } = useKeyboardNavigation(
|
||||
flatAlerts.length,
|
||||
{
|
||||
onMoveUp: () => {},
|
||||
onMoveDown: () => {},
|
||||
onSelect: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
toggleAlertSelection(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onExpand: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
toggleAlertExpansion(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onMarkAsRead: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleMarkAsRead(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onDismiss: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleRemoveAlert(flatAlerts[focusedIndex].id);
|
||||
}
|
||||
},
|
||||
onSnooze: () => {
|
||||
if (flatAlerts[focusedIndex]) {
|
||||
handleSnoozeAlert(flatAlerts[focusedIndex].id, '1hr');
|
||||
}
|
||||
},
|
||||
onEscape: () => {
|
||||
setExpandedAlerts(new Set());
|
||||
setSelectedAlerts(new Set());
|
||||
},
|
||||
onSelectAll: () => {
|
||||
handleSelectAll();
|
||||
},
|
||||
onSearch: () => {},
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const toggleAlertExpansion = useCallback((alertId: string) => {
|
||||
setExpandedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(alertId)) {
|
||||
next.delete(alertId);
|
||||
} else {
|
||||
next.add(alertId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAlertSelection = useCallback((alertId: string) => {
|
||||
setSelectedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(alertId)) {
|
||||
next.delete(alertId);
|
||||
} else {
|
||||
next.add(alertId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMarkAsRead = useCallback((alertId: string) => {
|
||||
markAsRead(alertId);
|
||||
};
|
||||
trackAcknowledgment(alertId).catch(err =>
|
||||
console.error('Failed to track acknowledgment:', err)
|
||||
);
|
||||
}, [markAsRead, trackAcknowledgment]);
|
||||
|
||||
const handleRemoveAlert = (alertId: string) => {
|
||||
const handleRemoveAlert = useCallback((alertId: string) => {
|
||||
removeNotification(alertId);
|
||||
if (expandedAlert === alertId) {
|
||||
setExpandedAlert(null);
|
||||
}
|
||||
};
|
||||
trackResolution(alertId).catch(err =>
|
||||
console.error('Failed to track resolution:', err)
|
||||
);
|
||||
setExpandedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(alertId);
|
||||
return next;
|
||||
});
|
||||
setSelectedAlerts(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(alertId);
|
||||
return next;
|
||||
});
|
||||
}, [removeNotification, trackResolution]);
|
||||
|
||||
const activeAlerts = alerts.filter(alert => alert.status === 'active');
|
||||
const urgentCount = activeAlerts.filter(alert => alert.severity === 'urgent').length;
|
||||
const highCount = activeAlerts.filter(alert => alert.severity === 'high').length;
|
||||
const handleSnoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
snoozeAlert(alertId, duration);
|
||||
}, [snoozeAlert]);
|
||||
|
||||
const handleUnsnoozeAlert = useCallback((alertId: string) => {
|
||||
unsnoozeAlert(alertId);
|
||||
}, [unsnoozeAlert]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedAlerts(new Set(flatAlerts.map(a => a.id)));
|
||||
setShowBulkActions(true);
|
||||
}, [flatAlerts]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedAlerts(new Set());
|
||||
setShowBulkActions(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkMarkAsRead = useCallback(() => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
markMultipleAsRead(ids);
|
||||
ids.forEach(id =>
|
||||
trackAcknowledgment(id).catch(err =>
|
||||
console.error('Failed to track acknowledgment:', err)
|
||||
)
|
||||
);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, markMultipleAsRead, trackAcknowledgment, handleDeselectAll]);
|
||||
|
||||
const handleBulkRemove = useCallback(() => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
removeMultiple(ids);
|
||||
ids.forEach(id =>
|
||||
trackResolution(id).catch(err =>
|
||||
console.error('Failed to track resolution:', err)
|
||||
)
|
||||
);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, removeMultiple, trackResolution, handleDeselectAll]);
|
||||
|
||||
const handleBulkSnooze = useCallback((duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
const ids = Array.from(selectedAlerts);
|
||||
snoozeMultiple(ids, duration);
|
||||
handleDeselectAll();
|
||||
}, [selectedAlerts, snoozeMultiple, handleDeselectAll]);
|
||||
|
||||
const activeAlerts = filteredNotifications.filter(a => a.status !== 'acknowledged' && !isAlertSnoozed(a.id));
|
||||
const urgentCount = activeAlerts.filter(a => a.severity === 'urgent').length;
|
||||
const highCount = activeAlerts.filter(a => a.severity === 'high').length;
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="md" divider>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-start sm:items-center justify-between w-full gap-4 flex-col sm:flex-row">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: 'var(--color-primary)20' }}
|
||||
className="p-2.5 rounded-xl shadow-sm flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--color-primary)15' }}
|
||||
>
|
||||
<Bell className="w-4 h-4" style={{ color: 'var(--color-primary)' }} />
|
||||
<Bell className="w-5 h-5" style={{ color: 'var(--color-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
<h3 className="text-lg font-bold mb-0.5" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.title', 'Alertas')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-3 h-3" style={{ color: 'var(--color-success)' }} />
|
||||
<Wifi className="w-3.5 h-3.5" style={{ color: 'var(--color-success)' }} />
|
||||
) : (
|
||||
<WifiOff className="w-3 h-3" style={{ color: 'var(--color-error)' }} />
|
||||
<WifiOff className="w-3.5 h-3.5" style={{ color: 'var(--color-error)' }} />
|
||||
)}
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="text-xs font-medium whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isConnected
|
||||
? t('dashboard:alerts.live', 'En vivo')
|
||||
: t('dashboard:alerts.offline', 'Desconectado')
|
||||
@@ -169,204 +292,181 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{urgentCount > 0 && (
|
||||
<Badge variant="error" size="sm">
|
||||
{urgentCount}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto">
|
||||
{/* Alert count badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
{urgentCount > 0 && (
|
||||
<Badge
|
||||
variant="error"
|
||||
size="sm"
|
||||
icon={<AlertTriangle className="w-4 h-4" />}
|
||||
>
|
||||
{urgentCount} Alto
|
||||
</Badge>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<Badge
|
||||
variant="warning"
|
||||
size="sm"
|
||||
icon={<AlertCircle className="w-4 h-4" />}
|
||||
>
|
||||
{highCount} Medio
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{showAnalytics && (
|
||||
<Button
|
||||
variant={showAnalyticsPanel ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowAnalyticsPanel(!showAnalyticsPanel)}
|
||||
className="h-9"
|
||||
title="Toggle analytics"
|
||||
aria-label="Toggle analytics panel"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{highCount > 0 && (
|
||||
<Badge variant="warning" size="sm">
|
||||
{highCount}
|
||||
</Badge>
|
||||
|
||||
{showGrouping && (
|
||||
<select
|
||||
value={groupingMode}
|
||||
onChange={(e) => setGroupingMode(e.target.value as GroupingMode)}
|
||||
className="px-3 py-2 text-sm font-medium border-2 border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent transition-all cursor-pointer hover:border-[var(--color-primary)]"
|
||||
aria-label="Group alerts by"
|
||||
>
|
||||
<option value="time">⏰ Por tiempo</option>
|
||||
<option value="category">📁 Por categoría</option>
|
||||
<option value="similarity">🔗 Similares</option>
|
||||
<option value="none">📋 Sin agrupar</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{activeAlerts.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<CheckCircle className="w-6 h-6 mx-auto mb-2" style={{ color: 'var(--color-success)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('dashboard:alerts.no_alerts', 'No hay alertas activas')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 p-2">
|
||||
{activeAlerts.map((alert) => {
|
||||
const isExpanded = expandedAlert === alert.id;
|
||||
const SeverityIcon = getSeverityIcon(alert.severity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`
|
||||
rounded-lg border transition-all duration-200
|
||||
${isExpanded ? 'ring-2 ring-opacity-20' : 'hover:shadow-sm'}
|
||||
`}
|
||||
style={{
|
||||
borderColor: getSeverityColor(alert.severity) + '40',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
...(isExpanded && {
|
||||
ringColor: getSeverityColor(alert.severity),
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
})
|
||||
}}
|
||||
>
|
||||
{/* Compact Card Header */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-[var(--bg-secondary)] transition-colors rounded-lg"
|
||||
onClick={() => toggleExpanded(alert.id)}
|
||||
>
|
||||
{/* Severity Icon */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-full"
|
||||
style={{ backgroundColor: getSeverityColor(alert.severity) + '15' }}
|
||||
>
|
||||
<SeverityIcon
|
||||
className="w-4 h-4"
|
||||
style={{ color: getSeverityColor(alert.severity) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alert Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title and Timestamp Row */}
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<h4 className="text-sm font-semibold leading-tight flex-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.title}
|
||||
</h4>
|
||||
<span className="text-xs font-medium flex-shrink-0" style={{ color: 'var(--text-secondary)' }}>
|
||||
{formatTimestamp(alert.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Badges Row */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant={getSeverityBadge(alert.severity)} size="sm">
|
||||
{alert.severity.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{alert.item_type === 'alert'
|
||||
? `🚨 ${t('dashboard:alerts.types.alert', 'Alerta')}`
|
||||
: `💡 ${t('dashboard:alerts.types.recommendation', 'Recomendación')}`
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Preview message when collapsed */}
|
||||
{!isExpanded && (
|
||||
<p className="text-xs leading-relaxed truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||
{alert.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse Button */}
|
||||
<div className="flex-shrink-0 p-1 rounded-full hover:bg-black/5 transition-colors">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t mt-3 pt-3" style={{ borderColor: getSeverityColor(alert.severity) + '20' }}>
|
||||
{/* Full Message */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-primary)' }}>
|
||||
{alert.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
{alert.actions && alert.actions.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-semibold mb-2 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.recommended_actions', 'Acciones Recomendadas')}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{alert.actions.map((action, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<span className="text-xs mt-0.5" style={{ color: getSeverityColor(alert.severity) }}>
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
{action}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{alert.metadata && Object.keys(alert.metadata).length > 0 && (
|
||||
<div className="mb-4 p-2 rounded-md" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
<p className="text-xs font-semibold mb-1 uppercase tracking-wide" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('dashboard:alerts.additional_details', 'Detalles Adicionales')}
|
||||
</p>
|
||||
<div className="text-xs space-y-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{Object.entries(alert.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(alert.id);
|
||||
}}
|
||||
className="h-8 px-3 text-xs font-medium"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
{t('dashboard:alerts.mark_as_read', 'Marcar como leído')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveAlert(alert.id);
|
||||
}}
|
||||
className="h-8 px-3 text-xs font-medium text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
{t('dashboard:alerts.remove', 'Eliminar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showAnalyticsPanel && (
|
||||
<div className="p-4 border-b border-[var(--border-primary)]">
|
||||
<AlertTrends analytics={analytics} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAlerts.length > 0 && (
|
||||
<div className="px-4 py-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/30">
|
||||
<AlertFilters
|
||||
selectedSeverities={filters.severities}
|
||||
selectedCategories={filters.categories}
|
||||
selectedTimeRange={filters.timeRange}
|
||||
searchQuery={filters.search}
|
||||
showSnoozed={filters.showSnoozed}
|
||||
onToggleSeverity={toggleSeverity}
|
||||
onToggleCategory={toggleCategory}
|
||||
onSetTimeRange={setTimeRange}
|
||||
onSearchChange={setSearch}
|
||||
onToggleShowSnoozed={toggleShowSnoozed}
|
||||
onClearFilters={clearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
activeFilterCount={activeFilterCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedAlerts.size > 0 && (
|
||||
<div className="p-4 border-b border-[var(--border-primary)]">
|
||||
<AlertBulkActions
|
||||
selectedCount={selectedAlerts.size}
|
||||
totalCount={flatAlerts.length}
|
||||
onMarkAsRead={handleBulkMarkAsRead}
|
||||
onRemove={handleBulkRemove}
|
||||
onSnooze={handleBulkSnooze}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--color-success)]/10 mb-4">
|
||||
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-base font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{hasActiveFilters ? 'Sin resultados' : 'Todo despejado'}
|
||||
</h4>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{hasActiveFilters
|
||||
? 'No hay alertas que coincidan con los filtros seleccionados'
|
||||
: t('dashboard:alerts.no_alerts', 'No hay alertas activas en este momento')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{groupedAlerts.map((group) => (
|
||||
<div key={group.id}>
|
||||
{(group.count > 1 || groupingMode !== 'none') && (
|
||||
<div className="mb-3">
|
||||
<AlertGroupHeader
|
||||
group={group}
|
||||
isCollapsed={isGroupCollapsed(group.id)}
|
||||
onToggleCollapse={() => toggleGroupCollapse(group.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isGroupCollapsed(group.id) && (
|
||||
<div className="space-y-3 ml-0">
|
||||
{group.alerts.map((alert) => (
|
||||
<AlertCard
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
isExpanded={expandedAlerts.has(alert.id)}
|
||||
isSelected={selectedAlerts.has(alert.id)}
|
||||
isSnoozed={isAlertSnoozed(alert.id)}
|
||||
snoozedUntil={snoozedAlerts.get(alert.id)?.until}
|
||||
onToggleExpand={() => toggleAlertExpansion(alert.id)}
|
||||
onToggleSelect={() => toggleAlertSelection(alert.id)}
|
||||
onMarkAsRead={() => handleMarkAsRead(alert.id)}
|
||||
onRemove={() => handleRemoveAlert(alert.id)}
|
||||
onSnooze={(duration) => handleSnoozeAlert(alert.id, duration)}
|
||||
onUnsnooze={() => handleUnsnoozeAlert(alert.id)}
|
||||
showCheckbox={showBulkActions || selectedAlerts.size > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredNotifications.length > 0 && (
|
||||
<div
|
||||
className="p-3 border-t text-center"
|
||||
className="px-4 py-3 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
backgroundColor: 'var(--bg-secondary)/50',
|
||||
}}
|
||||
>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('dashboard:alerts.active_count', '{{count}} alertas activas', { count: activeAlerts.length })}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="font-medium">
|
||||
Mostrando <span className="font-bold text-[var(--text-primary)]">{filteredNotifications.length}</span> de <span className="font-bold text-[var(--text-primary)]">{notifications.length}</span> alertas
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
{stats.unread > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[var(--color-info)] animate-pulse" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">{stats.unread}</span> sin leer
|
||||
</span>
|
||||
)}
|
||||
{stats.snoozed > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">{stats.snoozed}</span> pospuestas
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
@@ -374,4 +474,4 @@ const RealTimeAlerts: React.FC<RealTimeAlertsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default RealTimeAlerts;
|
||||
export default RealTimeAlerts;
|
||||
|
||||
413
frontend/src/components/domain/dashboard/TodayProduction.tsx
Normal file
413
frontend/src/components/domain/dashboard/TodayProduction.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardHeader, CardBody } from '../../ui/Card';
|
||||
import { StatusCard } from '../../ui/StatusCard/StatusCard';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useActiveBatches } from '../../../api/hooks/production';
|
||||
import {
|
||||
Factory,
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Timer,
|
||||
ChefHat,
|
||||
Flame,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface TodayProductionProps {
|
||||
className?: string;
|
||||
maxBatches?: number;
|
||||
onStartBatch?: (batchId: string) => void;
|
||||
onPauseBatch?: (batchId: string) => void;
|
||||
onViewDetails?: (batchId: string) => void;
|
||||
onViewAllPlans?: () => void;
|
||||
}
|
||||
|
||||
const TodayProduction: React.FC<TodayProductionProps> = ({
|
||||
className,
|
||||
maxBatches = 5,
|
||||
onStartBatch,
|
||||
onPauseBatch,
|
||||
onViewDetails,
|
||||
onViewAllPlans
|
||||
}) => {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
// Get today's date
|
||||
const todayDate = useMemo(() => {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}, []);
|
||||
|
||||
// Fetch active production batches
|
||||
const { data: productionData, isLoading, error } = useActiveBatches(
|
||||
tenantId,
|
||||
{
|
||||
enabled: !!tenantId,
|
||||
}
|
||||
);
|
||||
|
||||
const getBatchStatusConfig = (batch: any) => {
|
||||
const baseConfig = {
|
||||
isCritical: batch.status === 'FAILED' || batch.priority === 'URGENT',
|
||||
isHighlight: batch.status === 'IN_PROGRESS' || batch.priority === 'HIGH',
|
||||
};
|
||||
|
||||
switch (batch.status) {
|
||||
case 'PENDING':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-info)',
|
||||
text: 'En Proceso',
|
||||
icon: Flame
|
||||
};
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-success)',
|
||||
text: 'Completado',
|
||||
icon: CheckCircle
|
||||
};
|
||||
case 'ON_HOLD':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pausado',
|
||||
icon: Pause
|
||||
};
|
||||
case 'FAILED':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-error)',
|
||||
text: 'Fallido',
|
||||
icon: AlertTriangle
|
||||
};
|
||||
case 'QUALITY_CHECK':
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-info)',
|
||||
text: 'Control de Calidad',
|
||||
icon: CheckCircle
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...baseConfig,
|
||||
color: 'var(--color-warning)',
|
||||
text: 'Pendiente',
|
||||
icon: Clock
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
// Process batches and sort by priority
|
||||
const displayBatches = useMemo(() => {
|
||||
if (!productionData?.batches || !Array.isArray(productionData.batches)) return [];
|
||||
|
||||
const batches = [...productionData.batches];
|
||||
|
||||
// Filter for today's batches only
|
||||
const todayBatches = batches.filter(batch => {
|
||||
const batchDate = new Date(batch.planned_start_time || batch.created_at);
|
||||
return batchDate.toISOString().split('T')[0] === todayDate;
|
||||
});
|
||||
|
||||
// Sort by priority and start time
|
||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
||||
todayBatches.sort((a, b) => {
|
||||
// First sort by status (pending/in_progress first)
|
||||
const statusOrder = { PENDING: 0, IN_PROGRESS: 1, QUALITY_CHECK: 2, ON_HOLD: 3, COMPLETED: 4, FAILED: 5, CANCELLED: 6 };
|
||||
const aStatus = statusOrder[a.status as keyof typeof statusOrder] ?? 7;
|
||||
const bStatus = statusOrder[b.status as keyof typeof statusOrder] ?? 7;
|
||||
|
||||
if (aStatus !== bStatus) return aStatus - bStatus;
|
||||
|
||||
// Then by priority
|
||||
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 4;
|
||||
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 4;
|
||||
|
||||
if (aPriority !== bPriority) return aPriority - bPriority;
|
||||
|
||||
// Finally by start time
|
||||
const aTime = new Date(a.planned_start_time || a.created_at).getTime();
|
||||
const bTime = new Date(b.planned_start_time || b.created_at).getTime();
|
||||
return aTime - bTime;
|
||||
});
|
||||
|
||||
return todayBatches.slice(0, maxBatches);
|
||||
}, [productionData, todayDate, maxBatches]);
|
||||
|
||||
const inProgressBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'IN_PROGRESS'
|
||||
).length || 0;
|
||||
|
||||
const completedBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'COMPLETED'
|
||||
).length || 0;
|
||||
|
||||
const delayedBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'FAILED'
|
||||
).length || 0;
|
||||
|
||||
const pendingBatches = productionData?.batches?.filter(
|
||||
b => b.status === 'PENDING'
|
||||
).length || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.production_today', 'Producción de Hoy')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.production_today', 'Producción de Hoy')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
|
||||
<p className="text-[var(--color-error)] text-sm">
|
||||
{t('dashboard:messages.error_loading', 'Error al cargar los datos')}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className} variant="elevated" padding="none">
|
||||
<CardHeader padding="lg" divider>
|
||||
<div className="flex items-center justify-between w-full flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-[var(--color-primary)]/20">
|
||||
<Factory className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('dashboard:sections.production_today', 'Producción de Hoy')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('dashboard:production.title', '¿Qué necesito producir hoy?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{delayedBatches > 0 && (
|
||||
<Badge variant="error" size="sm">
|
||||
{delayedBatches} retrasados
|
||||
</Badge>
|
||||
)}
|
||||
{inProgressBatches > 0 && (
|
||||
<Badge variant="info" size="sm">
|
||||
{inProgressBatches} activos
|
||||
</Badge>
|
||||
)}
|
||||
{completedBatches > 0 && (
|
||||
<Badge variant="success" size="sm">
|
||||
{completedBatches} completados
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{new Date(todayDate).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody padding="none">
|
||||
{displayBatches.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className="p-4 rounded-full mx-auto mb-4 w-16 h-16 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--color-success)/20' }}
|
||||
>
|
||||
<CheckCircle className="w-8 h-8" style={{ color: 'var(--color-success)' }} />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium mb-2 text-[var(--text-primary)]">
|
||||
{t('dashboard:production.empty', 'Sin producción programada para hoy')}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
No hay lotes programados para iniciar hoy
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-4">
|
||||
{displayBatches.map((batch) => {
|
||||
const statusConfig = getBatchStatusConfig(batch);
|
||||
|
||||
// Calculate progress based on status and time
|
||||
let progress = 0;
|
||||
if (batch.status === 'COMPLETED') {
|
||||
progress = 100;
|
||||
} else if (batch.status === 'IN_PROGRESS' && batch.actual_start_time && batch.planned_duration_minutes) {
|
||||
const elapsed = Date.now() - new Date(batch.actual_start_time).getTime();
|
||||
const elapsedMinutes = elapsed / (1000 * 60);
|
||||
progress = Math.min(Math.round((elapsedMinutes / batch.planned_duration_minutes) * 100), 99);
|
||||
} else if (batch.status === 'QUALITY_CHECK') {
|
||||
progress = 95;
|
||||
}
|
||||
|
||||
const startTime = batch.planned_start_time
|
||||
? new Date(batch.planned_start_time).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'No programado';
|
||||
|
||||
const assignedStaff = batch.staff_assigned && batch.staff_assigned.length > 0
|
||||
? batch.staff_assigned[0]
|
||||
: 'Sin asignar';
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={batch.id}
|
||||
id={batch.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={batch.product_name}
|
||||
subtitle={`Lote ${batch.batch_number} • ${batch.planned_quantity} unidades`}
|
||||
primaryValue={`${progress}%`}
|
||||
primaryValueLabel="PROGRESO"
|
||||
secondaryInfo={{
|
||||
label: 'Panadero asignado',
|
||||
value: assignedStaff
|
||||
}}
|
||||
progress={batch.status !== 'PENDING' ? {
|
||||
label: `Progreso de producción`,
|
||||
percentage: progress,
|
||||
color: progress === 100 ? 'var(--color-success)' :
|
||||
progress > 70 ? 'var(--color-info)' :
|
||||
progress > 30 ? 'var(--color-warning)' : 'var(--color-error)'
|
||||
} : undefined}
|
||||
metadata={[
|
||||
`⏰ Inicio: ${startTime}`,
|
||||
...(batch.planned_duration_minutes ? [`⏱️ Duración: ${formatDuration(batch.planned_duration_minutes)}`] : []),
|
||||
...(batch.station_id ? [`🏭 Estación: ${batch.station_id}`] : []),
|
||||
...(batch.priority === 'URGENT' ? [`⚠️ URGENTE`] : []),
|
||||
...(batch.production_notes ? [`📋 ${batch.production_notes}`] : [])
|
||||
]}
|
||||
actions={[
|
||||
...(batch.status === 'PENDING' ? [{
|
||||
label: 'Iniciar',
|
||||
icon: Play,
|
||||
variant: 'primary' as const,
|
||||
onClick: () => onStartBatch?.(batch.id),
|
||||
priority: 'primary' as const
|
||||
}] : []),
|
||||
...(batch.status === 'IN_PROGRESS' ? [{
|
||||
label: 'Pausar',
|
||||
icon: Pause,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onPauseBatch?.(batch.id),
|
||||
priority: 'primary' as const,
|
||||
destructive: true
|
||||
}] : []),
|
||||
{
|
||||
label: 'Ver Detalles',
|
||||
icon: ChevronRight,
|
||||
variant: 'outline' as const,
|
||||
onClick: () => onViewDetails?.(batch.id),
|
||||
priority: 'secondary' as const
|
||||
}
|
||||
]}
|
||||
compact={true}
|
||||
className="border-l-4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayBatches.length > 0 && (
|
||||
<div
|
||||
className="p-4 border-t"
|
||||
style={{
|
||||
borderColor: 'var(--border-primary)',
|
||||
backgroundColor: 'var(--bg-secondary)'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{pendingBatches} {t('dashboard:production.batches_pending', 'lotes pendientes')} de {productionData?.batches?.length || 0} total
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onViewAllPlans && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onViewAllPlans}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Ver Todos los Planes
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayProduction;
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
// Existing dashboard components
|
||||
export { default as RealTimeAlerts } from './RealTimeAlerts';
|
||||
export { default as ProcurementPlansToday } from './ProcurementPlansToday';
|
||||
export { default as ProductionPlansToday } from './ProductionPlansToday';
|
||||
export { default as PurchaseOrdersTracking } from './PurchaseOrdersTracking';
|
||||
export { default as PendingPOApprovals } from './PendingPOApprovals';
|
||||
export { default as TodayProduction } from './TodayProduction';
|
||||
export { default as AlertTrends } from './AlertTrends';
|
||||
|
||||
// Production Management Dashboard Widgets
|
||||
export { default as ProductionCostMonitor } from './ProductionCostMonitor';
|
||||
|
||||
325
frontend/src/components/domain/pos/CreatePOSConfigModal.tsx
Normal file
325
frontend/src/components/domain/pos/CreatePOSConfigModal.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Zap, Key, Settings as SettingsIcon, RefreshCw } from 'lucide-react';
|
||||
import { AddModal, AddModalSection, AddModalField } from '../../ui/AddModal/AddModal';
|
||||
import { posService } from '../../../api/services/pos';
|
||||
import { POSProviderConfig, POSSystem, POSEnvironment } from '../../../api/types/pos';
|
||||
import { useToast } from '../../../hooks/ui/useToast';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreatePOSConfigModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tenantId: string;
|
||||
onSuccess?: () => void;
|
||||
existingConfig?: any; // For edit mode
|
||||
mode?: 'create' | 'edit';
|
||||
}
|
||||
|
||||
/**
|
||||
* CreatePOSConfigModal - Modal for creating/editing POS configurations
|
||||
* Uses the standard AddModal component for consistency across the application
|
||||
*/
|
||||
export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
tenantId,
|
||||
onSuccess,
|
||||
existingConfig,
|
||||
mode = 'create'
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<POSSystem | ''>('');
|
||||
const { addToast } = useToast();
|
||||
|
||||
// Supported POS providers configuration
|
||||
const supportedProviders: POSProviderConfig[] = [
|
||||
{
|
||||
id: 'toast',
|
||||
name: 'Toast POS',
|
||||
logo: '🍞',
|
||||
description: 'Sistema POS líder para restaurantes y panaderías. Muy popular en España.',
|
||||
features: ['Gestión de pedidos', 'Sincronización de inventario', 'Pagos integrados', 'Reportes en tiempo real'],
|
||||
required_fields: [
|
||||
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Obten tu API key desde Toast Dashboard > Settings > Integrations' },
|
||||
{ field: 'restaurant_guid', label: 'Restaurant GUID', type: 'text', required: true, help_text: 'ID único del restaurante en Toast' },
|
||||
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación específica' },
|
||||
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
|
||||
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
|
||||
{ value: 'production', label: 'Producción' }
|
||||
]},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'square',
|
||||
name: 'Square POS',
|
||||
logo: '⬜',
|
||||
description: 'Solución POS completa con tarifas transparentes. Ampliamente utilizada por pequeñas empresas.',
|
||||
features: ['Procesamiento de pagos', 'Gestión de inventario', 'Análisis de ventas', 'Integración con e-commerce'],
|
||||
required_fields: [
|
||||
{ field: 'application_id', label: 'Application ID', type: 'text', required: true, help_text: 'ID de aplicación de Square Developer Dashboard' },
|
||||
{ field: 'access_token', label: 'Access Token', type: 'password', required: true, help_text: 'Token de acceso para la API de Square' },
|
||||
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación de Square' },
|
||||
{ field: 'webhook_signature_key', label: 'Webhook Signature Key', type: 'password', required: false, help_text: 'Clave para verificar webhooks (opcional)' },
|
||||
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
|
||||
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
|
||||
{ value: 'production', label: 'Producción' }
|
||||
]},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lightspeed',
|
||||
name: 'Lightspeed POS',
|
||||
logo: '⚡',
|
||||
description: 'Sistema POS empresarial con API abierta e integración con múltiples herramientas.',
|
||||
features: ['API REST completa', 'Gestión multi-ubicación', 'Reportes avanzados', 'Integración con contabilidad'],
|
||||
required_fields: [
|
||||
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Clave API de Lightspeed Retail' },
|
||||
{ field: 'api_secret', label: 'API Secret', type: 'password', required: true, help_text: 'Secreto API de Lightspeed Retail' },
|
||||
{ field: 'account_id', label: 'Account ID', type: 'text', required: true, help_text: 'ID de cuenta de Lightspeed' },
|
||||
{ field: 'shop_id', label: 'Shop ID', type: 'text', required: true, help_text: 'ID de la tienda específica' },
|
||||
{ field: 'server_region', label: 'Región del Servidor', type: 'select', required: true, options: [
|
||||
{ value: 'eu', label: 'Europa' },
|
||||
{ value: 'us', label: 'Estados Unidos' },
|
||||
{ value: 'ca', label: 'Canadá' }
|
||||
]},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Initialize from existing config in edit mode
|
||||
const initialData = useMemo(() => {
|
||||
if (mode === 'edit' && existingConfig) {
|
||||
// Extract credentials from existing config
|
||||
const credentials: Record<string, any> = {};
|
||||
const provider = supportedProviders.find(p => p.id === existingConfig.pos_system);
|
||||
|
||||
if (provider && existingConfig.provider_settings) {
|
||||
provider.required_fields.forEach(field => {
|
||||
if (existingConfig.provider_settings[field.field]) {
|
||||
credentials[`credential_${field.field}`] = existingConfig.provider_settings[field.field];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: existingConfig.pos_system || '',
|
||||
config_name: existingConfig.provider_name || '',
|
||||
auto_sync_enabled: existingConfig.sync_enabled ?? true,
|
||||
sync_interval_minutes: existingConfig.sync_interval_minutes || '5',
|
||||
sync_sales: existingConfig.auto_sync_transactions ?? true,
|
||||
sync_inventory: existingConfig.auto_sync_products ?? true,
|
||||
...credentials
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: '',
|
||||
config_name: '',
|
||||
auto_sync_enabled: true,
|
||||
sync_interval_minutes: '5',
|
||||
sync_sales: true,
|
||||
sync_inventory: true,
|
||||
};
|
||||
}, [mode, existingConfig, supportedProviders]);
|
||||
|
||||
// Build dynamic sections based on selected provider
|
||||
const sections: AddModalSection[] = useMemo(() => {
|
||||
const baseSections: AddModalSection[] = [
|
||||
{
|
||||
title: 'Información del Proveedor',
|
||||
icon: Zap,
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
label: 'Sistema POS',
|
||||
name: 'provider',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: 'Selecciona un sistema POS',
|
||||
options: supportedProviders.map(provider => ({
|
||||
value: provider.id,
|
||||
label: `${provider.logo} ${provider.name}`
|
||||
})),
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Nombre de la Configuración',
|
||||
name: 'config_name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: 'Ej: Mi Square POS 2025',
|
||||
helpText: 'Un nombre descriptivo para identificar esta configuración',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Add credentials section if provider is selected
|
||||
const provider = supportedProviders.find(p => p.id === selectedProvider);
|
||||
if (provider) {
|
||||
const credentialFields: AddModalField[] = provider.required_fields.map(field => ({
|
||||
label: field.label,
|
||||
name: `credential_${field.field}`,
|
||||
type: field.type === 'select' ? 'select' : (field.type === 'password' ? 'text' : field.type),
|
||||
required: field.required,
|
||||
placeholder: field.placeholder || `Ingresa ${field.label}`,
|
||||
helpText: field.help_text,
|
||||
options: field.options,
|
||||
span: field.type === 'select' ? 2 : 1
|
||||
}));
|
||||
|
||||
baseSections.push({
|
||||
title: 'Credenciales de API',
|
||||
icon: Key,
|
||||
columns: 2,
|
||||
fields: credentialFields
|
||||
});
|
||||
}
|
||||
|
||||
// Add sync settings section
|
||||
baseSections.push({
|
||||
title: 'Configuración de Sincronización',
|
||||
icon: RefreshCw,
|
||||
columns: 2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Sincronización Automática',
|
||||
name: 'auto_sync_enabled',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'true', label: 'Activada' },
|
||||
{ value: 'false', label: 'Desactivada' }
|
||||
],
|
||||
defaultValue: 'true'
|
||||
},
|
||||
{
|
||||
label: 'Intervalo de Sincronización',
|
||||
name: 'sync_interval_minutes',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: '5', label: '5 minutos' },
|
||||
{ value: '15', label: '15 minutos' },
|
||||
{ value: '30', label: '30 minutos' },
|
||||
{ value: '60', label: '1 hora' }
|
||||
],
|
||||
defaultValue: '5'
|
||||
},
|
||||
{
|
||||
label: 'Sincronizar Ventas',
|
||||
name: 'sync_sales',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'true', label: 'Sí' },
|
||||
{ value: 'false', label: 'No' }
|
||||
],
|
||||
defaultValue: 'true'
|
||||
},
|
||||
{
|
||||
label: 'Sincronizar Inventario',
|
||||
name: 'sync_inventory',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'true', label: 'Sí' },
|
||||
{ value: 'false', label: 'No' }
|
||||
],
|
||||
defaultValue: 'true'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return baseSections;
|
||||
}, [selectedProvider, supportedProviders]);
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Find selected provider
|
||||
const provider = supportedProviders.find(p => p.id === formData.provider);
|
||||
if (!provider) {
|
||||
addToast('Por favor selecciona un sistema POS', { type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract credentials
|
||||
const credentials: Record<string, any> = {};
|
||||
provider.required_fields.forEach(field => {
|
||||
const credKey = `credential_${field.field}`;
|
||||
if (formData[credKey]) {
|
||||
credentials[field.field] = formData[credKey];
|
||||
}
|
||||
});
|
||||
|
||||
// Build request payload
|
||||
const payload = {
|
||||
tenant_id: tenantId,
|
||||
provider: formData.provider,
|
||||
config_name: formData.config_name,
|
||||
credentials,
|
||||
sync_settings: {
|
||||
auto_sync_enabled: formData.auto_sync_enabled === 'true' || formData.auto_sync_enabled === true,
|
||||
sync_interval_minutes: parseInt(formData.sync_interval_minutes),
|
||||
sync_sales: formData.sync_sales === 'true' || formData.sync_sales === true,
|
||||
sync_inventory: formData.sync_inventory === 'true' || formData.sync_inventory === true,
|
||||
sync_customers: false
|
||||
}
|
||||
};
|
||||
|
||||
// Create or update configuration
|
||||
if (mode === 'edit' && existingConfig) {
|
||||
await posService.updatePOSConfiguration({
|
||||
...payload,
|
||||
config_id: existingConfig.id
|
||||
});
|
||||
addToast('Configuración actualizada correctamente', { type: 'success' });
|
||||
} else {
|
||||
await posService.createPOSConfiguration(payload);
|
||||
addToast('Configuración creada correctamente', { type: 'success' });
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error('Error saving POS configuration:', error);
|
||||
addToast(error?.message || 'Error al guardar la configuración', { type: 'error' });
|
||||
throw error; // Let AddModal handle error state
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === 'edit' ? 'Editar Sistema POS' : 'Agregar Sistema POS'}
|
||||
subtitle={mode === 'edit' ? 'Actualiza la configuración del sistema POS' : 'Configura un nuevo sistema POS para sincronizar ventas e inventario'}
|
||||
statusIndicator={{
|
||||
color: statusColors.inProgress.primary,
|
||||
text: mode === 'edit' ? 'Edición' : 'Nueva Configuración',
|
||||
icon: Zap,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
}}
|
||||
sections={sections}
|
||||
onSave={handleSave}
|
||||
loading={loading}
|
||||
size="xl"
|
||||
initialData={initialData}
|
||||
validationErrors={{}}
|
||||
onValidationError={(errors) => {
|
||||
// Custom validation if needed
|
||||
if (errors && Object.keys(errors).length > 0) {
|
||||
const firstError = Object.values(errors)[0];
|
||||
addToast(firstError, { type: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePOSConfigModal;
|
||||
177
frontend/src/components/domain/pos/POSCart.tsx
Normal file
177
frontend/src/components/domain/pos/POSCart.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import { ShoppingCart, Plus, Minus, Trash2, X } from 'lucide-react';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
|
||||
interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
category: string;
|
||||
stock: number;
|
||||
}
|
||||
|
||||
interface POSCartProps {
|
||||
cart: CartItem[];
|
||||
onUpdateQuantity: (id: string, quantity: number) => void;
|
||||
onClearCart: () => void;
|
||||
taxRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSCart - Fixed sidebar cart component with clear totals and item management
|
||||
* Optimized for quick checkout operations
|
||||
*/
|
||||
export const POSCart: React.FC<POSCartProps> = ({
|
||||
cart,
|
||||
onUpdateQuantity,
|
||||
onClearCart,
|
||||
taxRate = 0.21, // 21% IVA by default
|
||||
}) => {
|
||||
// Calculate totals
|
||||
const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Cart Header */}
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-[var(--border-primary)]">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<ShoppingCart className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
Carrito ({itemCount})
|
||||
</h3>
|
||||
{cart.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearCart}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 mb-4">
|
||||
{cart.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center py-12">
|
||||
<ShoppingCart className="w-16 h-16 text-[var(--text-tertiary)] mb-3 opacity-30" />
|
||||
<p className="text-[var(--text-secondary)] font-medium">Carrito vacío</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Agrega productos para comenzar
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
cart.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className="p-3 bg-[var(--bg-secondary)] border-l-4 transition-all hover:shadow-md"
|
||||
style={{
|
||||
borderLeftColor: 'var(--color-primary)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Item Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4
|
||||
className="text-sm font-semibold text-[var(--text-primary)] truncate"
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</h4>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-sm font-medium text-[var(--color-primary)]">
|
||||
€{item.price.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">c/u</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Stock: {item.stock}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
onClick={() => onUpdateQuantity(item.id, 0)}
|
||||
className="text-[var(--color-error)] hover:bg-[var(--color-error)]/10 p-1 rounded transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-[var(--border-primary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onUpdateQuantity(item.id, item.quantity - 1)}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<span className="w-10 text-center text-base font-bold text-[var(--text-primary)]">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onUpdateQuantity(item.id, item.quantity + 1)}
|
||||
disabled={item.quantity >= item.stock}
|
||||
className="w-8 h-8 p-0"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Item Subtotal */}
|
||||
<div className="text-right">
|
||||
<p className="text-base font-bold text-[var(--text-primary)]">
|
||||
€{(item.price * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cart Totals */}
|
||||
{cart.length > 0 && (
|
||||
<Card className="p-4 bg-[var(--bg-tertiary)] border-2 border-[var(--color-primary)]/20">
|
||||
<div className="space-y-3">
|
||||
{/* Subtotal */}
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-[var(--text-secondary)]">Subtotal:</span>
|
||||
<span className="font-semibold text-[var(--text-primary)]">
|
||||
€{subtotal.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tax */}
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-[var(--text-secondary)]">IVA ({(taxRate * 100).toFixed(0)}%):</span>
|
||||
<span className="font-semibold text-[var(--text-primary)]">
|
||||
€{tax.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t-2 border-[var(--border-secondary)] pt-3">
|
||||
{/* Total */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-bold text-[var(--text-primary)]">TOTAL:</span>
|
||||
<span className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
€{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default POSCart;
|
||||
256
frontend/src/components/domain/pos/POSPayment.tsx
Normal file
256
frontend/src/components/domain/pos/POSPayment.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CreditCard, Banknote, ArrowRightLeft, Receipt, User } from 'lucide-react';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
|
||||
interface CustomerInfo {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface POSPaymentProps {
|
||||
total: number;
|
||||
onProcessPayment: (paymentData: {
|
||||
paymentMethod: 'cash' | 'card' | 'transfer';
|
||||
cashReceived?: number;
|
||||
change?: number;
|
||||
customerInfo?: CustomerInfo;
|
||||
}) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSPayment - Color-coded payment section with customer info
|
||||
* Optimized for quick checkout with visual payment method selection
|
||||
*/
|
||||
export const POSPayment: React.FC<POSPaymentProps> = ({
|
||||
total,
|
||||
onProcessPayment,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
|
||||
const [cashReceived, setCashReceived] = useState('');
|
||||
const [customerInfo, setCustomerInfo] = useState<CustomerInfo>({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
const [showCustomerForm, setShowCustomerForm] = useState(false);
|
||||
|
||||
const change = cashReceived ? Math.max(0, parseFloat(cashReceived) - total) : 0;
|
||||
const canProcessPayment =
|
||||
!disabled &&
|
||||
(paymentMethod === 'card' ||
|
||||
paymentMethod === 'transfer' ||
|
||||
(paymentMethod === 'cash' && cashReceived && parseFloat(cashReceived) >= total));
|
||||
|
||||
const handleProcessPayment = () => {
|
||||
if (!canProcessPayment) return;
|
||||
|
||||
onProcessPayment({
|
||||
paymentMethod,
|
||||
cashReceived: paymentMethod === 'cash' ? parseFloat(cashReceived) : undefined,
|
||||
change: paymentMethod === 'cash' ? change : undefined,
|
||||
customerInfo: showCustomerForm ? customerInfo : undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setCashReceived('');
|
||||
setCustomerInfo({ name: '', email: '', phone: '' });
|
||||
setShowCustomerForm(false);
|
||||
};
|
||||
|
||||
// Payment method configurations with colors
|
||||
const paymentMethods = [
|
||||
{
|
||||
id: 'cash' as const,
|
||||
name: 'Efectivo',
|
||||
icon: Banknote,
|
||||
color: 'var(--color-success)',
|
||||
bgColor: 'var(--color-success-light)',
|
||||
borderColor: 'var(--color-success-dark)',
|
||||
},
|
||||
{
|
||||
id: 'card' as const,
|
||||
name: 'Tarjeta',
|
||||
icon: CreditCard,
|
||||
color: 'var(--color-info)',
|
||||
bgColor: 'var(--color-info-light)',
|
||||
borderColor: 'var(--color-info-dark)',
|
||||
},
|
||||
{
|
||||
id: 'transfer' as const,
|
||||
name: 'Transferencia',
|
||||
icon: ArrowRightLeft,
|
||||
color: 'var(--color-secondary)',
|
||||
bgColor: 'var(--color-secondary-light)',
|
||||
borderColor: 'var(--color-secondary-dark)',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Customer Info Toggle */}
|
||||
<Card className="p-4">
|
||||
<button
|
||||
onClick={() => setShowCustomerForm(!showCustomerForm)}
|
||||
className="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-[var(--color-primary)]" />
|
||||
<span className="font-semibold text-[var(--text-primary)]">
|
||||
Cliente (Opcional)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[var(--text-tertiary)]">
|
||||
{showCustomerForm ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showCustomerForm && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<Input
|
||||
placeholder="Nombre"
|
||||
value={customerInfo.name}
|
||||
onChange={(e) =>
|
||||
setCustomerInfo((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
value={customerInfo.email}
|
||||
onChange={(e) =>
|
||||
setCustomerInfo((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Teléfono"
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) =>
|
||||
setCustomerInfo((prev) => ({ ...prev, phone: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Payment Method Selection */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-4">
|
||||
Método de Pago
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{paymentMethods.map((method) => {
|
||||
const Icon = method.icon;
|
||||
const isSelected = paymentMethod === method.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
onClick={() => setPaymentMethod(method.id)}
|
||||
className={`
|
||||
relative p-4 rounded-xl transition-all duration-200
|
||||
border-2 font-semibold text-left
|
||||
hover:scale-[1.02] active:scale-[0.98]
|
||||
${
|
||||
isSelected
|
||||
? 'shadow-lg ring-4 ring-opacity-30'
|
||||
: 'shadow hover:shadow-md'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: isSelected ? method.bgColor : 'var(--bg-secondary)',
|
||||
borderColor: isSelected ? method.borderColor : 'var(--border-secondary)',
|
||||
color: isSelected ? method.color : 'var(--text-primary)',
|
||||
...(isSelected && {
|
||||
ringColor: method.color,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="w-6 h-6" />
|
||||
<span className="text-base">{method.name}</span>
|
||||
{isSelected && (
|
||||
<span className="ml-auto text-2xl">✓</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cash Input */}
|
||||
{paymentMethod === 'cash' && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Efectivo Recibido
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="€0.00"
|
||||
value={cashReceived}
|
||||
onChange={(e) => setCashReceived(e.target.value)}
|
||||
className="text-lg font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Change Display */}
|
||||
{cashReceived && parseFloat(cashReceived) >= total && (
|
||||
<Card
|
||||
className="p-4 border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-success-light)',
|
||||
borderColor: 'var(--color-success)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-success-dark)' }}>
|
||||
Cambio:
|
||||
</span>
|
||||
<span className="text-2xl font-bold" style={{ color: 'var(--color-success-dark)' }}>
|
||||
€{change.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Insufficient Cash Warning */}
|
||||
{cashReceived && parseFloat(cashReceived) < total && (
|
||||
<Card
|
||||
className="p-3 border-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-warning-light)',
|
||||
borderColor: 'var(--color-warning)',
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-center" style={{ color: 'var(--color-warning-dark)' }}>
|
||||
Efectivo insuficiente: falta €{(total - parseFloat(cashReceived)).toFixed(2)}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Process Payment Button */}
|
||||
<Button
|
||||
onClick={handleProcessPayment}
|
||||
disabled={!canProcessPayment}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full text-lg font-bold py-6 shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
<Receipt className="w-6 h-6 mr-2" />
|
||||
Procesar Venta - €{total.toFixed(2)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default POSPayment;
|
||||
154
frontend/src/components/domain/pos/POSProductCard.tsx
Normal file
154
frontend/src/components/domain/pos/POSProductCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import { Plus, Package } from 'lucide-react';
|
||||
import { Card } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
|
||||
interface POSProductCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
category: string;
|
||||
stock: number;
|
||||
cartQuantity?: number;
|
||||
onAddToCart: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* POSProductCard - Large, touch-friendly product card optimized for POS operations
|
||||
* Designed for 80cm+ viewing distance with clear visual hierarchy
|
||||
*/
|
||||
export const POSProductCard: React.FC<POSProductCardProps> = ({
|
||||
name,
|
||||
price,
|
||||
category,
|
||||
stock,
|
||||
cartQuantity = 0,
|
||||
onAddToCart,
|
||||
onClick,
|
||||
}) => {
|
||||
const remainingStock = stock - cartQuantity;
|
||||
const isOutOfStock = remainingStock <= 0;
|
||||
const isLowStock = remainingStock > 0 && remainingStock <= 5;
|
||||
|
||||
// Stock status configuration
|
||||
const getStockConfig = () => {
|
||||
if (isOutOfStock) {
|
||||
return {
|
||||
color: 'var(--color-error)',
|
||||
bgColor: 'var(--color-error-light)',
|
||||
text: 'Sin Stock',
|
||||
icon: '🚫',
|
||||
};
|
||||
} else if (isLowStock) {
|
||||
return {
|
||||
color: 'var(--color-warning)',
|
||||
bgColor: 'var(--color-warning-light)',
|
||||
text: `${remainingStock} disponibles`,
|
||||
icon: '⚠️',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
color: 'var(--color-success)',
|
||||
bgColor: 'var(--color-success-light)',
|
||||
text: `${remainingStock} disponibles`,
|
||||
icon: '✓',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const stockConfig = getStockConfig();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`
|
||||
relative overflow-hidden transition-all duration-200 hover:shadow-xl
|
||||
${isOutOfStock ? 'opacity-60' : 'hover:scale-[1.02]'}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="p-4 sm:p-6 space-y-3">
|
||||
{/* Product Image Placeholder with Category Icon */}
|
||||
<div
|
||||
className="w-full h-32 sm:h-40 rounded-xl flex items-center justify-center mb-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: '2px dashed var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
<Package className="w-12 h-12 sm:w-16 sm:h-16 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
|
||||
{/* Product Name */}
|
||||
<div className="space-y-1">
|
||||
<h3
|
||||
className="text-base sm:text-lg font-bold text-[var(--text-primary)] truncate"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-[var(--text-secondary)] capitalize">
|
||||
{category}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price - Large and prominent */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl sm:text-3xl font-bold text-[var(--color-primary)]">
|
||||
€{price.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">c/u</span>
|
||||
</div>
|
||||
|
||||
{/* Stock Status Badge */}
|
||||
<div
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-semibold"
|
||||
style={{
|
||||
backgroundColor: stockConfig.bgColor,
|
||||
color: stockConfig.color,
|
||||
}}
|
||||
>
|
||||
<span>{stockConfig.icon}</span>
|
||||
<span>{stockConfig.text}</span>
|
||||
</div>
|
||||
|
||||
{/* In Cart Indicator */}
|
||||
{cartQuantity > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary" size="md">
|
||||
En carrito: {cartQuantity}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add to Cart Button - Large and prominent */}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToCart();
|
||||
}}
|
||||
disabled={isOutOfStock}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full mt-4 text-base sm:text-lg font-semibold py-3 sm:py-4"
|
||||
>
|
||||
<Plus className="w-5 h-5 sm:w-6 sm:h-6 mr-2" />
|
||||
{isOutOfStock ? 'Sin Stock' : 'Agregar'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Out of Stock Overlay */}
|
||||
{isOutOfStock && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)] bg-opacity-50 flex items-center justify-center pointer-events-none">
|
||||
<div className="bg-[var(--color-error)] text-white px-6 py-3 rounded-lg font-bold text-lg shadow-lg">
|
||||
AGOTADO
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default POSProductCard;
|
||||
4
frontend/src/components/domain/pos/index.ts
Normal file
4
frontend/src/components/domain/pos/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { POSProductCard } from './POSProductCard';
|
||||
export { POSCart } from './POSCart';
|
||||
export { POSPayment } from './POSPayment';
|
||||
export { CreatePOSConfigModal } from './CreatePOSConfigModal';
|
||||
@@ -5,6 +5,7 @@ import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useTenantStore } from '../../../stores/tenant.store';
|
||||
import { suppliersService } from '../../../api/services/suppliers';
|
||||
import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders';
|
||||
import type { SupplierSummary } from '../../../api/types/suppliers';
|
||||
import type { IngredientResponse } from '../../../api/types/inventory';
|
||||
@@ -31,6 +32,7 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<string>('');
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// Get current tenant
|
||||
const { currentTenant } = useTenantStore();
|
||||
@@ -44,13 +46,49 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
);
|
||||
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
||||
|
||||
// Fetch ingredients filtered by selected supplier (only when manually adding products)
|
||||
const { data: ingredientsData = [] } = useIngredients(
|
||||
// State for supplier products
|
||||
const [supplierProductIds, setSupplierProductIds] = useState<string[]>([]);
|
||||
const [isLoadingSupplierProducts, setIsLoadingSupplierProducts] = useState(false);
|
||||
|
||||
// Fetch ALL ingredients (we'll filter client-side based on supplier products)
|
||||
const { data: allIngredientsData = [], isLoading: isLoadingIngredients } = useIngredients(
|
||||
tenantId,
|
||||
selectedSupplier ? { supplier_id: selectedSupplier } : {},
|
||||
{ enabled: !!tenantId && isOpen && !requirements?.length && !!selectedSupplier }
|
||||
{},
|
||||
{ enabled: !!tenantId && isOpen && !requirements?.length }
|
||||
);
|
||||
|
||||
// Fetch supplier products when supplier is selected
|
||||
useEffect(() => {
|
||||
const fetchSupplierProducts = async () => {
|
||||
if (!selectedSupplier || !tenantId) {
|
||||
setSupplierProductIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingSupplierProducts(true);
|
||||
try {
|
||||
const products = await suppliersService.getSupplierProducts(tenantId, selectedSupplier);
|
||||
const productIds = products.map(p => p.inventory_product_id);
|
||||
setSupplierProductIds(productIds);
|
||||
} catch (error) {
|
||||
console.error('Error fetching supplier products:', error);
|
||||
setSupplierProductIds([]);
|
||||
} finally {
|
||||
setIsLoadingSupplierProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSupplierProducts();
|
||||
}, [selectedSupplier, tenantId]);
|
||||
|
||||
// Filter ingredients based on supplier products
|
||||
const ingredientsData = useMemo(() => {
|
||||
if (!selectedSupplier || supplierProductIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return allIngredientsData.filter(ing => supplierProductIds.includes(ing.id));
|
||||
}, [allIngredientsData, supplierProductIds, selectedSupplier]);
|
||||
|
||||
// Create purchase order mutation
|
||||
const createPurchaseOrderMutation = useCreatePurchaseOrder();
|
||||
|
||||
@@ -66,6 +104,14 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
data: ingredient // Store full ingredient data for later use
|
||||
})), [ingredientsData]);
|
||||
|
||||
// Reset selected supplier when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedSupplier('');
|
||||
setFormData({});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Unit options for select field
|
||||
const unitOptions = [
|
||||
{ value: 'kg', label: 'Kilogramos' },
|
||||
@@ -80,11 +126,6 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
setLoading(true);
|
||||
|
||||
// Update selectedSupplier if it changed
|
||||
if (formData.supplier_id && formData.supplier_id !== selectedSupplier) {
|
||||
setSelectedSupplier(formData.supplier_id);
|
||||
}
|
||||
|
||||
try {
|
||||
let items: PurchaseOrderItem[] = [];
|
||||
|
||||
@@ -187,8 +228,9 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
};
|
||||
|
||||
|
||||
const sections = [
|
||||
{
|
||||
// Build sections dynamically based on selectedSupplier
|
||||
const sections = useMemo(() => {
|
||||
const supplierSection = {
|
||||
title: 'Información del Proveedor',
|
||||
icon: Building2,
|
||||
fields: [
|
||||
@@ -199,11 +241,19 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
required: true,
|
||||
options: supplierOptions,
|
||||
placeholder: 'Seleccionar proveedor...',
|
||||
span: 2
|
||||
span: 2,
|
||||
validation: (value: any) => {
|
||||
// Update selectedSupplier when supplier changes
|
||||
if (value && value !== selectedSupplier) {
|
||||
setTimeout(() => setSelectedSupplier(value), 0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
};
|
||||
|
||||
const orderDetailsSection = {
|
||||
title: 'Detalles de la Orden',
|
||||
icon: Calendar,
|
||||
fields: [
|
||||
@@ -222,8 +272,9 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
helpText: 'Información adicional o instrucciones especiales'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
};
|
||||
|
||||
const ingredientsSection = {
|
||||
title: requirements && requirements.length > 0 ? 'Ingredientes Requeridos' : 'Productos a Comprar',
|
||||
icon: Package,
|
||||
fields: [
|
||||
@@ -281,7 +332,7 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
},
|
||||
helpText: 'Revisa y ajusta las cantidades y precios de los ingredientes requeridos'
|
||||
} : {
|
||||
label: 'Productos a Comprar',
|
||||
label: selectedSupplier ? 'Productos a Comprar' : 'Selecciona un proveedor primero',
|
||||
name: 'manual_products',
|
||||
type: 'list' as const,
|
||||
span: 2,
|
||||
@@ -294,8 +345,8 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ingredientOptions,
|
||||
placeholder: 'Seleccionar ingrediente...',
|
||||
disabled: false
|
||||
placeholder: isLoadingSupplierProducts || isLoadingIngredients ? 'Cargando ingredientes...' : ingredientOptions.length === 0 ? 'No hay ingredientes disponibles para este proveedor' : 'Seleccionar ingrediente...',
|
||||
disabled: !selectedSupplier || isLoadingIngredients || isLoadingSupplierProducts
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
@@ -322,16 +373,26 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
}
|
||||
],
|
||||
addButtonLabel: 'Agregar Ingrediente',
|
||||
emptyStateText: 'No hay ingredientes disponibles para este proveedor',
|
||||
emptyStateText: !selectedSupplier
|
||||
? 'Selecciona un proveedor para agregar ingredientes'
|
||||
: isLoadingSupplierProducts || isLoadingIngredients
|
||||
? 'Cargando ingredientes del proveedor...'
|
||||
: ingredientOptions.length === 0
|
||||
? 'Este proveedor no tiene ingredientes asignados en su lista de precios'
|
||||
: 'No hay ingredientes agregados',
|
||||
showSubtotals: true,
|
||||
subtotalFields: { quantity: 'quantity', price: 'unit_price' },
|
||||
disabled: !selectedSupplier
|
||||
},
|
||||
helpText: 'Selecciona ingredientes disponibles del proveedor seleccionado'
|
||||
helpText: !selectedSupplier
|
||||
? 'Primero selecciona un proveedor en la sección anterior'
|
||||
: 'Selecciona ingredientes disponibles del proveedor seleccionado'
|
||||
}
|
||||
]
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return [supplierSection, orderDetailsSection, ingredientsSection];
|
||||
}, [requirements, supplierOptions, ingredientOptions, selectedSupplier, isLoadingIngredients, unitOptions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -102,6 +102,16 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
|
||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||
|
||||
// Filter notifications to last 24 hours for the notification bell
|
||||
// This prevents showing old/stale alerts in the notification panel
|
||||
const recentNotifications = React.useMemo(() => {
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
return notifications.filter(n => {
|
||||
const alertTime = new Date(n.timestamp).getTime();
|
||||
return alertTime > oneDayAgo;
|
||||
});
|
||||
}, [notifications]);
|
||||
|
||||
const defaultSearchPlaceholder = searchPlaceholder || t('common:forms.search_placeholder', 'Search...');
|
||||
|
||||
// Expose ref methods
|
||||
@@ -259,7 +269,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
</Button>
|
||||
|
||||
<NotificationPanel
|
||||
notifications={notifications}
|
||||
notifications={recentNotifications}
|
||||
isOpen={isNotificationPanelOpen}
|
||||
onClose={() => setIsNotificationPanelOpen(false)}
|
||||
onMarkAsRead={markAsRead}
|
||||
|
||||
@@ -63,6 +63,7 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
||||
|
||||
const renderItemField = (item: any, itemIndex: number, fieldConfig: any) => {
|
||||
const fieldValue = item[fieldConfig.name] ?? '';
|
||||
const isFieldDisabled = fieldConfig.disabled ?? false;
|
||||
|
||||
switch (fieldConfig.type) {
|
||||
case 'select':
|
||||
@@ -70,10 +71,11 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
||||
<select
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
required={fieldConfig.required}
|
||||
disabled={isFieldDisabled}
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="">{fieldConfig.placeholder || 'Seleccionar...'}</option>
|
||||
{fieldConfig.options?.map((option: any) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
@@ -87,11 +89,12 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
||||
type="number"
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
min="0"
|
||||
step={fieldConfig.type === 'currency' ? '0.01' : '0.1'}
|
||||
placeholder={fieldConfig.placeholder}
|
||||
required={fieldConfig.required}
|
||||
disabled={isFieldDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -101,14 +104,17 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
||||
type="text"
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder={fieldConfig.placeholder}
|
||||
required={fieldConfig.required}
|
||||
disabled={isFieldDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = listConfig.disabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -116,7 +122,12 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
|
||||
disabled={isDisabled}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
isDisabled
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed opacity-50'
|
||||
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary)]/90'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{listConfig.addButtonLabel || t('common:modals.actions.add', 'Agregar')}
|
||||
@@ -129,7 +140,7 @@ const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onC
|
||||
<Plus className="w-full h-full" />
|
||||
</div>
|
||||
<p>{listConfig.emptyStateText || 'No hay elementos agregados'}</p>
|
||||
<p className="text-sm">Haz clic en "{listConfig.addButtonLabel || 'Agregar'}" para comenzar</p>
|
||||
{!isDisabled && <p className="text-sm">Haz clic en "{listConfig.addButtonLabel || 'Agregar'}" para comenzar</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
@@ -204,12 +215,14 @@ export interface AddModalField {
|
||||
options?: Array<{label: string; value: string | number}>;
|
||||
defaultValue?: any;
|
||||
validation?: (value: any) => string | null;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
addButtonLabel?: string;
|
||||
removeButtonLabel?: string;
|
||||
emptyStateText?: string;
|
||||
showSubtotals?: boolean; // For calculating item totals
|
||||
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
|
||||
disabled?: boolean; // Disable adding new items
|
||||
};
|
||||
}
|
||||
|
||||
@@ -686,8 +699,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
disabled={loading}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{t('common:modals.actions.cancel', 'Cancelar')}
|
||||
{t('common:modals.actions.cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -698,10 +710,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{t('common:modals.actions.save', 'Guardar')}
|
||||
</>
|
||||
t('common:modals.actions.save', 'Guardar')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -54,28 +54,52 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
'whitespace-nowrap',
|
||||
];
|
||||
|
||||
// Variant styling using CSS custom properties
|
||||
const variantStyles: Record<string, React.CSSProperties> = {
|
||||
default: {},
|
||||
primary: {
|
||||
backgroundColor: 'var(--color-primary)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-primary)',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: 'var(--color-secondary)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-secondary)',
|
||||
},
|
||||
success: {
|
||||
backgroundColor: 'var(--color-success)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-success)',
|
||||
},
|
||||
warning: {
|
||||
backgroundColor: 'var(--color-warning)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-warning)',
|
||||
},
|
||||
error: {
|
||||
backgroundColor: 'var(--color-error)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-error)',
|
||||
},
|
||||
info: {
|
||||
backgroundColor: 'var(--color-info)',
|
||||
color: 'white',
|
||||
borderColor: 'var(--color-info)',
|
||||
},
|
||||
outline: {},
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: [
|
||||
'bg-bg-tertiary text-text-primary border border-border-primary',
|
||||
],
|
||||
primary: [
|
||||
'bg-color-primary text-text-inverse',
|
||||
],
|
||||
secondary: [
|
||||
'bg-color-secondary text-text-inverse',
|
||||
],
|
||||
success: [
|
||||
'bg-color-success text-text-inverse',
|
||||
],
|
||||
warning: [
|
||||
'bg-color-warning text-text-inverse',
|
||||
],
|
||||
error: [
|
||||
'bg-color-error text-text-inverse',
|
||||
],
|
||||
info: [
|
||||
'bg-color-info text-text-inverse',
|
||||
'bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-primary)]',
|
||||
],
|
||||
primary: [],
|
||||
secondary: [],
|
||||
success: [],
|
||||
warning: [],
|
||||
error: [],
|
||||
info: [],
|
||||
outline: [
|
||||
'bg-transparent border border-current',
|
||||
],
|
||||
@@ -83,13 +107,13 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
|
||||
const sizeClasses = {
|
||||
xs: isStandalone ? 'px-1.5 py-0.5 text-xs min-h-4' : 'w-4 h-4 text-xs',
|
||||
sm: isStandalone ? 'px-2 py-0.5 text-xs min-h-5' : 'w-5 h-5 text-xs',
|
||||
md: isStandalone ? 'px-2.5 py-1 text-sm min-h-6' : 'w-6 h-6 text-sm',
|
||||
lg: isStandalone ? 'px-3 py-1.5 text-sm min-h-7' : 'w-7 h-7 text-sm',
|
||||
sm: isStandalone ? 'px-3 py-1.5 text-sm min-h-6 font-medium' : 'w-5 h-5 text-xs',
|
||||
md: isStandalone ? 'px-3 py-1.5 text-sm min-h-7 font-semibold' : 'w-6 h-6 text-sm',
|
||||
lg: isStandalone ? 'px-4 py-2 text-base min-h-8 font-semibold' : 'w-7 h-7 text-sm',
|
||||
};
|
||||
|
||||
const shapeClasses = {
|
||||
rounded: 'rounded-md',
|
||||
rounded: 'rounded-lg',
|
||||
pill: 'rounded-full',
|
||||
square: 'rounded-none',
|
||||
};
|
||||
@@ -171,18 +195,22 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
shapeClasses[shape],
|
||||
'border', // Always include border
|
||||
{
|
||||
'gap-1': icon || closable,
|
||||
'pr-1': closable,
|
||||
'gap-2': icon || closable,
|
||||
'pr-2': closable,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const customStyle = color ? {
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
color: getContrastColor(color),
|
||||
} : undefined;
|
||||
// Merge custom style with variant style
|
||||
const customStyle = color
|
||||
? {
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
color: getContrastColor(color),
|
||||
}
|
||||
: variantStyles[variant] || {};
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -192,9 +220,9 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<span className="flex-shrink-0 flex items-center">{icon}</span>
|
||||
)}
|
||||
<span>{text || displayCount || children}</span>
|
||||
<span className="whitespace-nowrap">{text || displayCount || children}</span>
|
||||
{closable && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface StatusCardProps {
|
||||
onClick: () => void;
|
||||
priority?: 'primary' | 'secondary' | 'tertiary';
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
@@ -292,14 +293,19 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
primaryActions[0].onClick();
|
||||
if (!primaryActions[0].disabled) {
|
||||
primaryActions[0].onClick();
|
||||
}
|
||||
}}
|
||||
disabled={primaryActions[0].disabled}
|
||||
className={`
|
||||
flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium rounded-lg
|
||||
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0 max-w-[120px] sm:max-w-[150px]
|
||||
${primaryActions[0].destructive
|
||||
? 'text-red-600 hover:bg-red-50 hover:text-red-700'
|
||||
: 'text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)] hover:text-[var(--color-primary-700)]'
|
||||
${primaryActions[0].disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: primaryActions[0].destructive
|
||||
? 'text-red-600 hover:bg-red-50 hover:text-red-700'
|
||||
: 'text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)] hover:text-[var(--color-primary-700)]'
|
||||
}
|
||||
`}
|
||||
title={primaryActions[0].label}
|
||||
@@ -318,14 +324,19 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
key={`action-${index}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
if (!action.disabled) {
|
||||
action.onClick();
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
title={action.label}
|
||||
className={`
|
||||
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
|
||||
${action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-secondary)]'
|
||||
${action.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -339,14 +350,19 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
key={`primary-icon-${index}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick();
|
||||
if (!action.disabled) {
|
||||
action.onClick();
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
title={action.label}
|
||||
className={`
|
||||
p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95
|
||||
${action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]'
|
||||
${action.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
71
frontend/src/hooks/useAlertActions.ts
Normal file
71
frontend/src/hooks/useAlertActions.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* useAlertActions Hook
|
||||
* Provides contextual actions for alerts
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NotificationData } from './useNotifications';
|
||||
import { getContextualActions, type ContextualAction } from '../utils/alertHelpers';
|
||||
|
||||
export interface UseAlertActionsReturn {
|
||||
getActions: (alert: NotificationData) => ContextualAction[];
|
||||
executeAction: (alert: NotificationData, action: ContextualAction) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage alert actions
|
||||
*/
|
||||
export function useAlertActions(): UseAlertActionsReturn {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getActions = useCallback((alert: NotificationData): ContextualAction[] => {
|
||||
return getContextualActions(alert);
|
||||
}, []);
|
||||
|
||||
const executeAction = useCallback((alert: NotificationData, action: ContextualAction) => {
|
||||
switch (action.action) {
|
||||
case 'order_stock':
|
||||
if (action.route) {
|
||||
navigate(action.route);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plan_usage':
|
||||
if (action.route) {
|
||||
navigate(action.route);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'schedule_maintenance':
|
||||
if (action.route) {
|
||||
navigate(action.route);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'contact_customer':
|
||||
// In a real app, this would open a communication modal
|
||||
console.log('Contact customer for alert:', alert.id);
|
||||
break;
|
||||
|
||||
case 'view_production':
|
||||
if (action.route) {
|
||||
navigate(action.route);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'view_details':
|
||||
// Default: expand the alert or navigate to details
|
||||
console.log('View details for alert:', alert.id);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown action:', action.action);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return {
|
||||
getActions,
|
||||
executeAction,
|
||||
};
|
||||
}
|
||||
181
frontend/src/hooks/useAlertAnalytics.ts
Normal file
181
frontend/src/hooks/useAlertAnalytics.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* useAlertAnalytics Hook
|
||||
* Fetches analytics data from backend API
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { NotificationData } from './useNotifications';
|
||||
import type { AlertCategory } from '../utils/alertHelpers';
|
||||
import { useCurrentTenant } from '../stores/tenant.store';
|
||||
import { useAuthUser } from '../stores/auth.store';
|
||||
|
||||
export interface AlertTrendData {
|
||||
date: string;
|
||||
count: number;
|
||||
urgentCount: number;
|
||||
highCount: number;
|
||||
mediumCount: number;
|
||||
lowCount: number;
|
||||
}
|
||||
|
||||
export interface AlertAnalytics {
|
||||
trends: AlertTrendData[];
|
||||
averageResponseTime: number;
|
||||
topCategories: Array<{ category: AlertCategory | string; count: number; percentage: number }>;
|
||||
totalAlerts: number;
|
||||
resolvedAlerts: number;
|
||||
activeAlerts: number;
|
||||
resolutionRate: number;
|
||||
predictedDailyAverage: number;
|
||||
busiestDay: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and display alert analytics from backend
|
||||
*/
|
||||
export function useAlertAnalytics(alerts: NotificationData[], days: number = 7): AlertAnalytics {
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
|
||||
const [analytics, setAnalytics] = useState<AlertAnalytics>({
|
||||
trends: [],
|
||||
averageResponseTime: 0,
|
||||
topCategories: [],
|
||||
totalAlerts: 0,
|
||||
resolvedAlerts: 0,
|
||||
activeAlerts: 0,
|
||||
resolutionRate: 0,
|
||||
predictedDailyAverage: 0,
|
||||
busiestDay: 'N/A',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch analytics from backend
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
if (!tenantId) {
|
||||
console.warn('[useAlertAnalytics] No tenant ID found, skipping analytics fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useAlertAnalytics] Fetching analytics for tenant:', tenantId, 'days:', days);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { getAlertAnalytics } = await import('../api/services/alert_analytics');
|
||||
const data = await getAlertAnalytics(tenantId, days);
|
||||
console.log('[useAlertAnalytics] Received data from API:', data);
|
||||
setAnalytics(data);
|
||||
console.log('[useAlertAnalytics] Analytics state updated');
|
||||
} catch (err) {
|
||||
console.error('[useAlertAnalytics] Failed to fetch alert analytics:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch analytics');
|
||||
|
||||
// Fallback to empty state on error
|
||||
setAnalytics({
|
||||
trends: [],
|
||||
averageResponseTime: 0,
|
||||
topCategories: [],
|
||||
totalAlerts: alerts.length,
|
||||
resolvedAlerts: 0,
|
||||
activeAlerts: alerts.length,
|
||||
resolutionRate: 0,
|
||||
predictedDailyAverage: 0,
|
||||
busiestDay: 'N/A',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tenantId, days, alerts.length]);
|
||||
|
||||
// Fetch on mount and when days changes
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
// Refetch when new alerts arrive (debounced)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchAnalytics();
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [alerts.length, fetchAnalytics]);
|
||||
|
||||
return analytics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to export analytics tracking methods
|
||||
* Uses backend API for persistent tracking across devices
|
||||
*/
|
||||
export function useAlertAnalyticsTracking() {
|
||||
const { trackAlertInteraction } = useAlertInteractions();
|
||||
|
||||
const trackAcknowledgment = useCallback(async (alertId: string) => {
|
||||
try {
|
||||
await trackAlertInteraction(alertId, 'acknowledged');
|
||||
} catch (error) {
|
||||
console.error('Failed to track acknowledgment:', error);
|
||||
}
|
||||
}, [trackAlertInteraction]);
|
||||
|
||||
const trackResolution = useCallback(async (alertId: string) => {
|
||||
try {
|
||||
await trackAlertInteraction(alertId, 'resolved');
|
||||
} catch (error) {
|
||||
console.error('Failed to track resolution:', error);
|
||||
}
|
||||
}, [trackAlertInteraction]);
|
||||
|
||||
const trackSnooze = useCallback(async (alertId: string, duration: string) => {
|
||||
try {
|
||||
await trackAlertInteraction(alertId, 'snoozed', { duration });
|
||||
} catch (error) {
|
||||
console.error('Failed to track snooze:', error);
|
||||
}
|
||||
}, [trackAlertInteraction]);
|
||||
|
||||
const trackDismiss = useCallback(async (alertId: string, reason?: string) => {
|
||||
try {
|
||||
await trackAlertInteraction(alertId, 'dismissed', { reason });
|
||||
} catch (error) {
|
||||
console.error('Failed to track dismiss:', error);
|
||||
}
|
||||
}, [trackAlertInteraction]);
|
||||
|
||||
return {
|
||||
trackAcknowledgment,
|
||||
trackResolution,
|
||||
trackSnooze,
|
||||
trackDismiss,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for tracking alert interactions
|
||||
*/
|
||||
function useAlertInteractions() {
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
|
||||
const trackAlertInteraction = useCallback(async (
|
||||
alertId: string,
|
||||
interactionType: 'acknowledged' | 'resolved' | 'snoozed' | 'dismissed',
|
||||
metadata?: Record<string, any>
|
||||
) => {
|
||||
if (!tenantId) {
|
||||
console.warn('No tenant ID found, skipping interaction tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackAlertInteraction: apiTrack } = await import('../api/services/alert_analytics');
|
||||
await apiTrack(tenantId, alertId, interactionType, metadata);
|
||||
}, [tenantId]);
|
||||
|
||||
return { trackAlertInteraction };
|
||||
}
|
||||
112
frontend/src/hooks/useAlertFilters.ts
Normal file
112
frontend/src/hooks/useAlertFilters.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* useAlertFilters Hook
|
||||
* Manages alert filtering state and logic
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { AlertFilters, AlertSeverity, AlertCategory, TimeGroup } from '../utils/alertHelpers';
|
||||
|
||||
export interface UseAlertFiltersReturn {
|
||||
filters: AlertFilters;
|
||||
setFilters: React.Dispatch<React.SetStateAction<AlertFilters>>;
|
||||
toggleSeverity: (severity: AlertSeverity) => void;
|
||||
toggleCategory: (category: AlertCategory) => void;
|
||||
setTimeRange: (range: TimeGroup | 'all') => void;
|
||||
setSearch: (search: string) => void;
|
||||
toggleShowSnoozed: () => void;
|
||||
clearFilters: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
activeFilterCount: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: AlertFilters = {
|
||||
severities: [],
|
||||
categories: [],
|
||||
timeRange: 'all',
|
||||
search: '',
|
||||
showSnoozed: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage alert filters
|
||||
*/
|
||||
export function useAlertFilters(initialFilters: Partial<AlertFilters> = {}): UseAlertFiltersReturn {
|
||||
const [filters, setFilters] = useState<AlertFilters>({
|
||||
...DEFAULT_FILTERS,
|
||||
...initialFilters,
|
||||
});
|
||||
|
||||
const toggleSeverity = useCallback((severity: AlertSeverity) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
severities: prev.severities.includes(severity)
|
||||
? prev.severities.filter(s => s !== severity)
|
||||
: [...prev.severities, severity],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const toggleCategory = useCallback((category: AlertCategory) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
categories: prev.categories.includes(category)
|
||||
? prev.categories.filter(c => c !== category)
|
||||
: [...prev.categories, category],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTimeRange = useCallback((range: TimeGroup | 'all') => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
timeRange: range,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setSearch = useCallback((search: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
search,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const toggleShowSnoozed = useCallback(() => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
showSnoozed: !prev.showSnoozed,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
filters.severities.length > 0 ||
|
||||
filters.categories.length > 0 ||
|
||||
filters.timeRange !== 'all' ||
|
||||
filters.search.trim() !== ''
|
||||
);
|
||||
}, [filters]);
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filters.severities.length > 0) count += filters.severities.length;
|
||||
if (filters.categories.length > 0) count += filters.categories.length;
|
||||
if (filters.timeRange !== 'all') count += 1;
|
||||
if (filters.search.trim() !== '') count += 1;
|
||||
return count;
|
||||
}, [filters]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
setFilters,
|
||||
toggleSeverity,
|
||||
toggleCategory,
|
||||
setTimeRange,
|
||||
setSearch,
|
||||
toggleShowSnoozed,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
activeFilterCount,
|
||||
};
|
||||
}
|
||||
102
frontend/src/hooks/useAlertGrouping.ts
Normal file
102
frontend/src/hooks/useAlertGrouping.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* useAlertGrouping Hook
|
||||
* Manages alert grouping logic and state
|
||||
*/
|
||||
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { NotificationData } from './useNotifications';
|
||||
import {
|
||||
groupAlertsByTime,
|
||||
groupAlertsByCategory,
|
||||
groupSimilarAlerts,
|
||||
sortAlerts,
|
||||
type AlertGroup,
|
||||
} from '../utils/alertHelpers';
|
||||
|
||||
export type GroupingMode = 'none' | 'time' | 'category' | 'similarity';
|
||||
|
||||
export interface UseAlertGroupingReturn {
|
||||
groupedAlerts: AlertGroup[];
|
||||
groupingMode: GroupingMode;
|
||||
setGroupingMode: (mode: GroupingMode) => void;
|
||||
collapsedGroups: Set<string>;
|
||||
toggleGroupCollapse: (groupId: string) => void;
|
||||
collapseAll: () => void;
|
||||
expandAll: () => void;
|
||||
isGroupCollapsed: (groupId: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage alert grouping
|
||||
*/
|
||||
export function useAlertGrouping(
|
||||
alerts: NotificationData[],
|
||||
initialMode: GroupingMode = 'time'
|
||||
): UseAlertGroupingReturn {
|
||||
const [groupingMode, setGroupingMode] = useState<GroupingMode>(initialMode);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const groupedAlerts = useMemo(() => {
|
||||
// Sort alerts first
|
||||
const sortedAlerts = sortAlerts(alerts);
|
||||
|
||||
switch (groupingMode) {
|
||||
case 'time':
|
||||
return groupAlertsByTime(sortedAlerts);
|
||||
|
||||
case 'category':
|
||||
return groupAlertsByCategory(sortedAlerts);
|
||||
|
||||
case 'similarity':
|
||||
return groupSimilarAlerts(sortedAlerts);
|
||||
|
||||
case 'none':
|
||||
default:
|
||||
// Return each alert as its own group
|
||||
return sortedAlerts.map((alert, index) => ({
|
||||
id: `single-${alert.id}`,
|
||||
type: 'time' as const,
|
||||
key: alert.id,
|
||||
title: alert.title,
|
||||
count: 1,
|
||||
severity: alert.severity as any,
|
||||
alerts: [alert],
|
||||
}));
|
||||
}
|
||||
}, [alerts, groupingMode]);
|
||||
|
||||
const toggleGroupCollapse = useCallback((groupId: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
setCollapsedGroups(new Set(groupedAlerts.map(g => g.id)));
|
||||
}, [groupedAlerts]);
|
||||
|
||||
const expandAll = useCallback(() => {
|
||||
setCollapsedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
const isGroupCollapsed = useCallback((groupId: string) => {
|
||||
return collapsedGroups.has(groupId);
|
||||
}, [collapsedGroups]);
|
||||
|
||||
return {
|
||||
groupedAlerts,
|
||||
groupingMode,
|
||||
setGroupingMode,
|
||||
collapsedGroups,
|
||||
toggleGroupCollapse,
|
||||
collapseAll,
|
||||
expandAll,
|
||||
isGroupCollapsed,
|
||||
};
|
||||
}
|
||||
153
frontend/src/hooks/useKeyboardNavigation.ts
Normal file
153
frontend/src/hooks/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* useKeyboardNavigation Hook
|
||||
* Provides keyboard navigation for alerts
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
|
||||
export interface KeyboardNavigationCallbacks {
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onSelect: () => void;
|
||||
onExpand: () => void;
|
||||
onMarkAsRead: () => void;
|
||||
onDismiss: () => void;
|
||||
onSnooze: () => void;
|
||||
onEscape: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export interface UseKeyboardNavigationReturn {
|
||||
focusedIndex: number;
|
||||
setFocusedIndex: (index: number) => void;
|
||||
handleKeyDown: (event: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable keyboard navigation for alerts
|
||||
*/
|
||||
export function useKeyboardNavigation(
|
||||
itemCount: number,
|
||||
callbacks: KeyboardNavigationCallbacks,
|
||||
enabled: boolean = true
|
||||
): UseKeyboardNavigationReturn {
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const callbacksRef = useRef(callbacks);
|
||||
|
||||
// Update callbacks ref when they change
|
||||
useEffect(() => {
|
||||
callbacksRef.current = callbacks;
|
||||
}, [callbacks]);
|
||||
|
||||
// Reset focused index when item count changes
|
||||
useEffect(() => {
|
||||
if (focusedIndex >= itemCount && itemCount > 0) {
|
||||
setFocusedIndex(Math.max(0, itemCount - 1));
|
||||
}
|
||||
}, [itemCount, focusedIndex]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (!enabled || itemCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cbs = callbacksRef.current;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setFocusedIndex(prev => Math.max(0, prev - 1));
|
||||
cbs.onMoveUp();
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setFocusedIndex(prev => Math.min(itemCount - 1, prev + 1));
|
||||
cbs.onMoveDown();
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
cbs.onExpand();
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
cbs.onSelect();
|
||||
break;
|
||||
|
||||
case 'r':
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
cbs.onMarkAsRead();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
cbs.onDismiss();
|
||||
}
|
||||
break;
|
||||
|
||||
case 's':
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
cbs.onSnooze();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
cbs.onEscape();
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
cbs.onSelectAll();
|
||||
}
|
||||
break;
|
||||
|
||||
case '/':
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
cbs.onSearch();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [enabled, itemCount]);
|
||||
|
||||
// Add global keyboard listener
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
// Only handle if no input is focused
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleKeyDown(event);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
};
|
||||
}, [enabled, handleKeyDown]);
|
||||
|
||||
return {
|
||||
focusedIndex,
|
||||
setFocusedIndex,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useSSE } from '../contexts/SSEContext';
|
||||
import { calculateSnoozeUntil, type SnoozedAlert } from '../utils/alertHelpers';
|
||||
|
||||
export interface NotificationData {
|
||||
id: string;
|
||||
@@ -9,16 +10,34 @@ export interface NotificationData {
|
||||
message: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'bakery-notifications';
|
||||
const SNOOZE_STORAGE_KEY = 'bakery-snoozed-alerts';
|
||||
|
||||
const loadNotificationsFromStorage = (): NotificationData[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
if (Array.isArray(parsed)) {
|
||||
// Clean up old alerts (older than 24 hours)
|
||||
// This prevents accumulation of stale alerts in localStorage
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
const recentAlerts = parsed.filter(n => {
|
||||
const alertTime = new Date(n.timestamp).getTime();
|
||||
return alertTime > oneDayAgo;
|
||||
});
|
||||
|
||||
// If we filtered out alerts, update localStorage
|
||||
if (recentAlerts.length !== parsed.length) {
|
||||
console.log(`Cleaned ${parsed.length - recentAlerts.length} old alerts from localStorage`);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(recentAlerts));
|
||||
}
|
||||
|
||||
return recentAlerts;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load notifications from localStorage:', error);
|
||||
@@ -34,8 +53,59 @@ const saveNotificationsToStorage = (notifications: NotificationData[]) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadSnoozedAlertsFromStorage = (): Map<string, SnoozedAlert> => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SNOOZE_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
const map = new Map<string, SnoozedAlert>();
|
||||
|
||||
Object.entries(parsed).forEach(([key, value]) => {
|
||||
const snoozed = value as SnoozedAlert;
|
||||
// Only add if not expired
|
||||
if (snoozed.until > Date.now()) {
|
||||
map.set(key, snoozed);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load snoozed alerts from localStorage:', error);
|
||||
}
|
||||
return new Map();
|
||||
};
|
||||
|
||||
const saveSnoozedAlertsToStorage = (snoozedAlerts: Map<string, SnoozedAlert>) => {
|
||||
try {
|
||||
const obj: Record<string, SnoozedAlert> = {};
|
||||
snoozedAlerts.forEach((value, key) => {
|
||||
// Only save if not expired
|
||||
if (value.until > Date.now()) {
|
||||
obj[key] = value;
|
||||
}
|
||||
});
|
||||
localStorage.setItem(SNOOZE_STORAGE_KEY, JSON.stringify(obj));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save snoozed alerts to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* useNotifications - Hook for managing real-time notifications and alerts
|
||||
*
|
||||
* Features:
|
||||
* - SSE connection for real-time alerts
|
||||
* - localStorage persistence with auto-cleanup (alerts >24h are removed on load)
|
||||
* - Snooze functionality with expiration tracking
|
||||
* - Bulk operations (mark multiple as read, remove, snooze)
|
||||
*
|
||||
* Note: localStorage is automatically cleaned of alerts older than 24 hours
|
||||
* on load to prevent accumulation of stale data.
|
||||
*/
|
||||
export const useNotifications = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationData[]>(() => loadNotificationsFromStorage());
|
||||
const [snoozedAlerts, setSnoozedAlerts] = useState<Map<string, SnoozedAlert>>(() => loadSnoozedAlertsFromStorage());
|
||||
const [unreadCount, setUnreadCount] = useState(() => {
|
||||
const stored = loadNotificationsFromStorage();
|
||||
return stored.filter(n => !n.read).length;
|
||||
@@ -48,6 +118,32 @@ export const useNotifications = () => {
|
||||
saveNotificationsToStorage(notifications);
|
||||
}, [notifications]);
|
||||
|
||||
// Save snoozed alerts to localStorage
|
||||
useEffect(() => {
|
||||
saveSnoozedAlertsToStorage(snoozedAlerts);
|
||||
}, [snoozedAlerts]);
|
||||
|
||||
// Clean up expired snoozed alerts periodically
|
||||
useEffect(() => {
|
||||
const cleanupInterval = setInterval(() => {
|
||||
setSnoozedAlerts(prev => {
|
||||
const updated = new Map(prev);
|
||||
let hasChanges = false;
|
||||
|
||||
updated.forEach((value, key) => {
|
||||
if (value.until <= Date.now()) {
|
||||
updated.delete(key);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges ? updated : prev;
|
||||
});
|
||||
}, 60 * 1000); // Check every minute
|
||||
|
||||
return () => clearInterval(cleanupInterval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for initial_items event (existing notifications)
|
||||
const removeInitialListener = addEventListener('initial_items', (data: any[]) => {
|
||||
@@ -59,7 +155,8 @@ export const useNotifications = () => {
|
||||
title: item.title,
|
||||
message: item.message,
|
||||
timestamp: item.timestamp || new Date().toISOString(),
|
||||
read: false, // Assume all initial items are unread
|
||||
read: false,
|
||||
metadata: item.metadata,
|
||||
}));
|
||||
|
||||
setNotifications(prev => {
|
||||
@@ -87,6 +184,7 @@ export const useNotifications = () => {
|
||||
message: data.message,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
read: false,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
|
||||
setNotifications(prev => {
|
||||
@@ -109,6 +207,7 @@ export const useNotifications = () => {
|
||||
message: data.message,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
read: false,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
|
||||
setNotifications(prev => {
|
||||
@@ -128,7 +227,7 @@ export const useNotifications = () => {
|
||||
};
|
||||
}, [addEventListener]);
|
||||
|
||||
const markAsRead = (notificationId: string) => {
|
||||
const markAsRead = useCallback((notificationId: string) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notification =>
|
||||
notification.id === notificationId
|
||||
@@ -137,28 +236,130 @@ export const useNotifications = () => {
|
||||
)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const markAllAsRead = useCallback(() => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notification => ({ ...notification, read: true }))
|
||||
);
|
||||
setUnreadCount(0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeNotification = (notificationId: string) => {
|
||||
const removeNotification = useCallback((notificationId: string) => {
|
||||
const notification = notifications.find(n => n.id === notificationId);
|
||||
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
||||
|
||||
if (notification && !notification.read) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllNotifications = () => {
|
||||
// Also remove from snoozed if present
|
||||
setSnoozedAlerts(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated.delete(notificationId);
|
||||
return updated;
|
||||
});
|
||||
}, [notifications]);
|
||||
|
||||
const clearAllNotifications = useCallback(() => {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Snooze an alert
|
||||
const snoozeAlert = useCallback((alertId: string, duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number, reason?: string) => {
|
||||
const until = calculateSnoozeUntil(duration);
|
||||
|
||||
setSnoozedAlerts(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(alertId, { alertId, until, reason });
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Unsnooze an alert
|
||||
const unsnoozeAlert = useCallback((alertId: string) => {
|
||||
setSnoozedAlerts(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated.delete(alertId);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check if alert is snoozed
|
||||
const isAlertSnoozed = useCallback((alertId: string): boolean => {
|
||||
const snoozed = snoozedAlerts.get(alertId);
|
||||
if (!snoozed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snoozed.until <= Date.now()) {
|
||||
// Expired, remove it
|
||||
setSnoozedAlerts(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated.delete(alertId);
|
||||
return updated;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [snoozedAlerts]);
|
||||
|
||||
// Get snoozed alerts that are active
|
||||
const activeSnoozedAlerts = useMemo(() => {
|
||||
const active = new Map<string, SnoozedAlert>();
|
||||
snoozedAlerts.forEach((value, key) => {
|
||||
if (value.until > Date.now()) {
|
||||
active.set(key, value);
|
||||
}
|
||||
});
|
||||
return active;
|
||||
}, [snoozedAlerts]);
|
||||
|
||||
// Bulk operations
|
||||
const markMultipleAsRead = useCallback((notificationIds: string[]) => {
|
||||
const idsSet = new Set(notificationIds);
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(notification =>
|
||||
idsSet.has(notification.id)
|
||||
? { ...notification, read: true }
|
||||
: notification
|
||||
)
|
||||
);
|
||||
|
||||
const unreadToMark = notifications.filter(n => idsSet.has(n.id) && !n.read).length;
|
||||
setUnreadCount(prev => Math.max(0, prev - unreadToMark));
|
||||
}, [notifications]);
|
||||
|
||||
const removeMultiple = useCallback((notificationIds: string[]) => {
|
||||
const idsSet = new Set(notificationIds);
|
||||
|
||||
const unreadToRemove = notifications.filter(n => idsSet.has(n.id) && !n.read).length;
|
||||
|
||||
setNotifications(prev => prev.filter(n => !idsSet.has(n.id)));
|
||||
setUnreadCount(prev => Math.max(0, prev - unreadToRemove));
|
||||
|
||||
// Also remove from snoozed
|
||||
setSnoozedAlerts(prev => {
|
||||
const updated = new Map(prev);
|
||||
notificationIds.forEach(id => updated.delete(id));
|
||||
return updated;
|
||||
});
|
||||
}, [notifications]);
|
||||
|
||||
const snoozeMultiple = useCallback((alertIds: string[], duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number) => {
|
||||
const until = calculateSnoozeUntil(duration);
|
||||
|
||||
setSnoozedAlerts(prev => {
|
||||
const updated = new Map(prev);
|
||||
alertIds.forEach(id => {
|
||||
updated.set(id, { alertId: id, until });
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
@@ -168,5 +369,12 @@ export const useNotifications = () => {
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAll: clearAllNotifications,
|
||||
snoozeAlert,
|
||||
unsnoozeAlert,
|
||||
isAlertSnoozed,
|
||||
snoozedAlerts: activeSnoozedAlerts,
|
||||
markMultipleAsRead,
|
||||
removeMultiple,
|
||||
snoozeMultiple,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,9 +25,30 @@
|
||||
"real_time_alerts": "Real-time Alerts",
|
||||
"procurement_today": "Procurement Today",
|
||||
"production_today": "Production Today",
|
||||
"tomorrow_procurement": "Tomorrow's Procurement",
|
||||
"today_production": "Today's Production",
|
||||
"pending_po_approvals": "Pending Purchase Orders",
|
||||
"recent_activity": "Recent Activity",
|
||||
"quick_actions": "Quick Actions"
|
||||
},
|
||||
"procurement": {
|
||||
"title": "What needs to be bought for tomorrow?",
|
||||
"empty": "All supplies ready for tomorrow",
|
||||
"items_needed": "items needed"
|
||||
},
|
||||
"production": {
|
||||
"title": "What needs to be produced today?",
|
||||
"empty": "No production scheduled for today",
|
||||
"batches_pending": "batches pending"
|
||||
},
|
||||
"po_approvals": {
|
||||
"title": "What purchase orders need approval?",
|
||||
"empty": "No purchase orders pending approval",
|
||||
"pos_pending": "purchase orders pending approval",
|
||||
"view_all": "View all orders",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject"
|
||||
},
|
||||
"quick_actions": {
|
||||
"add_new_bakery": "Add New Bakery",
|
||||
"create_order": "Create Order",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"smart_inventory": {
|
||||
"title": "Smart Inventory",
|
||||
"description": "Automatic stock control with predictive alerts, automated purchase orders, and cost optimization.",
|
||||
"description": "Automatic stock control with predictive alerts, automated purchase orders, and real-time raw material cost optimization.",
|
||||
"features": {
|
||||
"alerts": "Automatic low stock alerts",
|
||||
"orders": "Automated purchase orders",
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"production_planning": {
|
||||
"title": "Production Planning",
|
||||
"description": "Automatically schedules daily production based on predictions, optimizes schedules and available resources.",
|
||||
"description": "Automatically schedules daily production based on AI predictions, optimizes schedules, resources, and maximizes your ovens' efficiency.",
|
||||
"features": {
|
||||
"scheduling": "Automatic baking scheduling",
|
||||
"oven": "Oven usage optimization",
|
||||
@@ -102,7 +102,12 @@
|
||||
},
|
||||
"advanced_analytics": {
|
||||
"title": "Advanced Analytics",
|
||||
"description": "Real-time dashboards with key metrics"
|
||||
"description": "Real-time dashboards with key business metrics, product profitability analysis, and customizable reports for data-driven decisions.",
|
||||
"features": {
|
||||
"realtime": "Real-time dashboards",
|
||||
"profitability": "Product profitability analysis",
|
||||
"reports": "Customizable reports"
|
||||
}
|
||||
},
|
||||
"pos_integration": {
|
||||
"title": "Integrated POS",
|
||||
@@ -115,6 +120,10 @@
|
||||
"automation": {
|
||||
"title": "Automation",
|
||||
"description": "Automatic processes that save time"
|
||||
},
|
||||
"cloud_based": {
|
||||
"title": "Cloud-Based",
|
||||
"description": "Access from anywhere, always up-to-date"
|
||||
}
|
||||
},
|
||||
"benefits": {
|
||||
|
||||
124
frontend/src/locales/en/procurement.json
Normal file
124
frontend/src/locales/en/procurement.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Procurement Planning",
|
||||
"description": "Manage purchase orders and procurement"
|
||||
},
|
||||
"modes": {
|
||||
"ai": "AI Automatic",
|
||||
"manual": "Manual"
|
||||
},
|
||||
"guideline": {
|
||||
"title": "AI-Powered Automated Procurement System",
|
||||
"description": "The AI system automatically generates procurement plans based on:",
|
||||
"features": {
|
||||
"forecasts": "Sales and production forecasts",
|
||||
"inventory": "Current inventory levels",
|
||||
"history": "Order history and consumption patterns",
|
||||
"suppliers": "Trusted supplier relationships"
|
||||
},
|
||||
"note": "Approved plans are automatically converted into purchase orders."
|
||||
},
|
||||
"stats": {
|
||||
"plans": {
|
||||
"total": "Total Plans",
|
||||
"active": "Active Plans",
|
||||
"pending_requirements": "Pending Requirements",
|
||||
"critical": "Critical",
|
||||
"estimated_cost": "Estimated Cost",
|
||||
"approved_cost": "Approved Cost"
|
||||
},
|
||||
"purchase_orders": {
|
||||
"total": "Total Orders",
|
||||
"pending_approval": "Pending Approval",
|
||||
"urgent": "Urgent",
|
||||
"due_this_week": "Due This Week",
|
||||
"total_value": "Total Value",
|
||||
"approved_value": "Approved Value"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"plans_placeholder": "Search plans by number, status, or notes...",
|
||||
"pos_placeholder": "Search by PO number, supplier, or reference..."
|
||||
},
|
||||
"filters": {
|
||||
"status": "Status",
|
||||
"priority": "Priority",
|
||||
"supplier": "Supplier",
|
||||
"all_statuses": "All statuses",
|
||||
"all_priorities": "All"
|
||||
},
|
||||
"priority": {
|
||||
"urgent": "Urgent",
|
||||
"high": "High",
|
||||
"normal": "Normal",
|
||||
"low": "Low"
|
||||
},
|
||||
"show_archived": "Show completed/cancelled orders",
|
||||
"actions": {
|
||||
"create_po": "Create Purchase Order",
|
||||
"execute_scheduler": "Execute Scheduler",
|
||||
"executing": "Executing...",
|
||||
"send_to_approval": "Send to Approval",
|
||||
"send_to_supplier": "Send to Supplier",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"confirm": "Confirm",
|
||||
"receive_items": "Receive Items",
|
||||
"complete": "Complete",
|
||||
"cancel": "Cancel",
|
||||
"view_details": "View Details",
|
||||
"cancel_po": "Cancel Order"
|
||||
},
|
||||
"empty_states": {
|
||||
"plans": {
|
||||
"title": "No procurement plans found",
|
||||
"description": "Try adjusting your search or generate a new procurement plan",
|
||||
"action": "Generate Procurement Plan"
|
||||
},
|
||||
"purchase_orders": {
|
||||
"title": "No purchase orders found",
|
||||
"description": "Create a manual purchase order or switch to AI mode to generate automatic plans",
|
||||
"description_filtered": "No purchase orders match the selected filters",
|
||||
"action": "Create Purchase Order"
|
||||
}
|
||||
},
|
||||
"approval_modal": {
|
||||
"approve_plan": "Approve Plan",
|
||||
"reject_plan": "Reject Plan",
|
||||
"approve_order": "Approve Order",
|
||||
"reject_order": "Reject Order",
|
||||
"notes_optional": "Notes (Optional)",
|
||||
"notes_required": "Notes (Required)",
|
||||
"plan_details": "Plan Details",
|
||||
"order_details": "Order Details",
|
||||
"requirements": "Requirements",
|
||||
"estimated_cost": "Estimated Cost",
|
||||
"suppliers": "Suppliers",
|
||||
"supplier": "Supplier",
|
||||
"total_amount": "Total Amount",
|
||||
"priority": "Priority",
|
||||
"cancel_button": "Cancel",
|
||||
"processing": "Processing...",
|
||||
"approval_placeholder": "Reason for approval...",
|
||||
"rejection_placeholder": "Reason for rejection..."
|
||||
},
|
||||
"card": {
|
||||
"po_prefix": "PO",
|
||||
"plan_prefix": "Plan",
|
||||
"delivery": "Delivery",
|
||||
"no_date": "Not defined",
|
||||
"no_supplier": "No supplier",
|
||||
"ordered": "Ordered",
|
||||
"reference": "Reference",
|
||||
"trust_score": "Trust",
|
||||
"preferred_supplier": "⭐ Preferred Supplier",
|
||||
"auto_approve": "🤖 Auto-approve"
|
||||
},
|
||||
"messages": {
|
||||
"confirm_send": "Send order {{po_number}} to supplier?",
|
||||
"confirm_receive": "Confirm receipt of order {{po_number}}?",
|
||||
"confirm_items": "Mark items as received for {{po_number}}?",
|
||||
"confirm_complete": "Complete order {{po_number}}?",
|
||||
"cancel_reason": "Why do you want to cancel order {{po_number}}?"
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,30 @@
|
||||
"real_time_alerts": "Alertas en Tiempo Real",
|
||||
"procurement_today": "Compras Hoy",
|
||||
"production_today": "Producción Hoy",
|
||||
"tomorrow_procurement": "Compras para Mañana",
|
||||
"today_production": "Producción de Hoy",
|
||||
"pending_po_approvals": "Órdenes de Compra Pendientes",
|
||||
"recent_activity": "Actividad Reciente",
|
||||
"quick_actions": "Acciones Rápidas"
|
||||
},
|
||||
"procurement": {
|
||||
"title": "¿Qué necesito comprar para mañana?",
|
||||
"empty": "Todos los suministros listos para mañana",
|
||||
"items_needed": "artículos necesarios"
|
||||
},
|
||||
"production": {
|
||||
"title": "¿Qué necesito producir hoy?",
|
||||
"empty": "Sin producción programada para hoy",
|
||||
"batches_pending": "lotes pendientes"
|
||||
},
|
||||
"po_approvals": {
|
||||
"title": "¿Qué órdenes debo aprobar?",
|
||||
"empty": "Sin órdenes pendientes de aprobación",
|
||||
"pos_pending": "órdenes pendientes de aprobación",
|
||||
"view_all": "Ver todas las órdenes",
|
||||
"approve": "Aprobar",
|
||||
"reject": "Rechazar"
|
||||
},
|
||||
"quick_actions": {
|
||||
"add_new_bakery": "Agregar Nueva Panadería",
|
||||
"create_order": "Crear Pedido",
|
||||
@@ -48,7 +69,15 @@
|
||||
"hours_ago": "hace {{count}} h",
|
||||
"yesterday": "Ayer"
|
||||
},
|
||||
"severity": {
|
||||
"urgent": "Urgente",
|
||||
"high": "Alta",
|
||||
"medium": "Media",
|
||||
"low": "Baja"
|
||||
},
|
||||
"types": {
|
||||
"alert": "Alerta",
|
||||
"recommendation": "Recomendación",
|
||||
"low_stock": "Stock Bajo",
|
||||
"production_delay": "Retraso en Producción",
|
||||
"quality_issue": "Problema de Calidad",
|
||||
@@ -65,15 +94,42 @@
|
||||
"acknowledged": "Reconocido",
|
||||
"resolved": "Resuelto"
|
||||
},
|
||||
"types": {
|
||||
"alert": "Alerta",
|
||||
"recommendation": "Recomendación"
|
||||
"filters": {
|
||||
"search_placeholder": "Buscar alertas...",
|
||||
"severity": "Severidad",
|
||||
"category": "Categoría",
|
||||
"time_range": "Periodo",
|
||||
"show_snoozed": "Mostrar pospuestos",
|
||||
"active_filters": "Filtros activos:",
|
||||
"clear_all": "Limpiar filtros"
|
||||
},
|
||||
"grouping": {
|
||||
"by_time": "Por tiempo",
|
||||
"by_category": "Por categoría",
|
||||
"by_similarity": "Similares",
|
||||
"none": "Sin agrupar"
|
||||
},
|
||||
"recommended_actions": "Acciones Recomendadas",
|
||||
"additional_details": "Detalles Adicionales",
|
||||
"mark_as_read": "Marcar como leído",
|
||||
"remove": "Eliminar",
|
||||
"active_count": "{{count}} alertas activas"
|
||||
"snooze": "Posponer",
|
||||
"unsnooze": "Reactivar",
|
||||
"active_count": "{{count}} alertas activas",
|
||||
"empty_state": {
|
||||
"no_results": "Sin resultados",
|
||||
"all_clear": "Todo despejado",
|
||||
"no_matches": "No hay alertas que coincidan con los filtros seleccionados",
|
||||
"no_active": "No hay alertas activas en este momento"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"selected": "seleccionado",
|
||||
"selected_plural": "seleccionados",
|
||||
"select_all": "Seleccionar todos",
|
||||
"deselect_all": "Deseleccionar todo",
|
||||
"mark_read": "Marcar leídos",
|
||||
"delete": "Eliminar"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"welcome": "Bienvenido de vuelta",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"smart_inventory": {
|
||||
"title": "Inventario Inteligente",
|
||||
"description": "Control automático de stock con alertas predictivas, órdenes de compra automatizadas y optimización de costos.",
|
||||
"description": "Control automático de stock con alertas predictivas, órdenes de compra automatizadas y optimización de costos de materias primas en tiempo real.",
|
||||
"features": {
|
||||
"alerts": "Alertas automáticas de stock bajo",
|
||||
"orders": "Órdenes de compra automatizadas",
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"production_planning": {
|
||||
"title": "Planificación de Producción",
|
||||
"description": "Programa automáticamente la producción diaria basada en predicciones, optimiza horarios y recursos disponibles.",
|
||||
"description": "Programa automáticamente la producción diaria basada en predicciones de IA, optimiza horarios, recursos y maximiza la eficiencia de tus hornos.",
|
||||
"features": {
|
||||
"scheduling": "Programación automática de horneado",
|
||||
"oven": "Optimización de uso de hornos",
|
||||
@@ -102,7 +102,12 @@
|
||||
},
|
||||
"advanced_analytics": {
|
||||
"title": "Analytics Avanzado",
|
||||
"description": "Dashboards en tiempo real con métricas clave"
|
||||
"description": "Dashboards en tiempo real con métricas clave de negocio, análisis de rentabilidad por producto y reportes personalizables para tomar decisiones basadas en datos.",
|
||||
"features": {
|
||||
"realtime": "Dashboards en tiempo real",
|
||||
"profitability": "Análisis de rentabilidad por producto",
|
||||
"reports": "Reportes personalizables"
|
||||
}
|
||||
},
|
||||
"pos_integration": {
|
||||
"title": "POS Integrado",
|
||||
@@ -115,6 +120,10 @@
|
||||
"automation": {
|
||||
"title": "Automatización",
|
||||
"description": "Procesos automáticos que ahorran tiempo"
|
||||
},
|
||||
"cloud_based": {
|
||||
"title": "En la Nube",
|
||||
"description": "Accede desde cualquier lugar, siempre actualizado"
|
||||
}
|
||||
},
|
||||
"benefits": {
|
||||
|
||||
124
frontend/src/locales/es/procurement.json
Normal file
124
frontend/src/locales/es/procurement.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Planificación de Compras",
|
||||
"description": "Gestiona órdenes de compra y aprovisionamiento"
|
||||
},
|
||||
"modes": {
|
||||
"ai": "Automático IA",
|
||||
"manual": "Manual"
|
||||
},
|
||||
"guideline": {
|
||||
"title": "Sistema Automatizado de Compras con IA",
|
||||
"description": "El sistema de IA genera automáticamente planes de compras basados en:",
|
||||
"features": {
|
||||
"forecasts": "Previsiones de ventas y producción",
|
||||
"inventory": "Niveles de inventario actual",
|
||||
"history": "Historial de pedidos y patrones de consumo",
|
||||
"suppliers": "Relaciones con proveedores de confianza"
|
||||
},
|
||||
"note": "Los planes aprobados se convierten automáticamente en órdenes de compra."
|
||||
},
|
||||
"stats": {
|
||||
"plans": {
|
||||
"total": "Planes Totales",
|
||||
"active": "Planes Activos",
|
||||
"pending_requirements": "Requerimientos Pendientes",
|
||||
"critical": "Críticos",
|
||||
"estimated_cost": "Costo Estimado",
|
||||
"approved_cost": "Costo Aprobado"
|
||||
},
|
||||
"purchase_orders": {
|
||||
"total": "Órdenes Totales",
|
||||
"pending_approval": "Pendientes Aprobación",
|
||||
"urgent": "Urgentes",
|
||||
"due_this_week": "Vencen Esta Semana",
|
||||
"total_value": "Valor Total",
|
||||
"approved_value": "Valor Aprobado"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"plans_placeholder": "Buscar planes por número, estado o notas...",
|
||||
"pos_placeholder": "Buscar por número de PO, proveedor o referencia..."
|
||||
},
|
||||
"filters": {
|
||||
"status": "Estado",
|
||||
"priority": "Prioridad",
|
||||
"supplier": "Proveedor",
|
||||
"all_statuses": "Todos los estados",
|
||||
"all_priorities": "Todas"
|
||||
},
|
||||
"priority": {
|
||||
"urgent": "Urgente",
|
||||
"high": "Alta",
|
||||
"normal": "Normal",
|
||||
"low": "Baja"
|
||||
},
|
||||
"show_archived": "Mostrar órdenes completadas/canceladas",
|
||||
"actions": {
|
||||
"create_po": "Crear Orden de Compra",
|
||||
"execute_scheduler": "Ejecutar Programador",
|
||||
"executing": "Ejecutando...",
|
||||
"send_to_approval": "Enviar a Aprobación",
|
||||
"send_to_supplier": "Enviar a Proveedor",
|
||||
"approve": "Aprobar",
|
||||
"reject": "Rechazar",
|
||||
"confirm": "Confirmar",
|
||||
"receive_items": "Recibir Artículos",
|
||||
"complete": "Completar",
|
||||
"cancel": "Cancelar",
|
||||
"view_details": "Ver Detalles",
|
||||
"cancel_po": "Cancelar Orden"
|
||||
},
|
||||
"empty_states": {
|
||||
"plans": {
|
||||
"title": "No se encontraron planes de compra",
|
||||
"description": "Intenta ajustar la búsqueda o generar un nuevo plan de compra",
|
||||
"action": "Generar Plan de Compra"
|
||||
},
|
||||
"purchase_orders": {
|
||||
"title": "No se encontraron órdenes de compra",
|
||||
"description": "Crea una orden de compra manual o cambia al modo IA para generar planes automáticos",
|
||||
"description_filtered": "No hay órdenes de compra que coincidan con los filtros",
|
||||
"action": "Crear Orden de Compra"
|
||||
}
|
||||
},
|
||||
"approval_modal": {
|
||||
"approve_plan": "Aprobar Plan",
|
||||
"reject_plan": "Rechazar Plan",
|
||||
"approve_order": "Aprobar Orden",
|
||||
"reject_order": "Rechazar Orden",
|
||||
"notes_optional": "Notas (Opcional)",
|
||||
"notes_required": "Notas (Requerido)",
|
||||
"plan_details": "Detalles del Plan",
|
||||
"order_details": "Detalles de la Orden",
|
||||
"requirements": "Requerimientos",
|
||||
"estimated_cost": "Costo Estimado",
|
||||
"suppliers": "Proveedores",
|
||||
"supplier": "Proveedor",
|
||||
"total_amount": "Monto Total",
|
||||
"priority": "Prioridad",
|
||||
"cancel_button": "Cancelar",
|
||||
"processing": "Procesando...",
|
||||
"approval_placeholder": "Razón de aprobación...",
|
||||
"rejection_placeholder": "Razón de rechazo..."
|
||||
},
|
||||
"card": {
|
||||
"po_prefix": "PO",
|
||||
"plan_prefix": "Plan",
|
||||
"delivery": "Entrega",
|
||||
"no_date": "No definida",
|
||||
"no_supplier": "Sin proveedor",
|
||||
"ordered": "Pedido",
|
||||
"reference": "Referencia",
|
||||
"trust_score": "Confianza",
|
||||
"preferred_supplier": "⭐ Proveedor Preferido",
|
||||
"auto_approve": "🤖 Auto-aprobable"
|
||||
},
|
||||
"messages": {
|
||||
"confirm_send": "¿Enviar la orden {{po_number}} al proveedor?",
|
||||
"confirm_receive": "¿Confirmar recepción de la orden {{po_number}}?",
|
||||
"confirm_items": "¿Marcar items como recibidos para {{po_number}}?",
|
||||
"confirm_complete": "¿Completar la orden {{po_number}}?",
|
||||
"cancel_reason": "¿Por qué deseas cancelar la orden {{po_number}}?"
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,30 @@
|
||||
"real_time_alerts": "Denbora Errealeko Alertak",
|
||||
"procurement_today": "Gaurko Erosketak",
|
||||
"production_today": "Gaurko Ekoizpena",
|
||||
"tomorrow_procurement": "Biarko Erosketak",
|
||||
"today_production": "Gaurko Ekoizpena",
|
||||
"pending_po_approvals": "Erosketa Aginduak Zain",
|
||||
"recent_activity": "Azken Jarduera",
|
||||
"quick_actions": "Ekintza Azkarrak"
|
||||
},
|
||||
"procurement": {
|
||||
"title": "Zer erosi behar da biarko?",
|
||||
"empty": "Hornikuntza guztiak prest biarko",
|
||||
"items_needed": "elementu behar dira"
|
||||
},
|
||||
"production": {
|
||||
"title": "Zer ekoiztu behar da gaur?",
|
||||
"empty": "Ez dago ekoizpen programaturik gaur",
|
||||
"batches_pending": "sortak zain"
|
||||
},
|
||||
"po_approvals": {
|
||||
"title": "Zein erosketa agindu onartu behar ditut?",
|
||||
"empty": "Ez dago erosketa aginduk onartzeko zain",
|
||||
"pos_pending": "erosketa aginduak onartzeko zain",
|
||||
"view_all": "Ikusi agindu guztiak",
|
||||
"approve": "Onartu",
|
||||
"reject": "Baztertu"
|
||||
},
|
||||
"quick_actions": {
|
||||
"add_new_bakery": "Okindegi Berria Gehitu",
|
||||
"create_order": "Eskaera Sortu",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"smart_inventory": {
|
||||
"title": "Inbentario Adimenduna",
|
||||
"description": "Stock kontrol automatikoa alerta aurreikuspenekin, erosketako agindu automatizatuekin eta kostu optimizazioarekin.",
|
||||
"description": "Stock kontrol automatikoa alerta aurreikuspenekin, erosketako agindu automatizatuekin eta lehengaien kostu optimizazio erreala denbora errealean.",
|
||||
"features": {
|
||||
"alerts": "Stock baxuko alerta automatikoak",
|
||||
"orders": "Erosketako agindu automatizatuak",
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"production_planning": {
|
||||
"title": "Ekoizpen Planifikazioa",
|
||||
"description": "Automatikoki programatu egunero ekoizpena aurreikuspenen arabera, optimizatu ordutegiak eta eskuragarri dauden baliabideak.",
|
||||
"description": "Automatikoki programatu egunero ekoizpena AA aurreikuspenen arabera, optimizatu ordutegiak, baliabideak eta maximizatu zure labeen eraginkortasuna.",
|
||||
"features": {
|
||||
"scheduling": "Labe programazio automatikoa",
|
||||
"oven": "Labe erabilera optimizazioa",
|
||||
@@ -102,7 +102,12 @@
|
||||
},
|
||||
"advanced_analytics": {
|
||||
"title": "Analitika Aurreratua",
|
||||
"description": "Denbora errealeko panelak metrika gakoekin"
|
||||
"description": "Denbora errealeko panelak negozioaren metrika gakoekin, produktu errentagarritasun analisia eta txosten pertsonalizagarriak datuetan oinarritutako erabakiak hartzeko.",
|
||||
"features": {
|
||||
"realtime": "Denbora errealeko panelak",
|
||||
"profitability": "Produktu errentagarritasun analisia",
|
||||
"reports": "Txosten pertsonalizagarriak"
|
||||
}
|
||||
},
|
||||
"pos_integration": {
|
||||
"title": "POS Integratua",
|
||||
@@ -115,6 +120,10 @@
|
||||
"automation": {
|
||||
"title": "Automatizazioa",
|
||||
"description": "Denbora aurrezten duten prozesu automatikoak"
|
||||
},
|
||||
"cloud_based": {
|
||||
"title": "Hodeian",
|
||||
"description": "Sartu edozein lekutatik, beti eguneratuta"
|
||||
}
|
||||
},
|
||||
"benefits": {
|
||||
|
||||
124
frontend/src/locales/eu/procurement.json
Normal file
124
frontend/src/locales/eu/procurement.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Erosketen Plangintza",
|
||||
"description": "Kudeatu erosketa-aginduak eta hornidura"
|
||||
},
|
||||
"modes": {
|
||||
"ai": "IA Automatikoa",
|
||||
"manual": "Eskuzkoa"
|
||||
},
|
||||
"guideline": {
|
||||
"title": "IArekin Erosketen Sistema Automatizatua",
|
||||
"description": "IA sistemak automatikoki sortzen ditu erosketa-planak oinarrituta:",
|
||||
"features": {
|
||||
"forecasts": "Salmenta eta ekoizpen aurreikuspenak",
|
||||
"inventory": "Gaur egungo inbentario mailak",
|
||||
"history": "Eskaeren historia eta kontsumoko ereduak",
|
||||
"suppliers": "Hornitzaile fidagarriekin harremanak"
|
||||
},
|
||||
"note": "Onartutako planak automatikoki erosketa-agindu bihurtzen dira."
|
||||
},
|
||||
"stats": {
|
||||
"plans": {
|
||||
"total": "Plan Guztiak",
|
||||
"active": "Plan Aktiboak",
|
||||
"pending_requirements": "Eskakizun Zain",
|
||||
"critical": "Kritikoak",
|
||||
"estimated_cost": "Aurreikusitako Kostua",
|
||||
"approved_cost": "Onartutako Kostua"
|
||||
},
|
||||
"purchase_orders": {
|
||||
"total": "Agindu Guztiak",
|
||||
"pending_approval": "Onarpen Zain",
|
||||
"urgent": "Premiazkoak",
|
||||
"due_this_week": "Aste Honetan Iraungitzen",
|
||||
"total_value": "Balio Osoa",
|
||||
"approved_value": "Onartutako Balioa"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"plans_placeholder": "Bilatu planak zenbaki, egoera edo oharren arabera...",
|
||||
"pos_placeholder": "Bilatu EA zenbaki, hornitzaile edo erreferentziaren arabera..."
|
||||
},
|
||||
"filters": {
|
||||
"status": "Egoera",
|
||||
"priority": "Lehentasuna",
|
||||
"supplier": "Hornitzailea",
|
||||
"all_statuses": "Egoera guztiak",
|
||||
"all_priorities": "Guztiak"
|
||||
},
|
||||
"priority": {
|
||||
"urgent": "Premiazkoa",
|
||||
"high": "Handia",
|
||||
"normal": "Normala",
|
||||
"low": "Baxua"
|
||||
},
|
||||
"show_archived": "Erakutsi osatutako/bertan behera utzitako aginduak",
|
||||
"actions": {
|
||||
"create_po": "Sortu Erosketa Agindua",
|
||||
"execute_scheduler": "Exekutatu Programatzailea",
|
||||
"executing": "Exekutatzen...",
|
||||
"send_to_approval": "Bidali Onarpenera",
|
||||
"send_to_supplier": "Bidali Hornitzaileari",
|
||||
"approve": "Onartu",
|
||||
"reject": "Baztertu",
|
||||
"confirm": "Berretsi",
|
||||
"receive_items": "Jaso Artikuluak",
|
||||
"complete": "Osatu",
|
||||
"cancel": "Ezeztatu",
|
||||
"view_details": "Ikusi Xehetasunak",
|
||||
"cancel_po": "Ezeztatu Agindua"
|
||||
},
|
||||
"empty_states": {
|
||||
"plans": {
|
||||
"title": "Ez da erosketa-planik aurkitu",
|
||||
"description": "Saiatu bilaketa doituz edo plan berri bat sortuz",
|
||||
"action": "Sortu Erosketa Plana"
|
||||
},
|
||||
"purchase_orders": {
|
||||
"title": "Ez da erosketa-agindurik aurkitu",
|
||||
"description": "Sortu eskuzko erosketa-agindu bat edo aldatu IA modura plan automatikoak sortzeko",
|
||||
"description_filtered": "Ez dago filtroekin bat datorren erosketa-agindurik",
|
||||
"action": "Sortu Erosketa Agindua"
|
||||
}
|
||||
},
|
||||
"approval_modal": {
|
||||
"approve_plan": "Onartu Plana",
|
||||
"reject_plan": "Baztertu Plana",
|
||||
"approve_order": "Onartu Agindua",
|
||||
"reject_order": "Baztertu Agindua",
|
||||
"notes_optional": "Oharrak (Aukerakoa)",
|
||||
"notes_required": "Oharrak (Beharrezkoa)",
|
||||
"plan_details": "Planaren Xehetasunak",
|
||||
"order_details": "Aginduaren Xehetasunak",
|
||||
"requirements": "Eskakizunak",
|
||||
"estimated_cost": "Aurreikusitako Kostua",
|
||||
"suppliers": "Hornitzaileak",
|
||||
"supplier": "Hornitzailea",
|
||||
"total_amount": "Zenbateko Osoa",
|
||||
"priority": "Lehentasuna",
|
||||
"cancel_button": "Ezeztatu",
|
||||
"processing": "Prozesatzen...",
|
||||
"approval_placeholder": "Onarpenaren arrazoia...",
|
||||
"rejection_placeholder": "Bazterketaren arrazoia..."
|
||||
},
|
||||
"card": {
|
||||
"po_prefix": "EA",
|
||||
"plan_prefix": "Plana",
|
||||
"delivery": "Entrega",
|
||||
"no_date": "Zehaztugabea",
|
||||
"no_supplier": "Hornitzailerik gabe",
|
||||
"ordered": "Eskatua",
|
||||
"reference": "Erreferentzia",
|
||||
"trust_score": "Konfiantza",
|
||||
"preferred_supplier": "⭐ Hornitzaile Hobetsia",
|
||||
"auto_approve": "🤖 Auto-onartua"
|
||||
},
|
||||
"messages": {
|
||||
"confirm_send": "Bidali {{po_number}} agindua hornitzaileari?",
|
||||
"confirm_receive": "Berretsi {{po_number}} aginduaren harrera?",
|
||||
"confirm_items": "Markatu artikuluak jasota {{po_number}} aginduarentzat?",
|
||||
"confirm_complete": "Osatu {{po_number}} agindua?",
|
||||
"cancel_reason": "Zergatik ezeztatu nahi duzu {{po_number}} agindua?"
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,35 @@ import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '../../components/layout';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Card, CardHeader, CardBody } from '../../components/ui/Card';
|
||||
import StatsGrid from '../../components/ui/Stats/StatsGrid';
|
||||
import RealTimeAlerts from '../../components/domain/dashboard/RealTimeAlerts';
|
||||
import ProcurementPlansToday from '../../components/domain/dashboard/ProcurementPlansToday';
|
||||
import ProductionPlansToday from '../../components/domain/dashboard/ProductionPlansToday';
|
||||
import PurchaseOrdersTracking from '../../components/domain/dashboard/PurchaseOrdersTracking';
|
||||
import PendingPOApprovals from '../../components/domain/dashboard/PendingPOApprovals';
|
||||
import TodayProduction from '../../components/domain/dashboard/TodayProduction';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
|
||||
import { useDashboardStats } from '../../api/hooks/dashboard';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Euro,
|
||||
Package,
|
||||
Plus,
|
||||
Building2
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { availableTenants } = useTenant();
|
||||
const { availableTenants, currentTenant } = useTenant();
|
||||
const { startTour } = useDemoTour();
|
||||
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
|
||||
|
||||
// Fetch real dashboard statistics
|
||||
const { data: dashboardStats, isLoading: isLoadingStats, error: statsError } = useDashboardStats(
|
||||
currentTenant?.id || '',
|
||||
{
|
||||
enabled: !!currentTenant?.id,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[Dashboard] Demo mode:', isDemoMode);
|
||||
console.log('[Dashboard] Should start tour:', shouldStartTour());
|
||||
@@ -44,81 +48,137 @@ const DashboardPage: React.FC = () => {
|
||||
}
|
||||
}, [isDemoMode, startTour]);
|
||||
|
||||
const handleAddNewBakery = () => {
|
||||
navigate('/app/onboarding?new=true');
|
||||
const handleViewAllProcurement = () => {
|
||||
navigate('/app/operations/procurement');
|
||||
};
|
||||
|
||||
const criticalStats = [
|
||||
{
|
||||
title: t('dashboard:stats.sales_today', 'Sales Today'),
|
||||
value: '€1,247',
|
||||
icon: Euro,
|
||||
variant: 'success' as const,
|
||||
trend: {
|
||||
value: 12,
|
||||
direction: 'up' as const,
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
},
|
||||
subtitle: '+€135 ' + t('dashboard:messages.more_than_yesterday', 'more than yesterday')
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.pending_orders', 'Pending Orders'),
|
||||
value: '23',
|
||||
icon: Clock,
|
||||
variant: 'warning' as const,
|
||||
trend: {
|
||||
value: 4,
|
||||
direction: 'down' as const,
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
},
|
||||
subtitle: t('dashboard:messages.require_attention', 'Require attention')
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.products_sold', 'Products Sold'),
|
||||
value: '156',
|
||||
icon: Package,
|
||||
variant: 'info' as const,
|
||||
trend: {
|
||||
value: 8,
|
||||
direction: 'up' as const,
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
},
|
||||
subtitle: '+12 ' + t('dashboard:messages.more_units', 'more units')
|
||||
},
|
||||
{
|
||||
title: t('dashboard:stats.stock_alerts', 'Critical Stock'),
|
||||
value: '4',
|
||||
icon: AlertTriangle,
|
||||
variant: 'error' as const,
|
||||
trend: {
|
||||
value: 100,
|
||||
direction: 'up' as const,
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
},
|
||||
subtitle: t('dashboard:messages.action_required', 'Action required')
|
||||
}
|
||||
];
|
||||
const handleViewAllProduction = () => {
|
||||
navigate('/app/operations/production');
|
||||
};
|
||||
|
||||
const handleOrderItem = (itemId: string) => {
|
||||
console.log('Ordering item:', itemId);
|
||||
navigate('/app/operations/procurement');
|
||||
};
|
||||
|
||||
const handleStartOrder = (orderId: string) => {
|
||||
console.log('Starting production order:', orderId);
|
||||
const handleStartBatch = (batchId: string) => {
|
||||
console.log('Starting production batch:', batchId);
|
||||
};
|
||||
|
||||
const handlePauseOrder = (orderId: string) => {
|
||||
console.log('Pausing production order:', orderId);
|
||||
const handlePauseBatch = (batchId: string) => {
|
||||
console.log('Pausing production batch:', batchId);
|
||||
};
|
||||
|
||||
const handleViewDetails = (id: string) => {
|
||||
console.log('Viewing details for:', id);
|
||||
};
|
||||
|
||||
const handleViewAllPlans = () => {
|
||||
console.log('Viewing all plans');
|
||||
const handleApprovePO = (poId: string) => {
|
||||
console.log('Approved PO:', poId);
|
||||
};
|
||||
|
||||
const handleRejectPO = (poId: string) => {
|
||||
console.log('Rejected PO:', poId);
|
||||
};
|
||||
|
||||
const handleViewPODetails = (poId: string) => {
|
||||
console.log('Viewing PO details:', poId);
|
||||
navigate(`/app/suppliers/purchase-orders/${poId}`);
|
||||
};
|
||||
|
||||
const handleViewAllPOs = () => {
|
||||
navigate('/app/operations/procurement');
|
||||
};
|
||||
|
||||
// Build stats from real API data
|
||||
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';
|
||||
if (value < 0) return 'down';
|
||||
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(),
|
||||
icon: Clock,
|
||||
variant: dashboardStats.pendingOrders > 10 ? ('warning' as const) : ('info' as const),
|
||||
trend: dashboardStats.ordersTrend !== 0 ? {
|
||||
value: Math.abs(dashboardStats.ordersTrend),
|
||||
direction: getTrendDirection(dashboardStats.ordersTrend),
|
||||
label: t('dashboard:trends.vs_yesterday', '% vs yesterday')
|
||||
} : undefined,
|
||||
subtitle: dashboardStats.pendingOrders > 0
|
||||
? 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(),
|
||||
icon: AlertTriangle,
|
||||
variant: dashboardStats.criticalStock > 0 ? ('error' as const) : ('success' as const),
|
||||
trend: undefined, // Stock alerts don't have historical trends
|
||||
subtitle: dashboardStats.criticalStock > 0
|
||||
? t('dashboard:messages.action_required', 'Action required')
|
||||
: t('dashboard:messages.stock_healthy', 'Stock levels healthy')
|
||||
}
|
||||
];
|
||||
}, [dashboardStats, t]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
@@ -128,76 +188,57 @@ const DashboardPage: React.FC = () => {
|
||||
|
||||
{/* Critical Metrics using StatsGrid */}
|
||||
<div data-tour="dashboard-stats">
|
||||
<StatsGrid
|
||||
stats={criticalStats}
|
||||
columns={4}
|
||||
gap="lg"
|
||||
className="mb-6"
|
||||
/>
|
||||
{isLoadingStats ? (
|
||||
<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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : statsError ? (
|
||||
<div className="mb-6 p-4 bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg">
|
||||
<p className="text-[var(--color-error)] text-sm">
|
||||
{t('dashboard:errors.failed_to_load_stats', 'Failed to load dashboard statistics. Please try again.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<StatsGrid
|
||||
stats={criticalStats}
|
||||
columns={4}
|
||||
gap="lg"
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions - Add New Bakery */}
|
||||
{availableTenants && availableTenants.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{t('dashboard:sections.quick_actions', 'Quick Actions')}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('dashboard:messages.manage_organizations', 'Manage your organizations')}</p>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Button
|
||||
onClick={handleAddNewBakery}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-auto p-6 flex flex-col items-center gap-3 bg-gradient-to-br from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border-[var(--color-primary)]/20 hover:border-[var(--color-primary)]/40 hover:bg-[var(--color-primary)]/20 transition-all duration-200"
|
||||
>
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center">
|
||||
<Plus className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-[var(--text-primary)]">{t('dashboard:quick_actions.add_new_bakery', 'Add New Bakery')}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mt-1">{t('dashboard:messages.setup_new_business', 'Set up a new business from scratch')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-6 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
|
||||
<Building2 className="w-8 h-8 text-[var(--text-tertiary)] mb-2" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-[var(--text-secondary)]">{t('dashboard:messages.active_organizations', 'Active Organizations')}</div>
|
||||
<div className="text-2xl font-bold text-[var(--color-primary)]">{availableTenants.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Full width blocks - one after another */}
|
||||
{/* Dashboard Content - Four Main Sections */}
|
||||
<div className="space-y-6">
|
||||
{/* 1. Real-time alerts block */}
|
||||
{/* 1. Real-time Alerts */}
|
||||
<div data-tour="real-time-alerts">
|
||||
<RealTimeAlerts />
|
||||
</div>
|
||||
|
||||
{/* 2. Purchase Orders Tracking block */}
|
||||
<PurchaseOrdersTracking />
|
||||
|
||||
{/* 3. Procurement plans block */}
|
||||
<div data-tour="procurement-plans">
|
||||
<ProcurementPlansToday
|
||||
onOrderItem={handleOrderItem}
|
||||
onViewDetails={handleViewDetails}
|
||||
onViewAllPlans={handleViewAllPlans}
|
||||
{/* 2. Pending PO Approvals - What purchase orders need approval? */}
|
||||
<div data-tour="pending-po-approvals">
|
||||
<PendingPOApprovals
|
||||
onApprovePO={handleApprovePO}
|
||||
onRejectPO={handleRejectPO}
|
||||
onViewDetails={handleViewPODetails}
|
||||
onViewAllPOs={handleViewAllPOs}
|
||||
maxPOs={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. Production plans block */}
|
||||
<div data-tour="production-plans">
|
||||
<ProductionPlansToday
|
||||
onStartOrder={handleStartOrder}
|
||||
onPauseOrder={handlePauseOrder}
|
||||
{/* 3. Today's Production - What needs to be produced today? */}
|
||||
<div data-tour="today-production">
|
||||
<TodayProduction
|
||||
onStartBatch={handleStartBatch}
|
||||
onPauseBatch={handlePauseBatch}
|
||||
onViewDetails={handleViewDetails}
|
||||
onViewAllPlans={handleViewAllPlans}
|
||||
onViewAllPlans={handleViewAllProduction}
|
||||
maxBatches={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, Zap, User, PlusCircle } from 'lucide-react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle } from 'lucide-react';
|
||||
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
|
||||
import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
@@ -35,7 +35,6 @@ const ProductionPage: React.FC = () => {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showQualityModal, setShowQualityModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [isAIMode, setIsAIMode] = useState(true);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
@@ -289,49 +288,15 @@ const ProductionPage: React.FC = () => {
|
||||
title="Gestión de Producción"
|
||||
description="Planifica y controla la producción diaria de tu panadería"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* AI/Manual Mode Segmented Control */}
|
||||
<div className="inline-flex p-1 bg-[var(--surface-secondary)] rounded-xl border border-[var(--border-primary)] shadow-sm">
|
||||
<button
|
||||
onClick={() => setIsAIMode(true)}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out
|
||||
${isAIMode
|
||||
? 'bg-[var(--color-primary)] text-white shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-tertiary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Automático IA
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAIMode(false)}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out
|
||||
${!isAIMode
|
||||
? 'bg-[var(--color-primary)] text-white shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-tertiary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Manual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isAIMode && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2.5 px-6 py-3 text-sm font-semibold tracking-wide shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<PlusCircle className="w-5 h-5" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2.5 px-6 py-3 text-sm font-semibold tracking-wide shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<PlusCircle className="w-5 h-5" />
|
||||
Nueva Orden de Producción
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Production Stats */}
|
||||
|
||||
@@ -283,7 +283,7 @@ const TeamPage: React.FC = () => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Equipo"
|
||||
description="Administra los miembros del equipo, roles y permisos"
|
||||
@@ -300,7 +300,7 @@ const TeamPage: React.FC = () => {
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('settings:team.title', 'Gestión de Equipo')}
|
||||
description={t('settings:team.description', 'Administra los miembros del equipo, roles y permisos')}
|
||||
@@ -368,48 +368,46 @@ const TeamPage: React.FC = () => {
|
||||
] as FilterConfig[]}
|
||||
/>
|
||||
|
||||
{/* Add Member Button */}
|
||||
{canManageTeam && filteredMembers.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="font-medium px-4 py-2 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>Agregar Miembro</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Members List - Responsive grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{filteredMembers.map((member) => (
|
||||
<StatusCard
|
||||
key={member.id}
|
||||
id={`team-member-${member.id}`}
|
||||
statusIndicator={getMemberStatusConfig(member)}
|
||||
title={member.user?.full_name || member.user_full_name}
|
||||
subtitle={member.user?.email || member.user_email}
|
||||
primaryValue={Math.floor((Date.now() - new Date(member.joined_at).getTime()) / (1000 * 60 * 60 * 24))}
|
||||
primaryValueLabel="días"
|
||||
secondaryInfo={{
|
||||
label: 'Estado',
|
||||
value: member.is_active ? 'Activo' : 'Inactivo'
|
||||
}}
|
||||
metadata={[
|
||||
`Email: ${member.user?.email || member.user_email}`,
|
||||
`Teléfono: ${member.user?.phone || 'No disponible'}`,
|
||||
...(member.role === TENANT_ROLES.OWNER ? ['🏢 Propietario de la organización'] : [])
|
||||
]}
|
||||
actions={getMemberActions(member)}
|
||||
className={`
|
||||
${!member.is_active ? 'opacity-75' : ''}
|
||||
transition-all duration-200 hover:scale-[1.02]
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
{filteredMembers.map((member: any) => {
|
||||
const user = member.user;
|
||||
const daysInTeam = Math.floor((Date.now() - new Date(member.joined_at).getTime()) / (1000 * 60 * 60 * 24));
|
||||
const lastLogin = user?.last_login ? new Date(user.last_login).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}) : 'Nunca';
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={member.id}
|
||||
id={`team-member-${member.id}`}
|
||||
statusIndicator={getMemberStatusConfig(member)}
|
||||
title={user?.full_name || member.user_full_name || 'Usuario'}
|
||||
subtitle={user?.email || member.user_email || ''}
|
||||
primaryValue={daysInTeam}
|
||||
primaryValueLabel="días en el equipo"
|
||||
secondaryInfo={{
|
||||
label: 'Último acceso',
|
||||
value: lastLogin
|
||||
}}
|
||||
metadata={[
|
||||
`Email: ${user?.email || member.user_email || 'No disponible'}`,
|
||||
`Teléfono: ${user?.phone || 'No disponible'}`,
|
||||
`Idioma: ${user?.language?.toUpperCase() || 'No especificado'}`,
|
||||
`Unido: ${new Date(member.joined_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' })}`,
|
||||
...(member.role === TENANT_ROLES.OWNER ? ['🏢 Propietario de la organización'] : []),
|
||||
...(user?.timezone ? [`Zona horaria: ${user.timezone}`] : [])
|
||||
]}
|
||||
actions={getMemberActions(member)}
|
||||
className={`
|
||||
${!member.is_active ? 'opacity-75' : ''}
|
||||
transition-all duration-200 hover:scale-[1.02]
|
||||
`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
|
||||
@@ -325,7 +325,7 @@ const LandingPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="mt-20 grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* AI Technology */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-[var(--border-primary)] hover:border-blue-500/50">
|
||||
<div className="absolute -top-4 left-8">
|
||||
@@ -432,97 +432,144 @@ const LandingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Smart Inventory */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-[var(--border-primary)] hover:border-indigo-500/50">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">{t('landing:features.smart_inventory.title', 'Inventario Inteligente')}</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
{t('landing:features.smart_inventory.description', 'Control automático de stock con alertas predictivas, órdenes de compra automatizadas y optimización de costos.')}
|
||||
{t('landing:features.smart_inventory.description', 'Control automático de stock con alertas predictivas, órdenes de compra automatizadas y optimización de costos de materias primas en tiempo real.')}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('landing:features.smart_inventory.features.alerts', 'Alertas automáticas de stock bajo')}
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-indigo-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Package className="w-3 h-3 text-indigo-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.smart_inventory.features.alerts', 'Alertas automáticas de stock bajo')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('landing:features.smart_inventory.features.orders', 'Órdenes de compra automatizadas')}
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-indigo-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<TrendingUp className="w-3 h-3 text-indigo-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.smart_inventory.features.orders', 'Órdenes de compra automatizadas')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-secondary)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('landing:features.smart_inventory.features.optimization', 'Optimización de costos de materias primas')}
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-indigo-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Euro className="w-3 h-3 text-indigo-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.smart_inventory.features.optimization', 'Optimización de costos de materias primas')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production Planning */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30">
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-[var(--border-primary)] hover:border-rose-500/50">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-dark)] rounded-xl flex items-center justify-center shadow-lg">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-rose-600 to-pink-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Calendar className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">{t('landing:features.production_planning.title', 'Planificación de Producción')}</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
{t('landing:features.production_planning.description', 'Programa automáticamente la producción diaria basada en predicciones, optimiza horarios y recursos disponibles.')}
|
||||
{t('landing:features.production_planning.description', 'Programa automáticamente la producción diaria basada en predicciones de IA, optimiza horarios, recursos y maximiza la eficiencia de tus hornos.')}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)]">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('landing:features.production_planning.features.scheduling', 'Programación automática de horneado')}
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-rose-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Calendar className="w-3 h-3 text-rose-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.production_planning.features.scheduling', 'Programación automática de horneado')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('landing:features.production_planning.features.oven', 'Optimización de uso de hornos')}
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-rose-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Zap className="w-3 h-3 text-rose-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.production_planning.features.oven', 'Optimización de uso de hornos')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--color-accent)] mt-2">
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t('landing:features.production_planning.features.staff', 'Gestión de personal y turnos')}
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-rose-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<Users className="w-3 h-3 text-rose-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.production_planning.features.staff', 'Gestión de personal y turnos')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Analytics */}
|
||||
<div className="group relative bg-[var(--bg-primary)] rounded-2xl p-8 shadow-lg hover:shadow-2xl transition-all duration-300 border-2 border-[var(--border-primary)] hover:border-cyan-500/50">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-cyan-600 to-teal-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<PieChart className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold text-[var(--text-primary)]">{t('landing:features.advanced_analytics.title', 'Analytics Avanzado')}</h3>
|
||||
<p className="mt-4 text-[var(--text-secondary)]">
|
||||
{t('landing:features.advanced_analytics.description', 'Dashboards en tiempo real con métricas clave de negocio, análisis de rentabilidad por producto y reportes personalizables para tomar decisiones basadas en datos.')}
|
||||
</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-cyan-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<BarChart3 className="w-3 h-3 text-cyan-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.advanced_analytics.features.realtime', 'Dashboards en tiempo real')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-cyan-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<TrendingUp className="w-3 h-3 text-cyan-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.advanced_analytics.features.profitability', 'Análisis de rentabilidad por producto')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-cyan-500/10 rounded-full flex items-center justify-center mr-3">
|
||||
<PieChart className="w-3 h-3 text-cyan-600" />
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">{t('landing:features.advanced_analytics.features.reports', 'Reportes personalizables')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Features Grid */}
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
{/* Secondary Features - Compact Grid */}
|
||||
<div className="mt-16 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 transition-all duration-300">
|
||||
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<BarChart3 className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{t('landing:features.advanced_analytics.title', 'Analytics Avanzado')}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">{t('landing:features.advanced_analytics.description', 'Dashboards en tiempo real con métricas clave')}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-secondary)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Euro className="w-6 h-6 text-[var(--color-secondary)]" />
|
||||
<Euro className="w-6 h-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{t('landing:features.pos_integration.title', 'POS Integrado')}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">{t('landing:features.pos_integration.description', 'Sistema de ventas completo y fácil de usar')}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-accent)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-6 h-6 text-[var(--color-accent)]" />
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 transition-all duration-300">
|
||||
<div className="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{t('landing:features.quality_control.title', 'Control de Calidad')}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">{t('landing:features.quality_control.description', 'Trazabilidad completa y gestión HACCP')}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)]">
|
||||
<div className="w-12 h-12 bg-[var(--color-info)]/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Settings className="w-6 h-6 text-[var(--color-info)]" />
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 transition-all duration-300">
|
||||
<div className="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Settings className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{t('landing:features.automation.title', 'Automatización')}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">{t('landing:features.automation.description', 'Procesos automáticos que ahorran tiempo')}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-6 bg-[var(--bg-primary)] rounded-xl border border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 transition-all duration-300">
|
||||
<div className="w-12 h-12 bg-blue-500/10 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Zap className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{t('landing:features.cloud_based.title', 'En la Nube')}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-2">{t('landing:features.cloud_based.description', 'Accede desde cualquier lugar, siempre actualizado')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
601
frontend/src/utils/alertHelpers.ts
Normal file
601
frontend/src/utils/alertHelpers.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* Alert Helper Utilities
|
||||
* Provides grouping, filtering, sorting, and categorization logic for alerts
|
||||
*/
|
||||
|
||||
import { NotificationData } from '../hooks/useNotifications';
|
||||
|
||||
export type AlertSeverity = 'urgent' | 'high' | 'medium' | 'low';
|
||||
export type AlertCategory = 'inventory' | 'production' | 'orders' | 'equipment' | 'quality' | 'suppliers' | 'other';
|
||||
export type TimeGroup = 'today' | 'yesterday' | 'this_week' | 'older';
|
||||
|
||||
export interface AlertGroup {
|
||||
id: string;
|
||||
type: 'time' | 'category' | 'similarity';
|
||||
key: string;
|
||||
title: string;
|
||||
count: number;
|
||||
severity: AlertSeverity;
|
||||
alerts: NotificationData[];
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface AlertFilters {
|
||||
severities: AlertSeverity[];
|
||||
categories: AlertCategory[];
|
||||
timeRange: TimeGroup | 'all';
|
||||
search: string;
|
||||
showSnoozed: boolean;
|
||||
}
|
||||
|
||||
export interface SnoozedAlert {
|
||||
alertId: string;
|
||||
until: number; // timestamp
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize alert based on title and message content
|
||||
*/
|
||||
export function categorizeAlert(alert: NotificationData): AlertCategory {
|
||||
const text = `${alert.title} ${alert.message}`.toLowerCase();
|
||||
|
||||
if (text.includes('stock') || text.includes('inventario') || text.includes('caducad') || text.includes('expi')) {
|
||||
return 'inventory';
|
||||
}
|
||||
if (text.includes('producci') || text.includes('production') || text.includes('lote') || text.includes('batch')) {
|
||||
return 'production';
|
||||
}
|
||||
if (text.includes('pedido') || text.includes('order') || text.includes('entrega') || text.includes('delivery')) {
|
||||
return 'orders';
|
||||
}
|
||||
if (text.includes('equip') || text.includes('maquina') || text.includes('mantenimiento') || text.includes('maintenance')) {
|
||||
return 'equipment';
|
||||
}
|
||||
if (text.includes('calidad') || text.includes('quality') || text.includes('temperatura') || text.includes('temperature')) {
|
||||
return 'quality';
|
||||
}
|
||||
if (text.includes('proveedor') || text.includes('supplier') || text.includes('compra') || text.includes('purchase')) {
|
||||
return 'suppliers';
|
||||
}
|
||||
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category display name
|
||||
*/
|
||||
export function getCategoryName(category: AlertCategory, locale: string = 'es'): string {
|
||||
const names: Record<AlertCategory, Record<string, string>> = {
|
||||
inventory: { es: 'Inventario', en: 'Inventory' },
|
||||
production: { es: 'Producción', en: 'Production' },
|
||||
orders: { es: 'Pedidos', en: 'Orders' },
|
||||
equipment: { es: 'Maquinaria', en: 'Equipment' },
|
||||
quality: { es: 'Calidad', en: 'Quality' },
|
||||
suppliers: { es: 'Proveedores', en: 'Suppliers' },
|
||||
other: { es: 'Otros', en: 'Other' },
|
||||
};
|
||||
|
||||
return names[category][locale] || names[category]['es'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category icon emoji
|
||||
*/
|
||||
export function getCategoryIcon(category: AlertCategory): string {
|
||||
const icons: Record<AlertCategory, string> = {
|
||||
inventory: '📦',
|
||||
production: '🏭',
|
||||
orders: '🚚',
|
||||
equipment: '⚙️',
|
||||
quality: '✅',
|
||||
suppliers: '🏢',
|
||||
other: '📋',
|
||||
};
|
||||
|
||||
return icons[category];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine time group for an alert
|
||||
*/
|
||||
export function getTimeGroup(timestamp: string): TimeGroup {
|
||||
const alertDate = new Date(timestamp);
|
||||
const now = new Date();
|
||||
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
if (alertDate >= today) {
|
||||
return 'today';
|
||||
}
|
||||
if (alertDate >= yesterday) {
|
||||
return 'yesterday';
|
||||
}
|
||||
if (alertDate >= weekAgo) {
|
||||
return 'this_week';
|
||||
}
|
||||
return 'older';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time group display name
|
||||
*/
|
||||
export function getTimeGroupName(group: TimeGroup, locale: string = 'es'): string {
|
||||
const names: Record<TimeGroup, Record<string, string>> = {
|
||||
today: { es: 'Hoy', en: 'Today' },
|
||||
yesterday: { es: 'Ayer', en: 'Yesterday' },
|
||||
this_week: { es: 'Esta semana', en: 'This week' },
|
||||
older: { es: 'Anteriores', en: 'Older' },
|
||||
};
|
||||
|
||||
return names[group][locale] || names[group]['es'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two alerts are similar enough to group together
|
||||
*/
|
||||
export function areAlertsSimilar(alert1: NotificationData, alert2: NotificationData): boolean {
|
||||
// Must be same category and severity
|
||||
if (categorizeAlert(alert1) !== categorizeAlert(alert2)) {
|
||||
return false;
|
||||
}
|
||||
if (alert1.severity !== alert2.severity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract key terms from titles
|
||||
const getKeyTerms = (title: string): Set<string> => {
|
||||
const stopWords = new Set(['de', 'en', 'el', 'la', 'los', 'las', 'un', 'una', 'y', 'o', 'a', 'the', 'in', 'on', 'at', 'of', 'and', 'or']);
|
||||
return new Set(
|
||||
title
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 3 && !stopWords.has(word))
|
||||
);
|
||||
};
|
||||
|
||||
const terms1 = getKeyTerms(alert1.title);
|
||||
const terms2 = getKeyTerms(alert2.title);
|
||||
|
||||
// Calculate similarity: intersection / union
|
||||
const intersection = new Set([...terms1].filter(x => terms2.has(x)));
|
||||
const union = new Set([...terms1, ...terms2]);
|
||||
|
||||
const similarity = intersection.size / union.size;
|
||||
|
||||
return similarity > 0.5; // 50% similarity threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Group alerts by time periods
|
||||
*/
|
||||
export function groupAlertsByTime(alerts: NotificationData[]): AlertGroup[] {
|
||||
const groups: Map<TimeGroup, NotificationData[]> = new Map();
|
||||
|
||||
alerts.forEach(alert => {
|
||||
const timeGroup = getTimeGroup(alert.timestamp);
|
||||
if (!groups.has(timeGroup)) {
|
||||
groups.set(timeGroup, []);
|
||||
}
|
||||
groups.get(timeGroup)!.push(alert);
|
||||
});
|
||||
|
||||
const timeOrder: TimeGroup[] = ['today', 'yesterday', 'this_week', 'older'];
|
||||
|
||||
return timeOrder
|
||||
.filter(key => groups.has(key))
|
||||
.map(key => {
|
||||
const groupAlerts = groups.get(key)!;
|
||||
const highestSeverity = getHighestSeverity(groupAlerts);
|
||||
|
||||
return {
|
||||
id: `time-${key}`,
|
||||
type: 'time' as const,
|
||||
key,
|
||||
title: getTimeGroupName(key),
|
||||
count: groupAlerts.length,
|
||||
severity: highestSeverity,
|
||||
alerts: groupAlerts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group alerts by category
|
||||
*/
|
||||
export function groupAlertsByCategory(alerts: NotificationData[]): AlertGroup[] {
|
||||
const groups: Map<AlertCategory, NotificationData[]> = new Map();
|
||||
|
||||
alerts.forEach(alert => {
|
||||
const category = categorizeAlert(alert);
|
||||
if (!groups.has(category)) {
|
||||
groups.set(category, []);
|
||||
}
|
||||
groups.get(category)!.push(alert);
|
||||
});
|
||||
|
||||
// Sort by count (descending)
|
||||
const sortedCategories = Array.from(groups.entries())
|
||||
.sort((a, b) => b[1].length - a[1].length);
|
||||
|
||||
return sortedCategories.map(([category, groupAlerts]) => {
|
||||
const highestSeverity = getHighestSeverity(groupAlerts);
|
||||
|
||||
return {
|
||||
id: `category-${category}`,
|
||||
type: 'category' as const,
|
||||
key: category,
|
||||
title: `${getCategoryIcon(category)} ${getCategoryName(category)}`,
|
||||
count: groupAlerts.length,
|
||||
severity: highestSeverity,
|
||||
alerts: groupAlerts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Group similar alerts together
|
||||
*/
|
||||
export function groupSimilarAlerts(alerts: NotificationData[]): AlertGroup[] {
|
||||
const groups: AlertGroup[] = [];
|
||||
const processed = new Set<string>();
|
||||
|
||||
alerts.forEach(alert => {
|
||||
if (processed.has(alert.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find similar alerts
|
||||
const similarAlerts = alerts.filter(other =>
|
||||
!processed.has(other.id) && areAlertsSimilar(alert, other)
|
||||
);
|
||||
|
||||
if (similarAlerts.length > 1) {
|
||||
// Create a group
|
||||
similarAlerts.forEach(a => processed.add(a.id));
|
||||
|
||||
const category = categorizeAlert(alert);
|
||||
const highestSeverity = getHighestSeverity(similarAlerts);
|
||||
|
||||
groups.push({
|
||||
id: `similar-${alert.id}`,
|
||||
type: 'similarity',
|
||||
key: `${category}-${alert.severity}`,
|
||||
title: `${similarAlerts.length} alertas de ${getCategoryName(category).toLowerCase()}`,
|
||||
count: similarAlerts.length,
|
||||
severity: highestSeverity,
|
||||
alerts: similarAlerts,
|
||||
});
|
||||
} else {
|
||||
// Single alert, add as individual group
|
||||
processed.add(alert.id);
|
||||
groups.push({
|
||||
id: `single-${alert.id}`,
|
||||
type: 'similarity',
|
||||
key: alert.id,
|
||||
title: alert.title,
|
||||
count: 1,
|
||||
severity: alert.severity as AlertSeverity,
|
||||
alerts: [alert],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get highest severity from a list of alerts
|
||||
*/
|
||||
export function getHighestSeverity(alerts: NotificationData[]): AlertSeverity {
|
||||
const severityOrder: AlertSeverity[] = ['urgent', 'high', 'medium', 'low'];
|
||||
|
||||
for (const severity of severityOrder) {
|
||||
if (alerts.some(alert => alert.severity === severity)) {
|
||||
return severity;
|
||||
}
|
||||
}
|
||||
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort alerts by severity and timestamp
|
||||
*/
|
||||
export function sortAlerts(alerts: NotificationData[]): NotificationData[] {
|
||||
const severityOrder: Record<AlertSeverity, number> = {
|
||||
urgent: 4,
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
return [...alerts].sort((a, b) => {
|
||||
// First by severity
|
||||
const severityDiff = severityOrder[b.severity as AlertSeverity] - severityOrder[a.severity as AlertSeverity];
|
||||
if (severityDiff !== 0) {
|
||||
return severityDiff;
|
||||
}
|
||||
|
||||
// Then by timestamp (newest first)
|
||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter alerts based on criteria
|
||||
*/
|
||||
export function filterAlerts(
|
||||
alerts: NotificationData[],
|
||||
filters: AlertFilters,
|
||||
snoozedAlerts: Map<string, SnoozedAlert>
|
||||
): NotificationData[] {
|
||||
return alerts.filter(alert => {
|
||||
// Filter by severity
|
||||
if (filters.severities.length > 0 && !filters.severities.includes(alert.severity as AlertSeverity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (filters.categories.length > 0) {
|
||||
const category = categorizeAlert(alert);
|
||||
if (!filters.categories.includes(category)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by time range
|
||||
if (filters.timeRange !== 'all') {
|
||||
const timeGroup = getTimeGroup(alert.timestamp);
|
||||
if (timeGroup !== filters.timeRange) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if (filters.search.trim()) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
const searchableText = `${alert.title} ${alert.message}`.toLowerCase();
|
||||
if (!searchableText.includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter snoozed alerts
|
||||
if (!filters.showSnoozed) {
|
||||
const snoozed = snoozedAlerts.get(alert.id);
|
||||
if (snoozed && snoozed.until > Date.now()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if alert is snoozed
|
||||
*/
|
||||
export function isAlertSnoozed(alertId: string, snoozedAlerts: Map<string, SnoozedAlert>): boolean {
|
||||
const snoozed = snoozedAlerts.get(alertId);
|
||||
if (!snoozed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (snoozed.until <= Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time remaining for snoozed alert
|
||||
*/
|
||||
export function getSnoozedTimeRemaining(alertId: string, snoozedAlerts: Map<string, SnoozedAlert>): string | null {
|
||||
const snoozed = snoozedAlerts.get(alertId);
|
||||
if (!snoozed || snoozed.until <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remaining = snoozed.until - Date.now();
|
||||
const hours = Math.floor(remaining / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate snooze timestamp based on duration
|
||||
*/
|
||||
export function calculateSnoozeUntil(duration: '15min' | '1hr' | '4hr' | 'tomorrow' | number): number {
|
||||
const now = Date.now();
|
||||
|
||||
if (typeof duration === 'number') {
|
||||
return now + duration;
|
||||
}
|
||||
|
||||
switch (duration) {
|
||||
case '15min':
|
||||
return now + 15 * 60 * 1000;
|
||||
case '1hr':
|
||||
return now + 60 * 60 * 1000;
|
||||
case '4hr':
|
||||
return now + 4 * 60 * 60 * 1000;
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0); // 9 AM tomorrow
|
||||
return tomorrow.getTime();
|
||||
}
|
||||
default:
|
||||
return now + 60 * 60 * 1000; // default 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contextual action for alert type
|
||||
*/
|
||||
export interface ContextualAction {
|
||||
label: string;
|
||||
icon: string;
|
||||
variant: 'primary' | 'secondary' | 'outline';
|
||||
action: string; // action identifier
|
||||
route?: string; // navigation route
|
||||
}
|
||||
|
||||
export function getContextualActions(alert: NotificationData): ContextualAction[] {
|
||||
const category = categorizeAlert(alert);
|
||||
const text = `${alert.title} ${alert.message}`.toLowerCase();
|
||||
|
||||
const actions: ContextualAction[] = [];
|
||||
|
||||
// Category-specific actions
|
||||
if (category === 'inventory') {
|
||||
if (text.includes('bajo') || text.includes('low')) {
|
||||
actions.push({
|
||||
label: 'Ordenar Stock',
|
||||
icon: '🛒',
|
||||
variant: 'primary',
|
||||
action: 'order_stock',
|
||||
route: '/app/procurement',
|
||||
});
|
||||
}
|
||||
if (text.includes('caduca') || text.includes('expir')) {
|
||||
actions.push({
|
||||
label: 'Planificar Uso',
|
||||
icon: '📅',
|
||||
variant: 'primary',
|
||||
action: 'plan_usage',
|
||||
route: '/app/production',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (category === 'equipment') {
|
||||
actions.push({
|
||||
label: 'Programar Mantenimiento',
|
||||
icon: '🔧',
|
||||
variant: 'primary',
|
||||
action: 'schedule_maintenance',
|
||||
route: '/app/operations/maquinaria',
|
||||
});
|
||||
}
|
||||
|
||||
if (category === 'orders') {
|
||||
if (text.includes('retraso') || text.includes('delayed')) {
|
||||
actions.push({
|
||||
label: 'Contactar Cliente',
|
||||
icon: '📞',
|
||||
variant: 'primary',
|
||||
action: 'contact_customer',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (category === 'production') {
|
||||
actions.push({
|
||||
label: 'Ver Producción',
|
||||
icon: '🏭',
|
||||
variant: 'secondary',
|
||||
action: 'view_production',
|
||||
route: '/app/production',
|
||||
});
|
||||
}
|
||||
|
||||
// Always add generic view details action
|
||||
actions.push({
|
||||
label: 'Ver Detalles',
|
||||
icon: '👁️',
|
||||
variant: 'outline',
|
||||
action: 'view_details',
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search alerts with highlighting
|
||||
*/
|
||||
export interface SearchMatch {
|
||||
alert: NotificationData;
|
||||
highlights: {
|
||||
title: boolean;
|
||||
message: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function searchAlerts(alerts: NotificationData[], query: string): SearchMatch[] {
|
||||
if (!query.trim()) {
|
||||
return alerts.map(alert => ({
|
||||
alert,
|
||||
highlights: { title: false, message: false },
|
||||
}));
|
||||
}
|
||||
|
||||
const searchLower = query.toLowerCase();
|
||||
|
||||
return alerts
|
||||
.filter(alert => {
|
||||
const titleMatch = alert.title.toLowerCase().includes(searchLower);
|
||||
const messageMatch = alert.message.toLowerCase().includes(searchLower);
|
||||
return titleMatch || messageMatch;
|
||||
})
|
||||
.map(alert => ({
|
||||
alert,
|
||||
highlights: {
|
||||
title: alert.title.toLowerCase().includes(searchLower),
|
||||
message: alert.message.toLowerCase().includes(searchLower),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert statistics
|
||||
*/
|
||||
export interface AlertStats {
|
||||
total: number;
|
||||
bySeverity: Record<AlertSeverity, number>;
|
||||
byCategory: Record<AlertCategory, number>;
|
||||
unread: number;
|
||||
snoozed: number;
|
||||
}
|
||||
|
||||
export function getAlertStatistics(
|
||||
alerts: NotificationData[],
|
||||
snoozedAlerts: Map<string, SnoozedAlert>
|
||||
): AlertStats {
|
||||
const stats: AlertStats = {
|
||||
total: alerts.length,
|
||||
bySeverity: { urgent: 0, high: 0, medium: 0, low: 0 },
|
||||
byCategory: { inventory: 0, production: 0, orders: 0, equipment: 0, quality: 0, suppliers: 0, other: 0 },
|
||||
unread: 0,
|
||||
snoozed: 0,
|
||||
};
|
||||
|
||||
alerts.forEach(alert => {
|
||||
stats.bySeverity[alert.severity as AlertSeverity]++;
|
||||
stats.byCategory[categorizeAlert(alert)]++;
|
||||
|
||||
if (!alert.read) {
|
||||
stats.unread++;
|
||||
}
|
||||
|
||||
if (isAlertSnoozed(alert.id, snoozedAlerts)) {
|
||||
stats.snoozed++;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
306
frontend/src/utils/numberFormatting.ts
Normal file
306
frontend/src/utils/numberFormatting.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Number Formatting Utilities
|
||||
* Provides locale-aware number, currency, and measurement formatting
|
||||
* Fixes floating point precision issues
|
||||
*/
|
||||
|
||||
export interface FormatNumberOptions {
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
useGrouping?: boolean;
|
||||
}
|
||||
|
||||
export interface FormatCurrencyOptions extends FormatNumberOptions {
|
||||
currency?: string;
|
||||
currencyDisplay?: 'symbol' | 'code' | 'name';
|
||||
}
|
||||
|
||||
export interface FormatWeightOptions extends FormatNumberOptions {
|
||||
unit?: 'kg' | 'g' | 'lb' | 'oz';
|
||||
}
|
||||
|
||||
/**
|
||||
* Round number to specified decimal places to avoid floating point errors
|
||||
*/
|
||||
export function roundToPrecision(value: number, decimals: number = 2): number {
|
||||
const multiplier = Math.pow(10, decimals);
|
||||
return Math.round(value * multiplier) / multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with locale-specific formatting
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number | string,
|
||||
locale: string = 'es-ES',
|
||||
options: FormatNumberOptions = {}
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
// Round to avoid floating point errors
|
||||
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
|
||||
|
||||
const defaultOptions: Intl.NumberFormatOptions = {
|
||||
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||||
useGrouping: options.useGrouping ?? true,
|
||||
};
|
||||
|
||||
return new Intl.NumberFormat(locale, defaultOptions).format(rounded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency with locale-specific formatting
|
||||
*/
|
||||
export function formatCurrency(
|
||||
value: number | string,
|
||||
locale: string = 'es-ES',
|
||||
options: FormatCurrencyOptions = {}
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return '€0,00';
|
||||
}
|
||||
|
||||
// Round to avoid floating point errors
|
||||
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
|
||||
|
||||
const defaultOptions: Intl.NumberFormatOptions = {
|
||||
style: 'currency',
|
||||
currency: options.currency ?? 'EUR',
|
||||
currencyDisplay: options.currencyDisplay ?? 'symbol',
|
||||
minimumFractionDigits: options.minimumFractionDigits ?? 2,
|
||||
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||||
};
|
||||
|
||||
return new Intl.NumberFormat(locale, defaultOptions).format(rounded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format weight/mass with unit
|
||||
*/
|
||||
export function formatWeight(
|
||||
value: number | string,
|
||||
locale: string = 'es-ES',
|
||||
options: FormatWeightOptions = {}
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return '0 kg';
|
||||
}
|
||||
|
||||
const unit = options.unit ?? 'kg';
|
||||
// Round to avoid floating point errors
|
||||
const rounded = roundToPrecision(numValue, options.maximumFractionDigits ?? 2);
|
||||
|
||||
const formatted = formatNumber(rounded, locale, {
|
||||
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||||
useGrouping: options.useGrouping ?? true,
|
||||
});
|
||||
|
||||
return `${formatted} ${unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format volume with unit
|
||||
*/
|
||||
export function formatVolume(
|
||||
value: number | string,
|
||||
locale: string = 'es-ES',
|
||||
unit: 'L' | 'mL' | 'gal' = 'L',
|
||||
decimals: number = 2
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return `0 ${unit}`;
|
||||
}
|
||||
|
||||
const rounded = roundToPrecision(numValue, decimals);
|
||||
const formatted = formatNumber(rounded, locale, {
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
|
||||
return `${formatted} ${unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format percentage
|
||||
*/
|
||||
export function formatPercentage(
|
||||
value: number | string,
|
||||
locale: string = 'es-ES',
|
||||
decimals: number = 1
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return '0%';
|
||||
}
|
||||
|
||||
const rounded = roundToPrecision(numValue, decimals);
|
||||
const formatted = formatNumber(rounded, locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
|
||||
return `${formatted}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format compact number (1K, 1M, etc.)
|
||||
*/
|
||||
export function formatCompactNumber(
|
||||
value: number | string,
|
||||
locale: string = 'es-ES'
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const rounded = roundToPrecision(numValue, 1);
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(rounded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format integer (no decimals)
|
||||
*/
|
||||
export function formatInteger(
|
||||
value: number | string,
|
||||
locale: string = 'es-ES'
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Math.round(numValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse localized number string to number
|
||||
*/
|
||||
export function parseLocalizedNumber(
|
||||
value: string,
|
||||
locale: string = 'es-ES'
|
||||
): number {
|
||||
// Get locale-specific decimal and group separators
|
||||
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
|
||||
const decimalSeparator = parts.find(p => p.type === 'decimal')?.value ?? ',';
|
||||
const groupSeparator = parts.find(p => p.type === 'group')?.value ?? '.';
|
||||
|
||||
// Remove group separators and replace decimal separator with dot
|
||||
const normalized = value
|
||||
.replace(new RegExp(`\\${groupSeparator}`, 'g'), '')
|
||||
.replace(decimalSeparator, '.');
|
||||
|
||||
return parseFloat(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format unit of measure with value
|
||||
*/
|
||||
export function formatMeasurement(
|
||||
value: number | string,
|
||||
unit: string,
|
||||
locale: string = 'es-ES',
|
||||
decimals: number = 2
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return `0 ${unit}`;
|
||||
}
|
||||
|
||||
const rounded = roundToPrecision(numValue, decimals);
|
||||
const formatted = formatNumber(rounded, locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
|
||||
return `${formatted} ${unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe division to avoid floating point errors
|
||||
*/
|
||||
export function safeDivide(
|
||||
numerator: number,
|
||||
denominator: number,
|
||||
decimals: number = 2
|
||||
): number {
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
return roundToPrecision(numerator / denominator, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe multiplication to avoid floating point errors
|
||||
*/
|
||||
export function safeMultiply(
|
||||
a: number,
|
||||
b: number,
|
||||
decimals: number = 2
|
||||
): number {
|
||||
return roundToPrecision(a * b, decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe addition to avoid floating point errors
|
||||
*/
|
||||
export function safeAdd(
|
||||
...values: number[]
|
||||
): number {
|
||||
const sum = values.reduce((acc, val) => acc + val, 0);
|
||||
return roundToPrecision(sum, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format quantity with appropriate unit of measure
|
||||
* Handles common bakery units
|
||||
*/
|
||||
export function formatQuantity(
|
||||
value: number | string,
|
||||
unit: string,
|
||||
locale: string = 'es-ES'
|
||||
): string {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
return `0 ${unit}`;
|
||||
}
|
||||
|
||||
// Determine decimals based on unit
|
||||
let decimals = 2;
|
||||
if (unit.toLowerCase() === 'unidades' || unit.toLowerCase() === 'units') {
|
||||
decimals = 0;
|
||||
} else if (['kg', 'l', 'lb'].includes(unit.toLowerCase())) {
|
||||
decimals = 2;
|
||||
}
|
||||
|
||||
const rounded = roundToPrecision(numValue, decimals);
|
||||
const formatted = formatNumber(rounded, locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
|
||||
return `${formatted} ${unit}`;
|
||||
}
|
||||
Reference in New Issue
Block a user