Improve the UI and tests

This commit is contained in:
Urtzi Alfaro
2025-11-15 21:21:06 +01:00
parent 86d704b354
commit 54b7a5e080
44 changed files with 2268 additions and 1414 deletions

View File

@@ -0,0 +1,409 @@
// ================================================================
// frontend/src/components/dashboard/PurchaseOrderDetailsModal.tsx
// ================================================================
/**
* Purchase Order Details Modal
* Quick view of PO details from the Action Queue
*/
import React from 'react';
import {
X,
Package,
Building2,
Calendar,
Truck,
Euro,
FileText,
CheckCircle,
Clock,
AlertCircle,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePurchaseOrder } from '../../api/hooks/purchase-orders';
import { formatDistanceToNow } from 'date-fns';
import { es, enUS, eu as euLocale } from 'date-fns/locale';
interface PurchaseOrderDetailsModalProps {
poId: string;
tenantId: string;
isOpen: boolean;
onClose: () => void;
onApprove?: (poId: string) => void;
onModify?: (poId: string) => void;
}
const localeMap = {
es: es,
en: enUS,
eu: euLocale,
};
export const PurchaseOrderDetailsModal: React.FC<PurchaseOrderDetailsModalProps> = ({
poId,
tenantId,
isOpen,
onClose,
onApprove,
onModify,
}) => {
const { t, i18n } = useTranslation();
const { data: po, isLoading } = usePurchaseOrder(tenantId, poId);
if (!isOpen) return null;
const dateLocale = localeMap[i18n.language as keyof typeof localeMap] || enUS;
// Format currency
const formatCurrency = (value: any) => {
const num = Number(value);
return isNaN(num) ? '0.00' : num.toFixed(2);
};
const getStatusBadge = (status: string) => {
const statusConfig = {
draft: {
bg: 'var(--color-gray-100)',
text: 'var(--color-gray-700)',
label: t('purchase_orders:status.draft'),
},
pending_approval: {
bg: 'var(--color-warning-100)',
text: 'var(--color-warning-700)',
label: t('purchase_orders:status.pending_approval'),
},
approved: {
bg: 'var(--color-success-100)',
text: 'var(--color-success-700)',
label: t('purchase_orders:status.approved'),
},
sent: {
bg: 'var(--color-info-100)',
text: 'var(--color-info-700)',
label: t('purchase_orders:status.sent'),
},
partially_received: {
bg: 'var(--color-warning-100)',
text: 'var(--color-warning-700)',
label: t('purchase_orders:status.partially_received'),
},
received: {
bg: 'var(--color-success-100)',
text: 'var(--color-success-700)',
label: t('purchase_orders:status.received'),
},
cancelled: {
bg: 'var(--color-error-100)',
text: 'var(--color-error-700)',
label: t('purchase_orders:status.cancelled'),
},
};
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.draft;
return (
<span
className="px-3 py-1 rounded-full text-sm font-medium"
style={{ backgroundColor: config.bg, color: config.text }}
>
{config.label}
</span>
);
};
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 animate-fadeIn"
onClick={onClose}
style={{
backdropFilter: 'blur(4px)',
animation: 'fadeIn 0.2s ease-out'
}}
>
<div
className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden transform transition-all"
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'var(--bg-primary)',
animation: 'slideUp 0.3s ease-out'
}}
>
<style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
`}</style>
{/* Header */}
<div
className="flex items-center justify-between p-6 border-b bg-gradient-to-r from-transparent to-transparent"
style={{
borderColor: 'var(--border-primary)',
background: 'linear-gradient(to right, var(--bg-primary), var(--bg-secondary))'
}}
>
<div className="flex items-center gap-4">
<div
className="p-3 rounded-xl"
style={{
backgroundColor: 'var(--color-primary-100)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)'
}}
>
<Package className="w-6 h-6" style={{ color: 'var(--color-primary-600)' }} />
</div>
<div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--text-primary)' }}>
{isLoading ? t('common:loading') : po?.po_number || t('purchase_orders:purchase_order')}
</h2>
{po && (
<p className="text-sm flex items-center gap-1" style={{ color: 'var(--text-secondary)' }}>
<Clock className="w-3.5 h-3.5" />
{t('purchase_orders:created')} {formatDistanceToNow(new Date(po.created_at), { addSuffix: true, locale: dateLocale })}
</p>
)}
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-gray-100 transition-all hover:scale-110"
style={{ color: 'var(--text-secondary)' }}
aria-label={t('purchase_orders:actions.close')}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-10 w-10 border-3 border-primary-200 border-t-primary-600"></div>
</div>
) : po ? (
<div className="space-y-6">
{/* Status and Key Info */}
<div className="flex flex-wrap items-center gap-4 p-4 rounded-xl" style={{
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-primary)'
}}>
{getStatusBadge(po.status)}
<div className="flex-1 min-w-[200px]">
<p className="text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--text-secondary)' }}>
{t('purchase_orders:total_amount')}
</p>
<div className="flex items-baseline gap-2">
<Euro className="w-5 h-5" style={{ color: 'var(--color-primary-600)' }} />
<span className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{formatCurrency(po.total_amount)}
</span>
</div>
</div>
</div>
{/* Supplier Info */}
<div className="rounded-xl p-5 border" style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)'
}}>
<div className="flex items-center gap-2 mb-3">
<div className="p-2 rounded-lg" style={{ backgroundColor: 'var(--color-primary-100)' }}>
<Building2 className="w-5 h-5" style={{ color: 'var(--color-primary-600)' }} />
</div>
<h3 className="font-semibold text-sm uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
{t('purchase_orders:supplier')}
</h3>
</div>
<p className="text-xl font-semibold ml-1" style={{ color: 'var(--text-primary)' }}>
{po.supplier_name || t('common:unknown')}
</p>
</div>
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-xl p-4 border" style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)'
}}>
<div className="flex items-center gap-2 mb-3">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: 'var(--color-primary-100)' }}>
<Calendar className="w-4 h-4" style={{ color: 'var(--color-primary-600)' }} />
</div>
<h3 className="font-semibold text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
{t('purchase_orders:order_date')}
</h3>
</div>
<p className="text-lg font-semibold ml-1" style={{ color: 'var(--text-primary)' }}>
{new Date(po.order_date).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p>
</div>
{po.expected_delivery_date && (
<div className="rounded-xl p-4 border" style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)'
}}>
<div className="flex items-center gap-2 mb-3">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: 'var(--color-success-100)' }}>
<Truck className="w-4 h-4" style={{ color: 'var(--color-success-600)' }} />
</div>
<h3 className="font-semibold text-xs uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>
{t('purchase_orders:expected_delivery')}
</h3>
</div>
<p className="text-lg font-semibold ml-1" style={{ color: 'var(--text-primary)' }}>
{new Date(po.expected_delivery_date).toLocaleDateString(i18n.language, {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</p>
</div>
)}
</div>
{/* Items */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg" style={{ backgroundColor: 'var(--color-primary-100)' }}>
<FileText className="w-5 h-5" style={{ color: 'var(--color-primary-600)' }} />
</div>
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
{t('purchase_orders:items')}
</h3>
{po.items && po.items.length > 0 && (
<span
className="ml-auto px-3 py-1 rounded-full text-sm font-medium"
style={{
backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-700)'
}}
>
{po.items.length} {po.items.length === 1 ? t('common:item') : t('common:items')}
</span>
)}
</div>
<div className="space-y-3">
{po.items && po.items.length > 0 ? (
po.items.map((item: any, index: number) => (
<div
key={index}
className="flex justify-between items-center p-4 rounded-xl border transition-all hover:shadow-md"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)'
}}
>
<div className="flex-1">
<p className="font-semibold text-base mb-1" style={{ color: 'var(--text-primary)' }}>
{item.ingredient_name || item.product_name}
</p>
<p className="text-sm flex items-center gap-1.5" style={{ color: 'var(--text-secondary)' }}>
<span className="font-medium">{item.quantity} {item.unit}</span>
<span>×</span>
<span>{formatCurrency(item.unit_price)}</span>
</p>
</div>
<p className="font-bold text-lg ml-4" style={{ color: 'var(--color-primary-600)' }}>
{formatCurrency(item.subtotal)}
</p>
</div>
))
) : (
<div className="text-center py-8 rounded-xl border-2 border-dashed" style={{
borderColor: 'var(--border-primary)',
color: 'var(--text-secondary)'
}}>
<FileText className="w-12 h-12 mx-auto mb-2 opacity-30" />
<p>{t('purchase_orders:no_items')}</p>
</div>
)}
</div>
</div>
{/* Notes */}
{po.notes && (
<div className="rounded-xl p-5 border-l-4" style={{
backgroundColor: 'var(--color-info-50)',
borderLeftColor: 'var(--color-info-500)',
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)'
}}>
<div className="flex items-center gap-2 mb-3">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: 'var(--color-info-100)' }}>
<AlertCircle className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
</div>
<h3 className="font-semibold text-sm uppercase tracking-wide" style={{ color: 'var(--color-info-700)' }}>
{t('purchase_orders:notes')}
</h3>
</div>
<p className="text-sm leading-relaxed ml-1" style={{ color: 'var(--text-primary)' }}>{po.notes}</p>
</div>
)}
</div>
) : (
<div className="text-center py-12">
<p style={{ color: 'var(--text-secondary)' }}>{t('purchase_orders:not_found')}</p>
</div>
)}
</div>
{/* Footer Actions */}
{po && po.status === 'pending_approval' && (
<div
className="flex justify-end gap-3 p-6 border-t bg-gradient-to-r from-transparent to-transparent"
style={{
borderColor: 'var(--border-primary)',
background: 'linear-gradient(to right, var(--bg-secondary), var(--bg-primary))'
}}
>
<button
onClick={() => {
onModify?.(poId);
onClose();
}}
className="px-6 py-2.5 rounded-xl font-semibold transition-all hover:scale-105 hover:shadow-md border"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-primary)',
borderColor: 'var(--border-primary)'
}}
>
{t('purchase_orders:actions.modify')}
</button>
<button
onClick={() => {
onApprove?.(poId);
onClose();
}}
className="px-6 py-2.5 rounded-xl font-semibold text-white transition-all hover:scale-105 hover:shadow-lg flex items-center gap-2"
style={{
backgroundColor: 'var(--color-primary-600)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
}}
>
<CheckCircle className="w-4 h-4" />
{t('purchase_orders:actions.approve')}
</button>
</div>
)}
</div>
</div>
);
};

View File

@@ -61,6 +61,10 @@ export const DemoBanner: React.FC = () => {
const { useTenantStore } = await import('../../../stores/tenant.store');
useTenantStore.getState().clearTenants();
// Clear notification storage to ensure notifications don't persist across sessions
const { clearNotificationStorage } = await import('../../../hooks/useNotifications');
clearNotificationStorage();
navigate('/demo');
};

View File

@@ -16,46 +16,59 @@ export interface NotificationData {
const STORAGE_KEY = 'bakery-notifications';
const SNOOZE_STORAGE_KEY = 'bakery-snoozed-alerts';
/**
* Clear all notification data from sessionStorage
* This is typically called during logout to ensure notifications don't persist across sessions
*/
export const clearNotificationStorage = () => {
try {
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(SNOOZE_STORAGE_KEY);
} catch (error) {
console.warn('Failed to clear notification storage:', error);
}
};
const loadNotificationsFromStorage = (): NotificationData[] => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
// Clean up old alerts (older than 24 hours)
// This prevents accumulation of stale alerts in localStorage
// This prevents accumulation of stale alerts in sessionStorage
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 we filtered out alerts, update sessionStorage
if (recentAlerts.length !== parsed.length) {
console.log(`Cleaned ${parsed.length - recentAlerts.length} old alerts from localStorage`);
localStorage.setItem(STORAGE_KEY, JSON.stringify(recentAlerts));
console.log(`Cleaned ${parsed.length - recentAlerts.length} old alerts from sessionStorage`);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(recentAlerts));
}
return recentAlerts;
}
}
} catch (error) {
console.warn('Failed to load notifications from localStorage:', error);
console.warn('Failed to load notifications from sessionStorage:', error);
}
return [];
};
const saveNotificationsToStorage = (notifications: NotificationData[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
} catch (error) {
console.warn('Failed to save notifications to localStorage:', error);
console.warn('Failed to save notifications to sessionStorage:', error);
}
};
const loadSnoozedAlertsFromStorage = (): Map<string, SnoozedAlert> => {
try {
const stored = localStorage.getItem(SNOOZE_STORAGE_KEY);
const stored = sessionStorage.getItem(SNOOZE_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
const map = new Map<string, SnoozedAlert>();
@@ -71,7 +84,7 @@ const loadSnoozedAlertsFromStorage = (): Map<string, SnoozedAlert> => {
return map;
}
} catch (error) {
console.warn('Failed to load snoozed alerts from localStorage:', error);
console.warn('Failed to load snoozed alerts from sessionStorage:', error);
}
return new Map();
};
@@ -85,9 +98,9 @@ const saveSnoozedAlertsToStorage = (snoozedAlerts: Map<string, SnoozedAlert>) =>
obj[key] = value;
}
});
localStorage.setItem(SNOOZE_STORAGE_KEY, JSON.stringify(obj));
sessionStorage.setItem(SNOOZE_STORAGE_KEY, JSON.stringify(obj));
} catch (error) {
console.warn('Failed to save snoozed alerts to localStorage:', error);
console.warn('Failed to save snoozed alerts to sessionStorage:', error);
}
};
@@ -96,11 +109,12 @@ const saveSnoozedAlertsToStorage = (snoozedAlerts: Map<string, SnoozedAlert>) =>
*
* Features:
* - SSE connection for real-time alerts
* - localStorage persistence with auto-cleanup (alerts >24h are removed on load)
* - sessionStorage 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
* Note: Notifications are session-only and cleared when the browser tab/window closes
* or when the user logs out. Alerts older than 24 hours are automatically cleaned
* on load to prevent accumulation of stale data.
*/
export const useNotifications = () => {

View File

@@ -1,6 +1,7 @@
// frontend/src/i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import ICU from 'i18next-icu';
import { resources, defaultLanguage, supportedLanguages } from '../locales';
// Get saved language from localStorage or default
@@ -24,6 +25,7 @@ const getSavedLanguage = () => {
const initialLanguage = getSavedLanguage();
i18n
.use(ICU)
.use(initReactI18next)
.init({
resources,

View File

@@ -67,6 +67,9 @@
"expand": "Expand",
"collapse": "Collapse"
},
"item": "item",
"items": "items",
"unknown": "Unknown",
"status": {
"active": "Active",
"inactive": "Inactive",

View File

@@ -34,6 +34,32 @@
"quick_actions": "Quick Actions",
"key_metrics": "Key Metrics"
},
"insights": {
"savings": {
"label": "💰 SAVINGS",
"this_week": "this week",
"vs_last": "vs. last"
},
"inventory": {
"label": "📦 INVENTORY",
"all_stocked": "All stocked",
"low_stock": "Low stock",
"stock_issues": "⚠️ Stock issues",
"no_alerts": "No alerts",
"out_of_stock": "{count} out of stock",
"alerts": "{count} alert{count, plural, one {} other {s}}"
},
"waste": {
"label": "♻️ WASTE",
"this_month": "this month",
"vs_goal": "vs. goal"
},
"deliveries": {
"label": "🚚 DELIVERIES",
"arriving_today": "{count} arriving today",
"none_scheduled": "None scheduled"
}
},
"procurement": {
"title": "What needs to be bought for tomorrow?",
"empty": "All supplies ready for tomorrow",
@@ -72,8 +98,8 @@
"view_all": "View all alerts",
"time": {
"now": "Now",
"minutes_ago": "{{count}} min ago",
"hours_ago": "{{count}} h ago",
"minutes_ago": "{count} min ago",
"hours_ago": "{count} h ago",
"yesterday": "Yesterday"
},
"types": {
@@ -101,7 +127,7 @@
"additional_details": "Additional Details",
"mark_as_read": "Mark as read",
"remove": "Remove",
"active_count": "{{count}} active alerts"
"active_count": "{count} active alerts"
},
"messages": {
"welcome": "Welcome back",
@@ -131,16 +157,16 @@
},
"health": {
"production_on_schedule": "Production on schedule",
"production_delayed": "{{count}} production batch{{count, plural, one {} other {es}}} delayed",
"production_delayed": "{count} production batch{count, plural, one {} other {es}} delayed",
"all_ingredients_in_stock": "All ingredients in stock",
"ingredients_out_of_stock": "{{count}} ingredient{{count, plural, one {} other {s}}} out of stock",
"ingredients_out_of_stock": "{count} ingredient{count, plural, one {} other {s}} out of stock",
"no_pending_approvals": "No pending approvals",
"approvals_awaiting": "{{count}} purchase order{{count, plural, one {} other {s}}} awaiting approval",
"approvals_awaiting": "{count} purchase order{count, plural, one {} other {s}} awaiting approval",
"all_systems_operational": "All systems operational",
"critical_issues": "{{count}} critical issue{{count, plural, one {} other {s}}}",
"critical_issues": "{count} critical issue{count, plural, one {} other {s}}",
"headline_green": "Your bakery is running smoothly",
"headline_yellow_approvals": "Please review {{count}} pending approval{{count, plural, one {} other {s}}}",
"headline_yellow_alerts": "You have {{count}} alert{{count, plural, one {} other {s}}} needing attention",
"headline_yellow_approvals": "Please review {count} pending approval{count, plural, one {} other {s}}",
"headline_yellow_alerts": "You have {count} alert{count, plural, one {} other {s}} needing attention",
"headline_yellow_general": "Some items need your attention",
"headline_red": "Critical issues require immediate action"
},
@@ -160,7 +186,7 @@
"suppliers": "Suppliers",
"recipes": "Recipes",
"quality": "Quality Standards",
"add_ingredients": "Add at least {{count}} ingredients",
"add_ingredients": "Add at least {count} ingredients",
"add_supplier": "Add your first supplier",
"add_recipe": "Create your first recipe",
"add_quality": "Add quality checks (optional)",

View File

@@ -0,0 +1,36 @@
{
"purchase_order": "Purchase Order",
"purchase_orders": "Purchase Orders",
"created": "Created",
"supplier": "Supplier",
"order_date": "Order Date",
"expected_delivery": "Expected Delivery",
"items": "Items",
"no_items": "No items in this order",
"notes": "Notes",
"not_found": "Purchase order not found",
"total_amount": "Total Amount",
"status": {
"draft": "Draft",
"pending_approval": "Pending Approval",
"approved": "Approved",
"sent": "Sent",
"partially_received": "Partially Received",
"received": "Received",
"cancelled": "Cancelled"
},
"details": {
"title": "Purchase Order Details",
"quick_view": "Quick Order View",
"summary": "Summary",
"supplier_info": "Supplier Information",
"delivery_info": "Delivery Information",
"order_items": "Order Items",
"additional_notes": "Additional Notes"
},
"actions": {
"approve": "Approve Order",
"modify": "Modify Order",
"close": "Close"
}
}

View File

@@ -75,8 +75,8 @@
"last_updated": "Last updated",
"next_check": "Next check",
"never": "Never",
"critical_issues": "{{count}} critical issue{{count, plural, one {} other {s}}}",
"actions_needed": "{{count}} action{{count, plural, one {} other {s}}} needed"
"critical_issues": "{count} critical issue{count, plural, one {} other {s}}",
"actions_needed": "{count} action{count, plural, one {} other {s}} needed"
},
"action_queue": {
"title": "What Needs Your Attention",
@@ -85,11 +85,23 @@
"estimated_time": "Estimated time",
"all_caught_up": "All caught up!",
"no_actions": "No actions requiring your attention right now.",
"show_more": "Show {{count}} More Action{{count, plural, one {} other {s}}}",
"show_more": "Show {count} More Action{count, plural, one {} other {s}}",
"show_less": "Show Less",
"total": "total",
"critical": "critical",
"important": "important"
"important": "important",
"consequences": {
"delayed_delivery_impact": "Delayed delivery may impact production schedule",
"immediate_action_required": "Immediate action required to prevent production issues",
"some_features_limited": "Some features are limited"
},
"actions": {
"approve": "Approve",
"view_details": "View Details",
"modify": "Modify",
"dismiss": "Dismiss",
"complete_setup": "Complete Setup"
}
},
"orchestration_summary": {
"title": "Last Night I Planned Your Day",
@@ -97,17 +109,17 @@
"run_planning": "Run Daily Planning",
"run_info": "Orchestration run #{{runNumber}}",
"took": "Took {{seconds}}s",
"created_pos": "Created {{count}} purchase order{{count, plural, one {} other {s}}}",
"scheduled_batches": "Scheduled {{count}} production batch{{count, plural, one {} other {es}}}",
"show_more": "Show {{count}} more",
"created_pos": "Created {count} purchase order{count, plural, one {} other {s}}",
"scheduled_batches": "Scheduled {count} production batch{count, plural, one {} other {es}}",
"show_more": "Show {count} more",
"show_less": "Show less",
"no_actions": "No new actions needed - everything is on track!",
"based_on": "Based on:",
"customer_orders": "{{count}} customer order{{count, plural, one {} other {s}}}",
"customer_orders": "{count} customer order{count, plural, one {} other {s}}",
"historical_demand": "Historical demand",
"inventory_levels": "Inventory levels",
"ai_optimization": "AI optimization",
"actions_required": "{{count}} item{{count, plural, one {} other {s}}} need{{count, plural, one {s} other {}}} your approval before proceeding",
"actions_required": "{count} item{count, plural, one {} other {s}} need{count, plural, one {s} other {}}} your approval before proceeding",
"no_tenant_error": "No tenant ID found. Please ensure you're logged in.",
"planning_started": "Planning started successfully",
"planning_failed": "Failed to start planning",

View File

@@ -12,6 +12,16 @@
"title": "Add Inventory",
"inventoryDetails": "Inventory Item Details",
"fillRequiredInfo": "Fill in the required information to create an inventory item",
"summary": "Summary",
"steps": {
"productType": "Product Type",
"basicInfo": "Basic Information",
"stockConfig": "Stock Configuration"
},
"typeDescriptions": {
"ingredient": "Raw materials and ingredients used in recipes",
"finished_product": "Final products ready for sale or consumption"
},
"fields": {
"name": "Name",
"namePlaceholder": "E.g., All-Purpose Flour, Sourdough Bread",
@@ -81,6 +91,11 @@
"basicInformation": "Basic Information",
"advancedOptions": "Advanced Options",
"advancedOptionsDescription": "Optional fields for comprehensive inventory management",
"additionalInformationDescription": "Optional product identifiers",
"additionalDetails": "Additional Details",
"additionalDetailsDescription": "Optional product details",
"advancedStockSettings": "Advanced Stock Settings",
"advancedStockSettingsDescription": "Configure inventory thresholds and reorder points",
"pricingInformation": "Pricing Information",
"inventoryManagement": "Inventory Management",
"productInformation": "Product Information",

View File

@@ -67,6 +67,9 @@
"expand": "Expandir",
"collapse": "Contraer"
},
"item": "artículo",
"items": "artículos",
"unknown": "Desconocido",
"status": {
"active": "Activo",
"inactive": "Inactivo",

View File

@@ -34,6 +34,32 @@
"quick_actions": "Acciones Rápidas",
"key_metrics": "Métricas Clave"
},
"insights": {
"savings": {
"label": "💰 AHORROS",
"this_week": "esta semana",
"vs_last": "vs. anterior"
},
"inventory": {
"label": "📦 INVENTARIO",
"all_stocked": "Todo en stock",
"low_stock": "Stock bajo",
"stock_issues": "⚠️ Problemas de stock",
"no_alerts": "Sin alertas",
"out_of_stock": "{count} sin stock",
"alerts": "{count} alerta{count, plural, one {} other {s}}"
},
"waste": {
"label": "♻️ DESPERDICIO",
"this_month": "este mes",
"vs_goal": "vs. objetivo"
},
"deliveries": {
"label": "🚚 ENTREGAS",
"arriving_today": "{count} llegan hoy",
"none_scheduled": "Ninguna programada"
}
},
"procurement": {
"title": "¿Qué necesito comprar para mañana?",
"empty": "Todos los suministros listos para mañana",
@@ -166,16 +192,16 @@
},
"health": {
"production_on_schedule": "Producción a tiempo",
"production_delayed": "{{count}} lote{{count, plural, one {} other {s}}} de producción retrasado{{count, plural, one {} other {s}}}",
"production_delayed": "{count} lote{count, plural, one {} other {s}} de producción retrasado{count, plural, one {} other {s}}",
"all_ingredients_in_stock": "Todos los ingredientes en stock",
"ingredients_out_of_stock": "{{count}} ingrediente{{count, plural, one {} other {s}}} sin stock",
"ingredients_out_of_stock": "{count} ingrediente{count, plural, one {} other {s}} sin stock",
"no_pending_approvals": "Sin aprobaciones pendientes",
"approvals_awaiting": "{{count}} orden{{count, plural, one {} other {es}}} de compra esperando aprobación",
"approvals_awaiting": "{count} orden{count, plural, one {} other {es}} de compra esperando aprobación",
"all_systems_operational": "Todos los sistemas operativos",
"critical_issues": "{{count}} problema{{count, plural, one {} other {s}}} crítico{{count, plural, one {} other {s}}}",
"critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}",
"headline_green": "Tu panadería funciona sin problemas",
"headline_yellow_approvals": "Por favor revisa {{count}} aprobación{{count, plural, one {} other {es}}} pendiente{{count, plural, one {} other {s}}}",
"headline_yellow_alerts": "Tienes {{count}} alerta{{count, plural, one {} other {s}}} que necesita{{count, plural, one {} other {n}}} atención",
"headline_yellow_approvals": "Por favor revisa {count} aprobación{count, plural, one {} other {es}} pendiente{count, plural, one {} other {s}}",
"headline_yellow_alerts": "Tienes {count} alerta{count, plural, one {} other {s}} que necesita{count, plural, one {} other {n}} atención",
"headline_yellow_general": "Algunos elementos necesitan tu atención",
"headline_red": "Problemas críticos requieren acción inmediata"
},

View File

@@ -0,0 +1,36 @@
{
"purchase_order": "Orden de Compra",
"purchase_orders": "Órdenes de Compra",
"created": "Creada",
"supplier": "Proveedor",
"order_date": "Fecha de Pedido",
"expected_delivery": "Entrega Esperada",
"items": "Artículos",
"no_items": "No hay artículos en esta orden",
"notes": "Notas",
"not_found": "Orden de compra no encontrada",
"total_amount": "Monto Total",
"status": {
"draft": "Borrador",
"pending_approval": "Pendiente de Aprobación",
"approved": "Aprobada",
"sent": "Enviada",
"partially_received": "Parcialmente Recibida",
"received": "Recibida",
"cancelled": "Cancelada"
},
"details": {
"title": "Detalles de la Orden de Compra",
"quick_view": "Vista Rápida de la Orden",
"summary": "Resumen",
"supplier_info": "Información del Proveedor",
"delivery_info": "Información de Entrega",
"order_items": "Artículos del Pedido",
"additional_notes": "Notas Adicionales"
},
"actions": {
"approve": "Aprobar Orden",
"modify": "Modificar Orden",
"close": "Cerrar"
}
}

View File

@@ -75,8 +75,8 @@
"last_updated": "Última actualización",
"next_check": "Próxima verificación",
"never": "Nunca",
"critical_issues": "{{count}} problema{{count, plural, one {} other {s}}} crítico{{count, plural, one {} other {s}}}",
"actions_needed": "{{count}} acción{{count, plural, one {} other {es}}} necesaria{{count, plural, one {} other {s}}}"
"critical_issues": "{count} problema{count, plural, one {} other {s}} crítico{count, plural, one {} other {s}}",
"actions_needed": "{count} acción{count, plural, one {} other {es}} necesaria{count, plural, one {} other {s}}"
},
"action_queue": {
"title": "Qué Necesita Tu Atención",
@@ -85,11 +85,23 @@
"estimated_time": "Tiempo estimado",
"all_caught_up": "¡Todo al día!",
"no_actions": "No hay acciones que requieran tu atención en este momento.",
"show_more": "Mostrar {{count}} Acción{{count, plural, one {} other {es}}} Más",
"show_more": "Mostrar {count} Acción{count, plural, one {} other {es}} Más",
"show_less": "Mostrar Menos",
"total": "total",
"critical": "críticas",
"important": "importantes"
"important": "importantes",
"consequences": {
"delayed_delivery_impact": "El retraso en la entrega puede afectar el cronograma de producción",
"immediate_action_required": "Se requiere acción inmediata para prevenir problemas de producción",
"some_features_limited": "Algunas funciones están limitadas"
},
"actions": {
"approve": "Aprobar",
"view_details": "Ver Detalles",
"modify": "Modificar",
"dismiss": "Descartar",
"complete_setup": "Completar Configuración"
}
},
"orchestration_summary": {
"title": "Anoche Planifiqué Tu Día",
@@ -97,17 +109,17 @@
"run_planning": "Ejecutar Planificación Diaria",
"run_info": "Ejecución de orquestación #{{runNumber}}",
"took": "Duró {{seconds}}s",
"created_pos": "{{count}} orden{{count, plural, one {} other {es}}} de compra creada{{count, plural, one {} other {s}}}",
"scheduled_batches": "{{count}} lote{{count, plural, one {} other {s}}} de producción programado{{count, plural, one {} other {s}}}",
"show_more": "Mostrar {{count}} más",
"created_pos": "{count} orden{count, plural, one {} other {es}} de compra creada{count, plural, one {} other {s}}",
"scheduled_batches": "{count} lote{count, plural, one {} other {s}} de producción programado{count, plural, one {} other {s}}",
"show_more": "Mostrar {count} más",
"show_less": "Mostrar menos",
"no_actions": "¡No se necesitan nuevas acciones - todo va según lo planeado!",
"based_on": "Basado en:",
"customer_orders": "{{count}} pedido{{count, plural, one {} other {s}}} de cliente",
"customer_orders": "{count} pedido{count, plural, one {} other {s}} de cliente",
"historical_demand": "Demanda histórica",
"inventory_levels": "Niveles de inventario",
"ai_optimization": "Optimización por IA",
"actions_required": "{{count}} elemento{{count, plural, one {} other {s}}} necesita{{count, plural, one {} other {n}}} tu aprobación antes de continuar",
"actions_required": "{count} elemento{count, plural, one {} other {s}} necesita{count, plural, one {} other {n}} tu aprobación antes de continuar",
"no_tenant_error": "No se encontró ID de inquilino. Por favor, asegúrate de haber iniciado sesión.",
"planning_started": "Planificación iniciada correctamente",
"planning_failed": "Error al iniciar la planificación",

View File

@@ -12,6 +12,16 @@
"title": "Agregar Inventario",
"inventoryDetails": "Detalles del Artículo de Inventario",
"fillRequiredInfo": "Complete la información requerida para crear un artículo de inventario",
"summary": "Resumen",
"steps": {
"productType": "Tipo de Producto",
"basicInfo": "Información Básica",
"stockConfig": "Configuración de Stock"
},
"typeDescriptions": {
"ingredient": "Materias primas e ingredientes utilizados en recetas",
"finished_product": "Productos finales listos para venta o consumo"
},
"fields": {
"name": "Nombre",
"namePlaceholder": "Ej: Harina de Uso General, Pan de Masa Madre",
@@ -81,6 +91,11 @@
"basicInformation": "Información Básica",
"advancedOptions": "Opciones Avanzadas",
"advancedOptionsDescription": "Campos opcionales para gestión completa de inventario",
"additionalInformationDescription": "Identificadores de producto opcionales",
"additionalDetails": "Detalles Adicionales",
"additionalDetailsDescription": "Detalles opcionales del producto",
"advancedStockSettings": "Configuración Avanzada de Stock",
"advancedStockSettingsDescription": "Configurar umbrales de inventario y puntos de reorden",
"pricingInformation": "Información de Precios",
"inventoryManagement": "Gestión de Inventario",
"productInformation": "Información del Producto",

View File

@@ -67,6 +67,9 @@
"expand": "Zabaldu",
"collapse": "Tolestu"
},
"item": "produktua",
"items": "produktuak",
"unknown": "Ezezaguna",
"status": {
"active": "Aktibo",
"inactive": "Ez aktibo",

View File

@@ -32,6 +32,32 @@
"quick_actions": "Ekintza Azkarrak",
"key_metrics": "Metrika Nagusiak"
},
"insights": {
"savings": {
"label": "💰 AURREZKIAK",
"this_week": "aste honetan",
"vs_last": "vs. aurrekoa"
},
"inventory": {
"label": "📦 INBENTARIOA",
"all_stocked": "Guztia stock-ean",
"low_stock": "Stock baxua",
"stock_issues": "⚠️ Stock arazoak",
"no_alerts": "Ez dago alertarik",
"out_of_stock": "{count} stock-ik gabe",
"alerts": "{count} alerta"
},
"waste": {
"label": "♻️ HONDAKINAK",
"this_month": "hilabete honetan",
"vs_goal": "vs. helburua"
},
"deliveries": {
"label": "🚚 BIDALKETA",
"arriving_today": "{count} gaur iristen",
"none_scheduled": "Ez dago programaturik"
}
},
"procurement": {
"title": "Zer erosi behar da biarko?",
"empty": "Hornikuntza guztiak prest biarko",
@@ -70,8 +96,8 @@
"view_all": "Alerta guztiak ikusi",
"time": {
"now": "Orain",
"minutes_ago": "duela {{count}} min",
"hours_ago": "duela {{count}} h",
"minutes_ago": "duela {count} min",
"hours_ago": "duela {count} h",
"yesterday": "Atzo"
},
"types": {
@@ -99,7 +125,7 @@
"additional_details": "Xehetasun Gehigarriak",
"mark_as_read": "Irakurritako gisa markatu",
"remove": "Kendu",
"active_count": "{{count}} alerta aktibo"
"active_count": "{count} alerta aktibo"
},
"messages": {
"welcome": "Ongi etorri berriro",
@@ -129,16 +155,16 @@
},
"health": {
"production_on_schedule": "Ekoizpena orduan",
"production_delayed": "{{count}} ekoizpen sorta atzeratuta",
"production_delayed": "{count} ekoizpen sorta atzeratuta",
"all_ingredients_in_stock": "Osagai guztiak stockean",
"ingredients_out_of_stock": "{{count}} osagai stockik gabe",
"ingredients_out_of_stock": "{count} osagai stockik gabe",
"no_pending_approvals": "Ez dago onarpen pendienteik",
"approvals_awaiting": "{{count}} erosketa agindu{{count, plural, one {} other {k}}} onarpenaren zai",
"approvals_awaiting": "{count} erosketa agindu{count, plural, one {} other {k}}} onarpenaren zai",
"all_systems_operational": "Sistema guztiak martxan",
"critical_issues": "{{count}} arazo kritiko",
"critical_issues": "{count} arazo kritiko",
"headline_green": "Zure okindegia arazorik gabe dabil",
"headline_yellow_approvals": "Mesedez berrikusi {{count}} onarpen zain",
"headline_yellow_alerts": "{{count}} alerta{{count, plural, one {} other {k}}} arreta behar d{{count, plural, one {u} other {ute}}}",
"headline_yellow_approvals": "Mesedez berrikusi {count} onarpen zain",
"headline_yellow_alerts": "{count} alerta{count, plural, one {} other {k}}} arreta behar d{count, plural, one {u} other {ute}}}",
"headline_yellow_general": "Zenbait elementuk zure arreta behar dute",
"headline_red": "Arazo kritikoek berehalako ekintza behar dute"
},
@@ -158,7 +184,7 @@
"suppliers": "Hornitzaileak",
"recipes": "Errezetak",
"quality": "Kalitate Estandarrak",
"add_ingredients": "Gehitu gutxienez {{count}} osagai",
"add_ingredients": "Gehitu gutxienez {count} osagai",
"add_supplier": "Gehitu zure lehen hornitzailea",
"add_recipe": "Sortu zure lehen errezeta",
"add_quality": "Gehitu kalitate kontrolak (aukerakoa)",

View File

@@ -0,0 +1,36 @@
{
"purchase_order": "Erosketa Agindua",
"purchase_orders": "Erosketa Aginduak",
"created": "Sortua",
"supplier": "Hornitzailea",
"order_date": "Eskabidearen Data",
"expected_delivery": "Espero den Entrega",
"items": "Produktuak",
"no_items": "Ez dago produkturik eskaera honetan",
"notes": "Oharrak",
"not_found": "Erosketa agindua ez da aurkitu",
"total_amount": "Guztira",
"status": {
"draft": "Zirriborroa",
"pending_approval": "Onarpenaren Zain",
"approved": "Onartuta",
"sent": "Bidalita",
"partially_received": "Partzialki Jasota",
"received": "Jasota",
"cancelled": "Bertan Behera Utzita"
},
"details": {
"title": "Erosketa Aginduaren Xehetasunak",
"quick_view": "Eskaeraren Ikuspegi Azkarra",
"summary": "Laburpena",
"supplier_info": "Hornitzailearen Informazioa",
"delivery_info": "Entregaren Informazioa",
"order_items": "Eskaeraren Produktuak",
"additional_notes": "Ohar Gehigarriak"
},
"actions": {
"approve": "Agindua Onartu",
"modify": "Agindua Aldatu",
"close": "Itxi"
}
}

View File

@@ -85,11 +85,23 @@
"estimated_time": "Estimatutako denbora",
"all_caught_up": "Dena egunean!",
"no_actions": "Ez dago une honetan zure arreta behar duen ekintzarik.",
"show_more": "Erakutsi {{count}} Ekintza gehiago",
"show_more": "Erakutsi {count} Ekintza gehiago",
"show_less": "Erakutsi Gutxiago",
"total": "guztira",
"critical": "kritiko",
"important": "garrantzitsu"
"important": "garrantzitsu",
"consequences": {
"delayed_delivery_impact": "Entregatze atzerapena ekoizpen programan eragina izan dezake",
"immediate_action_required": "Berehalako ekintza behar da ekoizpen arazoak saihesteko",
"some_features_limited": "Funtzio batzuk mugatuta daude"
},
"actions": {
"approve": "Onartu",
"view_details": "Xehetasunak Ikusi",
"modify": "Aldatu",
"dismiss": "Baztertu",
"complete_setup": "Osatu Konfigurazioa"
}
},
"orchestration_summary": {
"title": "Bart Gauean Zure Eguna Planifikatu Nuen",

View File

@@ -12,6 +12,16 @@
"title": "Inbentarioa Gehitu",
"inventoryDetails": "Inbentario Elementuaren Xehetasunak",
"fillRequiredInfo": "Bete beharrezko informazioa inbentario elementu bat sortzeko",
"summary": "Laburpena",
"steps": {
"productType": "Produktu Mota",
"basicInfo": "Oinarrizko Informazioa",
"stockConfig": "Stock Konfigurazioa"
},
"typeDescriptions": {
"ingredient": "Errezetetan erabiltzen diren lehengaiak eta osagaiak",
"finished_product": "Salmentarako edo kontsumorako prest dauden produktu finalak"
},
"fields": {
"name": "Izena",
"namePlaceholder": "Adib: Erabilera Anitzeko Irina, Masa Zaharreko Ogia",
@@ -81,6 +91,11 @@
"basicInformation": "Oinarrizko Informazioa",
"advancedOptions": "Aukera Aurreratuak",
"advancedOptionsDescription": "Inbentario kudeaketa osoa egiteko eremu aukerazkoak",
"additionalInformationDescription": "Produktu identifikatzaile aukerazkoak",
"additionalDetails": "Xehetasun Gehigarriak",
"additionalDetailsDescription": "Produktuaren xehetasun aukerazkoak",
"advancedStockSettings": "Stock Ezarpen Aurreratuak",
"advancedStockSettingsDescription": "Konfiguratu inbentario atalaseak eta berriro eskatzeko puntuak",
"pricingInformation": "Prezioen Informazioa",
"inventoryManagement": "Inbentario Kudeaketa",
"productInformation": "Produktuaren Informazioa",

View File

@@ -16,6 +16,7 @@ import ajustesEs from './es/ajustes.json';
import reasoningEs from './es/reasoning.json';
import wizardsEs from './es/wizards.json';
import subscriptionEs from './es/subscription.json';
import purchaseOrdersEs from './es/purchase_orders.json';
// English translations
import commonEn from './en/common.json';
@@ -35,6 +36,7 @@ import ajustesEn from './en/ajustes.json';
import reasoningEn from './en/reasoning.json';
import wizardsEn from './en/wizards.json';
import subscriptionEn from './en/subscription.json';
import purchaseOrdersEn from './en/purchase_orders.json';
// Basque translations
import commonEu from './eu/common.json';
@@ -54,6 +56,7 @@ import ajustesEu from './eu/ajustes.json';
import reasoningEu from './eu/reasoning.json';
import wizardsEu from './eu/wizards.json';
import subscriptionEu from './eu/subscription.json';
import purchaseOrdersEu from './eu/purchase_orders.json';
// Translation resources by language
export const resources = {
@@ -75,6 +78,7 @@ export const resources = {
reasoning: reasoningEs,
wizards: wizardsEs,
subscription: subscriptionEs,
purchase_orders: purchaseOrdersEs,
},
en: {
common: commonEn,
@@ -94,6 +98,7 @@ export const resources = {
reasoning: reasoningEn,
wizards: wizardsEn,
subscription: subscriptionEn,
purchase_orders: purchaseOrdersEn,
},
eu: {
common: commonEu,
@@ -113,6 +118,7 @@ export const resources = {
reasoning: reasoningEu,
wizards: wizardsEu,
subscription: subscriptionEu,
purchase_orders: purchaseOrdersEu,
},
};
@@ -149,7 +155,7 @@ export const languageConfig = {
};
// Namespaces available in translations
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription'] as const;
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders'] as const;
export type Namespace = typeof namespaces[number];
// Helper function to get language display name

View File

@@ -36,6 +36,7 @@ import { ActionQueueCard } from '../../components/dashboard/ActionQueueCard';
import { OrchestrationSummaryCard } from '../../components/dashboard/OrchestrationSummaryCard';
import { ProductionTimelineCard } from '../../components/dashboard/ProductionTimelineCard';
import { InsightsGrid } from '../../components/dashboard/InsightsGrid';
import { PurchaseOrderDetailsModal } from '../../components/dashboard/PurchaseOrderDetailsModal';
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
import type { ItemType } from '../../components/domain/unified-wizard';
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
@@ -52,6 +53,10 @@ export function NewDashboardPage() {
const [isAddWizardOpen, setIsAddWizardOpen] = useState(false);
const [addWizardError, setAddWizardError] = useState<string | null>(null);
// PO Details Modal state
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
const [isPOModalOpen, setIsPOModalOpen] = useState(false);
// Data fetching
const {
data: healthStatus,
@@ -113,11 +118,13 @@ export function NewDashboardPage() {
};
const handleViewDetails = (actionId: string) => {
// Navigate to appropriate detail page based on action type
navigate(`/app/operations/procurement`);
// Open modal to show PO details
setSelectedPOId(actionId);
setIsPOModalOpen(true);
};
const handleModify = (actionId: string) => {
// Navigate to procurement page for modification
navigate(`/app/operations/procurement`);
};
@@ -327,6 +334,21 @@ export function NewDashboardPage() {
onClose={() => setIsAddWizardOpen(false)}
onComplete={handleAddWizardComplete}
/>
{/* Purchase Order Details Modal */}
{selectedPOId && (
<PurchaseOrderDetailsModal
poId={selectedPOId}
tenantId={tenantId}
isOpen={isPOModalOpen}
onClose={() => {
setIsPOModalOpen(false);
setSelectedPOId(null);
}}
onApprove={handleApprove}
onModify={handleModify}
/>
)}
</div>
);
}

View File

@@ -160,6 +160,13 @@ export const useAuthStore = create<AuthState>()(
console.warn('Failed to clear tenant store on logout:', err);
});
// Clear notification storage to ensure notifications don't persist across sessions
import('../hooks/useNotifications').then(({ clearNotificationStorage }) => {
clearNotificationStorage();
}).catch(err => {
console.warn('Failed to clear notification storage on logout:', err);
});
set({
user: null,
token: null,