Improve the frontend

This commit is contained in:
Urtzi Alfaro
2025-10-21 19:50:07 +02:00
parent 05da20357d
commit 8d30172483
105 changed files with 14699 additions and 4630 deletions

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

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

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
export { POSProductCard } from './POSProductCard';
export { POSCart } from './POSCart';
export { POSPayment } from './POSPayment';
export { CreatePOSConfigModal } from './CreatePOSConfigModal';

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -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": {

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

View File

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

View File

@@ -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": {

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

View File

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

View File

@@ -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": {

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

View File

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

View File

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

View File

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

View File

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

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

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