Improve frontend 5
This commit is contained in:
@@ -75,11 +75,17 @@ export interface OrchestrationSummary {
|
||||
userActionsRequired: number;
|
||||
durationSeconds: number | null;
|
||||
aiAssisted: boolean;
|
||||
message?: string;
|
||||
message_i18n?: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for message
|
||||
}
|
||||
|
||||
export interface ActionButton {
|
||||
label: string;
|
||||
label_i18n: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for button label
|
||||
type: 'primary' | 'secondary' | 'tertiary';
|
||||
action: string;
|
||||
}
|
||||
@@ -88,10 +94,25 @@ export interface ActionItem {
|
||||
id: string;
|
||||
type: string;
|
||||
urgency: 'critical' | 'important' | 'normal';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
reasoning?: string; // Deprecated: Use reasoning_data instead
|
||||
consequence?: string; // Deprecated: Use reasoning_data instead
|
||||
title?: string; // Legacy field kept for alerts
|
||||
title_i18n?: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for title
|
||||
subtitle?: string; // Legacy field kept for alerts
|
||||
subtitle_i18n?: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for subtitle
|
||||
reasoning?: string; // Legacy field kept for alerts
|
||||
reasoning_i18n?: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for reasoning
|
||||
consequence_i18n: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for consequence
|
||||
reasoning_data?: any; // Structured reasoning data for i18n translation
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
@@ -123,6 +144,14 @@ export interface ProductionTimelineItem {
|
||||
priority: string;
|
||||
reasoning?: string; // Deprecated: Use reasoning_data instead
|
||||
reasoning_data?: any; // Structured reasoning data for i18n translation
|
||||
reasoning_i18n?: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for reasoning
|
||||
status_i18n?: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
}; // i18n data for status text
|
||||
}
|
||||
|
||||
export interface ProductionTimeline {
|
||||
@@ -134,10 +163,21 @@ export interface ProductionTimeline {
|
||||
}
|
||||
|
||||
export interface InsightCard {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
color: 'green' | 'amber' | 'red';
|
||||
i18n: {
|
||||
label: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
value: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
detail: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Insights {
|
||||
|
||||
@@ -89,7 +89,8 @@ function ActionItemCard({
|
||||
const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal;
|
||||
const UrgencyIcon = config.icon;
|
||||
const { formatPOAction } = useReasoningFormatter();
|
||||
const { t } = useTranslation('reasoning');
|
||||
const { t: tReasoning } = useTranslation('reasoning');
|
||||
const { t: tDashboard } = useTranslation('dashboard');
|
||||
|
||||
// Fetch PO details if this is a PO action and details are expanded
|
||||
const { data: poDetail } = usePurchaseOrder(
|
||||
@@ -98,18 +99,28 @@ function ActionItemCard({
|
||||
{ enabled: !!tenantId && showDetails && action.type === 'po_approval' }
|
||||
);
|
||||
|
||||
// Translate reasoning_data (or fallback to deprecated text fields)
|
||||
// Memoize to prevent undefined values from being created on each render
|
||||
const { reasoning, consequence, severity } = useMemo(() => {
|
||||
if (action.reasoning_data) {
|
||||
return formatPOAction(action.reasoning_data);
|
||||
// Translate i18n fields (or fallback to deprecated text fields or reasoning_data for alerts)
|
||||
const reasoning = useMemo(() => {
|
||||
if (action.reasoning_i18n) {
|
||||
return tDashboard(action.reasoning_i18n.key, action.reasoning_i18n.params);
|
||||
}
|
||||
return {
|
||||
reasoning: action.reasoning || '',
|
||||
consequence: action.consequence || '',
|
||||
severity: ''
|
||||
};
|
||||
}, [action.reasoning_data, action.reasoning, action.consequence, formatPOAction]);
|
||||
if (action.reasoning_data) {
|
||||
const formatted = formatPOAction(action.reasoning_data);
|
||||
return formatted.reasoning;
|
||||
}
|
||||
return action.reasoning || '';
|
||||
}, [action.reasoning_i18n, action.reasoning_data, action.reasoning, tDashboard, formatPOAction]);
|
||||
|
||||
const consequence = useMemo(() => {
|
||||
if (action.consequence_i18n) {
|
||||
return tDashboard(action.consequence_i18n.key, action.consequence_i18n.params);
|
||||
}
|
||||
if (action.reasoning_data) {
|
||||
const formatted = formatPOAction(action.reasoning_data);
|
||||
return formatted.consequence;
|
||||
}
|
||||
return '';
|
||||
}, [action.consequence_i18n, action.reasoning_data, tDashboard, formatPOAction]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -124,7 +135,9 @@ function ActionItemCard({
|
||||
<UrgencyIcon className="w-6 h-6 flex-shrink-0" style={{ color: config.iconColor }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>{action.title || 'Action Required'}</h3>
|
||||
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
|
||||
{action.title_i18n ? tDashboard(action.title_i18n.key, action.title_i18n.params) : (action.title || 'Action Required')}
|
||||
</h3>
|
||||
<span
|
||||
className="px-2 py-1 rounded text-xs font-semibold uppercase flex-shrink-0"
|
||||
style={{
|
||||
@@ -135,7 +148,9 @@ function ActionItemCard({
|
||||
{action.urgency || 'normal'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{action.subtitle || ''}</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{action.subtitle_i18n ? tDashboard(action.subtitle_i18n.key, action.subtitle_i18n.params) : (action.subtitle || '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +167,7 @@ function ActionItemCard({
|
||||
{/* Reasoning (always visible) */}
|
||||
<div className="rounded-md p-3 mb-3" style={{ backgroundColor: 'var(--bg-primary)' }}>
|
||||
<p className="text-sm font-medium mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{t('jtbd.action_queue.why_needed')}
|
||||
{tReasoning('jtbd.action_queue.why_needed')}
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{reasoning}</p>
|
||||
</div>
|
||||
@@ -166,7 +181,7 @@ function ActionItemCard({
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
<span className="font-medium">{t('jtbd.action_queue.what_if_not')}</span>
|
||||
<span className="font-medium">{tReasoning('jtbd.action_queue.what_if_not')}</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
@@ -178,11 +193,6 @@ function ActionItemCard({
|
||||
}}
|
||||
>
|
||||
<p className="text-sm" style={{ color: 'var(--color-warning-900)' }}>{consequence}</p>
|
||||
{severity && (
|
||||
<span className="text-xs font-semibold mt-1 block" style={{ color: 'var(--color-warning-900)' }}>
|
||||
{severity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -343,7 +353,7 @@ function ActionItemCard({
|
||||
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{t('jtbd.action_queue.estimated_time')}: {action.estimatedTimeMinutes || 5} min
|
||||
{tReasoning('jtbd.action_queue.estimated_time')}: {action.estimatedTimeMinutes || 5} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -483,7 +493,7 @@ function ActionItemCard({
|
||||
{button.action === 'reject' && <X className="w-4 h-4" />}
|
||||
{button.action === 'view_details' && <Eye className="w-4 h-4" />}
|
||||
{button.action === 'modify' && <Edit className="w-4 h-4" />}
|
||||
{button.label}
|
||||
{tDashboard(button.label_i18n.key, button.label_i18n.params)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -502,7 +512,7 @@ export function ActionQueueCard({
|
||||
tenantId,
|
||||
}: ActionQueueCardProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const { t } = useTranslation('reasoning');
|
||||
const { t: tReasoning } = useTranslation('reasoning');
|
||||
|
||||
if (loading || !actionQueue) {
|
||||
return (
|
||||
@@ -527,9 +537,9 @@ export function ActionQueueCard({
|
||||
>
|
||||
<CheckCircle2 className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--color-success-600)' }} />
|
||||
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}>
|
||||
{t('jtbd.action_queue.all_caught_up')}
|
||||
{tReasoning('jtbd.action_queue.all_caught_up')}
|
||||
</h3>
|
||||
<p style={{ color: 'var(--color-success-700)' }}>{t('jtbd.action_queue.no_actions')}</p>
|
||||
<p style={{ color: 'var(--color-success-700)' }}>{tReasoning('jtbd.action_queue.no_actions')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -540,7 +550,7 @@ export function ActionQueueCard({
|
||||
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('jtbd.action_queue.title')}</h2>
|
||||
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{tReasoning('jtbd.action_queue.title')}</h2>
|
||||
{(actionQueue.totalActions || 0) > 3 && (
|
||||
<span
|
||||
className="px-3 py-1 rounded-full text-sm font-semibold"
|
||||
@@ -549,7 +559,7 @@ export function ActionQueueCard({
|
||||
color: 'var(--color-error-800)',
|
||||
}}
|
||||
>
|
||||
{actionQueue.totalActions || 0} {t('jtbd.action_queue.total')}
|
||||
{actionQueue.totalActions || 0} {tReasoning('jtbd.action_queue.total')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -565,7 +575,7 @@ export function ActionQueueCard({
|
||||
color: 'var(--color-error-800)',
|
||||
}}
|
||||
>
|
||||
{actionQueue.criticalCount || 0} {t('jtbd.action_queue.critical')}
|
||||
{actionQueue.criticalCount || 0} {tReasoning('jtbd.action_queue.critical')}
|
||||
</span>
|
||||
)}
|
||||
{(actionQueue.importantCount || 0) > 0 && (
|
||||
@@ -576,7 +586,7 @@ export function ActionQueueCard({
|
||||
color: 'var(--color-warning-800)',
|
||||
}}
|
||||
>
|
||||
{actionQueue.importantCount || 0} {t('jtbd.action_queue.important')}
|
||||
{actionQueue.importantCount || 0} {tReasoning('jtbd.action_queue.important')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -608,8 +618,8 @@ export function ActionQueueCard({
|
||||
}}
|
||||
>
|
||||
{showAll
|
||||
? t('jtbd.action_queue.show_less')
|
||||
: t('jtbd.action_queue.show_more', { count: (actionQueue.totalActions || 3) - 3 })}
|
||||
? tReasoning('jtbd.action_queue.show_less')
|
||||
: tReasoning('jtbd.action_queue.show_more', { count: (actionQueue.totalActions || 3) - 3 })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import React from 'react';
|
||||
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw } from 'lucide-react';
|
||||
import { BakeryHealthStatus } from '../../api/hooks/newDashboard';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { es, eu, enUS } from 'date-fns/locale';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HealthStatusCardProps {
|
||||
@@ -50,7 +51,10 @@ const iconMap = {
|
||||
};
|
||||
|
||||
export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProps) {
|
||||
const { t } = useTranslation('reasoning');
|
||||
const { t, i18n } = useTranslation('reasoning');
|
||||
|
||||
// Get date-fns locale based on current language
|
||||
const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS;
|
||||
|
||||
if (loading || !healthStatus) {
|
||||
return (
|
||||
@@ -81,7 +85,7 @@ export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProp
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl md:text-2xl font-bold mb-2" style={{ color: config.textColor }}>
|
||||
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key
|
||||
? t(healthStatus.headline.key.replace('.', ':'), healthStatus.headline.params || {})
|
||||
? t(healthStatus.headline.key, healthStatus.headline.params || {})
|
||||
: healthStatus.headline || t(`jtbd.health_status.${status}`)}
|
||||
</h2>
|
||||
|
||||
@@ -93,6 +97,7 @@ export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProp
|
||||
{healthStatus.lastOrchestrationRun
|
||||
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
|
||||
addSuffix: true,
|
||||
locale: dateLocale,
|
||||
})
|
||||
: t('jtbd.health_status.never')}
|
||||
</span>
|
||||
@@ -104,7 +109,7 @@ export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProp
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>
|
||||
{t('jtbd.health_status.next_check')}:{' '}
|
||||
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Insights } from '../../api/hooks/newDashboard';
|
||||
|
||||
interface InsightsGridProps {
|
||||
@@ -38,18 +39,33 @@ const colorConfig = {
|
||||
};
|
||||
|
||||
function InsightCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
color,
|
||||
i18n,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
color: 'green' | 'amber' | 'red';
|
||||
i18n: {
|
||||
label: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
value: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
};
|
||||
detail: {
|
||||
key: string;
|
||||
params?: Record<string, any>;
|
||||
} | null;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const config = colorConfig[color];
|
||||
|
||||
// Translate using i18n keys
|
||||
const displayLabel = t(i18n.label.key, i18n.label.params);
|
||||
const displayValue = t(i18n.value.key, i18n.value.params);
|
||||
const displayDetail = i18n.detail ? t(i18n.detail.key, i18n.detail.params) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-2 rounded-xl p-4 md:p-6 transition-all duration-200 hover:shadow-lg cursor-pointer"
|
||||
@@ -59,13 +75,13 @@ function InsightCard({
|
||||
}}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="text-sm md:text-base font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{label}</div>
|
||||
<div className="text-sm md:text-base font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{displayLabel}</div>
|
||||
|
||||
{/* Value */}
|
||||
<div className="text-xl md:text-2xl font-bold mb-1" style={{ color: config.textColor }}>{value}</div>
|
||||
<div className="text-xl md:text-2xl font-bold mb-1" style={{ color: config.textColor }}>{displayValue}</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="text-sm font-medium" style={{ color: config.detailColor }}>{detail}</div>
|
||||
<div className="text-sm font-medium" style={{ color: config.detailColor }}>{displayDetail}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,28 +109,20 @@ export function InsightsGrid({ insights, loading }: InsightsGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<InsightCard
|
||||
label={insights.savings?.label || 'Savings'}
|
||||
value={insights.savings?.value || '€0'}
|
||||
detail={insights.savings?.detail || 'This week'}
|
||||
color={insights.savings?.color || 'green'}
|
||||
color={insights.savings.color}
|
||||
i18n={insights.savings.i18n}
|
||||
/>
|
||||
<InsightCard
|
||||
label={insights.inventory?.label || 'Inventory'}
|
||||
value={insights.inventory?.value || '0%'}
|
||||
detail={insights.inventory?.detail || 'Status'}
|
||||
color={insights.inventory?.color || 'green'}
|
||||
color={insights.inventory.color}
|
||||
i18n={insights.inventory.i18n}
|
||||
/>
|
||||
<InsightCard
|
||||
label={insights.waste?.label || 'Waste'}
|
||||
value={insights.waste?.value || '0%'}
|
||||
detail={insights.waste?.detail || 'Reduction'}
|
||||
color={insights.waste?.color || 'green'}
|
||||
color={insights.waste.color}
|
||||
i18n={insights.waste.i18n}
|
||||
/>
|
||||
<InsightCard
|
||||
label={insights.deliveries?.label || 'Deliveries'}
|
||||
value={insights.deliveries?.value || '0%'}
|
||||
detail={insights.deliveries?.detail || 'On-time'}
|
||||
color={insights.deliveries?.color || 'green'}
|
||||
color={insights.deliveries.color}
|
||||
i18n={insights.deliveries.i18n}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,16 +38,20 @@ function TimelineItemCard({
|
||||
}) {
|
||||
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || 'var(--text-tertiary)';
|
||||
const { formatBatchAction } = useReasoningFormatter();
|
||||
const { t } = useTranslation('reasoning');
|
||||
const { t } = useTranslation(['reasoning', 'dashboard']);
|
||||
|
||||
// Translate reasoning_data (or fallback to deprecated text field)
|
||||
// Translate reasoning_data (or use new reasoning_i18n or fallback to deprecated text field)
|
||||
// Memoize to prevent undefined values from being created on each render
|
||||
const { reasoning } = useMemo(() => {
|
||||
if (item.reasoning_data) {
|
||||
if (item.reasoning_i18n) {
|
||||
// Use new i18n structure if available
|
||||
const { key, params } = item.reasoning_i18n;
|
||||
return { reasoning: t(key, params, { defaultValue: item.reasoning || '' }) };
|
||||
} else if (item.reasoning_data) {
|
||||
return formatBatchAction(item.reasoning_data);
|
||||
}
|
||||
return { reasoning: item.reasoning || '' };
|
||||
}, [item.reasoning_data, item.reasoning, formatBatchAction]);
|
||||
}, [item.reasoning_i18n, item.reasoning_data, item.reasoning, formatBatchAction, t]);
|
||||
|
||||
const startTime = item.plannedStartTime
|
||||
? new Date(item.plannedStartTime).toLocaleTimeString('en-US', {
|
||||
@@ -97,7 +101,9 @@ function TimelineItemCard({
|
||||
{/* Status and Progress */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{item.statusText || 'Status'}</span>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{item.status_i18n ? t(item.status_i18n.key, item.status_i18n.params) : item.statusText || 'Status'}
|
||||
</span>
|
||||
{item.status === 'IN_PROGRESS' && (
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>{item.progress || 0}%</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, Trash2, FileText, Clock } from 'lucide-react';
|
||||
|
||||
interface RecipeInstructionsEditorProps {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}
|
||||
|
||||
export const RecipeInstructionsEditor: React.FC<RecipeInstructionsEditorProps> = ({ value, onChange }) => {
|
||||
// Initialize with empty steps array if value is empty/null
|
||||
const initialSteps = useMemo(() => {
|
||||
if (!value) {
|
||||
return [{ step: 1, title: '', description: '', duration_minutes: '' }];
|
||||
}
|
||||
|
||||
// Handle structured format with steps
|
||||
if (value && value.steps && Array.isArray(value.steps)) {
|
||||
return value.steps.map((step: any, index: number) => ({
|
||||
step: step.step || index + 1,
|
||||
title: step.title || '',
|
||||
description: step.description || '',
|
||||
duration_minutes: step.duration_minutes || ''
|
||||
}));
|
||||
}
|
||||
|
||||
// Handle array format
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item: any, index: number) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return {
|
||||
step: item.step || index + 1,
|
||||
title: item.title || '',
|
||||
description: item.description || '',
|
||||
duration_minutes: item.duration_minutes || ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
step: index + 1,
|
||||
title: '',
|
||||
description: typeof item === 'string' ? item : '',
|
||||
duration_minutes: ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// For any other format, start with one empty step
|
||||
return [{ step: 1, title: '', description: '', duration_minutes: '' }];
|
||||
}, [value]);
|
||||
|
||||
const [steps, setSteps] = useState<any[]>(initialSteps);
|
||||
|
||||
// Update parent when steps change
|
||||
useEffect(() => {
|
||||
// Format as structured object with steps array
|
||||
const formattedSteps = steps.map((step, index) => ({
|
||||
...step,
|
||||
step: index + 1 // Ensure steps are numbered sequentially
|
||||
}));
|
||||
|
||||
onChange({
|
||||
steps: formattedSteps
|
||||
});
|
||||
}, [steps, onChange]);
|
||||
|
||||
const addStep = () => {
|
||||
const newStepNumber = steps.length + 1;
|
||||
setSteps([...steps, { step: newStepNumber, title: '', description: '', duration_minutes: '' }]);
|
||||
};
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
if (steps.length <= 1) return; // Don't remove the last step
|
||||
const newSteps = steps.filter((_, i) => i !== index);
|
||||
// Renumber the steps after removal
|
||||
const renumberedSteps = newSteps.map((step, i) => ({
|
||||
...step,
|
||||
step: i + 1
|
||||
}));
|
||||
setSteps(renumberedSteps);
|
||||
};
|
||||
|
||||
const updateStep = (index: number, field: string, newValue: string | number) => {
|
||||
const newSteps = [...steps];
|
||||
newSteps[index] = { ...newSteps[index], [field]: newValue };
|
||||
setSteps(newSteps);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">Pasos de Preparación</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStep}
|
||||
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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar Paso
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={`step-editor-${step.step || index}`}
|
||||
className="p-4 border-2 border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 hover:border-[var(--color-primary)]/30 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-sm font-bold text-[var(--color-primary)]">
|
||||
{step.step || index + 1}
|
||||
</div>
|
||||
<h5 className="font-medium text-[var(--text-primary)]">Paso {step.step || index + 1}</h5>
|
||||
</div>
|
||||
{steps.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(index)}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950 rounded transition-colors"
|
||||
title="Eliminar paso"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Step Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Título del paso
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.title || ''}
|
||||
onChange={(e) => updateStep(index, 'title', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="Ej: Amasado, Fermentación, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Descripción
|
||||
</label>
|
||||
<textarea
|
||||
value={step.description || ''}
|
||||
onChange={(e) => updateStep(index, 'description', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="Descripción detallada del paso..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Duración (minutos)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={step.duration_minutes || ''}
|
||||
onChange={(e) => updateStep(index, 'duration_minutes', e.target.value ? parseInt(e.target.value) : '')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{steps.length === 0 && (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)] bg-[var(--bg-secondary)]/30 rounded-lg border-2 border-dashed border-[var(--border-secondary)]">
|
||||
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No hay pasos de preparación. Haz clic en "Agregar Paso" para comenzar.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeInstructionsEditor;
|
||||
@@ -0,0 +1,309 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, CheckCircle, Clock, AlertTriangle, Settings } from 'lucide-react';
|
||||
|
||||
interface QualityControlEditorProps {
|
||||
value: any; // The quality control configuration
|
||||
onChange: (value: any) => void;
|
||||
allTemplates: Array<{ id: string; name: string; category: string }>; // Available templates
|
||||
}
|
||||
|
||||
export interface RecipeQualityConfiguration {
|
||||
stages: {
|
||||
[key: string]: {
|
||||
template_ids: string[];
|
||||
is_required?: boolean;
|
||||
blocking?: boolean;
|
||||
min_checks_passed?: number;
|
||||
};
|
||||
};
|
||||
overall_quality_threshold?: number;
|
||||
auto_create_quality_checks?: boolean;
|
||||
quality_manager_approval_required?: boolean;
|
||||
critical_stage_blocking?: boolean;
|
||||
}
|
||||
|
||||
export const RecipeQualityControlEditor: React.FC<QualityControlEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
allTemplates
|
||||
}) => {
|
||||
// Initialize with default structure if no value
|
||||
const initialConfig: RecipeQualityConfiguration = value || {
|
||||
stages: {
|
||||
MIXING: { template_ids: [], is_required: false, blocking: false },
|
||||
PROOFING: { template_ids: [], is_required: false, blocking: false },
|
||||
SHAPING: { template_ids: [], is_required: false, blocking: false },
|
||||
BAKING: { template_ids: [], is_required: false, blocking: false },
|
||||
COOLING: { template_ids: [], is_required: false, blocking: false },
|
||||
PACKAGING: { template_ids: [], is_required: false, blocking: false },
|
||||
FINISHING: { template_ids: [], is_required: false, blocking: false },
|
||||
},
|
||||
overall_quality_threshold: 70,
|
||||
auto_create_quality_checks: false,
|
||||
quality_manager_approval_required: false,
|
||||
critical_stage_blocking: false
|
||||
};
|
||||
|
||||
const [config, setConfig] = useState<RecipeQualityConfiguration>(initialConfig);
|
||||
|
||||
// Update parent when config changes
|
||||
useEffect(() => {
|
||||
onChange(config);
|
||||
}, [config, onChange]);
|
||||
|
||||
const stageOptions = [
|
||||
{ value: 'MIXING', label: 'Mezclado', icon: Settings },
|
||||
{ value: 'PROOFING', label: 'Fermentación', icon: Clock },
|
||||
{ value: 'SHAPING', label: 'Formado', icon: Settings },
|
||||
{ value: 'BAKING', label: 'Horneado', icon: AlertTriangle },
|
||||
{ value: 'COOLING', label: 'Enfriado', icon: Clock },
|
||||
{ value: 'PACKAGING', label: 'Empaquetado', icon: CheckCircle },
|
||||
{ value: 'FINISHING', label: 'Acabado', icon: CheckCircle },
|
||||
];
|
||||
|
||||
const addTemplateToStage = (stage: string, templateId: string) => {
|
||||
setConfig(prev => {
|
||||
const newConfig = { ...prev };
|
||||
const stageConfig = newConfig.stages[stage] || { template_ids: [], is_required: false, blocking: false };
|
||||
|
||||
if (!stageConfig.template_ids.includes(templateId)) {
|
||||
stageConfig.template_ids = [...stageConfig.template_ids, templateId];
|
||||
newConfig.stages = { ...newConfig.stages, [stage]: stageConfig };
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
};
|
||||
|
||||
const removeTemplateFromStage = (stage: string, templateId: string) => {
|
||||
setConfig(prev => {
|
||||
const newConfig = { ...prev };
|
||||
const stageConfig = newConfig.stages[stage] || { template_ids: [], is_required: false, blocking: false };
|
||||
|
||||
stageConfig.template_ids = stageConfig.template_ids.filter(id => id !== templateId);
|
||||
newConfig.stages = { ...newConfig.stages, [stage]: stageConfig };
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStageSetting = (stage: string, setting: 'is_required' | 'blocking', value: boolean) => {
|
||||
setConfig(prev => {
|
||||
const newConfig = { ...prev };
|
||||
const existingStage = newConfig.stages[stage] || { template_ids: [], is_required: false, blocking: false };
|
||||
|
||||
newConfig.stages[stage] = {
|
||||
...existingStage,
|
||||
[setting]: value
|
||||
};
|
||||
|
||||
return newConfig;
|
||||
});
|
||||
};
|
||||
|
||||
const updateGlobalSetting = (setting: keyof RecipeQualityConfiguration, value: any) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[setting]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Global Settings */}
|
||||
<div className="p-4 rounded-lg border border-[var(--border-secondary)] bg-[var(--bg-secondary)]/30">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Configuración General
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Umbral de calidad general</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Porcentaje mínimo para calidad aprobada</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={config.overall_quality_threshold || 70}
|
||||
onChange={(e) => updateGlobalSetting('overall_quality_threshold', parseInt(e.target.value) || 0)}
|
||||
className="w-20 px-2 py-1 border border-[var(--border-secondary)] rounded text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
<span className="text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-[var(--border-secondary)]">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Auto-crear controles</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Crear controles automáticamente</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_create_quality_checks || false}
|
||||
onChange={(e) => updateGlobalSetting('auto_create_quality_checks', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-[var(--border-secondary)] peer-focus:outline-none 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 className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Aprobación requerida</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Requiere aprobación de responsable</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.quality_manager_approval_required || false}
|
||||
onChange={(e) => updateGlobalSetting('quality_manager_approval_required', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-[var(--border-secondary)] peer-focus:outline-none 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 className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--text-primary)]">Etapas críticas bloqueantes</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Bloquear producción en fallo crítico</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.critical_stage_blocking || false}
|
||||
onChange={(e) => updateGlobalSetting('critical_stage_blocking', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-[var(--border-secondary)] peer-focus:outline-none 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>
|
||||
</div>
|
||||
|
||||
{/* Stage Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Configuración por Etapas</h4>
|
||||
|
||||
{stageOptions.map(({ value: stageValue, label, icon: Icon }) => {
|
||||
const stageConfig = config.stages[stageValue] || { template_ids: [], is_required: false, blocking: false };
|
||||
const availableTemplates = allTemplates.filter(template =>
|
||||
!stageConfig.template_ids.includes(template.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stageValue}
|
||||
className="p-4 rounded-lg border border-[var(--border-secondary)] bg-[var(--bg-secondary)]/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className="w-4 h-4 text-[var(--color-primary)]" />
|
||||
<h5 className="font-medium text-[var(--text-primary)]">{label}</h5>
|
||||
</div>
|
||||
|
||||
{/* Add Template Dropdown */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Añadir Plantilla de Control
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="flex-1 px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
addTemplateToStage(stageValue, e.target.value);
|
||||
e.target.value = ''; // Reset selection
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar plantilla...</option>
|
||||
{availableTemplates.map(template => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name} ({template.category})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Templates */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Plantillas Asignadas
|
||||
</label>
|
||||
{stageConfig.template_ids.length === 0 ? (
|
||||
<div className="text-sm text-[var(--text-tertiary)] italic py-2">
|
||||
No hay plantillas asignadas a esta etapa
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stageConfig.template_ids.map(templateId => {
|
||||
const template = allTemplates.find(t => t.id === templateId);
|
||||
return template ? (
|
||||
<div
|
||||
key={templateId}
|
||||
className="flex items-center justify-between p-2 bg-[var(--bg-primary)]/70 border border-[var(--border-secondary)] rounded-md"
|
||||
>
|
||||
<span className="text-sm">{template.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTemplateFromStage(stageValue, templateId)}
|
||||
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage Settings */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-[var(--border-secondary)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-primary)]">Requerido</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Obligatorio para esta etapa</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stageConfig.is_required || false}
|
||||
onChange={(e) => toggleStageSetting(stageValue, 'is_required', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-[var(--border-secondary)] peer-focus:outline-none 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-4 after:w-4 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-primary)]">Bloqueante</label>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Bloquea la producción si falla</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stageConfig.blocking || false}
|
||||
onChange={(e) => toggleStageSetting(stageValue, 'blocking', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-[var(--border-secondary)] peer-focus:outline-none 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-4 after:w-4 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeQualityControlEditor;
|
||||
1190
frontend/src/components/domain/recipes/RecipeViewEditModal.tsx
Normal file
1190
frontend/src/components/domain/recipes/RecipeViewEditModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
|
||||
export { DeleteRecipeModal } from './DeleteRecipeModal';
|
||||
export { DeleteRecipeModal } from './DeleteRecipeModal';
|
||||
export { default as RecipeViewEditModal } from './RecipeViewEditModal';
|
||||
export { default as RecipeInstructionsEditor } from './RecipeInstructionsEditor';
|
||||
export { default as RecipeQualityControlEditor } from './RecipeQualityControlEditor';
|
||||
@@ -2,11 +2,174 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
|
||||
import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection';
|
||||
import { KeyValueEditor } from '../../../ui/KeyValueEditor/KeyValueEditor';
|
||||
import Tooltip from '../../../ui/Tooltip/Tooltip';
|
||||
import { Info } from 'lucide-react';
|
||||
import {
|
||||
Info,
|
||||
Eye,
|
||||
Ruler,
|
||||
Thermometer,
|
||||
Weight,
|
||||
CheckSquare,
|
||||
Clock,
|
||||
ClipboardList
|
||||
} from 'lucide-react';
|
||||
|
||||
// Single comprehensive step with all fields
|
||||
const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
||||
// STEP 1: Quality Check Type Selection
|
||||
const QualityCheckTypeStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
||||
const data = dataRef?.current || {};
|
||||
const { t } = useTranslation('wizards');
|
||||
|
||||
const handleTypeSelect = (type: string) => {
|
||||
onDataChange?.({ ...data, checkType: type });
|
||||
};
|
||||
|
||||
const handleFieldChange = (field: string, value: any) => {
|
||||
onDataChange?.({ ...data, [field]: value });
|
||||
};
|
||||
|
||||
const checkTypes = [
|
||||
{
|
||||
value: 'visual',
|
||||
icon: Eye,
|
||||
titleKey: 'qualityTemplate.checkTypes.visual',
|
||||
descriptionKey: 'qualityTemplate.checkTypeDescriptions.visual'
|
||||
},
|
||||
{
|
||||
value: 'measurement',
|
||||
icon: Ruler,
|
||||
titleKey: 'qualityTemplate.checkTypes.measurement',
|
||||
descriptionKey: 'qualityTemplate.checkTypeDescriptions.measurement'
|
||||
},
|
||||
{
|
||||
value: 'temperature',
|
||||
icon: Thermometer,
|
||||
titleKey: 'qualityTemplate.checkTypes.temperature',
|
||||
descriptionKey: 'qualityTemplate.checkTypeDescriptions.temperature'
|
||||
},
|
||||
{
|
||||
value: 'weight',
|
||||
icon: Weight,
|
||||
titleKey: 'qualityTemplate.checkTypes.weight',
|
||||
descriptionKey: 'qualityTemplate.checkTypeDescriptions.weight'
|
||||
},
|
||||
{
|
||||
value: 'boolean',
|
||||
icon: CheckSquare,
|
||||
titleKey: 'qualityTemplate.checkTypes.boolean',
|
||||
descriptionKey: 'qualityTemplate.checkTypeDescriptions.boolean'
|
||||
},
|
||||
{
|
||||
value: 'timing',
|
||||
icon: Clock,
|
||||
titleKey: 'qualityTemplate.checkTypes.timing',
|
||||
descriptionKey: 'qualityTemplate.checkTypeDescriptions.timing'
|
||||
},
|
||||
{
|
||||
value: 'checklist',
|
||||
icon: ClipboardList,
|
||||
titleKey: 'qualityTemplate.checkTypes.checklist',
|
||||
descriptionKey: 'qualityTemplate.checkTypeDescriptions.checklist'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{t('qualityTemplate.selectCheckType')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.selectCheckTypeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Check Type Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{checkTypes.map((type) => {
|
||||
const IconComponent = type.icon;
|
||||
const isSelected = data.checkType === type.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => handleTypeSelect(type.value)}
|
||||
className={`p-5 border-2 rounded-lg transition-all text-left ${
|
||||
isSelected
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5 shadow-md'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`p-3 rounded-lg ${
|
||||
isSelected
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-[var(--text-primary)] mb-1">
|
||||
{t(type.titleKey)}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t(type.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<AdvancedOptionsSection
|
||||
title={t('qualityTemplate.sections.additionalIdentifiers')}
|
||||
description={t('qualityTemplate.sections.additionalIdentifiersDescription')}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Template Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.templateCode')}
|
||||
<Tooltip content={t('qualityTemplate.fields.templateCodeTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.templateCode || ''}
|
||||
onChange={(e) => handleFieldChange('templateCode', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.templateCodePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.category')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.category || ''}
|
||||
onChange={(e) => handleFieldChange('category', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.categoryPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionsSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// STEP 2: Essential Configuration
|
||||
const EssentialConfigurationStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
||||
const data = dataRef?.current || {};
|
||||
const { t } = useTranslation('wizards');
|
||||
|
||||
@@ -14,51 +177,69 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
onDataChange?.({ ...data, [field]: value });
|
||||
};
|
||||
|
||||
const handleStageToggle = (stage: string) => {
|
||||
const currentStages = data.applicableStages || [];
|
||||
const newStages = currentStages.includes(stage)
|
||||
? currentStages.filter((s: string) => s !== stage)
|
||||
: [...currentStages, stage];
|
||||
handleFieldChange('applicableStages', newStages);
|
||||
};
|
||||
|
||||
const isMeasurementType = ['measurement', 'temperature', 'weight'].includes(data.checkType);
|
||||
|
||||
const processStages = [
|
||||
'mixing',
|
||||
'proofing',
|
||||
'shaping',
|
||||
'baking',
|
||||
'cooling',
|
||||
'packaging',
|
||||
'finishing'
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{t('qualityTemplate.templateDetails')}
|
||||
{t('qualityTemplate.essentialConfiguration')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.fillRequiredInfo')}
|
||||
{t('qualityTemplate.essentialConfigurationDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
{/* Core Fields */}
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.name}
|
||||
value={data.name || ''}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.namePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.checkType')} *
|
||||
{t('qualityTemplate.fields.description')}
|
||||
</label>
|
||||
<select
|
||||
value={data.checkType}
|
||||
onChange={(e) => handleFieldChange('checkType', e.target.value)}
|
||||
<textarea
|
||||
value={data.description || ''}
|
||||
onChange={(e) => handleFieldChange('description', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="product_quality">{t('qualityTemplate.checkTypes.product_quality')}</option>
|
||||
<option value="process_hygiene">{t('qualityTemplate.checkTypes.process_hygiene')}</option>
|
||||
<option value="equipment">{t('qualityTemplate.checkTypes.equipment')}</option>
|
||||
<option value="safety">{t('qualityTemplate.checkTypes.safety')}</option>
|
||||
<option value="cleaning">{t('qualityTemplate.checkTypes.cleaning')}</option>
|
||||
<option value="temperature">{t('qualityTemplate.checkTypes.temperature')}</option>
|
||||
<option value="documentation">{t('qualityTemplate.checkTypes.documentation')}</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Weight */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.weight')} *
|
||||
@@ -68,8 +249,8 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.weight}
|
||||
onChange={(e) => handleFieldChange('weight', e.target.value)}
|
||||
value={data.weight || 5.0}
|
||||
onChange={(e) => handleFieldChange('weight', parseFloat(e.target.value))}
|
||||
placeholder="5.0"
|
||||
step="0.1"
|
||||
min="0"
|
||||
@@ -77,82 +258,189 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Applicable Stages */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.applicableStages')}
|
||||
<Tooltip content={t('qualityTemplate.fields.applicableStagesTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{processStages.map((stage) => {
|
||||
const isSelected = (data.applicableStages || []).includes(stage);
|
||||
return (
|
||||
<button
|
||||
key={stage}
|
||||
type="button"
|
||||
onClick={() => handleStageToggle(stage)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[var(--color-primary)] text-white'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--color-primary)]/10'
|
||||
}`}
|
||||
>
|
||||
{t(`qualityTemplate.processStages.${stage}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-2">
|
||||
{t('qualityTemplate.fields.applicableStagesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="border-t border-[var(--border-primary)] pt-4">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-3">{t('qualityTemplate.sections.basicInformation')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Measurement-Specific Fields */}
|
||||
{isMeasurementType && (
|
||||
<div className="border-t border-[var(--border-primary)] pt-4">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
{t('qualityTemplate.sections.measurementSpecifications')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Min Value */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.minValue')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.minValue || ''}
|
||||
onChange={(e) => handleFieldChange('minValue', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="0"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Value */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.maxValue')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.maxValue || ''}
|
||||
onChange={(e) => handleFieldChange('maxValue', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="100"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Target Value */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.targetValue')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.targetValue || ''}
|
||||
onChange={(e) => handleFieldChange('targetValue', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="50"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unit */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.unit')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.unit || ''}
|
||||
onChange={(e) => handleFieldChange('unit', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.unitPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tolerance Percentage */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.tolerancePercentage')}
|
||||
<Tooltip content={t('qualityTemplate.fields.toleranceTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.tolerancePercentage || ''}
|
||||
onChange={(e) => handleFieldChange('tolerancePercentage', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="5.0"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Options */}
|
||||
<AdvancedOptionsSection
|
||||
title={t('qualityTemplate.sections.additionalDetails')}
|
||||
description={t('qualityTemplate.sections.additionalDetailsDescription')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Instructions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.templateCode')} ({t('common.optional')})
|
||||
<Tooltip content={t('qualityTemplate.fields.templateCodeTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.templateCode}
|
||||
onChange={(e) => handleFieldChange('templateCode', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.templateCodePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.version')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.version}
|
||||
onChange={(e) => handleFieldChange('version', e.target.value)}
|
||||
placeholder="1.0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.description')}
|
||||
{t('qualityTemplate.fields.instructions')}
|
||||
</label>
|
||||
<textarea
|
||||
value={data.description}
|
||||
onChange={(e) => handleFieldChange('description', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.descriptionPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.fields.applicableStages')}
|
||||
<Tooltip content={t('qualityTemplate.fields.applicableStagesTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.applicableStages}
|
||||
onChange={(e) => handleFieldChange('applicableStages', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.applicablePlaceholder')}
|
||||
value={data.instructions || ''}
|
||||
onChange={(e) => handleFieldChange('instructions', e.target.value)}
|
||||
placeholder={t('qualityTemplate.fields.instructionsPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionsSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// STEP 3: Quality Criteria & Settings
|
||||
const QualityCriteriaSettingsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
||||
const data = dataRef?.current || {};
|
||||
const { t } = useTranslation('wizards');
|
||||
|
||||
const handleFieldChange = (field: string, value: any) => {
|
||||
onDataChange?.({ ...data, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{t('qualityTemplate.criteriaAndSettings')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.criteriaAndSettingsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scoring Configuration */}
|
||||
<div className="border-t border-[var(--border-primary)] pt-4">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-3">{t('qualityTemplate.sections.scoringConfiguration')}</h4>
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-[var(--text-primary)]">
|
||||
{t('qualityTemplate.sections.scoringConfiguration')}
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Scoring Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.scoringMethods.scoringMethod')}
|
||||
{t('qualityTemplate.fields.scoringMethod')}
|
||||
</label>
|
||||
<select
|
||||
value={data.scoringMethod}
|
||||
value={data.scoringMethod || 'weighted_average'}
|
||||
onChange={(e) => handleFieldChange('scoringMethod', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
@@ -163,17 +451,18 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Pass Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.passThresholdPercent')}
|
||||
<Tooltip content={t('tooltips.passThreshold')}>
|
||||
{t('qualityTemplate.fields.passThreshold')}
|
||||
<Tooltip content={t('qualityTemplate.fields.passThresholdTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.passThreshold}
|
||||
onChange={(e) => handleFieldChange('passThreshold', e.target.value)}
|
||||
value={data.passThreshold || 70}
|
||||
onChange={(e) => handleFieldChange('passThreshold', parseFloat(e.target.value))}
|
||||
placeholder="70.0"
|
||||
step="0.1"
|
||||
min="0"
|
||||
@@ -182,32 +471,34 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frequency Days */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.frequencyDays')}
|
||||
<Tooltip content={t('tooltips.frequencyDays')}>
|
||||
{t('qualityTemplate.fields.frequencyDays')}
|
||||
<Tooltip content={t('qualityTemplate.fields.frequencyDaysTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.frequencyDays}
|
||||
onChange={(e) => handleFieldChange('frequencyDays', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.frequencyPlaceholder')}
|
||||
value={data.frequencyDays || ''}
|
||||
onChange={(e) => handleFieldChange('frequencyDays', e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder={t('qualityTemplate.fields.frequencyDaysPlaceholder')}
|
||||
min="1"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Is Required */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.isRequired}
|
||||
checked={data.isRequired || false}
|
||||
onChange={(e) => handleFieldChange('isRequired', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.advancedFields.requiredCheck')}
|
||||
{t('qualityTemplate.fields.requiredCheck')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,15 +517,15 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.checkPointsJsonArray')}
|
||||
<Tooltip content={t('qualityTemplate.advancedFields.checkPointsTooltip')}>
|
||||
{t('qualityTemplate.fields.checkPointsJsonArray')}
|
||||
<Tooltip content={t('qualityTemplate.fields.checkPointsTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<textarea
|
||||
value={data.checkPoints}
|
||||
value={data.checkPoints || ''}
|
||||
onChange={(e) => handleFieldChange('checkPoints', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.checkPointsPlaceholder')}
|
||||
placeholder={t('qualityTemplate.fields.checkPointsPlaceholder')}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs"
|
||||
/>
|
||||
@@ -242,12 +533,12 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.acceptanceCriteria')}
|
||||
{t('qualityTemplate.fields.acceptanceCriteria')}
|
||||
</label>
|
||||
<textarea
|
||||
value={data.acceptanceCriteria}
|
||||
value={data.acceptanceCriteria || ''}
|
||||
onChange={(e) => handleFieldChange('acceptanceCriteria', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.acceptanceCriteriaPlaceholder')}
|
||||
placeholder={t('qualityTemplate.fields.acceptanceCriteriaPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
@@ -260,54 +551,58 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
<h5 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
{t('qualityTemplate.sections.advancedConfiguration')}
|
||||
</h5>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.parametersJson')}
|
||||
<Tooltip content={t('qualityTemplate.advancedFields.parametersTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<textarea
|
||||
value={data.parameters}
|
||||
onChange={(e) => handleFieldChange('parameters', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.parametersPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* Parameters */}
|
||||
<KeyValueEditor
|
||||
label={t('qualityTemplate.fields.parametersJson')}
|
||||
tooltip={t('qualityTemplate.fields.parametersTooltip')}
|
||||
value={data.parameters}
|
||||
onChange={(value) => handleFieldChange('parameters', value)}
|
||||
placeholder="{}"
|
||||
suggestions={
|
||||
data.checkType === 'temperature'
|
||||
? [
|
||||
{ key: 'temp_min', value: '75', type: 'number' },
|
||||
{ key: 'temp_max', value: '85', type: 'number' },
|
||||
{ key: 'humidity', value: '65', type: 'number' }
|
||||
]
|
||||
: data.checkType === 'weight'
|
||||
? [
|
||||
{ key: 'weight_min', value: '450', type: 'number' },
|
||||
{ key: 'weight_max', value: '550', type: 'number' },
|
||||
{ key: 'unit', value: 'g', type: 'string' }
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.thresholdsJson')}
|
||||
<Tooltip content={t('qualityTemplate.advancedFields.thresholdsTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<textarea
|
||||
value={data.thresholds}
|
||||
onChange={(e) => handleFieldChange('thresholds', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.thresholdsPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* Thresholds */}
|
||||
<KeyValueEditor
|
||||
label={t('qualityTemplate.fields.thresholdsJson')}
|
||||
tooltip={t('qualityTemplate.fields.thresholdsTooltip')}
|
||||
value={data.thresholds}
|
||||
onChange={(value) => handleFieldChange('thresholds', value)}
|
||||
placeholder="{}"
|
||||
suggestions={[
|
||||
{ key: 'critical', value: '90', type: 'number' },
|
||||
{ key: 'warning', value: '70', type: 'number' },
|
||||
{ key: 'acceptable', value: '50', type: 'number' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.scoringCriteriaJson')}
|
||||
<Tooltip content={t('qualityTemplate.advancedFields.scoringCriteriaTooltip')}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<textarea
|
||||
value={data.scoringCriteria}
|
||||
onChange={(e) => handleFieldChange('scoringCriteria', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.scoringCriteriaPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* Scoring Criteria */}
|
||||
<KeyValueEditor
|
||||
label={t('qualityTemplate.fields.scoringCriteriaJson')}
|
||||
tooltip={t('qualityTemplate.fields.scoringCriteriaTooltip')}
|
||||
value={data.scoringCriteria}
|
||||
onChange={(value) => handleFieldChange('scoringCriteria', value)}
|
||||
placeholder="{}"
|
||||
suggestions={[
|
||||
{ key: 'appearance', value: '30', type: 'number' },
|
||||
{ key: 'texture', value: '30', type: 'number' },
|
||||
{ key: 'taste', value: '40', type: 'number' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,38 +614,38 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.responsibleRole')}
|
||||
{t('qualityTemplate.fields.responsibleRole')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.responsibleRole}
|
||||
value={data.responsibleRole || ''}
|
||||
onChange={(e) => handleFieldChange('responsibleRole', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.responsibleRolePlaceholder')}
|
||||
placeholder={t('qualityTemplate.fields.responsibleRolePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.requiredEquipment')}
|
||||
{t('qualityTemplate.fields.requiredEquipment')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.requiredEquipment}
|
||||
value={data.requiredEquipment || ''}
|
||||
onChange={(e) => handleFieldChange('requiredEquipment', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.requiredEquipmentPlaceholder')}
|
||||
placeholder={t('qualityTemplate.fields.requiredEquipmentPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('qualityTemplate.advancedFields.specificConditions')}
|
||||
{t('qualityTemplate.fields.specificConditions')}
|
||||
</label>
|
||||
<textarea
|
||||
value={data.specificConditions}
|
||||
value={data.specificConditions || ''}
|
||||
onChange={(e) => handleFieldChange('specificConditions', e.target.value)}
|
||||
placeholder={t('qualityTemplate.advancedFields.specificConditionsPlaceholder')}
|
||||
placeholder={t('qualityTemplate.fields.specificConditionsPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
@@ -367,48 +662,48 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.isActive}
|
||||
checked={data.isActive !== false}
|
||||
onChange={(e) => handleFieldChange('isActive', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.advancedFields.activeTemplate')}
|
||||
{t('qualityTemplate.fields.activeTemplate')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.requiresPhoto}
|
||||
checked={data.requiresPhoto || false}
|
||||
onChange={(e) => handleFieldChange('requiresPhoto', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.advancedFields.requiresPhotoEvidence')}
|
||||
{t('qualityTemplate.fields.requiresPhotoEvidence')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.criticalControlPoint}
|
||||
onChange={(e) => handleFieldChange('criticalControlPoint', e.target.checked)}
|
||||
checked={data.isCritical || false}
|
||||
onChange={(e) => handleFieldChange('isCritical', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.advancedFields.criticalControlPoint')}
|
||||
{t('qualityTemplate.fields.criticalControlPoint')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.notifyOnFail}
|
||||
checked={data.notifyOnFail || false}
|
||||
onChange={(e) => handleFieldChange('notifyOnFail', e.target.checked)}
|
||||
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<label className="text-sm text-[var(--text-secondary)]">
|
||||
{t('qualityTemplate.advancedFields.notifyOnFailure')}
|
||||
{t('qualityTemplate.fields.notifyOnFailure')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -422,13 +717,39 @@ export const QualityTemplateWizardSteps = (
|
||||
dataRef: React.MutableRefObject<Record<string, any>>,
|
||||
setData: (data: Record<string, any>) => void
|
||||
): WizardStep[] => {
|
||||
// New architecture: return direct component references instead of arrow functions
|
||||
// dataRef and onDataChange are now passed through WizardModal props
|
||||
return [
|
||||
{
|
||||
id: 'template-details',
|
||||
title: 'qualityTemplate.advancedFields.templateDetailsTitle',
|
||||
component: QualityTemplateDetailsStep,
|
||||
id: 'check-type',
|
||||
title: 'qualityTemplate.steps.checkType',
|
||||
component: QualityCheckTypeStep,
|
||||
validate: () => {
|
||||
return !!dataRef.current.checkType;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'essential-configuration',
|
||||
title: 'qualityTemplate.steps.essentialConfiguration',
|
||||
component: EssentialConfigurationStep,
|
||||
validate: () => {
|
||||
const name = dataRef.current.name;
|
||||
const weight = dataRef.current.weight;
|
||||
return !!(
|
||||
name &&
|
||||
name.trim().length >= 1 &&
|
||||
weight !== undefined &&
|
||||
weight >= 0 &&
|
||||
weight <= 10
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'criteria-settings',
|
||||
title: 'qualityTemplate.steps.criteriaSettings',
|
||||
component: QualityCriteriaSettingsStep,
|
||||
validate: () => {
|
||||
// Optional step - all fields are optional
|
||||
return true;
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
|
||||
import { ChefHat, Package, ClipboardCheck, CheckCircle2, Loader2, Plus, X, Search } from 'lucide-react';
|
||||
import { ChefHat, Package, ClipboardCheck, CheckCircle2, Loader2, Plus, X, Search, FileText } from 'lucide-react';
|
||||
import { useTenant } from '../../../../stores/tenant.store';
|
||||
import { recipesService } from '../../../../api/services/recipes';
|
||||
import { inventoryService } from '../../../../api/services/inventory';
|
||||
import { qualityTemplateService } from '../../../../api/services/qualityTemplates';
|
||||
import { IngredientResponse } from '../../../../api/types/inventory';
|
||||
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit, RecipeQualityConfiguration, RecipeStatus } from '../../../../api/types/recipes';
|
||||
import { QualityCheckTemplateResponse } from '../../../../api/types/qualityTemplates';
|
||||
import { QualityCheckTemplate } from '../../../../api/types/qualityTemplates';
|
||||
import { showToast } from '../../../../utils/toast';
|
||||
import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection';
|
||||
import Tooltip from '../../../ui/Tooltip/Tooltip';
|
||||
import { RecipeInstructionsEditor } from '../../recipes/RecipeInstructionsEditor';
|
||||
import { RecipeQualityControlEditor } from '../../recipes/RecipeQualityControlEditor';
|
||||
|
||||
const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
||||
const { t } = useTranslation('wizards');
|
||||
@@ -51,7 +53,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('recipe.recipeDetailsDescription')}</p>
|
||||
</div>
|
||||
|
||||
{/* Required Fields */}
|
||||
{/* Essential Fields Only */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
@@ -59,7 +61,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.name}
|
||||
value={data.name || ''}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
placeholder={t('recipe.fields.namePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
@@ -72,7 +74,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
{t('recipe.fields.category')} *
|
||||
</label>
|
||||
<select
|
||||
value={data.category}
|
||||
value={data.category || 'bread'}
|
||||
onChange={(e) => handleFieldChange('category', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
@@ -95,7 +97,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
</Tooltip>
|
||||
</label>
|
||||
<select
|
||||
value={data.finishedProductId}
|
||||
value={data.finishedProductId || ''}
|
||||
onChange={(e) => handleFieldChange('finishedProductId', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
disabled={loading}
|
||||
@@ -110,12 +112,15 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
|
||||
{t('recipe.fields.yieldQuantity')} *
|
||||
<Tooltip content="How many units this recipe produces (e.g., 12 loaves)">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.yieldQuantity}
|
||||
value={data.yieldQuantity || ''}
|
||||
onChange={(e) => handleFieldChange('yieldQuantity', e.target.value)}
|
||||
placeholder="12"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
@@ -129,7 +134,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
{t('recipe.fields.yieldUnit')} *
|
||||
</label>
|
||||
<select
|
||||
value={data.yieldUnit}
|
||||
value={data.yieldUnit || 'units'}
|
||||
onChange={(e) => handleFieldChange('yieldUnit', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
@@ -144,31 +149,21 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('recipe.fields.prepTime')}
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
|
||||
{t('recipe.fields.prepTime')} (minutes)
|
||||
<Tooltip content="Total preparation time in minutes">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.prepTime}
|
||||
value={data.prepTime || ''}
|
||||
onChange={(e) => handleFieldChange('prepTime', e.target.value)}
|
||||
placeholder="60"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
{t('recipe.fields.instructions')}
|
||||
</label>
|
||||
<textarea
|
||||
value={data.instructions}
|
||||
onChange={(e) => handleFieldChange('instructions', e.target.value)}
|
||||
placeholder={t('recipe.fields.instructionsPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
@@ -456,13 +451,16 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Preparation Notes
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
|
||||
Recipe Notes & Tips
|
||||
<Tooltip content="General notes, tips, or context about this recipe (not step-by-step instructions)">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<textarea
|
||||
value={data.preparationNotes}
|
||||
onChange={(e) => handleFieldChange('preparationNotes', e.target.value)}
|
||||
placeholder="Tips and notes for preparation..."
|
||||
placeholder="e.g., 'Works best in humid conditions', 'Can be prepared a day ahead', 'Traditional family recipe'..."
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -511,12 +509,47 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
|
||||
);
|
||||
};
|
||||
|
||||
// New Step 2: Recipe Instructions
|
||||
const RecipeInstructionsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
||||
const { t } = useTranslation('wizards');
|
||||
const data = dataRef?.current || {};
|
||||
|
||||
const handleInstructionsChange = (instructions: any) => {
|
||||
onDataChange?.({ ...data, instructions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Recipe Instructions
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Add step-by-step instructions for preparing {data.name || 'this recipe'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RecipeInstructionsEditor
|
||||
value={data.instructions || null}
|
||||
onChange={handleInstructionsChange}
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
||||
<strong>Tip:</strong> Break down the recipe into clear, manageable steps. Include durations to help with production planning.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectedIngredient {
|
||||
id: string;
|
||||
ingredientId: string;
|
||||
quantity: number;
|
||||
unit: MeasurementUnit;
|
||||
notes: string;
|
||||
preparationMethod?: string;
|
||||
isOptional?: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
@@ -555,6 +588,8 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
|
||||
quantity: 0,
|
||||
unit: MeasurementUnit.GRAMS,
|
||||
notes: '',
|
||||
preparationMethod: '',
|
||||
isOptional: false,
|
||||
order: (data.ingredients || []).length + 1,
|
||||
};
|
||||
onDataChange?.({ ...data, ingredients: [...(data.ingredients || []), newIngredient] });
|
||||
@@ -576,7 +611,7 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
|
||||
});
|
||||
};
|
||||
|
||||
const filteredIngredients = ingredients.filter(ing =>
|
||||
const filteredIngredients = ingredients.filter((ing: IngredientResponse) =>
|
||||
ing.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -584,8 +619,8 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
|
||||
<div className="space-y-6">
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">{t('recipe.ingredients')}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{data.name}</p>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Recipe Ingredients</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Add ingredients for {data.name || 'this recipe'}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -609,76 +644,109 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Click "Add Ingredient" to begin</p>
|
||||
</div>
|
||||
) : (
|
||||
(data.ingredients || []).map((selectedIng) => (
|
||||
(data.ingredients || []).map((selectedIng: SelectedIngredient) => (
|
||||
<div key={selectedIng.id} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-start">
|
||||
<div className="md:col-span-5">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingredient *</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<div className="space-y-3">
|
||||
{/* Row 1: Ingredient, Quantity, Unit */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-start">
|
||||
<div className="md:col-span-6">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingredient *</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<select
|
||||
value={selectedIng.ingredientId}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'ingredientId', e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{filteredIngredients.map((ing: IngredientResponse) => (
|
||||
<option key={ing.id} value={ing.id}>
|
||||
{ing.name} {ing.category ? `(${ing.category})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Quantity *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={selectedIng.quantity || ''}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unit *</label>
|
||||
<select
|
||||
value={selectedIng.ingredientId}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'ingredientId', e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
value={selectedIng.unit}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{filteredIngredients.map(ing => (
|
||||
<option key={ing.id} value={ing.id}>
|
||||
{ing.name} {ing.category ? `(${ing.category})` : ''}
|
||||
</option>
|
||||
))}
|
||||
<option value={MeasurementUnit.GRAMS}>Grams (g)</option>
|
||||
<option value={MeasurementUnit.KILOGRAMS}>Kilograms (kg)</option>
|
||||
<option value={MeasurementUnit.MILLILITERS}>Milliliters (ml)</option>
|
||||
<option value={MeasurementUnit.LITERS}>Liters (l)</option>
|
||||
<option value={MeasurementUnit.UNITS}>Units</option>
|
||||
<option value={MeasurementUnit.PIECES}>Pieces</option>
|
||||
<option value={MeasurementUnit.CUPS}>Cups</option>
|
||||
<option value={MeasurementUnit.TABLESPOONS}>Tablespoons</option>
|
||||
<option value={MeasurementUnit.TEASPOONS}>Teaspoons</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveIngredient(selectedIng.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Remove ingredient"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Quantity *</label>
|
||||
|
||||
{/* Row 2: Preparation Method and Optional Checkbox */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-start">
|
||||
<div className="md:col-span-8">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Preparation Method
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedIng.preparationMethod || ''}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'preparationMethod', e.target.value)}
|
||||
placeholder="e.g., sifted, melted, chopped"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-4">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedIng.notes}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'notes', e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Optional Checkbox */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={selectedIng.quantity || ''}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="checkbox"
|
||||
id={`optional-${selectedIng.id}`}
|
||||
checked={selectedIng.isOptional || false}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'isOptional', e.target.checked)}
|
||||
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unit *</label>
|
||||
<select
|
||||
value={selectedIng.unit}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
>
|
||||
<option value={MeasurementUnit.GRAMS}>Grams (g)</option>
|
||||
<option value={MeasurementUnit.KILOGRAMS}>Kilograms (kg)</option>
|
||||
<option value={MeasurementUnit.MILLILITERS}>Milliliters (ml)</option>
|
||||
<option value={MeasurementUnit.LITERS}>Liters (l)</option>
|
||||
<option value={MeasurementUnit.UNITS}>Units</option>
|
||||
<option value={MeasurementUnit.PIECES}>Pieces</option>
|
||||
<option value={MeasurementUnit.CUPS}>Cups</option>
|
||||
<option value={MeasurementUnit.TABLESPOONS}>Tablespoons</option>
|
||||
<option value={MeasurementUnit.TEASPOONS}>Teaspoons</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedIng.notes}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'notes', e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveIngredient(selectedIng.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Remove ingredient"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<label htmlFor={`optional-${selectedIng.id}`} className="text-xs text-[var(--text-secondary)]">
|
||||
This ingredient is optional
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -700,11 +768,12 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
|
||||
);
|
||||
};
|
||||
|
||||
const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onComplete }) => {
|
||||
// New Step 4: Enhanced Quality Control Configuration
|
||||
const QualityControlStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onComplete }) => {
|
||||
const { t } = useTranslation('wizards');
|
||||
const data = dataRef?.current || {};
|
||||
const { currentTenant } = useTenant();
|
||||
const [templates, setTemplates] = useState<QualityCheckTemplateResponse[]>([]);
|
||||
const [templates, setTemplates] = useState<QualityCheckTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -727,12 +796,8 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTemplate = (templateId: string) => {
|
||||
const currentTemplates = data.selectedTemplates || [];
|
||||
const newTemplates = currentTemplates.includes(templateId)
|
||||
? currentTemplates.filter(id => id !== templateId)
|
||||
: [...currentTemplates, templateId];
|
||||
onDataChange?.({ ...data, selectedTemplates: newTemplates });
|
||||
const handleQualityConfigChange = (config: any) => {
|
||||
onDataChange?.({ ...data, qualityConfiguration: config });
|
||||
};
|
||||
|
||||
const handleCreateRecipe = async () => {
|
||||
@@ -750,28 +815,13 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
ingredient_notes: ing.notes || null,
|
||||
is_optional: false,
|
||||
preparation_method: ing.preparationMethod || null,
|
||||
is_optional: ing.isOptional || false,
|
||||
ingredient_order: index + 1,
|
||||
}));
|
||||
|
||||
let qualityConfig: RecipeQualityConfiguration | undefined;
|
||||
if ((data.selectedTemplates || []).length > 0) {
|
||||
qualityConfig = {
|
||||
stages: {
|
||||
production: {
|
||||
template_ids: data.selectedTemplates || [],
|
||||
required_checks: [],
|
||||
optional_checks: [],
|
||||
blocking_on_failure: true,
|
||||
min_quality_score: 7.0,
|
||||
}
|
||||
},
|
||||
overall_quality_threshold: 7.0,
|
||||
critical_stage_blocking: true,
|
||||
auto_create_quality_checks: true,
|
||||
quality_manager_approval_required: false,
|
||||
};
|
||||
}
|
||||
// Use the quality configuration from the editor if available
|
||||
const qualityConfig: RecipeQualityConfiguration | undefined = data.qualityConfiguration;
|
||||
|
||||
const recipeData: RecipeCreate = {
|
||||
name: data.name,
|
||||
@@ -800,7 +850,7 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
|
||||
is_signature_item: data.isSignatureItem || false,
|
||||
season_start_month: data.seasonStartMonth ? parseInt(data.seasonStartMonth) : null,
|
||||
season_end_month: data.seasonEndMonth ? parseInt(data.seasonEndMonth) : null,
|
||||
instructions: data.instructions ? { steps: data.instructions } : null,
|
||||
instructions: data.instructions || null,
|
||||
allergen_info: data.allergens ? data.allergens.split(',').map((a: string) => a.trim()) : null,
|
||||
dietary_tags: data.dietaryTags ? data.dietaryTags.split(',').map((t: string) => t.trim()) : null,
|
||||
ingredients: recipeIngredients,
|
||||
@@ -820,15 +870,22 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
|
||||
}
|
||||
};
|
||||
|
||||
// Format templates for the editor
|
||||
const formattedTemplates = templates.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
category: t.check_type || 'general'
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Quality Templates (Optional)
|
||||
Quality Control Configuration (Optional)
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Select quality control templates to apply to this recipe
|
||||
Configure quality control checks for each production stage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -843,67 +900,25 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
<span className="ml-3 text-[var(--text-secondary)]">Loading templates...</span>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
||||
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" />
|
||||
<p className="text-[var(--text-secondary)] mb-2">No quality templates available</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
You can create templates from the Database menu
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
||||
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" />
|
||||
<p className="text-[var(--text-secondary)] mb-2">No quality templates available</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
You can create templates from the main wizard
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => toggleTemplate(template.id)}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
|
||||
(data.selectedTemplates || []).includes(template.id)
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{template.name}</h4>
|
||||
{template.is_required && (
|
||||
<span className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded-full">
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-sm text-[var(--text-secondary)] line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--text-tertiary)]">
|
||||
<span>Type: {template.check_type}</span>
|
||||
{template.frequency_days && (
|
||||
<span>• Every {template.frequency_days} days</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(data.selectedTemplates || []).includes(template.id) && (
|
||||
<CheckCircle2 className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0 ml-3" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<RecipeQualityControlEditor
|
||||
value={data.qualityConfiguration || null}
|
||||
onChange={handleQualityConfigChange}
|
||||
allTemplates={formattedTemplates}
|
||||
/>
|
||||
|
||||
{(data.selectedTemplates || []).length > 0 && (
|
||||
<div className="p-4 bg-[var(--color-primary)]/5 rounded-lg border border-[var(--color-primary)]/20">
|
||||
<p className="text-sm text-[var(--text-primary)]">
|
||||
<strong>{(data.selectedTemplates || []).length}</strong> template(s) selected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
||||
<strong>Tip:</strong> Configure which quality checks are required at each production stage. You can make certain stages blocking to halt production if quality checks fail.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -940,18 +955,62 @@ export const RecipeWizardSteps = (dataRef: React.MutableRefObject<Record<string,
|
||||
title: 'wizards:recipe.steps.recipeDetails',
|
||||
description: 'wizards:recipe.steps.recipeDetailsDescription',
|
||||
component: RecipeDetailsStep,
|
||||
validate: () => {
|
||||
const data = dataRef.current;
|
||||
// Validate required fields
|
||||
if (!data.name || data.name.trim().length < 2) {
|
||||
return false;
|
||||
}
|
||||
if (!data.category) {
|
||||
return false;
|
||||
}
|
||||
if (!data.finishedProductId) {
|
||||
return false;
|
||||
}
|
||||
if (!data.yieldQuantity || parseFloat(data.yieldQuantity) <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (!data.yieldUnit) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recipe-instructions',
|
||||
title: 'Recipe Instructions',
|
||||
description: 'Add step-by-step preparation instructions',
|
||||
component: RecipeInstructionsStep,
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
id: 'recipe-ingredients',
|
||||
title: 'wizards:recipe.steps.ingredients',
|
||||
description: 'wizards:recipe.steps.ingredientsDescription',
|
||||
component: IngredientsStep,
|
||||
validate: () => {
|
||||
const data = dataRef.current;
|
||||
const ingredients = data.ingredients || [];
|
||||
|
||||
// Must have at least one ingredient
|
||||
if (ingredients.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each ingredient must have required fields
|
||||
return ingredients.every((ing: any) =>
|
||||
ing.ingredientId &&
|
||||
ing.quantity &&
|
||||
parseFloat(ing.quantity) > 0 &&
|
||||
ing.unit
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recipe-quality-templates',
|
||||
title: 'wizards:recipe.steps.qualityTemplates',
|
||||
description: 'wizards:recipe.steps.qualityTemplatesDescription',
|
||||
component: QualityTemplatesStep,
|
||||
id: 'recipe-quality-control',
|
||||
title: 'Quality Control',
|
||||
description: 'Configure quality checks for production stages',
|
||||
component: QualityControlStep,
|
||||
isOptional: true,
|
||||
},
|
||||
];
|
||||
|
||||
324
frontend/src/components/ui/KeyValueEditor/KeyValueEditor.tsx
Normal file
324
frontend/src/components/ui/KeyValueEditor/KeyValueEditor.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, Code } from 'lucide-react';
|
||||
import Tooltip from '../Tooltip/Tooltip';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
interface KeyValuePair {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
}
|
||||
|
||||
interface KeyValueEditorProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
value?: Record<string, any> | string; // Can accept JSON object or JSON string
|
||||
onChange?: (value: Record<string, any>) => void;
|
||||
placeholder?: string;
|
||||
suggestions?: Array<{ key: string; value: string; type?: 'string' | 'number' | 'boolean' }>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
|
||||
label,
|
||||
tooltip,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
suggestions = [],
|
||||
className = ''
|
||||
}) => {
|
||||
const { t } = useTranslation('wizards');
|
||||
const [pairs, setPairs] = useState<KeyValuePair[]>([]);
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
const [rawJson, setRawJson] = useState('');
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
// Initialize pairs from value
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setPairs([]);
|
||||
setRawJson('{}');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let jsonObj: Record<string, any>;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value.trim() === '') {
|
||||
setPairs([]);
|
||||
setRawJson('{}');
|
||||
return;
|
||||
}
|
||||
jsonObj = JSON.parse(value);
|
||||
} else {
|
||||
jsonObj = value;
|
||||
}
|
||||
|
||||
const newPairs: KeyValuePair[] = Object.entries(jsonObj).map(([key, val], index) => ({
|
||||
id: `pair-${Date.now()}-${index}`,
|
||||
key,
|
||||
value: String(val),
|
||||
type: detectType(val)
|
||||
}));
|
||||
|
||||
setPairs(newPairs);
|
||||
setRawJson(JSON.stringify(jsonObj, null, 2));
|
||||
setJsonError(null);
|
||||
} catch (error) {
|
||||
// If parsing fails, treat as empty
|
||||
setPairs([]);
|
||||
setRawJson(typeof value === 'string' ? value : '{}');
|
||||
setJsonError('Invalid JSON');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const detectType = (value: any): 'string' | 'number' | 'boolean' => {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
return 'string';
|
||||
};
|
||||
|
||||
const convertValue = (value: string, type: 'string' | 'number' | 'boolean'): any => {
|
||||
if (type === 'boolean') {
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
if (type === 'number') {
|
||||
const num = parseFloat(value);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const pairsToJson = (pairs: KeyValuePair[]): Record<string, any> => {
|
||||
const obj: Record<string, any> = {};
|
||||
pairs.forEach(pair => {
|
||||
if (pair.key.trim()) {
|
||||
obj[pair.key.trim()] = convertValue(pair.value, pair.type);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
};
|
||||
|
||||
const handleAddPair = () => {
|
||||
const newPair: KeyValuePair = {
|
||||
id: `pair-${Date.now()}`,
|
||||
key: '',
|
||||
value: '',
|
||||
type: 'string'
|
||||
};
|
||||
const newPairs = [...pairs, newPair];
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
};
|
||||
|
||||
const handleRemovePair = (id: string) => {
|
||||
const newPairs = pairs.filter(p => p.id !== id);
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
};
|
||||
|
||||
const handlePairChange = (id: string, field: 'key' | 'value' | 'type', newValue: string) => {
|
||||
const newPairs = pairs.map(pair => {
|
||||
if (pair.id === id) {
|
||||
return { ...pair, [field]: newValue };
|
||||
}
|
||||
return pair;
|
||||
});
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
};
|
||||
|
||||
const handleApplySuggestion = (suggestion: { key: string; value: string; type?: 'string' | 'number' | 'boolean' }) => {
|
||||
// Check if key already exists
|
||||
const existingPair = pairs.find(p => p.key === suggestion.key);
|
||||
if (existingPair) {
|
||||
// Update existing
|
||||
handlePairChange(existingPair.id, 'value', suggestion.value);
|
||||
if (suggestion.type) {
|
||||
handlePairChange(existingPair.id, 'type', suggestion.type);
|
||||
}
|
||||
} else {
|
||||
// Add new
|
||||
const newPair: KeyValuePair = {
|
||||
id: `pair-${Date.now()}`,
|
||||
key: suggestion.key,
|
||||
value: suggestion.value,
|
||||
type: suggestion.type || 'string'
|
||||
};
|
||||
const newPairs = [...pairs, newPair];
|
||||
setPairs(newPairs);
|
||||
onChange?.(pairsToJson(newPairs));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRawJsonChange = (jsonString: string) => {
|
||||
setRawJson(jsonString);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
setJsonError(null);
|
||||
|
||||
const newPairs: KeyValuePair[] = Object.entries(parsed).map(([key, val], index) => ({
|
||||
id: `pair-${Date.now()}-${index}`,
|
||||
key,
|
||||
value: String(val),
|
||||
type: detectType(val)
|
||||
}));
|
||||
|
||||
setPairs(newPairs);
|
||||
onChange?.(parsed);
|
||||
} catch (error) {
|
||||
setJsonError('Invalid JSON format');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)]">
|
||||
{label}
|
||||
{tooltip && (
|
||||
<Tooltip content={tooltip}>
|
||||
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRawJson(!showRawJson)}
|
||||
className="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<Code className="w-3 h-3" />
|
||||
{showRawJson ? t('keyValueEditor.showBuilder') : t('keyValueEditor.showJson')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRawJson ? (
|
||||
/* Raw JSON Editor */
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={rawJson}
|
||||
onChange={(e) => handleRawJsonChange(e.target.value)}
|
||||
placeholder={placeholder || '{}'}
|
||||
rows={6}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs ${
|
||||
jsonError ? 'border-red-500' : 'border-[var(--border-secondary)]'
|
||||
}`}
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-xs text-red-500">{jsonError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Key-Value Builder */
|
||||
<div className="space-y-2">
|
||||
{/* Suggestions */}
|
||||
{suggestions.length > 0 && pairs.length === 0 && (
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg p-3">
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-2">
|
||||
{t('keyValueEditor.suggestions')}:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleApplySuggestion(suggestion)}
|
||||
className="text-xs px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded hover:border-[var(--color-primary)] transition-colors"
|
||||
>
|
||||
{suggestion.key}: {suggestion.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key-Value Pairs */}
|
||||
{pairs.length > 0 && (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{pairs.map((pair) => (
|
||||
<div key={pair.id} className="flex items-center gap-2">
|
||||
{/* Key Input */}
|
||||
<input
|
||||
type="text"
|
||||
value={pair.key}
|
||||
onChange={(e) => handlePairChange(pair.id, 'key', e.target.value)}
|
||||
placeholder={t('keyValueEditor.keyPlaceholder')}
|
||||
className="flex-1 px-2 py-1.5 text-sm 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)]"
|
||||
/>
|
||||
|
||||
{/* Value Input */}
|
||||
{pair.type === 'boolean' ? (
|
||||
<select
|
||||
value={pair.value}
|
||||
onChange={(e) => handlePairChange(pair.id, 'value', e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 text-sm 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)]"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={pair.type === 'number' ? 'number' : 'text'}
|
||||
value={pair.value}
|
||||
onChange={(e) => handlePairChange(pair.id, 'value', e.target.value)}
|
||||
placeholder={t('keyValueEditor.valuePlaceholder')}
|
||||
step={pair.type === 'number' ? '0.01' : undefined}
|
||||
className="flex-1 px-2 py-1.5 text-sm 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)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type Selector */}
|
||||
<select
|
||||
value={pair.type}
|
||||
onChange={(e) => handlePairChange(pair.id, 'type', e.target.value)}
|
||||
className="w-24 px-2 py-1.5 text-xs border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-secondary)]"
|
||||
>
|
||||
<option value="string">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Bool</option>
|
||||
</select>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePair(pair.id)}
|
||||
className="p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title={t('keyValueEditor.remove')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPair}
|
||||
className="w-full py-2 border-2 border-dashed border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors font-medium text-sm inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('keyValueEditor.addPair')}
|
||||
</button>
|
||||
|
||||
{/* Empty State */}
|
||||
{pairs.length === 0 && suggestions.length === 0 && (
|
||||
<p className="text-xs text-center text-[var(--text-tertiary)] py-4">
|
||||
{t('keyValueEditor.emptyState')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyValueEditor;
|
||||
2
frontend/src/components/ui/KeyValueEditor/index.ts
Normal file
2
frontend/src/components/ui/KeyValueEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { KeyValueEditor } from './KeyValueEditor';
|
||||
export default from './KeyValueEditor';
|
||||
@@ -38,7 +38,10 @@
|
||||
"savings": {
|
||||
"label": "💰 SAVINGS",
|
||||
"this_week": "this week",
|
||||
"vs_last": "vs. last"
|
||||
"vs_last": "vs. last",
|
||||
"value_this_week": "€{amount} this week",
|
||||
"detail_vs_last_positive": "+{percentage}% vs. last",
|
||||
"detail_vs_last_negative": "{percentage}% vs. last"
|
||||
},
|
||||
"inventory": {
|
||||
"label": "📦 INVENTORY",
|
||||
@@ -52,7 +55,9 @@
|
||||
"waste": {
|
||||
"label": "♻️ WASTE",
|
||||
"this_month": "this month",
|
||||
"vs_goal": "vs. goal"
|
||||
"vs_goal": "vs. goal",
|
||||
"value_this_month": "{percentage}% this month",
|
||||
"detail_vs_goal": "{change}% vs. goal"
|
||||
},
|
||||
"deliveries": {
|
||||
"label": "🚚 DELIVERIES",
|
||||
@@ -129,6 +134,14 @@
|
||||
"remove": "Remove",
|
||||
"active_count": "{count} active alerts"
|
||||
},
|
||||
"production": {
|
||||
"scheduled_based_on": "Scheduled based on {{type}}",
|
||||
"status": {
|
||||
"completed": "COMPLETED",
|
||||
"in_progress": "IN PROGRESS",
|
||||
"pending": "PENDING"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"welcome": "Welcome back",
|
||||
"good_morning": "Good morning",
|
||||
@@ -203,6 +216,28 @@
|
||||
"cost_analysis": "Cost Analysis"
|
||||
}
|
||||
},
|
||||
"action_queue": {
|
||||
"consequences": {
|
||||
"delayed_delivery": "Delayed delivery may impact production schedule",
|
||||
"immediate_action": "Immediate action required to prevent production issues",
|
||||
"limited_features": "Some features are limited"
|
||||
},
|
||||
"titles": {
|
||||
"purchase_order": "Purchase Order {po_number}",
|
||||
"supplier": "Supplier: {supplier_name}",
|
||||
"pending_approval": "Pending approval for {supplier_name} - {type}"
|
||||
},
|
||||
"buttons": {
|
||||
"view_details": "View Details",
|
||||
"dismiss": "Dismiss",
|
||||
"approve": "Approve",
|
||||
"modify": "Modify",
|
||||
"complete_setup": "Complete Setup"
|
||||
}
|
||||
},
|
||||
"orchestration": {
|
||||
"no_runs_message": "No orchestration has been run yet. Click 'Run Daily Planning' to generate your first plan."
|
||||
},
|
||||
"errors": {
|
||||
"failed_to_load_stats": "Failed to load dashboard statistics. Please try again."
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"accuracy": "Accuracy: 92% (vs 60-70% for generic systems)",
|
||||
"cta": "See All Features",
|
||||
"key1": "🎯 Precision:",
|
||||
"key1": "🎯 Precision: ",
|
||||
"key2": "(vs 60-70% of generic systems)"
|
||||
},
|
||||
"pillar2": {
|
||||
@@ -121,7 +121,7 @@
|
||||
"cta": "See All Features"
|
||||
},
|
||||
"pillar3": {
|
||||
"title": "Your Data, Your Environmental Impact",
|
||||
"title": "🌱 Your Data, Your Environmental Impact",
|
||||
"intro": "100% of your data belongs to you. Measure your environmental impact automatically and generate sustainability reports that comply with international standards.",
|
||||
"data_ownership_value": "100%",
|
||||
"data_ownership": "Data ownership",
|
||||
|
||||
@@ -157,5 +157,15 @@
|
||||
"view_alert": "View Details",
|
||||
"run_planning": "Run Daily Planning"
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"low_stock_detection": "Low stock detected for {{product_name}}. Stock will run out in {{days_until_stockout}} days.",
|
||||
"stockout_prevention": "Preventing stockout for critical ingredients",
|
||||
"forecast_demand": "Based on demand forecast: {{predicted_demand}} units predicted ({{confidence_score}}% confidence)",
|
||||
"customer_orders": "Fulfilling confirmed customer orders",
|
||||
"seasonal_demand": "Anticipated seasonal demand increase",
|
||||
"inventory_replenishment": "Regular inventory replenishment",
|
||||
"production_schedule": "Scheduled production batch",
|
||||
"other": "Standard replenishment"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
"willBeGeneratedAutomatically": "Will be generated automatically",
|
||||
"autoGeneratedOnSave": "Auto-generated on save"
|
||||
},
|
||||
"keyValueEditor": {
|
||||
"showBuilder": "Show Builder",
|
||||
"showJson": "Show JSON",
|
||||
"suggestions": "Quick suggestions",
|
||||
"keyPlaceholder": "Key",
|
||||
"valuePlaceholder": "Value",
|
||||
"remove": "Remove",
|
||||
"addPair": "Add Parameter",
|
||||
"emptyState": "No parameters yet. Click 'Add Parameter' to get started."
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Add Inventory",
|
||||
"inventoryDetails": "Inventory Item Details",
|
||||
@@ -168,6 +178,17 @@
|
||||
"title": "Add Quality Template",
|
||||
"templateDetails": "Quality Template Details",
|
||||
"fillRequiredInfo": "Fill in the required information to create a quality check template",
|
||||
"selectCheckType": "Select Quality Check Type",
|
||||
"selectCheckTypeDescription": "Choose the type of quality check you want to create",
|
||||
"essentialConfiguration": "Essential Configuration",
|
||||
"essentialConfigurationDescription": "Define the core properties of your quality check template",
|
||||
"criteriaAndSettings": "Quality Criteria & Settings",
|
||||
"criteriaAndSettingsDescription": "Configure scoring methods and advanced quality criteria",
|
||||
"steps": {
|
||||
"checkType": "Check Type",
|
||||
"essentialConfiguration": "Configuration",
|
||||
"criteriaSettings": "Criteria & Settings"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"namePlaceholder": "E.g., Bread Quality Control, Hygiene Inspection",
|
||||
@@ -177,24 +198,90 @@
|
||||
"templateCode": "Template Code",
|
||||
"templateCodePlaceholder": "Leave empty for auto-generation",
|
||||
"templateCodeTooltip": "Leave empty to auto-generate from backend, or enter custom code",
|
||||
"category": "Category",
|
||||
"categoryPlaceholder": "E.g., appearance, structure, texture",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Detailed description of the quality check template",
|
||||
"applicableStages": "Applicable Stages",
|
||||
"applicableStagesTooltip": "Comma-separated list of production stages: e.g., mixing, proofing, baking, cooling",
|
||||
"applicablePlaceholder": "mixing, proofing, baking, cooling"
|
||||
"applicableStagesTooltip": "Select the production stages where this quality check applies",
|
||||
"applicableStagesHelp": "Leave empty to apply to all stages",
|
||||
"applicablePlaceholder": "mixing, proofing, baking, cooling",
|
||||
"instructions": "Instructions",
|
||||
"instructionsPlaceholder": "Step-by-step instructions for performing this quality check",
|
||||
"minValue": "Minimum Value",
|
||||
"maxValue": "Maximum Value",
|
||||
"targetValue": "Target Value",
|
||||
"unit": "Unit",
|
||||
"unitPlaceholder": "E.g., °C, g, cm, %",
|
||||
"tolerancePercentage": "Tolerance Percentage",
|
||||
"toleranceTooltip": "Acceptable deviation from target value (0-100%)",
|
||||
"scoringMethod": "Scoring Method",
|
||||
"passThreshold": "Pass Threshold (%)",
|
||||
"passThresholdTooltip": "Minimum score percentage required to pass (0-100%)",
|
||||
"frequencyDays": "Frequency (days)",
|
||||
"frequencyDaysTooltip": "How often this check should be performed (in days)",
|
||||
"frequencyDaysPlaceholder": "Leave empty for batch-based",
|
||||
"requiredCheck": "Required Check",
|
||||
"checkPointsJsonArray": "Check Points (JSON Array)",
|
||||
"checkPointsTooltip": "Array of check points: [{\"name\": \"Visual Check\", \"description\": \"...\", \"weight\": 1.0}]",
|
||||
"checkPointsPlaceholder": "[{\"name\": \"Visual Inspection\", \"description\": \"Check appearance\", \"expected_value\": \"Golden brown\", \"measurement_type\": \"visual\", \"is_critical\": false, \"weight\": 1.0}]",
|
||||
"acceptanceCriteria": "Acceptance Criteria",
|
||||
"acceptanceCriteriaPlaceholder": "E.g., Golden uniform color, fluffy texture, no burns...",
|
||||
"parametersJson": "Parameters (JSON)",
|
||||
"parametersTooltip": "Template parameters: {\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
|
||||
"parametersPlaceholder": "{\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
|
||||
"thresholdsJson": "Thresholds (JSON)",
|
||||
"thresholdsTooltip": "Threshold values: {\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
|
||||
"thresholdsPlaceholder": "{\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
|
||||
"scoringCriteriaJson": "Scoring Criteria (JSON)",
|
||||
"scoringCriteriaTooltip": "Custom scoring criteria: {\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
|
||||
"scoringCriteriaPlaceholder": "{\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
|
||||
"responsibleRole": "Responsible Role/Person",
|
||||
"responsibleRolePlaceholder": "E.g., Production Manager, Baker",
|
||||
"requiredEquipment": "Required Equipment/Tools",
|
||||
"requiredEquipmentPlaceholder": "E.g., Thermometer, scale, timer",
|
||||
"specificConditions": "Specific Conditions or Notes",
|
||||
"specificConditionsPlaceholder": "E.g., Only applicable on humid days, check 30 min after baking...",
|
||||
"activeTemplate": "Active Template",
|
||||
"requiresPhotoEvidence": "Requires Photo Evidence",
|
||||
"criticalControlPoint": "Critical Control Point (CCP)",
|
||||
"notifyOnFailure": "Notify on Failure"
|
||||
},
|
||||
"checkTypes": {
|
||||
"product_quality": "Product Quality",
|
||||
"process_hygiene": "Process Hygiene",
|
||||
"equipment": "Equipment",
|
||||
"safety": "Safety",
|
||||
"cleaning": "Cleaning",
|
||||
"temperature": "Temperature Control",
|
||||
"documentation": "Documentation"
|
||||
"visual": "Visual Inspection",
|
||||
"measurement": "Measurement",
|
||||
"temperature": "Temperature",
|
||||
"weight": "Weight",
|
||||
"boolean": "Pass/Fail Check",
|
||||
"timing": "Timing",
|
||||
"checklist": "Checklist"
|
||||
},
|
||||
"checkTypeDescriptions": {
|
||||
"visual": "Inspect appearance, color, and visual quality characteristics",
|
||||
"measurement": "Measure specific dimensions, sizes, or quantities",
|
||||
"temperature": "Monitor and verify temperature readings",
|
||||
"weight": "Check weight and mass measurements",
|
||||
"boolean": "Simple yes/no or pass/fail checks",
|
||||
"timing": "Track time-based quality criteria",
|
||||
"checklist": "Multi-point checklist verification"
|
||||
},
|
||||
"processStages": {
|
||||
"mixing": "Mixing",
|
||||
"proofing": "Proofing",
|
||||
"shaping": "Shaping",
|
||||
"baking": "Baking",
|
||||
"cooling": "Cooling",
|
||||
"packaging": "Packaging",
|
||||
"finishing": "Finishing"
|
||||
},
|
||||
"sections": {
|
||||
"basicInformation": "Basic Information",
|
||||
"additionalIdentifiers": "Additional Identifiers",
|
||||
"additionalIdentifiersDescription": "Optional identifiers for organization",
|
||||
"measurementSpecifications": "Measurement Specifications",
|
||||
"additionalDetails": "Additional Details",
|
||||
"additionalDetailsDescription": "Optional detailed instructions",
|
||||
"scoringConfiguration": "Scoring Configuration",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"advancedOptionsDescription": "Optional fields for comprehensive quality template configuration",
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
"savings": {
|
||||
"label": "💰 AHORROS",
|
||||
"this_week": "esta semana",
|
||||
"vs_last": "vs. anterior"
|
||||
"vs_last": "vs. anterior",
|
||||
"value_this_week": "€{amount} esta semana",
|
||||
"detail_vs_last_positive": "+{percentage}% vs. anterior",
|
||||
"detail_vs_last_negative": "{percentage}% vs. anterior"
|
||||
},
|
||||
"inventory": {
|
||||
"label": "📦 INVENTARIO",
|
||||
@@ -52,7 +55,9 @@
|
||||
"waste": {
|
||||
"label": "♻️ DESPERDICIO",
|
||||
"this_month": "este mes",
|
||||
"vs_goal": "vs. objetivo"
|
||||
"vs_goal": "vs. objetivo",
|
||||
"value_this_month": "{percentage}% este mes",
|
||||
"detail_vs_goal": "{change}% vs. objetivo"
|
||||
},
|
||||
"deliveries": {
|
||||
"label": "🚚 ENTREGAS",
|
||||
@@ -164,6 +169,14 @@
|
||||
"delete": "Eliminar"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"scheduled_based_on": "Programado según {{type}}",
|
||||
"status": {
|
||||
"completed": "COMPLETADO",
|
||||
"in_progress": "EN PROGRESO",
|
||||
"pending": "PENDIENTE"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"welcome": "Bienvenido de vuelta",
|
||||
"good_morning": "Buenos días",
|
||||
@@ -238,6 +251,28 @@
|
||||
"cost_analysis": "Análisis de Costos"
|
||||
}
|
||||
},
|
||||
"action_queue": {
|
||||
"consequences": {
|
||||
"delayed_delivery": "La entrega retrasada puede afectar el programa de producción",
|
||||
"immediate_action": "Se requiere acción inmediata para prevenir problemas de producción",
|
||||
"limited_features": "Algunas funciones están limitadas"
|
||||
},
|
||||
"titles": {
|
||||
"purchase_order": "Orden de Compra {po_number}",
|
||||
"supplier": "Proveedor: {supplier_name}",
|
||||
"pending_approval": "Aprobación pendiente para {supplier_name} - {type}"
|
||||
},
|
||||
"buttons": {
|
||||
"view_details": "Ver Detalles",
|
||||
"dismiss": "Descartar",
|
||||
"approve": "Aprobar",
|
||||
"modify": "Modificar",
|
||||
"complete_setup": "Completar Configuración"
|
||||
}
|
||||
},
|
||||
"orchestration": {
|
||||
"no_runs_message": "Aún no se ha ejecutado ninguna orquestación. Haga clic en 'Ejecutar Planificación Diaria' para generar su primer plan."
|
||||
},
|
||||
"errors": {
|
||||
"failed_to_load_stats": "Error al cargar las estadísticas del panel. Por favor, inténtelo de nuevo."
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"accuracy": "Precisión: 92% (vs 60-70% de sistemas genéricos)",
|
||||
"cta": "Ver Todas las Funcionalidades",
|
||||
"key1": "🎯 Precisión:",
|
||||
"key1": "🎯 Precisión: ",
|
||||
"key2": "(vs 60-70% de sistemas genéricos)"
|
||||
},
|
||||
"pillar2": {
|
||||
@@ -121,12 +121,12 @@
|
||||
"cta": "Ver Todas las Funcionalidades"
|
||||
},
|
||||
"pillar3": {
|
||||
"title": "Tus Datos, Tu Impacto Ambiental",
|
||||
"title": "🌱 Tus Datos, Tu Impacto Ambiental",
|
||||
"intro": "100% de tus datos te pertenecen. Mide tu impacto ambiental automáticamente y genera informes de sostenibilidad que cumplen con los estándares internacionales.",
|
||||
"data_ownership_value": "100%",
|
||||
"data_ownership": "Propiedad de datos",
|
||||
"co2_metric": "Hondakinak",
|
||||
"co2": "Murrizketa automatikoa",
|
||||
"co2_metric": "Residuos",
|
||||
"co2": "Reducción automática",
|
||||
"sdg_value": "Verde",
|
||||
"sdg": "Certificado de sostenibilidad",
|
||||
"sustainability_title": "🔒 Privados por defecto, sostenibles de serie.",
|
||||
|
||||
@@ -157,5 +157,15 @@
|
||||
"view_alert": "Ver Detalles",
|
||||
"run_planning": "Ejecutar Planificación Diaria"
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"low_stock_detection": "Stock bajo detectado para {{product_name}}. El stock se agotará en {{days_until_stockout}} días.",
|
||||
"stockout_prevention": "Previniendo desabastecimiento de ingredientes críticos",
|
||||
"forecast_demand": "Basado en pronóstico de demanda: {{predicted_demand}} unidades predichas ({{confidence_score}}% confianza)",
|
||||
"customer_orders": "Cumpliendo pedidos confirmados de clientes",
|
||||
"seasonal_demand": "Aumento anticipado de demanda estacional",
|
||||
"inventory_replenishment": "Reposición regular de inventario",
|
||||
"production_schedule": "Lote de producción programado",
|
||||
"other": "Reposición estándar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,16 @@
|
||||
"complete": "Completar",
|
||||
"stepOf": "Paso {{current}} de {{total}}"
|
||||
},
|
||||
"keyValueEditor": {
|
||||
"showBuilder": "Mostrar Constructor",
|
||||
"showJson": "Mostrar JSON",
|
||||
"suggestions": "Sugerencias rápidas",
|
||||
"keyPlaceholder": "Clave",
|
||||
"valuePlaceholder": "Valor",
|
||||
"remove": "Eliminar",
|
||||
"addPair": "Agregar Parámetro",
|
||||
"emptyState": "No hay parámetros aún. Haz clic en 'Agregar Parámetro' para comenzar."
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Agregar Inventario",
|
||||
"inventoryDetails": "Detalles del Artículo de Inventario",
|
||||
@@ -174,6 +184,17 @@
|
||||
"title": "Agregar Plantilla de Calidad",
|
||||
"templateDetails": "Detalles de la Plantilla de Calidad",
|
||||
"fillRequiredInfo": "Complete la información requerida para crear una plantilla de control de calidad",
|
||||
"selectCheckType": "Seleccionar Tipo de Control de Calidad",
|
||||
"selectCheckTypeDescription": "Elija el tipo de control de calidad que desea crear",
|
||||
"essentialConfiguration": "Configuración Esencial",
|
||||
"essentialConfigurationDescription": "Defina las propiedades principales de su plantilla de control de calidad",
|
||||
"criteriaAndSettings": "Criterios y Configuración de Calidad",
|
||||
"criteriaAndSettingsDescription": "Configure los métodos de puntuación y criterios avanzados de calidad",
|
||||
"steps": {
|
||||
"checkType": "Tipo de Control",
|
||||
"essentialConfiguration": "Configuración",
|
||||
"criteriaSettings": "Criterios y Ajustes"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "Ej: Control de Calidad del Pan, Inspección de Higiene",
|
||||
@@ -183,24 +204,90 @@
|
||||
"templateCode": "Código de Plantilla",
|
||||
"templateCodePlaceholder": "Dejar vacío para auto-generar",
|
||||
"templateCodeTooltip": "Dejar vacío para auto-generar desde el backend, o introducir código personalizado",
|
||||
"category": "Categoría",
|
||||
"categoryPlaceholder": "Ej: apariencia, estructura, textura",
|
||||
"version": "Versión",
|
||||
"description": "Descripción",
|
||||
"descriptionPlaceholder": "Descripción detallada de la plantilla de control de calidad",
|
||||
"applicableStages": "Etapas Aplicables",
|
||||
"applicableStagesTooltip": "Lista separada por comas de etapas de producción: ej: amasado, fermentación, horneado, enfriamiento",
|
||||
"applicablePlaceholder": "amasado, fermentación, horneado, enfriamiento"
|
||||
"applicableStagesTooltip": "Seleccione las etapas de producción donde se aplica este control de calidad",
|
||||
"applicableStagesHelp": "Dejar vacío para aplicar a todas las etapas",
|
||||
"applicablePlaceholder": "amasado, fermentación, horneado, enfriamiento",
|
||||
"instructions": "Instrucciones",
|
||||
"instructionsPlaceholder": "Instrucciones paso a paso para realizar este control de calidad",
|
||||
"minValue": "Valor Mínimo",
|
||||
"maxValue": "Valor Máximo",
|
||||
"targetValue": "Valor Objetivo",
|
||||
"unit": "Unidad",
|
||||
"unitPlaceholder": "Ej: °C, g, cm, %",
|
||||
"tolerancePercentage": "Porcentaje de Tolerancia",
|
||||
"toleranceTooltip": "Desviación aceptable del valor objetivo (0-100%)",
|
||||
"scoringMethod": "Método de Puntuación",
|
||||
"passThreshold": "Umbral de Aprobación (%)",
|
||||
"passThresholdTooltip": "Porcentaje de puntuación mínimo requerido para aprobar (0-100%)",
|
||||
"frequencyDays": "Frecuencia (días)",
|
||||
"frequencyDaysTooltip": "Con qué frecuencia debe realizarse este control (en días)",
|
||||
"frequencyDaysPlaceholder": "Dejar vacío para basado en lotes",
|
||||
"requiredCheck": "Verificación Requerida",
|
||||
"checkPointsJsonArray": "Puntos de Control (Array JSON)",
|
||||
"checkPointsTooltip": "Array de puntos de control: [{\"name\": \"Control Visual\", \"description\": \"...\", \"weight\": 1.0}]",
|
||||
"checkPointsPlaceholder": "[{\"name\": \"Inspección Visual\", \"description\": \"Verificar apariencia\", \"expected_value\": \"Marrón dorado\", \"measurement_type\": \"visual\", \"is_critical\": false, \"weight\": 1.0}]",
|
||||
"acceptanceCriteria": "Criterios de Aceptación",
|
||||
"acceptanceCriteriaPlaceholder": "Ej: Color dorado uniforme, textura esponjosa, sin quemaduras...",
|
||||
"parametersJson": "Parámetros (JSON)",
|
||||
"parametersTooltip": "Parámetros de plantilla: {\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
|
||||
"parametersPlaceholder": "{\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
|
||||
"thresholdsJson": "Umbrales (JSON)",
|
||||
"thresholdsTooltip": "Valores de umbral: {\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
|
||||
"thresholdsPlaceholder": "{\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
|
||||
"scoringCriteriaJson": "Criterios de Puntuación (JSON)",
|
||||
"scoringCriteriaTooltip": "Criterios de puntuación personalizados: {\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
|
||||
"scoringCriteriaPlaceholder": "{\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
|
||||
"responsibleRole": "Rol/Persona Responsable",
|
||||
"responsibleRolePlaceholder": "Ej: Gerente de Producción, Panadero",
|
||||
"requiredEquipment": "Equipos/Herramientas Requeridas",
|
||||
"requiredEquipmentPlaceholder": "Ej: Termómetro, báscula, temporizador",
|
||||
"specificConditions": "Condiciones o Notas Específicas",
|
||||
"specificConditionsPlaceholder": "Ej: Solo aplicable en días húmedos, verificar 30 min después de hornear...",
|
||||
"activeTemplate": "Plantilla Activa",
|
||||
"requiresPhotoEvidence": "Requiere Evidencia Fotográfica",
|
||||
"criticalControlPoint": "Punto Crítico de Control (PCC)",
|
||||
"notifyOnFailure": "Notificar en Falla"
|
||||
},
|
||||
"checkTypes": {
|
||||
"product_quality": "Calidad del Producto",
|
||||
"process_hygiene": "Higiene del Proceso",
|
||||
"equipment": "Equipamiento",
|
||||
"safety": "Seguridad",
|
||||
"cleaning": "Limpieza",
|
||||
"temperature": "Control de Temperatura",
|
||||
"documentation": "Documentación"
|
||||
"visual": "Inspección Visual",
|
||||
"measurement": "Medición",
|
||||
"temperature": "Temperatura",
|
||||
"weight": "Peso",
|
||||
"boolean": "Control Aprobado/Reprobado",
|
||||
"timing": "Temporización",
|
||||
"checklist": "Lista de Verificación"
|
||||
},
|
||||
"checkTypeDescriptions": {
|
||||
"visual": "Inspeccionar apariencia, color y características visuales de calidad",
|
||||
"measurement": "Medir dimensiones, tamaños o cantidades específicas",
|
||||
"temperature": "Monitorear y verificar lecturas de temperatura",
|
||||
"weight": "Verificar mediciones de peso y masa",
|
||||
"boolean": "Controles simples de sí/no o aprobado/reprobado",
|
||||
"timing": "Rastrear criterios de calidad basados en tiempo",
|
||||
"checklist": "Verificación de lista de puntos múltiples"
|
||||
},
|
||||
"processStages": {
|
||||
"mixing": "Amasado",
|
||||
"proofing": "Fermentación",
|
||||
"shaping": "Formado",
|
||||
"baking": "Horneado",
|
||||
"cooling": "Enfriamiento",
|
||||
"packaging": "Empaquetado",
|
||||
"finishing": "Acabado"
|
||||
},
|
||||
"sections": {
|
||||
"basicInformation": "Información Básica",
|
||||
"additionalIdentifiers": "Identificadores Adicionales",
|
||||
"additionalIdentifiersDescription": "Identificadores opcionales para organización",
|
||||
"measurementSpecifications": "Especificaciones de Medición",
|
||||
"additionalDetails": "Detalles Adicionales",
|
||||
"additionalDetailsDescription": "Instrucciones detalladas opcionales",
|
||||
"scoringConfiguration": "Configuración de Puntuación",
|
||||
"advancedOptions": "Opciones Avanzadas",
|
||||
"advancedOptionsDescription": "Campos opcionales para configuración completa de plantilla de calidad",
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"savings": {
|
||||
"label": "💰 AURREZKIAK",
|
||||
"this_week": "aste honetan",
|
||||
"vs_last": "vs. aurrekoa"
|
||||
"vs_last": "vs. aurrekoa",
|
||||
"value_this_week": "€{amount} aste honetan",
|
||||
"detail_vs_last_positive": "+{percentage}% vs. aurrekoa",
|
||||
"detail_vs_last_negative": "{percentage}% vs. aurrekoa"
|
||||
},
|
||||
"inventory": {
|
||||
"label": "📦 INBENTARIOA",
|
||||
@@ -50,7 +53,9 @@
|
||||
"waste": {
|
||||
"label": "♻️ HONDAKINAK",
|
||||
"this_month": "hilabete honetan",
|
||||
"vs_goal": "vs. helburua"
|
||||
"vs_goal": "vs. helburua",
|
||||
"value_this_month": "{percentage}% hilabete honetan",
|
||||
"detail_vs_goal": "{change}% vs. helburua"
|
||||
},
|
||||
"deliveries": {
|
||||
"label": "🚚 BIDALKETA",
|
||||
@@ -127,6 +132,14 @@
|
||||
"remove": "Kendu",
|
||||
"active_count": "{count} alerta aktibo"
|
||||
},
|
||||
"production": {
|
||||
"scheduled_based_on": "{{type}} arabera programatuta",
|
||||
"status": {
|
||||
"completed": "OSATUTA",
|
||||
"in_progress": "MARTXAN",
|
||||
"pending": "ITXAROTEAN"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"welcome": "Ongi etorri berriro",
|
||||
"good_morning": "Egun on",
|
||||
@@ -200,5 +213,30 @@
|
||||
"production_planning": "Ekoizpen Plangintza",
|
||||
"cost_analysis": "Kostu Analisia"
|
||||
}
|
||||
},
|
||||
"action_queue": {
|
||||
"consequences": {
|
||||
"delayed_delivery": "Entrega atzeratuak ekoizpen-egutegian eragina izan dezake",
|
||||
"immediate_action": "Berehalako ekintza beharrezkoa da ekoizpen-arazoak saihesteko",
|
||||
"limited_features": "Funtzio batzuk mugatuta daude"
|
||||
},
|
||||
"titles": {
|
||||
"purchase_order": "Erosketa Agindua {po_number}",
|
||||
"supplier": "Hornitzailea: {supplier_name}",
|
||||
"pending_approval": "{supplier_name} - {type} onarpenaren zai"
|
||||
},
|
||||
"buttons": {
|
||||
"view_details": "Xehetasunak Ikusi",
|
||||
"dismiss": "Baztertu",
|
||||
"approve": "Onartu",
|
||||
"modify": "Aldatu",
|
||||
"complete_setup": "Konfigurazioa Osatu"
|
||||
}
|
||||
},
|
||||
"orchestration": {
|
||||
"no_runs_message": "Oraindik ez da orkestraziorik exekutatu. Sakatu 'Eguneko Plangintza Exekutatu' zure lehen plana sortzeko."
|
||||
},
|
||||
"errors": {
|
||||
"failed_to_load_stats": "Huts egin du aginte-paneleko estatistikak kargatzean. Saiatu berriro mesedez."
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
"accuracy": "Zehaztasuna: %92 (vs %60-70 sistema generikoetan)",
|
||||
"cta": "Ikusi Ezaugarri Guztiak",
|
||||
"key1": "🎯 Zehatasuna:",
|
||||
"key1": "🎯 Zehatasuna: ",
|
||||
"key2": "(sistema generikoen %60-70aren aldean)"
|
||||
},
|
||||
"pillar2": {
|
||||
@@ -121,7 +121,7 @@
|
||||
"cta": "Ikusi Ezaugarri Guztiak"
|
||||
},
|
||||
"pillar3": {
|
||||
"title": "Zure Datuak, Zure Ingurumen Inpaktua",
|
||||
"title": "🌱 Zure Datuak, Zure Ingurumen Inpaktua",
|
||||
"intro": "Zure datuen %100 zureak dira. Neurtu zure ingurumen-inpaktua automatikoki eta sortu nazioarteko estandarrak betetzen dituzten iraunkortasun-txostenak.",
|
||||
"data_ownership_value": "100%",
|
||||
"data_ownership": "Datuen jabetza",
|
||||
|
||||
@@ -157,5 +157,15 @@
|
||||
"view_alert": "Ikusi Xehetasunak",
|
||||
"run_planning": "Exekutatu Eguneko Plangintza"
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"low_stock_detection": "Stock baxua detektatu da {{product_name}}-(e)rako. Stocka {{days_until_stockout}} egunetan agortuko da.",
|
||||
"stockout_prevention": "Osagai kritikoen desabastetzea saihestea",
|
||||
"forecast_demand": "Eskari aurreikuspenean oinarrituta: {{predicted_demand}} unitate aurreikusita ({{confidence_score}}% konfiantza)",
|
||||
"customer_orders": "Bezeroen eskaera bermatuen betetze",
|
||||
"seasonal_demand": "Aurreikusitako sasoiko eskariaren igoera",
|
||||
"inventory_replenishment": "Inbentario berritze erregularra",
|
||||
"production_schedule": "Ekoizpen sorta programatua",
|
||||
"other": "Berritze estandarra"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
"willBeGeneratedAutomatically": "Automatikoki sortuko da",
|
||||
"autoGeneratedOnSave": "Automatikoki sortua gordetzean"
|
||||
},
|
||||
"keyValueEditor": {
|
||||
"showBuilder": "Eraikitzailea Erakutsi",
|
||||
"showJson": "JSON Erakutsi",
|
||||
"suggestions": "Iradokizun azkarrak",
|
||||
"keyPlaceholder": "Gakoa",
|
||||
"valuePlaceholder": "Balioa",
|
||||
"remove": "Kendu",
|
||||
"addPair": "Parametroa Gehitu",
|
||||
"emptyState": "Oraindik ez dago parametrorik. Egin klik 'Parametroa Gehitu'-n hasteko."
|
||||
},
|
||||
"inventory": {
|
||||
"title": "Inbentarioa Gehitu",
|
||||
"inventoryDetails": "Inbentario Elementuaren Xehetasunak",
|
||||
@@ -168,6 +178,17 @@
|
||||
"title": "Kalitate Txantiloia Gehitu",
|
||||
"templateDetails": "Kalitate Txantiloiaren Xehetasunak",
|
||||
"fillRequiredInfo": "Bete beharrezko informazioa kalitate kontrol txantiloi bat sortzeko",
|
||||
"selectCheckType": "Kalitate Kontrol Mota Hautatu",
|
||||
"selectCheckTypeDescription": "Hautatu sortu nahi duzun kalitate kontrol mota",
|
||||
"essentialConfiguration": "Oinarrizko Konfigurazioa",
|
||||
"essentialConfigurationDescription": "Zehaztu zure kalitate kontrol txantiloiaren oinarrizko ezaugarriak",
|
||||
"criteriaAndSettings": "Kalitate Irizpideak eta Ezarpenak",
|
||||
"criteriaAndSettingsDescription": "Konfiguratu puntuazio metodoak eta kalitate irizpide aurreratuak",
|
||||
"steps": {
|
||||
"checkType": "Kontrol Mota",
|
||||
"essentialConfiguration": "Konfigurazioa",
|
||||
"criteriaSettings": "Irizpideak eta Ezarpenak"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Izena",
|
||||
"namePlaceholder": "Adib: Ogiaren Kalitate Kontrola, Higiene Ikuskatzea",
|
||||
@@ -177,24 +198,90 @@
|
||||
"templateCode": "Txantiloi Kodea",
|
||||
"templateCodePlaceholder": "Utzi hutsik automatikoki sortzeko",
|
||||
"templateCodeTooltip": "Utzi hutsik backend-etik automatikoki sortzeko, edo sartu kode pertsonalizatua",
|
||||
"category": "Kategoria",
|
||||
"categoryPlaceholder": "Adib: itxura, egitura, ehundura",
|
||||
"version": "Bertsioa",
|
||||
"description": "Deskribapena",
|
||||
"descriptionPlaceholder": "Kalitate kontrol txantiloiaren deskribapen zehatza",
|
||||
"applicableStages": "Aplikagarriak Diren Faseak",
|
||||
"applicableStagesTooltip": "Komaz bereizitako ekoizpen faseen zerrenda: adib: nahasketaNahasketa, hartzidura, labean, hoztetanHozte",
|
||||
"applicablePlaceholder": "nahasketa, hartzidura, labea, hozte"
|
||||
"applicableStagesTooltip": "Hautatu kalitate kontrol hau aplikatzen den ekoizpen faseak",
|
||||
"applicableStagesHelp": "Utzi hutsik fase guztietan aplikatzeko",
|
||||
"applicablePlaceholder": "nahasketa, hartzidura, labea, hozte",
|
||||
"instructions": "Jarraibideak",
|
||||
"instructionsPlaceholder": "Kalitate kontrol hau egiteko urrats-urratseko jarraibideak",
|
||||
"minValue": "Balio Minimoa",
|
||||
"maxValue": "Balio Maximoa",
|
||||
"targetValue": "Helburu Balioa",
|
||||
"unit": "Unitatea",
|
||||
"unitPlaceholder": "Adib: °C, g, cm, %",
|
||||
"tolerancePercentage": "Tolerantzia Ehunekoa",
|
||||
"toleranceTooltip": "Helburu baliotik onartutako desbideratzea (0-100%)",
|
||||
"scoringMethod": "Puntuazio Metodoa",
|
||||
"passThreshold": "Gainditzeko Atalasea (%)",
|
||||
"passThresholdTooltip": "Gaindit zeko beharrezko gutxieneko puntuazio ehunekoa (0-100%)",
|
||||
"frequencyDays": "Maiztasuna (egunak)",
|
||||
"frequencyDaysTooltip": "Kontrol hau zenbat denboratan egin behar den (egunetan)",
|
||||
"frequencyDaysPlaceholder": "Utzi hutsik lote oinarritua izateko",
|
||||
"requiredCheck": "Beharrezko Egiaztapena",
|
||||
"checkPointsJsonArray": "Kontrol Puntuak (JSON Array)",
|
||||
"checkPointsTooltip": "Kontrol puntuen array-a: [{\"name\": \"Ikusizko Kontrola\", \"description\": \"...\", \"weight\": 1.0}]",
|
||||
"checkPointsPlaceholder": "[{\"name\": \"Ikusizko Ikuskatzea\", \"description\": \"Itxura egiaztatu\", \"expected_value\": \"Urre marroia\", \"measurement_type\": \"visual\", \"is_critical\": false, \"weight\": 1.0}]",
|
||||
"acceptanceCriteria": "Onarpenerako Irizpideak",
|
||||
"acceptanceCriteriaPlaceholder": "Adib: Kolore urre uniformea, ehundura puzgatua, erreadurak gabe...",
|
||||
"parametersJson": "Parametroak (JSON)",
|
||||
"parametersTooltip": "Txantiloiaren parametroak: {\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
|
||||
"parametersPlaceholder": "{\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
|
||||
"thresholdsJson": "Atalaseak (JSON)",
|
||||
"thresholdsTooltip": "Atalase balioak: {\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
|
||||
"thresholdsPlaceholder": "{\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
|
||||
"scoringCriteriaJson": "Puntuazio Irizpideak (JSON)",
|
||||
"scoringCriteriaTooltip": "Puntuazio irizpide pertsonalizatuak: {\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
|
||||
"scoringCriteriaPlaceholder": "{\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
|
||||
"responsibleRole": "Arduradunaren Rola/Pertsona",
|
||||
"responsibleRolePlaceholder": "Adib: Ekoizpen Kudeatzailea, Okindegilea",
|
||||
"requiredEquipment": "Beharrezko Ekipamendua/Tresnak",
|
||||
"requiredEquipmentPlaceholder": "Adib: Termometroa, balantza, kronometroa",
|
||||
"specificConditions": "Baldintza Espezifikoak edo Oharrak",
|
||||
"specificConditionsPlaceholder": "Adib: Egun hezetan soilik aplikagarria, labean 30 minutu geroago egiaztatu...",
|
||||
"activeTemplate": "Txantiloi Aktiboa",
|
||||
"requiresPhotoEvidence": "Argazki Frogak Behar Ditu",
|
||||
"criticalControlPoint": "Kontrol Puntu Kritikoa (KPK)",
|
||||
"notifyOnFailure": "Jakinarazi Hutsegitean"
|
||||
},
|
||||
"checkTypes": {
|
||||
"product_quality": "Produktuaren Kalitatea",
|
||||
"process_hygiene": "Prozesuaren Higienea",
|
||||
"equipment": "Ekipamendua",
|
||||
"safety": "Segurtasuna",
|
||||
"cleaning": "Garbiketa",
|
||||
"temperature": "Tenperatura Kontrola",
|
||||
"documentation": "Dokumentazioa"
|
||||
"visual": "Ikusizko Ikuskatzea",
|
||||
"measurement": "Neurketa",
|
||||
"temperature": "Tenperatura",
|
||||
"weight": "Pisua",
|
||||
"boolean": "Gainditu/Huts Egin Kontrola",
|
||||
"timing": "Denboratzea",
|
||||
"checklist": "Egiaztapen Zerrenda"
|
||||
},
|
||||
"checkTypeDescriptions": {
|
||||
"visual": "Itxura, kolorea eta kalitate ezaugarri bisualak ikuskatu",
|
||||
"measurement": "Dimentsio, tamaina edo kopuru zehatzak neurtu",
|
||||
"temperature": "Tenperatura irakurketak kontrolatu eta egiaztatu",
|
||||
"weight": "Pisu eta masa neurketak egiaztatu",
|
||||
"boolean": "Bai/ez edo gainditu/huts egin kontrol sinpleak",
|
||||
"timing": "Denboran oinarritutako kalitate irizpideak jarraitu",
|
||||
"checklist": "Puntu anitzeko egiaztapen zerrenda egiaztapena"
|
||||
},
|
||||
"processStages": {
|
||||
"mixing": "Nahasketa",
|
||||
"proofing": "Hartzidura",
|
||||
"shaping": "Moldatzea",
|
||||
"baking": "Labea",
|
||||
"cooling": "Hoztea",
|
||||
"packaging": "Ontziratzea",
|
||||
"finishing": "Amaiera"
|
||||
},
|
||||
"sections": {
|
||||
"basicInformation": "Oinarrizko Informazioa",
|
||||
"additionalIdentifiers": "Identifikatzaile Gehigarriak",
|
||||
"additionalIdentifiersDescription": "Antolakuntza rako identifikatzaile aukerazkoak",
|
||||
"measurementSpecifications": "Neurketa Zehaztapenak",
|
||||
"additionalDetails": "Xehetasun Gehigarriak",
|
||||
"additionalDetailsDescription": "Jarraibide zehatz aukerazkoak",
|
||||
"scoringConfiguration": "Puntuazio Konfigurazioa",
|
||||
"advancedOptions": "Aukera Aurreratuak",
|
||||
"advancedOptionsDescription": "Kalitate txantiloi konfigurazio osoa egiteko eremu aukerazkoak",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
|
||||
import { StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState, type EditViewModalSection } from '../../../../components/ui';
|
||||
import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
@@ -11,7 +11,7 @@ import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes
|
||||
import { MeasurementUnit } from '../../../../api/types/recipes';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
|
||||
import { CreateRecipeModal, DeleteRecipeModal } from '../../../../components/domain/recipes';
|
||||
import { CreateRecipeModal, DeleteRecipeModal, RecipeViewEditModal } from '../../../../components/domain/recipes';
|
||||
import { QualityCheckConfigurationModal } from '../../../../components/domain/recipes/QualityCheckConfigurationModal';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { RecipeIngredientResponse } from '../../../../api/types/recipes';
|
||||
@@ -408,7 +408,7 @@ const RecipesPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const totalTemplates = Object.values(stages).reduce(
|
||||
(sum, stage) => sum + (stage.template_ids?.length || 0),
|
||||
(sum, stage) => sum + (stage?.template_ids?.length || 0),
|
||||
0
|
||||
);
|
||||
|
||||
@@ -785,7 +785,7 @@ const RecipesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Get modal sections with editable fields
|
||||
const getModalSections = () => {
|
||||
const getModalSections = (): EditViewModalSection[] => {
|
||||
if (!selectedRecipe) return [];
|
||||
|
||||
return [
|
||||
@@ -965,8 +965,8 @@ const RecipesPage: React.FC = () => {
|
||||
: (selectedRecipe.is_seasonal ? 'Sí' : 'No'),
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
{ value: 'false', label: 'No' },
|
||||
{ value: 'true', label: 'Sí' }
|
||||
] : undefined,
|
||||
editable: modalMode === 'edit'
|
||||
},
|
||||
@@ -989,8 +989,8 @@ const RecipesPage: React.FC = () => {
|
||||
: (selectedRecipe.is_signature_item ? 'Sí' : 'No'),
|
||||
type: modalMode === 'edit' ? 'select' : 'text',
|
||||
options: modalMode === 'edit' ? [
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
{ value: 'false', label: 'No' },
|
||||
{ value: 'true', label: 'Sí' }
|
||||
] : undefined,
|
||||
editable: modalMode === 'edit',
|
||||
highlight: selectedRecipe.is_signature_item
|
||||
@@ -1059,10 +1059,96 @@ const RecipesPage: React.FC = () => {
|
||||
label: 'Instrucciones de preparación',
|
||||
value: modalMode === 'edit'
|
||||
? formatJsonField(getFieldValue(selectedRecipe.instructions, 'instructions'))
|
||||
: formatJsonField(selectedRecipe.instructions),
|
||||
type: modalMode === 'edit' ? 'textarea' : 'text',
|
||||
: '', // Empty string when using customRenderer in view mode
|
||||
type: modalMode === 'edit' ? 'textarea' : 'custom',
|
||||
editable: modalMode === 'edit',
|
||||
span: 2
|
||||
span: 2,
|
||||
customRenderer: modalMode === 'view' ? () => {
|
||||
const instructions = selectedRecipe.instructions;
|
||||
|
||||
if (!instructions) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic">
|
||||
No especificado
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle array of instruction objects
|
||||
if (Array.isArray(instructions)) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{instructions.map((item: any, index: number) => {
|
||||
// Handle different item types
|
||||
let stepTitle = '';
|
||||
let stepDescription = '';
|
||||
|
||||
if (typeof item === 'string') {
|
||||
stepDescription = item;
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
stepTitle = item.step || item.title || '';
|
||||
stepDescription = item.description || '';
|
||||
|
||||
// If no description but has other fields, stringify them
|
||||
if (!stepDescription && !stepTitle) {
|
||||
stepDescription = JSON.stringify(item);
|
||||
}
|
||||
} else {
|
||||
stepDescription = String(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-xs font-semibold text-[var(--color-primary)]">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{stepTitle && stepDescription ? (
|
||||
<>
|
||||
<div className="font-medium text-[var(--text-primary)]">{stepTitle}</div>
|
||||
<div className="text-sm text-[var(--text-secondary)] mt-1">{stepDescription}</div>
|
||||
</>
|
||||
) : stepDescription ? (
|
||||
<div className="text-sm text-[var(--text-primary)]">{stepDescription}</div>
|
||||
) : stepTitle ? (
|
||||
<div className="text-sm text-[var(--text-primary)]">{stepTitle}</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic">
|
||||
Sin información
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle string
|
||||
if (typeof instructions === 'string') {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
|
||||
{instructions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle object
|
||||
if (typeof instructions === 'object') {
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
|
||||
{formatJsonField(instructions)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm text-[var(--text-secondary)] italic">
|
||||
No especificado
|
||||
</div>
|
||||
);
|
||||
} : undefined
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1105,43 +1191,11 @@ const RecipesPage: React.FC = () => {
|
||||
fields: [
|
||||
{
|
||||
label: '',
|
||||
value: modalMode === 'edit'
|
||||
? (editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [])
|
||||
: (selectedRecipe.ingredients
|
||||
?.sort((a, b) => a.ingredient_order - b.ingredient_order)
|
||||
?.map(ing => {
|
||||
const ingredientName = ingredientLookup[ing.ingredient_id] || 'Ingrediente desconocido';
|
||||
const parts = [];
|
||||
|
||||
// Main ingredient line with quantity
|
||||
parts.push(`${ing.quantity} ${ing.unit}`);
|
||||
parts.push(ingredientName);
|
||||
|
||||
// Add optional indicator
|
||||
if (ing.is_optional) {
|
||||
parts.push('(opcional)');
|
||||
}
|
||||
|
||||
// Add preparation method on new line if exists
|
||||
if (ing.preparation_method) {
|
||||
parts.push(`\n → ${ing.preparation_method}`);
|
||||
}
|
||||
|
||||
// Add notes on new line if exists
|
||||
if (ing.ingredient_notes) {
|
||||
parts.push(`\n 💡 ${ing.ingredient_notes}`);
|
||||
}
|
||||
|
||||
// Add substitution info if exists
|
||||
if (ing.substitution_options) {
|
||||
parts.push(`\n 🔄 Sustituto: ${ing.substitution_options}`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}) || ['No especificados']),
|
||||
type: modalMode === 'edit' ? 'component' as const : 'custom' as const,
|
||||
value: '',
|
||||
type: modalMode === 'edit' ? ('component' as const) : ('custom' as const),
|
||||
component: modalMode === 'edit' ? IngredientsEditComponent : undefined,
|
||||
componentProps: modalMode === 'edit' ? {
|
||||
value: editedIngredients.length > 0 ? editedIngredients : (selectedRecipe.ingredients || []),
|
||||
availableIngredients,
|
||||
unitOptions,
|
||||
onChange: (newIngredients: RecipeIngredientResponse[]) => {
|
||||
@@ -1208,7 +1262,7 @@ const RecipesPage: React.FC = () => {
|
||||
<div className="flex items-start gap-2 text-[var(--text-secondary)]">
|
||||
<span className="flex-shrink-0">🔄</span>
|
||||
<span>
|
||||
<strong>Sustituto:</strong> {ing.substitution_options}
|
||||
<strong>Sustituto:</strong> {typeof ing.substitution_options === 'string' ? ing.substitution_options : JSON.stringify(ing.substitution_options)}
|
||||
{ing.substitution_ratio && ` (ratio: ${ing.substitution_ratio})`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1519,37 +1573,20 @@ const RecipesPage: React.FC = () => {
|
||||
|
||||
{/* Recipe Details Modal */}
|
||||
{showForm && selectedRecipe && (
|
||||
<EditViewModal
|
||||
<RecipeViewEditModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedRecipe(null);
|
||||
setModalMode('view');
|
||||
setEditedRecipe({});
|
||||
setEditedIngredients([]);
|
||||
}}
|
||||
recipe={selectedRecipe}
|
||||
mode={modalMode}
|
||||
onModeChange={(newMode) => {
|
||||
setModalMode(newMode);
|
||||
if (newMode === 'view') {
|
||||
setEditedRecipe({});
|
||||
setEditedIngredients([]);
|
||||
} else if (newMode === 'edit' && selectedRecipe) {
|
||||
// Initialize edited ingredients when entering edit mode
|
||||
setEditedIngredients(selectedRecipe.ingredients || []);
|
||||
}
|
||||
}}
|
||||
title={selectedRecipe.name}
|
||||
subtitle={selectedRecipe.description || ''}
|
||||
statusIndicator={getRecipeStatusConfig(selectedRecipe)}
|
||||
size="xl"
|
||||
sections={getModalSections()}
|
||||
onFieldChange={handleFieldChange}
|
||||
showDefaultActions={true}
|
||||
onSave={handleSaveRecipe}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingRecipes}
|
||||
onSaveComplete={handleRecipeSaveComplete}
|
||||
isSaving={updateRecipeMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ const LandingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Scarcity Badge */}
|
||||
<div className="mb-8 inline-block">
|
||||
<div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-6 py-3 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<p className="text-sm font-bold text-amber-700 dark:text-amber-300">
|
||||
<div className="mb-8 inline-flex">
|
||||
<div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-5 py-2 shadow-lg hover:shadow-xl transition-all hover:scale-105">
|
||||
<p className="text-sm font-bold text-amber-700 dark:text-amber-300 leading-tight">
|
||||
🔥 {t('landing:hero.scarcity', 'Solo 12 plazas restantes de 20 • 3 meses GRATIS')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user