Add improved production UI 3

This commit is contained in:
Urtzi Alfaro
2025-09-23 19:24:22 +02:00
parent 7f871fc933
commit 7892c5a739
47 changed files with 6211 additions and 267 deletions

View File

@@ -0,0 +1,275 @@
// frontend/src/api/hooks/qualityTemplates.ts
/**
* React hooks for Quality Check Template API integration
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { qualityTemplateService } from '../services/qualityTemplates';
import type {
QualityCheckTemplate,
QualityCheckTemplateCreate,
QualityCheckTemplateUpdate,
QualityTemplateQueryParams,
ProcessStage,
QualityCheckExecutionRequest
} from '../types/qualityTemplates';
// Query Keys
export const qualityTemplateKeys = {
all: ['qualityTemplates'] as const,
lists: () => [...qualityTemplateKeys.all, 'list'] as const,
list: (tenantId: string, params?: QualityTemplateQueryParams) =>
[...qualityTemplateKeys.lists(), tenantId, params] as const,
details: () => [...qualityTemplateKeys.all, 'detail'] as const,
detail: (tenantId: string, templateId: string) =>
[...qualityTemplateKeys.details(), tenantId, templateId] as const,
forStage: (tenantId: string, stage: ProcessStage) =>
[...qualityTemplateKeys.all, 'stage', tenantId, stage] as const,
forRecipe: (tenantId: string, recipeId: string) =>
[...qualityTemplateKeys.all, 'recipe', tenantId, recipeId] as const,
};
/**
* Hook to fetch quality check templates
*/
export function useQualityTemplates(
tenantId: string,
params?: QualityTemplateQueryParams,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.list(tenantId, params),
queryFn: () => qualityTemplateService.getTemplates(tenantId, params),
enabled: !!tenantId && (options?.enabled ?? true),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch a specific quality check template
*/
export function useQualityTemplate(
tenantId: string,
templateId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.detail(tenantId, templateId),
queryFn: () => qualityTemplateService.getTemplate(tenantId, templateId),
enabled: !!tenantId && !!templateId && (options?.enabled ?? true),
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to fetch templates for a specific process stage
*/
export function useQualityTemplatesForStage(
tenantId: string,
stage: ProcessStage,
isActive: boolean = true,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.forStage(tenantId, stage),
queryFn: () => qualityTemplateService.getTemplatesForStage(tenantId, stage, isActive),
enabled: !!tenantId && !!stage && (options?.enabled ?? true),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch templates organized by stages for recipe configuration
*/
export function useQualityTemplatesForRecipe(
tenantId: string,
recipeId: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: qualityTemplateKeys.forRecipe(tenantId, recipeId),
queryFn: () => qualityTemplateService.getTemplatesForRecipe(tenantId, recipeId),
enabled: !!tenantId && !!recipeId && (options?.enabled ?? true),
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to create a quality check template
*/
export function useCreateQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (templateData: QualityCheckTemplateCreate) =>
qualityTemplateService.createTemplate(tenantId, templateData),
onSuccess: (newTemplate) => {
// Invalidate and refetch quality template lists
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
// Add to cache
queryClient.setQueryData(
qualityTemplateKeys.detail(tenantId, newTemplate.id),
newTemplate
);
toast.success('Plantilla de calidad creada exitosamente');
},
onError: (error: any) => {
console.error('Error creating quality template:', error);
toast.error(error.response?.data?.detail || 'Error al crear la plantilla de calidad');
},
});
}
/**
* Hook to update a quality check template
*/
export function useUpdateQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ templateId, templateData }: {
templateId: string;
templateData: QualityCheckTemplateUpdate;
}) => qualityTemplateService.updateTemplate(tenantId, templateId, templateData),
onSuccess: (updatedTemplate, { templateId }) => {
// Update cached data
queryClient.setQueryData(
qualityTemplateKeys.detail(tenantId, templateId),
updatedTemplate
);
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
toast.success('Plantilla de calidad actualizada exitosamente');
},
onError: (error: any) => {
console.error('Error updating quality template:', error);
toast.error(error.response?.data?.detail || 'Error al actualizar la plantilla de calidad');
},
});
}
/**
* Hook to delete a quality check template
*/
export function useDeleteQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (templateId: string) =>
qualityTemplateService.deleteTemplate(tenantId, templateId),
onSuccess: (_, templateId) => {
// Remove from cache
queryClient.removeQueries({
queryKey: qualityTemplateKeys.detail(tenantId, templateId)
});
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
toast.success('Plantilla de calidad eliminada exitosamente');
},
onError: (error: any) => {
console.error('Error deleting quality template:', error);
toast.error(error.response?.data?.detail || 'Error al eliminar la plantilla de calidad');
},
});
}
/**
* Hook to duplicate a quality check template
*/
export function useDuplicateQualityTemplate(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (templateId: string) =>
qualityTemplateService.duplicateTemplate(tenantId, templateId),
onSuccess: (duplicatedTemplate) => {
// Add to cache
queryClient.setQueryData(
qualityTemplateKeys.detail(tenantId, duplicatedTemplate.id),
duplicatedTemplate
);
// Invalidate lists to refresh
queryClient.invalidateQueries({ queryKey: qualityTemplateKeys.lists() });
toast.success('Plantilla de calidad duplicada exitosamente');
},
onError: (error: any) => {
console.error('Error duplicating quality template:', error);
toast.error(error.response?.data?.detail || 'Error al duplicar la plantilla de calidad');
},
});
}
/**
* Hook to execute a quality check
*/
export function useExecuteQualityCheck(tenantId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (executionData: QualityCheckExecutionRequest) =>
qualityTemplateService.executeQualityCheck(tenantId, executionData),
onSuccess: (result, executionData) => {
// Invalidate production batch data to refresh status
queryClient.invalidateQueries({
queryKey: ['production', 'batches', tenantId]
});
// Invalidate quality check history
queryClient.invalidateQueries({
queryKey: ['qualityChecks', tenantId, executionData.batch_id]
});
const message = result.overall_pass
? 'Control de calidad completado exitosamente'
: 'Control de calidad completado con observaciones';
if (result.overall_pass) {
toast.success(message);
} else {
toast.error(message);
}
},
onError: (error: any) => {
console.error('Error executing quality check:', error);
toast.error(error.response?.data?.detail || 'Error al ejecutar el control de calidad');
},
});
}
/**
* Hook to get default templates for a product category
*/
export function useDefaultQualityTemplates(
tenantId: string,
productCategory: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: [...qualityTemplateKeys.all, 'defaults', tenantId, productCategory],
queryFn: () => qualityTemplateService.getDefaultTemplates(tenantId, productCategory),
enabled: !!tenantId && !!productCategory && (options?.enabled ?? true),
staleTime: 15 * 60 * 1000, // 15 minutes
});
}
/**
* Hook to validate template configuration
*/
export function useValidateQualityTemplate(tenantId: string) {
return useMutation({
mutationFn: (templateData: Partial<QualityCheckTemplateCreate | QualityCheckTemplateUpdate>) =>
qualityTemplateService.validateTemplate(tenantId, templateData),
onError: (error: any) => {
console.error('Error validating quality template:', error);
},
});
}

View File

@@ -0,0 +1,205 @@
// frontend/src/api/services/qualityTemplates.ts
/**
* Quality Check Template API service
*/
import { apiClient } from '../client';
import type {
QualityCheckTemplate,
QualityCheckTemplateCreate,
QualityCheckTemplateUpdate,
QualityCheckTemplateList,
QualityTemplateQueryParams,
ProcessStage,
QualityCheckExecutionRequest,
QualityCheckExecutionResponse
} from '../types/qualityTemplates';
class QualityTemplateService {
private readonly baseURL = '/production/api/v1/quality-templates';
/**
* Create a new quality check template
*/
async createTemplate(
tenantId: string,
templateData: QualityCheckTemplateCreate
): Promise<QualityCheckTemplate> {
const response = await apiClient.post(this.baseURL, templateData, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Get quality check templates with filtering and pagination
*/
async getTemplates(
tenantId: string,
params?: QualityTemplateQueryParams
): Promise<QualityCheckTemplateList> {
const response = await apiClient.get(this.baseURL, {
params,
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Get a specific quality check template
*/
async getTemplate(
tenantId: string,
templateId: string
): Promise<QualityCheckTemplate> {
const response = await apiClient.get(`${this.baseURL}/${templateId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Update a quality check template
*/
async updateTemplate(
tenantId: string,
templateId: string,
templateData: QualityCheckTemplateUpdate
): Promise<QualityCheckTemplate> {
const response = await apiClient.put(`${this.baseURL}/${templateId}`, templateData, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Delete a quality check template
*/
async deleteTemplate(tenantId: string, templateId: string): Promise<void> {
await apiClient.delete(`${this.baseURL}/${templateId}`, {
headers: { 'X-Tenant-ID': tenantId }
});
}
/**
* Get templates applicable to a specific process stage
*/
async getTemplatesForStage(
tenantId: string,
stage: ProcessStage,
isActive: boolean = true
): Promise<QualityCheckTemplateList> {
const response = await apiClient.get(`${this.baseURL}/stages/${stage}`, {
params: { is_active: isActive },
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Duplicate an existing quality check template
*/
async duplicateTemplate(
tenantId: string,
templateId: string
): Promise<QualityCheckTemplate> {
const response = await apiClient.post(`${this.baseURL}/${templateId}/duplicate`, {}, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Execute a quality check using a template
*/
async executeQualityCheck(
tenantId: string,
executionData: QualityCheckExecutionRequest
): Promise<QualityCheckExecutionResponse> {
const response = await apiClient.post('/production/api/v1/quality-checks/execute', executionData, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Get quality check history for a batch
*/
async getQualityCheckHistory(
tenantId: string,
batchId: string,
stage?: ProcessStage
): Promise<any[]> {
const response = await apiClient.get('/production/api/v1/quality-checks', {
params: { batch_id: batchId, process_stage: stage },
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
}
/**
* Get quality check templates for recipe configuration
*/
async getTemplatesForRecipe(
tenantId: string,
recipeId: string
): Promise<Record<ProcessStage, QualityCheckTemplate[]>> {
const allTemplates = await this.getTemplates(tenantId, { is_active: true });
// Group templates by applicable stages
const templatesByStage: Record<ProcessStage, QualityCheckTemplate[]> = {} as any;
Object.values(ProcessStage).forEach(stage => {
templatesByStage[stage] = allTemplates.templates.filter(template =>
!template.applicable_stages ||
template.applicable_stages.length === 0 ||
template.applicable_stages.includes(stage)
);
});
return templatesByStage;
}
/**
* Validate template configuration
*/
async validateTemplate(
tenantId: string,
templateData: Partial<QualityCheckTemplateCreate | QualityCheckTemplateUpdate>
): Promise<{ valid: boolean; errors: string[] }> {
try {
const response = await apiClient.post(`${this.baseURL}/validate`, templateData, {
headers: { 'X-Tenant-ID': tenantId }
});
return response.data;
} catch (error: any) {
if (error.response?.status === 400) {
return {
valid: false,
errors: [error.response.data.detail || 'Validation failed']
};
}
throw error;
}
}
/**
* Get default template suggestions based on product type
*/
async getDefaultTemplates(
tenantId: string,
productCategory: string
): Promise<QualityCheckTemplate[]> {
const templates = await this.getTemplates(tenantId, {
is_active: true,
category: productCategory
});
// Return commonly used templates for the product category
return templates.templates.filter(template =>
template.is_required || template.weight > 5.0
).sort((a, b) => b.weight - a.weight);
}
}
export const qualityTemplateService = new QualityTemplateService();

View File

@@ -0,0 +1,173 @@
// frontend/src/api/types/qualityTemplates.ts
/**
* Quality Check Template types for API integration
*/
export enum QualityCheckType {
VISUAL = 'visual',
MEASUREMENT = 'measurement',
TEMPERATURE = 'temperature',
WEIGHT = 'weight',
BOOLEAN = 'boolean',
TIMING = 'timing'
}
export enum ProcessStage {
MIXING = 'mixing',
PROOFING = 'proofing',
SHAPING = 'shaping',
BAKING = 'baking',
COOLING = 'cooling',
PACKAGING = 'packaging',
FINISHING = 'finishing'
}
export interface QualityCheckTemplate {
id: string;
tenant_id: string;
name: string;
template_code?: string;
check_type: QualityCheckType;
category?: string;
description?: string;
instructions?: string;
parameters?: Record<string, any>;
thresholds?: Record<string, any>;
scoring_criteria?: Record<string, any>;
is_active: boolean;
is_required: boolean;
is_critical: boolean;
weight: number;
min_value?: number;
max_value?: number;
target_value?: number;
unit?: string;
tolerance_percentage?: number;
applicable_stages?: ProcessStage[];
created_by: string;
created_at: string;
updated_at: string;
}
export interface QualityCheckTemplateCreate {
name: string;
template_code?: string;
check_type: QualityCheckType;
category?: string;
description?: string;
instructions?: string;
parameters?: Record<string, any>;
thresholds?: Record<string, any>;
scoring_criteria?: Record<string, any>;
is_active?: boolean;
is_required?: boolean;
is_critical?: boolean;
weight?: number;
min_value?: number;
max_value?: number;
target_value?: number;
unit?: string;
tolerance_percentage?: number;
applicable_stages?: ProcessStage[];
created_by: string;
}
export interface QualityCheckTemplateUpdate {
name?: string;
template_code?: string;
check_type?: QualityCheckType;
category?: string;
description?: string;
instructions?: string;
parameters?: Record<string, any>;
thresholds?: Record<string, any>;
scoring_criteria?: Record<string, any>;
is_active?: boolean;
is_required?: boolean;
is_critical?: boolean;
weight?: number;
min_value?: number;
max_value?: number;
target_value?: number;
unit?: string;
tolerance_percentage?: number;
applicable_stages?: ProcessStage[];
}
export interface QualityCheckTemplateList {
templates: QualityCheckTemplate[];
total: number;
skip: number;
limit: number;
}
export interface QualityCheckCriterion {
id: string;
name: string;
description: string;
check_type: QualityCheckType;
required: boolean;
weight: number;
acceptable_criteria: string;
min_value?: number;
max_value?: number;
unit?: string;
is_critical: boolean;
}
export interface QualityCheckResult {
criterion_id: string;
value: number | string | boolean;
score: number;
notes?: string;
photos?: string[];
pass_check: boolean;
timestamp: string;
}
export interface QualityCheckExecutionRequest {
template_id: string;
batch_id: string;
process_stage: ProcessStage;
checker_id?: string;
results: QualityCheckResult[];
final_notes?: string;
photos?: string[];
}
export interface QualityCheckExecutionResponse {
check_id: string;
overall_score: number;
overall_pass: boolean;
critical_failures: string[];
corrective_actions: string[];
timestamp: string;
}
export interface ProcessStageQualityConfig {
stage: ProcessStage;
template_ids: string[];
custom_parameters?: Record<string, any>;
is_required: boolean;
blocking: boolean;
}
export interface RecipeQualityConfiguration {
stages: Record<string, ProcessStageQualityConfig>;
global_parameters?: Record<string, any>;
default_templates?: string[];
}
// Filter and query types
export interface QualityTemplateFilters {
stage?: ProcessStage;
check_type?: QualityCheckType;
is_active?: boolean;
category?: string;
search?: string;
}
export interface QualityTemplateQueryParams extends QualityTemplateFilters {
skip?: number;
limit?: number;
}

View File

@@ -15,9 +15,43 @@ import {
ChevronRight,
Timer,
Package,
Flame
Flame,
ChefHat,
Eye,
Scale,
FlaskRound,
CircleDot,
ArrowRight,
CheckSquare,
XSquare,
Zap,
Snowflake,
Box
} from 'lucide-react';
export interface QualityCheckRequirement {
id: string;
name: string;
stage: ProcessStage;
isRequired: boolean;
isCritical: boolean;
status: 'pending' | 'completed' | 'failed' | 'skipped';
checkType: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean';
}
export interface ProcessStageInfo {
current: ProcessStage;
history: Array<{
stage: ProcessStage;
timestamp: string;
duration?: number;
}>;
pendingQualityChecks: QualityCheckRequirement[];
completedQualityChecks: QualityCheckRequirement[];
}
export type ProcessStage = 'mixing' | 'proofing' | 'shaping' | 'baking' | 'cooling' | 'packaging' | 'finishing';
export interface ProductionOrder {
id: string;
product: string;
@@ -39,6 +73,7 @@ export interface ProductionOrder {
unit: string;
available: boolean;
}>;
processStage?: ProcessStageInfo;
}
export interface ProductionPlansProps {
@@ -50,6 +85,56 @@ export interface ProductionPlansProps {
onViewAllPlans?: () => void;
}
const getProcessStageIcon = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return ChefHat;
case 'proofing': return Timer;
case 'shaping': return Package;
case 'baking': return Flame;
case 'cooling': return Snowflake;
case 'packaging': return Box;
case 'finishing': return CheckCircle;
default: return CircleDot;
}
};
const getProcessStageColor = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'var(--color-info)';
case 'proofing': return 'var(--color-warning)';
case 'shaping': return 'var(--color-primary)';
case 'baking': return 'var(--color-error)';
case 'cooling': return 'var(--color-info)';
case 'packaging': return 'var(--color-success)';
case 'finishing': return 'var(--color-success)';
default: return 'var(--color-gray)';
}
};
const getProcessStageLabel = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'Mezclado';
case 'proofing': return 'Fermentado';
case 'shaping': return 'Formado';
case 'baking': return 'Horneado';
case 'cooling': return 'Enfriado';
case 'packaging': return 'Empaquetado';
case 'finishing': return 'Acabado';
default: return 'Sin etapa';
}
};
const getQualityCheckIcon = (checkType: string) => {
switch (checkType) {
case 'visual': return Eye;
case 'measurement': return Scale;
case 'temperature': return Thermometer;
case 'weight': return Scale;
case 'boolean': return CheckSquare;
default: return FlaskRound;
}
};
const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
className,
orders = [],
@@ -78,7 +163,38 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Levadura', quantity: 0.5, unit: 'kg', available: true },
{ name: 'Sal', quantity: 0.2, unit: 'kg', available: true },
{ name: 'Agua', quantity: 3, unit: 'L', available: true }
]
],
processStage: {
current: 'baking',
history: [
{ stage: 'mixing', timestamp: '06:00', duration: 30 },
{ stage: 'proofing', timestamp: '06:30', duration: 90 },
{ stage: 'shaping', timestamp: '08:00', duration: 15 },
{ stage: 'baking', timestamp: '08:15' }
],
pendingQualityChecks: [
{
id: 'qc1',
name: 'Control de temperatura interna',
stage: 'baking',
isRequired: true,
isCritical: true,
status: 'pending',
checkType: 'temperature'
}
],
completedQualityChecks: [
{
id: 'qc2',
name: 'Inspección visual de masa',
stage: 'mixing',
isRequired: true,
isCritical: false,
status: 'completed',
checkType: 'visual'
}
]
}
},
{
id: '2',
@@ -99,7 +215,34 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Masa de croissant', quantity: 3, unit: 'kg', available: true },
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
{ name: 'Huevo', quantity: 6, unit: 'unidades', available: true }
]
],
processStage: {
current: 'shaping',
history: [
{ stage: 'proofing', timestamp: '07:30', duration: 120 }
],
pendingQualityChecks: [
{
id: 'qc3',
name: 'Verificar formado de hojaldre',
stage: 'shaping',
isRequired: true,
isCritical: false,
status: 'pending',
checkType: 'visual'
},
{
id: 'qc4',
name: 'Control de peso individual',
stage: 'shaping',
isRequired: false,
isCritical: false,
status: 'pending',
checkType: 'weight'
}
],
completedQualityChecks: []
}
},
{
id: '3',
@@ -120,7 +263,39 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Levadura', quantity: 0.3, unit: 'kg', available: true },
{ name: 'Sal', quantity: 0.15, unit: 'kg', available: true },
{ name: 'Agua', quantity: 2.5, unit: 'L', available: true }
]
],
processStage: {
current: 'finishing',
history: [
{ stage: 'mixing', timestamp: '05:00', duration: 20 },
{ stage: 'proofing', timestamp: '05:20', duration: 120 },
{ stage: 'shaping', timestamp: '07:20', duration: 30 },
{ stage: 'baking', timestamp: '07:50', duration: 45 },
{ stage: 'cooling', timestamp: '08:35', duration: 30 },
{ stage: 'finishing', timestamp: '09:05' }
],
pendingQualityChecks: [],
completedQualityChecks: [
{
id: 'qc5',
name: 'Inspección visual final',
stage: 'finishing',
isRequired: true,
isCritical: false,
status: 'completed',
checkType: 'visual'
},
{
id: 'qc6',
name: 'Control de temperatura de cocción',
stage: 'baking',
isRequired: true,
isCritical: true,
status: 'completed',
checkType: 'temperature'
}
]
}
},
{
id: '4',
@@ -143,7 +318,23 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
{ name: 'Huevos', quantity: 24, unit: 'unidades', available: true },
{ name: 'Mantequilla', quantity: 1, unit: 'kg', available: false },
{ name: 'Vainilla', quantity: 50, unit: 'ml', available: true }
]
],
processStage: {
current: 'mixing',
history: [],
pendingQualityChecks: [
{
id: 'qc7',
name: 'Verificar consistencia de masa',
stage: 'mixing',
isRequired: true,
isCritical: false,
status: 'pending',
checkType: 'visual'
}
],
completedQualityChecks: []
}
}
];
@@ -214,6 +405,63 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
const completedOrders = displayOrders.filter(order => order.status === 'completed').length;
const delayedOrders = displayOrders.filter(order => order.status === 'delayed').length;
// Cross-batch quality overview calculations
const totalPendingQualityChecks = displayOrders.reduce((total, order) =>
total + (order.processStage?.pendingQualityChecks.length || 0), 0);
const criticalPendingQualityChecks = displayOrders.reduce((total, order) =>
total + (order.processStage?.pendingQualityChecks.filter(qc => qc.isCritical).length || 0), 0);
const ordersBlockedByQuality = displayOrders.filter(order =>
order.processStage?.pendingQualityChecks.some(qc => qc.isCritical && qc.isRequired) || false).length;
// Helper function to create enhanced metadata with process stage info
const createEnhancedMetadata = (order: ProductionOrder) => {
const baseMetadata = [
`⏰ Inicio: ${order.startTime}`,
`⏱️ Duración: ${formatDuration(order.estimatedDuration)}`,
...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : [])
];
if (order.processStage) {
const { current, pendingQualityChecks, completedQualityChecks } = order.processStage;
const currentStageIcon = getProcessStageIcon(current);
const currentStageLabel = getProcessStageLabel(current);
// Add current stage info
baseMetadata.push(`🔄 Etapa: ${currentStageLabel}`);
// Add quality check info
if (pendingQualityChecks.length > 0) {
const criticalPending = pendingQualityChecks.filter(qc => qc.isCritical).length;
const requiredPending = pendingQualityChecks.filter(qc => qc.isRequired).length;
if (criticalPending > 0) {
baseMetadata.push(`🚨 ${criticalPending} controles críticos pendientes`);
} else if (requiredPending > 0) {
baseMetadata.push(`${requiredPending} controles requeridos pendientes`);
} else {
baseMetadata.push(`📋 ${pendingQualityChecks.length} controles opcionales pendientes`);
}
}
if (completedQualityChecks.length > 0) {
baseMetadata.push(`${completedQualityChecks.length} controles completados`);
}
}
// Add ingredients info
const availableIngredients = order.ingredients.filter(ing => ing.available).length;
const totalIngredients = order.ingredients.length;
const ingredientsReady = availableIngredients === totalIngredients;
baseMetadata.push(`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`);
// Add notes if any
if (order.notes) {
baseMetadata.push(`📝 ${order.notes}`);
}
return baseMetadata;
};
return (
<Card className={className} variant="elevated" padding="none">
<CardHeader padding="lg" divider>
@@ -236,6 +484,21 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
</div>
<div className="flex items-center gap-2">
{ordersBlockedByQuality > 0 && (
<Badge variant="error" size="sm">
🚨 {ordersBlockedByQuality} bloqueadas por calidad
</Badge>
)}
{criticalPendingQualityChecks > 0 && ordersBlockedByQuality === 0 && (
<Badge variant="warning" size="sm">
🔍 {criticalPendingQualityChecks} controles críticos
</Badge>
)}
{totalPendingQualityChecks > 0 && criticalPendingQualityChecks === 0 && (
<Badge variant="info" size="sm">
📋 {totalPendingQualityChecks} controles pendientes
</Badge>
)}
{delayedOrders > 0 && (
<Badge variant="error" size="sm">
{delayedOrders} retrasadas
@@ -273,23 +536,44 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
<div className="space-y-3 p-4">
{displayOrders.map((order) => {
const statusConfig = getOrderStatusConfig(order);
const availableIngredients = order.ingredients.filter(ing => ing.available).length;
const totalIngredients = order.ingredients.length;
const ingredientsReady = availableIngredients === totalIngredients;
const enhancedMetadata = createEnhancedMetadata(order);
// Enhanced secondary info that includes stage information
const getSecondaryInfo = () => {
if (order.processStage) {
const currentStageLabel = getProcessStageLabel(order.processStage.current);
return {
label: 'Etapa actual',
value: `${currentStageLabel}${order.assignedBaker}`
};
}
return {
label: 'Panadero asignado',
value: order.assignedBaker
};
};
// Enhanced status indicator with process stage color for active orders
const getEnhancedStatusConfig = () => {
if (order.processStage && order.status === 'in_progress') {
return {
...statusConfig,
color: getProcessStageColor(order.processStage.current)
};
}
return statusConfig;
};
return (
<StatusCard
key={order.id}
id={order.id}
statusIndicator={statusConfig}
statusIndicator={getEnhancedStatusConfig()}
title={order.product}
subtitle={`${order.recipe}${order.quantity} ${order.unit}`}
primaryValue={`${order.progress}%`}
primaryValueLabel="PROGRESO"
secondaryInfo={{
label: 'Panadero asignado',
value: order.assignedBaker
}}
secondaryInfo={getSecondaryInfo()}
progress={order.status !== 'pending' ? {
label: `Progreso de producción`,
percentage: order.progress,
@@ -297,13 +581,7 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
order.progress > 70 ? 'var(--color-info)' :
order.progress > 30 ? 'var(--color-warning)' : 'var(--color-error)'
} : undefined}
metadata={[
`⏰ Inicio: ${order.startTime}`,
`⏱️ Duración: ${formatDuration(order.estimatedDuration)}`,
...(order.ovenNumber ? [`🔥 Horno ${order.ovenNumber} - ${order.temperature}°C`] : []),
`📋 Ingredientes: ${availableIngredients}/${totalIngredients} ${ingredientsReady ? '✅' : '❌'}`,
...(order.notes ? [`📝 ${order.notes}`] : [])
]}
metadata={enhancedMetadata}
actions={[
...(order.status === 'pending' ? [{
label: 'Iniciar',
@@ -320,6 +598,22 @@ const ProductionPlansToday: React.FC<ProductionPlansProps> = ({
priority: 'primary' as const,
destructive: true
}] : []),
// Add quality check action if there are pending quality checks
...(order.processStage?.pendingQualityChecks.length > 0 ? [{
label: `${order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? '🚨 ' : ''}Controles Calidad`,
icon: FlaskRound,
variant: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'outline' as const,
onClick: () => onViewDetails?.(order.id), // This would open the quality check modal
priority: order.processStage.pendingQualityChecks.filter(qc => qc.isCritical).length > 0 ? 'primary' as const : 'secondary' as const
}] : []),
// Add next stage action for orders that can progress
...(order.status === 'in_progress' && order.processStage && order.processStage.pendingQualityChecks.length === 0 ? [{
label: 'Siguiente Etapa',
icon: ArrowRight,
variant: 'primary' as const,
onClick: () => console.log(`Advancing stage for order ${order.id}`),
priority: 'primary' as const
}] : []),
{
label: 'Ver Detalles',
icon: ChevronRight,

View File

@@ -0,0 +1,303 @@
import React from 'react';
import { Badge } from '../../ui/Badge';
import { Button } from '../../ui/Button';
import {
ChefHat,
Timer,
Package,
Flame,
Snowflake,
Box,
CheckCircle,
CircleDot,
Eye,
Scale,
Thermometer,
FlaskRound,
CheckSquare,
ArrowRight,
Clock,
AlertTriangle
} from 'lucide-react';
export type ProcessStage = 'mixing' | 'proofing' | 'shaping' | 'baking' | 'cooling' | 'packaging' | 'finishing';
export interface QualityCheckRequirement {
id: string;
name: string;
stage: ProcessStage;
isRequired: boolean;
isCritical: boolean;
status: 'pending' | 'completed' | 'failed' | 'skipped';
checkType: 'visual' | 'measurement' | 'temperature' | 'weight' | 'boolean';
}
export interface ProcessStageInfo {
current: ProcessStage;
history: Array<{
stage: ProcessStage;
timestamp: string;
duration?: number;
}>;
pendingQualityChecks: QualityCheckRequirement[];
completedQualityChecks: QualityCheckRequirement[];
}
export interface CompactProcessStageTrackerProps {
processStage: ProcessStageInfo;
onAdvanceStage?: (currentStage: ProcessStage) => void;
onQualityCheck?: (checkId: string) => void;
className?: string;
}
const getProcessStageIcon = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return ChefHat;
case 'proofing': return Timer;
case 'shaping': return Package;
case 'baking': return Flame;
case 'cooling': return Snowflake;
case 'packaging': return Box;
case 'finishing': return CheckCircle;
default: return CircleDot;
}
};
const getProcessStageColor = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'var(--color-info)';
case 'proofing': return 'var(--color-warning)';
case 'shaping': return 'var(--color-primary)';
case 'baking': return 'var(--color-error)';
case 'cooling': return 'var(--color-info)';
case 'packaging': return 'var(--color-success)';
case 'finishing': return 'var(--color-success)';
default: return 'var(--color-gray)';
}
};
const getProcessStageLabel = (stage: ProcessStage) => {
switch (stage) {
case 'mixing': return 'Mezclado';
case 'proofing': return 'Fermentado';
case 'shaping': return 'Formado';
case 'baking': return 'Horneado';
case 'cooling': return 'Enfriado';
case 'packaging': return 'Empaquetado';
case 'finishing': return 'Acabado';
default: return 'Sin etapa';
}
};
const getQualityCheckIcon = (checkType: string) => {
switch (checkType) {
case 'visual': return Eye;
case 'measurement': return Scale;
case 'temperature': return Thermometer;
case 'weight': return Scale;
case 'boolean': return CheckSquare;
default: return FlaskRound;
}
};
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
};
const formatDuration = (minutes?: number) => {
if (!minutes) return '';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
};
const CompactProcessStageTracker: React.FC<CompactProcessStageTrackerProps> = ({
processStage,
onAdvanceStage,
onQualityCheck,
className = ''
}) => {
const allStages: ProcessStage[] = ['mixing', 'proofing', 'shaping', 'baking', 'cooling', 'packaging', 'finishing'];
const currentStageIndex = allStages.indexOf(processStage.current);
const completedStages = processStage.history.map(h => h.stage);
const criticalPendingChecks = processStage.pendingQualityChecks.filter(qc => qc.isCritical);
const canAdvanceStage = processStage.pendingQualityChecks.length === 0 && currentStageIndex < allStages.length - 1;
return (
<div className={`space-y-4 ${className}`}>
{/* Current Stage Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="p-2 rounded-lg"
style={{
backgroundColor: `${getProcessStageColor(processStage.current)}20`,
color: getProcessStageColor(processStage.current)
}}
>
{React.createElement(getProcessStageIcon(processStage.current), { className: 'w-4 h-4' })}
</div>
<div>
<h4 className="font-medium text-[var(--text-primary)]">
{getProcessStageLabel(processStage.current)}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
Etapa actual
</p>
</div>
</div>
{canAdvanceStage && (
<Button
size="sm"
variant="primary"
onClick={() => onAdvanceStage?.(processStage.current)}
className="flex items-center gap-1"
>
<ArrowRight className="w-4 h-4" />
Siguiente
</Button>
)}
</div>
{/* Process Timeline */}
<div className="relative">
<div className="flex items-center justify-between">
{allStages.map((stage, index) => {
const StageIcon = getProcessStageIcon(stage);
const isCompleted = completedStages.includes(stage);
const isCurrent = stage === processStage.current;
const stageHistory = processStage.history.find(h => h.stage === stage);
return (
<div key={stage} className="flex flex-col items-center relative">
{/* Connection Line */}
{index < allStages.length - 1 && (
<div
className="absolute left-full top-4 w-full h-0.5 -translate-y-1/2 z-0"
style={{
backgroundColor: isCompleted || isCurrent ? getProcessStageColor(stage) : 'var(--border-primary)',
opacity: isCompleted || isCurrent ? 1 : 0.3
}}
/>
)}
{/* Stage Icon */}
<div
className="w-8 h-8 rounded-full flex items-center justify-center relative z-10 border-2"
style={{
backgroundColor: isCompleted || isCurrent ? getProcessStageColor(stage) : 'var(--bg-primary)',
borderColor: isCompleted || isCurrent ? getProcessStageColor(stage) : 'var(--border-primary)',
color: isCompleted || isCurrent ? 'white' : 'var(--text-tertiary)'
}}
>
<StageIcon className="w-4 h-4" />
</div>
{/* Stage Label */}
<div className="text-xs mt-1 text-center max-w-12">
<div className={`font-medium ${isCurrent ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'}`}>
{getProcessStageLabel(stage).split(' ')[0]}
</div>
{stageHistory && (
<div className="text-[var(--text-tertiary)]">
{formatTime(stageHistory.timestamp)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Quality Checks Section */}
{processStage.pendingQualityChecks.length > 0 && (
<div className="bg-[var(--bg-secondary)] rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<FlaskRound className="w-4 h-4 text-[var(--text-secondary)]" />
<h5 className="font-medium text-[var(--text-primary)]">
Controles de Calidad Pendientes
</h5>
{criticalPendingChecks.length > 0 && (
<Badge variant="error" size="xs">
{criticalPendingChecks.length} críticos
</Badge>
)}
</div>
<div className="space-y-2">
{processStage.pendingQualityChecks.map((check) => {
const CheckIcon = getQualityCheckIcon(check.checkType);
return (
<div
key={check.id}
className="flex items-center justify-between bg-[var(--bg-primary)] rounded-md p-2"
>
<div className="flex items-center gap-2">
<CheckIcon className="w-4 h-4 text-[var(--text-secondary)]" />
<div>
<div className="text-sm font-medium text-[var(--text-primary)]">
{check.name}
</div>
<div className="text-xs text-[var(--text-secondary)] flex items-center gap-2">
{check.isCritical && <AlertTriangle className="w-3 h-3 text-[var(--color-error)]" />}
{check.isRequired ? 'Obligatorio' : 'Opcional'}
</div>
</div>
</div>
<Button
size="xs"
variant={check.isCritical ? 'primary' : 'outline'}
onClick={() => onQualityCheck?.(check.id)}
>
{check.isCritical ? 'Realizar' : 'Verificar'}
</Button>
</div>
);
})}
</div>
</div>
)}
{/* Completed Quality Checks Summary */}
{processStage.completedQualityChecks.length > 0 && (
<div className="text-sm text-[var(--text-secondary)]">
<div className="flex items-center gap-1">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>{processStage.completedQualityChecks.length} controles de calidad completados</span>
</div>
</div>
)}
{/* Stage History Summary */}
{processStage.history.length > 0 && (
<div className="text-xs text-[var(--text-tertiary)] bg-[var(--bg-secondary)] rounded-md p-2">
<div className="font-medium mb-1">Historial de etapas:</div>
<div className="space-y-1">
{processStage.history.map((historyItem, index) => (
<div key={index} className="flex justify-between">
<span>{getProcessStageLabel(historyItem.stage)}</span>
<span>
{formatTime(historyItem.timestamp)}
{historyItem.duration && ` (${formatDuration(historyItem.duration)})`}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default CompactProcessStageTracker;

View File

@@ -0,0 +1,406 @@
import React, { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
Modal,
Button,
Input,
Textarea,
Select,
Badge,
Card
} from '../../ui';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplateCreate
} from '../../../api/types/qualityTemplates';
import { useCurrentTenant } from '../../../stores/tenant.store';
interface CreateQualityTemplateModalProps {
isOpen: boolean;
onClose: () => void;
onCreateTemplate: (templateData: QualityCheckTemplateCreate) => Promise<void>;
isLoading?: boolean;
}
const QUALITY_CHECK_TYPE_OPTIONS = [
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
];
const PROCESS_STAGE_OPTIONS = [
{ value: ProcessStage.MIXING, label: 'Mezclado' },
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
{ value: ProcessStage.SHAPING, label: 'Formado' },
{ value: ProcessStage.BAKING, label: 'Horneado' },
{ value: ProcessStage.COOLING, label: 'Enfriado' },
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
{ value: ProcessStage.FINISHING, label: 'Acabado' }
];
const CATEGORY_OPTIONS = [
{ value: 'appearance', label: 'Apariencia' },
{ value: 'structure', label: 'Estructura' },
{ value: 'texture', label: 'Textura' },
{ value: 'flavor', label: 'Sabor' },
{ value: 'safety', label: 'Seguridad' },
{ value: 'packaging', label: 'Empaque' },
{ value: 'temperature', label: 'Temperatura' },
{ value: 'weight', label: 'Peso' },
{ value: 'dimensions', label: 'Dimensiones' }
];
export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProps> = ({
isOpen,
onClose,
onCreateTemplate,
isLoading = false
}) => {
const currentTenant = useCurrentTenant();
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>([]);
const {
register,
control,
handleSubmit,
watch,
reset,
formState: { errors, isValid }
} = useForm<QualityCheckTemplateCreate>({
defaultValues: {
name: '',
template_code: '',
check_type: QualityCheckType.VISUAL,
category: '',
description: '',
instructions: '',
is_active: true,
is_required: false,
is_critical: false,
weight: 1.0,
applicable_stages: [],
created_by: currentTenant?.id || ''
}
});
const checkType = watch('check_type');
const showMeasurementFields = [
QualityCheckType.MEASUREMENT,
QualityCheckType.TEMPERATURE,
QualityCheckType.WEIGHT
].includes(checkType);
const handleStageToggle = (stage: ProcessStage) => {
const newStages = selectedStages.includes(stage)
? selectedStages.filter(s => s !== stage)
: [...selectedStages, stage];
setSelectedStages(newStages);
};
const onSubmit = async (data: QualityCheckTemplateCreate) => {
try {
await onCreateTemplate({
...data,
applicable_stages: selectedStages.length > 0 ? selectedStages : undefined,
created_by: currentTenant?.id || ''
});
// Reset form
reset();
setSelectedStages([]);
} catch (error) {
console.error('Error creating template:', error);
}
};
const handleClose = () => {
reset();
setSelectedStages([]);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Nueva Plantilla de Control de Calidad"
size="xl"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Información Básica
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre *
</label>
<Input
{...register('name', { required: 'El nombre es requerido' })}
placeholder="Ej: Control Visual de Pan"
error={errors.name?.message}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Código de Plantilla
</label>
<Input
{...register('template_code')}
placeholder="Ej: CV_PAN_01"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tipo de Control *
</label>
<Controller
name="check_type"
control={control}
render={({ field }) => (
<Select {...field} options={QUALITY_CHECK_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))}>
{QUALITY_CHECK_TYPE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Categoría
</label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select {...field} options={[{ value: '', label: 'Seleccionar categoría' }, ...CATEGORY_OPTIONS]}>
<option value="">Seleccionar categoría</option>
{CATEGORY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Descripción
</label>
<Textarea
{...register('description')}
placeholder="Describe qué evalúa esta plantilla de calidad"
rows={2}
/>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Instrucciones para el Personal
</label>
<Textarea
{...register('instructions')}
placeholder="Instrucciones detalladas para realizar este control de calidad"
rows={3}
/>
</div>
</Card>
{/* Measurement Configuration */}
{showMeasurementFields && (
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración de Medición
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Mínimo
</label>
<Input
type="number"
step="0.01"
{...register('min_value', { valueAsNumber: true })}
placeholder="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Máximo
</label>
<Input
type="number"
step="0.01"
{...register('max_value', { valueAsNumber: true })}
placeholder="100"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Objetivo
</label>
<Input
type="number"
step="0.01"
{...register('target_value', { valueAsNumber: true })}
placeholder="50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Unidad
</label>
<Input
{...register('unit')}
placeholder={
checkType === QualityCheckType.TEMPERATURE ? '°C' :
checkType === QualityCheckType.WEIGHT ? 'g' : 'unidad'
}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tolerancia (%)
</label>
<Input
type="number"
step="0.1"
{...register('tolerance_percentage', { valueAsNumber: true })}
placeholder="5"
className="w-32"
/>
</div>
</Card>
)}
{/* Process Stages */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Etapas del Proceso Aplicables
</h4>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Selecciona las etapas donde se debe aplicar este control. Si no seleccionas ninguna, se aplicará a todas las etapas.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{PROCESS_STAGE_OPTIONS.map(stage => (
<label
key={stage.value}
className="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)]"
>
<input
type="checkbox"
checked={selectedStages.includes(stage.value)}
onChange={() => handleStageToggle(stage.value)}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">{stage.label}</span>
</label>
))}
</div>
</Card>
{/* Settings */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Peso en Puntuación General
</label>
<Input
type="number"
step="0.1"
min="0"
max="10"
{...register('weight', { valueAsNumber: true })}
placeholder="1.0"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Mayor peso = mayor importancia en la puntuación final
</p>
</div>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_active')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Plantilla activa</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_required')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control requerido</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_critical')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control crítico</span>
<Badge variant="error" size="sm">
Bloquea producción si falla
</Badge>
</label>
</div>
</div>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Cancelar
</Button>
<Button
type="submit"
variant="primary"
disabled={!isValid || isLoading}
isLoading={isLoading}
>
Crear Plantilla
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,463 @@
import React, { useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
Modal,
Button,
Input,
Textarea,
Select,
Badge,
Card
} from '../../ui';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplate,
type QualityCheckTemplateUpdate
} from '../../../api/types/qualityTemplates';
interface EditQualityTemplateModalProps {
isOpen: boolean;
onClose: () => void;
template: QualityCheckTemplate;
onUpdateTemplate: (templateData: QualityCheckTemplateUpdate) => Promise<void>;
isLoading?: boolean;
}
const QUALITY_CHECK_TYPE_OPTIONS = [
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
];
const PROCESS_STAGE_OPTIONS = [
{ value: ProcessStage.MIXING, label: 'Mezclado' },
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
{ value: ProcessStage.SHAPING, label: 'Formado' },
{ value: ProcessStage.BAKING, label: 'Horneado' },
{ value: ProcessStage.COOLING, label: 'Enfriado' },
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
{ value: ProcessStage.FINISHING, label: 'Acabado' }
];
const CATEGORY_OPTIONS = [
{ value: 'appearance', label: 'Apariencia' },
{ value: 'structure', label: 'Estructura' },
{ value: 'texture', label: 'Textura' },
{ value: 'flavor', label: 'Sabor' },
{ value: 'safety', label: 'Seguridad' },
{ value: 'packaging', label: 'Empaque' },
{ value: 'temperature', label: 'Temperatura' },
{ value: 'weight', label: 'Peso' },
{ value: 'dimensions', label: 'Dimensiones' }
];
export const EditQualityTemplateModal: React.FC<EditQualityTemplateModalProps> = ({
isOpen,
onClose,
template,
onUpdateTemplate,
isLoading = false
}) => {
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>(
template.applicable_stages || []
);
const {
register,
control,
handleSubmit,
watch,
reset,
formState: { errors, isDirty }
} = useForm<QualityCheckTemplateUpdate>({
defaultValues: {
name: template.name,
template_code: template.template_code || '',
check_type: template.check_type,
category: template.category || '',
description: template.description || '',
instructions: template.instructions || '',
is_active: template.is_active,
is_required: template.is_required,
is_critical: template.is_critical,
weight: template.weight,
min_value: template.min_value,
max_value: template.max_value,
target_value: template.target_value,
unit: template.unit || '',
tolerance_percentage: template.tolerance_percentage
}
});
const checkType = watch('check_type');
const showMeasurementFields = [
QualityCheckType.MEASUREMENT,
QualityCheckType.TEMPERATURE,
QualityCheckType.WEIGHT
].includes(checkType || template.check_type);
// Update form when template changes
useEffect(() => {
if (template) {
reset({
name: template.name,
template_code: template.template_code || '',
check_type: template.check_type,
category: template.category || '',
description: template.description || '',
instructions: template.instructions || '',
is_active: template.is_active,
is_required: template.is_required,
is_critical: template.is_critical,
weight: template.weight,
min_value: template.min_value,
max_value: template.max_value,
target_value: template.target_value,
unit: template.unit || '',
tolerance_percentage: template.tolerance_percentage
});
setSelectedStages(template.applicable_stages || []);
}
}, [template, reset]);
const handleStageToggle = (stage: ProcessStage) => {
const newStages = selectedStages.includes(stage)
? selectedStages.filter(s => s !== stage)
: [...selectedStages, stage];
setSelectedStages(newStages);
};
const onSubmit = async (data: QualityCheckTemplateUpdate) => {
try {
// Only include changed fields
const updates: QualityCheckTemplateUpdate = {};
Object.entries(data).forEach(([key, value]) => {
const originalValue = (template as any)[key];
if (value !== originalValue) {
(updates as any)[key] = value;
}
});
// Handle applicable stages
const stagesChanged = JSON.stringify(selectedStages.sort()) !==
JSON.stringify((template.applicable_stages || []).sort());
if (stagesChanged) {
updates.applicable_stages = selectedStages.length > 0 ? selectedStages : undefined;
}
// Only submit if there are actual changes
if (Object.keys(updates).length > 0) {
await onUpdateTemplate(updates);
} else {
onClose();
}
} catch (error) {
console.error('Error updating template:', error);
}
};
const handleClose = () => {
reset();
setSelectedStages(template.applicable_stages || []);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={`Editar Plantilla: ${template.name}`}
size="xl"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Información Básica
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre *
</label>
<Input
{...register('name', { required: 'El nombre es requerido' })}
placeholder="Ej: Control Visual de Pan"
error={errors.name?.message}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Código de Plantilla
</label>
<Input
{...register('template_code')}
placeholder="Ej: CV_PAN_01"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tipo de Control *
</label>
<Controller
name="check_type"
control={control}
render={({ field }) => (
<Select {...field} options={QUALITY_CHECK_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))}>
{QUALITY_CHECK_TYPE_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Categoría
</label>
<Controller
name="category"
control={control}
render={({ field }) => (
<Select {...field} options={[{ value: '', label: 'Seleccionar categoría' }, ...CATEGORY_OPTIONS]}>
<option value="">Seleccionar categoría</option>
{CATEGORY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
)}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Descripción
</label>
<Textarea
{...register('description')}
placeholder="Describe qué evalúa esta plantilla de calidad"
rows={2}
/>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Instrucciones para el Personal
</label>
<Textarea
{...register('instructions')}
placeholder="Instrucciones detalladas para realizar este control de calidad"
rows={3}
/>
</div>
</Card>
{/* Measurement Configuration */}
{showMeasurementFields && (
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración de Medición
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Mínimo
</label>
<Input
type="number"
step="0.01"
{...register('min_value', { valueAsNumber: true })}
placeholder="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Máximo
</label>
<Input
type="number"
step="0.01"
{...register('max_value', { valueAsNumber: true })}
placeholder="100"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Valor Objetivo
</label>
<Input
type="number"
step="0.01"
{...register('target_value', { valueAsNumber: true })}
placeholder="50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Unidad
</label>
<Input
{...register('unit')}
placeholder={
checkType === QualityCheckType.TEMPERATURE ? '°C' :
checkType === QualityCheckType.WEIGHT ? 'g' : 'unidad'
}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Tolerancia (%)
</label>
<Input
type="number"
step="0.1"
{...register('tolerance_percentage', { valueAsNumber: true })}
placeholder="5"
className="w-32"
/>
</div>
</Card>
)}
{/* Process Stages */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Etapas del Proceso Aplicables
</h4>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Selecciona las etapas donde se debe aplicar este control. Si no seleccionas ninguna, se aplicará a todas las etapas.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{PROCESS_STAGE_OPTIONS.map(stage => (
<label
key={stage.value}
className="flex items-center space-x-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)]"
>
<input
type="checkbox"
checked={selectedStages.includes(stage.value)}
onChange={() => handleStageToggle(stage.value)}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">{stage.label}</span>
</label>
))}
</div>
</Card>
{/* Settings */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4">
Configuración
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Peso en Puntuación General
</label>
<Input
type="number"
step="0.1"
min="0"
max="10"
{...register('weight', { valueAsNumber: true })}
placeholder="1.0"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Mayor peso = mayor importancia en la puntuación final
</p>
</div>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_active')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Plantilla activa</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_required')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control requerido</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
{...register('is_critical')}
className="rounded border-[var(--border-primary)]"
/>
<span className="text-sm">Control crítico</span>
<Badge variant="error" size="sm">
Bloquea producción si falla
</Badge>
</label>
</div>
</div>
</Card>
{/* Template Info */}
<Card className="p-4 bg-[var(--bg-secondary)]">
<h4 className="font-medium text-[var(--text-primary)] mb-2">
Información de la Plantilla
</h4>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
<p>ID: {template.id}</p>
<p>Creado: {new Date(template.created_at).toLocaleDateString('es-ES')}</p>
<p>Última actualización: {new Date(template.updated_at).toLocaleDateString('es-ES')}</p>
</div>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
Cancelar
</Button>
<Button
type="submit"
variant="primary"
disabled={!isDirty || isLoading}
isLoading={isLoading}
>
Guardar Cambios
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,377 @@
import React from 'react';
import {
CheckCircle,
Clock,
AlertTriangle,
Play,
Pause,
ArrowRight,
Settings,
Thermometer,
Scale,
Eye
} from 'lucide-react';
import { Button, Badge, Card, ProgressBar } from '../../ui';
import { ProcessStage } from '../../../api/types/qualityTemplates';
import type { ProductionBatchResponse } from '../../../api/types/production';
interface ProcessStageTrackerProps {
batch: ProductionBatchResponse;
onStageAdvance?: (stage: ProcessStage) => void;
onQualityCheck?: (stage: ProcessStage) => void;
onStageStart?: (stage: ProcessStage) => void;
onStagePause?: (stage: ProcessStage) => void;
className?: string;
}
const PROCESS_STAGES_ORDER: ProcessStage[] = [
ProcessStage.MIXING,
ProcessStage.PROOFING,
ProcessStage.SHAPING,
ProcessStage.BAKING,
ProcessStage.COOLING,
ProcessStage.PACKAGING,
ProcessStage.FINISHING
];
const STAGE_CONFIG = {
[ProcessStage.MIXING]: {
label: 'Mezclado',
icon: Settings,
color: 'bg-blue-500',
description: 'Preparación y mezclado de ingredientes'
},
[ProcessStage.PROOFING]: {
label: 'Fermentación',
icon: Clock,
color: 'bg-yellow-500',
description: 'Proceso de fermentación y crecimiento'
},
[ProcessStage.SHAPING]: {
label: 'Formado',
icon: Settings,
color: 'bg-purple-500',
description: 'Dar forma al producto'
},
[ProcessStage.BAKING]: {
label: 'Horneado',
icon: Thermometer,
color: 'bg-red-500',
description: 'Cocción en horno'
},
[ProcessStage.COOLING]: {
label: 'Enfriado',
icon: Settings,
color: 'bg-cyan-500',
description: 'Enfriamiento del producto'
},
[ProcessStage.PACKAGING]: {
label: 'Empaquetado',
icon: Settings,
color: 'bg-green-500',
description: 'Empaque del producto terminado'
},
[ProcessStage.FINISHING]: {
label: 'Acabado',
icon: CheckCircle,
color: 'bg-indigo-500',
description: 'Toques finales y control final'
}
};
export const ProcessStageTracker: React.FC<ProcessStageTrackerProps> = ({
batch,
onStageAdvance,
onQualityCheck,
onStageStart,
onStagePause,
className = ''
}) => {
const currentStage = batch.current_process_stage;
const stageHistory = batch.process_stage_history || {};
const pendingQualityChecks = batch.pending_quality_checks || {};
const completedQualityChecks = batch.completed_quality_checks || {};
const getCurrentStageIndex = () => {
if (!currentStage) return -1;
return PROCESS_STAGES_ORDER.indexOf(currentStage);
};
const isStageCompleted = (stage: ProcessStage): boolean => {
const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage);
const currentIndex = getCurrentStageIndex();
return stageIndex < currentIndex || (stageIndex === currentIndex && batch.status === 'COMPLETED');
};
const isStageActive = (stage: ProcessStage): boolean => {
return currentStage === stage && batch.status === 'IN_PROGRESS';
};
const isStageUpcoming = (stage: ProcessStage): boolean => {
const stageIndex = PROCESS_STAGES_ORDER.indexOf(stage);
const currentIndex = getCurrentStageIndex();
return stageIndex > currentIndex;
};
const hasQualityChecksRequired = (stage: ProcessStage): boolean => {
return pendingQualityChecks[stage]?.length > 0;
};
const hasQualityChecksCompleted = (stage: ProcessStage): boolean => {
return completedQualityChecks[stage]?.length > 0;
};
const getStageProgress = (stage: ProcessStage): number => {
if (isStageCompleted(stage)) return 100;
if (isStageActive(stage)) {
// Calculate progress based on time elapsed vs estimated duration
const stageStartTime = stageHistory[stage]?.start_time;
if (stageStartTime) {
const elapsed = Date.now() - new Date(stageStartTime).getTime();
const estimated = 30 * 60 * 1000; // Default 30 minutes per stage
return Math.min(100, (elapsed / estimated) * 100);
}
}
return 0;
};
const getStageStatus = (stage: ProcessStage) => {
if (isStageCompleted(stage)) {
return hasQualityChecksCompleted(stage)
? { status: 'completed', icon: CheckCircle, color: 'text-green-500' }
: { status: 'completed-no-quality', icon: CheckCircle, color: 'text-yellow-500' };
}
if (isStageActive(stage)) {
return hasQualityChecksRequired(stage)
? { status: 'active-quality-pending', icon: AlertTriangle, color: 'text-orange-500' }
: { status: 'active', icon: Play, color: 'text-blue-500' };
}
if (isStageUpcoming(stage)) {
return { status: 'upcoming', icon: Clock, color: 'text-gray-400' };
}
return { status: 'pending', icon: Clock, color: 'text-gray-400' };
};
const getOverallProgress = (): number => {
const currentIndex = getCurrentStageIndex();
if (currentIndex === -1) return 0;
const stageProgress = getStageProgress(currentStage!);
return ((currentIndex + stageProgress / 100) / PROCESS_STAGES_ORDER.length) * 100;
};
return (
<Card className={`p-6 ${className}`}>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
Etapas del Proceso
</h3>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{batch.product_name} Lote #{batch.batch_number}
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-[var(--text-primary)]">
{Math.round(getOverallProgress())}%
</div>
<div className="text-sm text-[var(--text-secondary)]">
Completado
</div>
</div>
</div>
{/* Overall Progress Bar */}
<ProgressBar
percentage={getOverallProgress()}
color="#f59e0b"
height={8}
showLabel={false}
/>
{/* Stage Timeline */}
<div className="space-y-4">
{PROCESS_STAGES_ORDER.map((stage, index) => {
const config = STAGE_CONFIG[stage];
const status = getStageStatus(stage);
const progress = getStageProgress(stage);
const StageIcon = config.icon;
const StatusIcon = status.icon;
return (
<div key={stage} className="relative">
{/* Connection Line */}
{index < PROCESS_STAGES_ORDER.length - 1 && (
<div
className={`absolute left-6 top-12 w-0.5 h-8 ${
isStageCompleted(stage) ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
)}
<div className="flex items-start gap-4">
{/* Stage Icon */}
<div
className={`relative flex items-center justify-center w-12 h-12 rounded-full border-2 ${
isStageCompleted(stage)
? 'bg-green-500 border-green-500 text-white'
: isStageActive(stage)
? 'bg-blue-500 border-blue-500 text-white'
: 'bg-white border-gray-300 text-gray-400'
}`}
>
<StageIcon className="w-5 h-5" />
{/* Status Indicator */}
<div className="absolute -bottom-1 -right-1">
<StatusIcon className={`w-4 h-4 ${status.color}`} />
</div>
</div>
{/* Stage Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-[var(--text-primary)]">
{config.label}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
{config.description}
</p>
</div>
{/* Stage Actions */}
<div className="flex items-center gap-2">
{/* Quality Check Indicators */}
{hasQualityChecksRequired(stage) && (
<Badge variant="warning" size="sm">
Control pendiente
</Badge>
)}
{hasQualityChecksCompleted(stage) && (
<Badge variant="success" size="sm">
Calidad
</Badge>
)}
{/* Action Buttons */}
{isStageActive(stage) && (
<div className="flex gap-1">
{hasQualityChecksRequired(stage) && (
<Button
size="sm"
variant="outline"
onClick={() => onQualityCheck?.(stage)}
>
<Eye className="w-4 h-4 mr-1" />
Control
</Button>
)}
<Button
size="sm"
variant="primary"
onClick={() => onStageAdvance?.(stage)}
disabled={hasQualityChecksRequired(stage)}
>
<ArrowRight className="w-4 h-4" />
</Button>
</div>
)}
{getCurrentStageIndex() === index - 1 && (
<Button
size="sm"
variant="outline"
onClick={() => onStageStart?.(stage)}
>
<Play className="w-4 h-4 mr-1" />
Iniciar
</Button>
)}
</div>
</div>
{/* Stage Progress */}
{isStageActive(stage) && progress > 0 && (
<div className="mt-2">
<ProgressBar
percentage={progress}
color="#3b82f6"
height={4}
showLabel={false}
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Progreso de la etapa: {Math.round(progress)}%
</p>
</div>
)}
{/* Stage Details */}
{(isStageActive(stage) || isStageCompleted(stage)) && (
<div className="mt-3 text-xs text-[var(--text-secondary)] space-y-1">
{stageHistory[stage]?.start_time && (
<div>
Inicio: {new Date(stageHistory[stage].start_time).toLocaleTimeString('es-ES')}
</div>
)}
{stageHistory[stage]?.end_time && (
<div>
Fin: {new Date(stageHistory[stage].end_time).toLocaleTimeString('es-ES')}
</div>
)}
{completedQualityChecks[stage]?.length > 0 && (
<div>
Controles completados: {completedQualityChecks[stage].length}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Stage Summary */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-lg font-semibold text-green-500">
{PROCESS_STAGES_ORDER.filter(s => isStageCompleted(s)).length}
</div>
<div className="text-xs text-[var(--text-secondary)]">
Completadas
</div>
</div>
<div>
<div className="text-lg font-semibold text-blue-500">
{currentStage ? 1 : 0}
</div>
<div className="text-xs text-[var(--text-secondary)]">
En Proceso
</div>
</div>
<div>
<div className="text-lg font-semibold text-gray-400">
{PROCESS_STAGES_ORDER.filter(s => isStageUpcoming(s)).length}
</div>
<div className="text-xs text-[var(--text-secondary)]">
Pendientes
</div>
</div>
</div>
</div>
</div>
</Card>
);
};
export default ProcessStageTracker;

View File

@@ -19,11 +19,14 @@ import { statusColors } from '../../../styles/colors';
import { productionService, type QualityCheckResponse, QualityCheckStatus } from '../../../api';
import { ProductionBatchResponse } from '../../../api/types/production';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useQualityTemplatesForStage, useExecuteQualityCheck } from '../../../api/hooks/qualityTemplates';
import { ProcessStage, type QualityCheckTemplate, type QualityCheckExecutionRequest } from '../../../api/types/qualityTemplates';
export interface QualityCheckModalProps {
isOpen: boolean;
onClose: () => void;
batch: ProductionBatchResponse;
processStage?: ProcessStage;
onComplete?: (result: QualityCheckResult) => void;
}
@@ -220,54 +223,72 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
isOpen,
onClose,
batch,
processStage,
onComplete
}) => {
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const [activeTab, setActiveTab] = useState('inspection');
const [selectedTemplate, setSelectedTemplate] = useState<string>('visual_inspection');
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
const [currentCriteriaIndex, setCurrentCriteriaIndex] = useState(0);
const [results, setResults] = useState<Record<string, QualityCheckResult>>({});
const [results, setResults] = useState<Record<string, any>>({});
const [finalNotes, setFinalNotes] = useState('');
const [photos, setPhotos] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const template = QUALITY_CHECK_TEMPLATES[selectedTemplate as keyof typeof QUALITY_CHECK_TEMPLATES];
const currentCriteria = template?.criteria[currentCriteriaIndex];
const currentResult = currentCriteria ? results[currentCriteria.id] : undefined;
// Get available templates for the current stage
const currentStage = processStage || batch.current_process_stage;
const {
data: templatesData,
isLoading: templatesLoading,
error: templatesError
} = useQualityTemplatesForStage(tenantId, currentStage!, true, {
enabled: !!currentStage
});
const updateResult = useCallback((criterionId: string, updates: Partial<QualityCheckResult>) => {
// Execute quality check mutation
const executeQualityCheckMutation = useExecuteQualityCheck(tenantId);
// Initialize selected template
React.useEffect(() => {
if (templatesData?.templates && templatesData.templates.length > 0 && !selectedTemplate) {
setSelectedTemplate(templatesData.templates[0]);
}
}, [templatesData?.templates, selectedTemplate]);
const availableTemplates = templatesData?.templates || [];
const updateResult = useCallback((field: string, value: any, score?: number) => {
setResults(prev => ({
...prev,
[criterionId]: {
criterionId,
value: '',
score: 0,
pass: false,
timestamp: new Date().toISOString(),
...prev[criterionId],
...updates
[field]: {
value,
score: score !== undefined ? score : (typeof value === 'boolean' ? (value ? 10 : 0) : value),
pass_check: score !== undefined ? score >= 7 : (typeof value === 'boolean' ? value : value >= 7),
timestamp: new Date().toISOString()
}
}));
}, []);
const calculateOverallScore = useCallback((): number => {
if (!template || Object.keys(results).length === 0) return 0;
if (!selectedTemplate || Object.keys(results).length === 0) return 0;
const totalWeight = template.criteria.reduce((sum, c) => sum + c.weight, 0);
const weightedScore = Object.values(results).reduce((sum, result) => {
const criterion = template.criteria.find(c => c.id === result.criterionId);
return sum + (result.score * (criterion?.weight || 0));
}, 0);
const templateWeight = selectedTemplate.weight || 1;
const resultValues = Object.values(results);
return totalWeight > 0 ? weightedScore / totalWeight : 0;
}, [template, results]);
if (resultValues.length === 0) return 0;
const averageScore = resultValues.reduce((sum: number, result: any) => sum + (result.score || 0), 0) / resultValues.length;
return Math.min(10, averageScore * templateWeight);
}, [selectedTemplate, results]);
const getCriticalFailures = useCallback((): string[] => {
if (!template) return [];
if (!selectedTemplate) return [];
const failures: string[] = [];
template.criteria.forEach(criterion => {
selectedTemplate.criteria.forEach(criterion => {
if (criterion.isCritical && results[criterion.id]) {
const result = results[criterion.id];
if (criterion.type === 'boolean' && !result.value) {
@@ -279,7 +300,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
});
return failures;
}, [template, results]);
}, [selectedTemplate, results]);
const handlePhotoUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
@@ -287,7 +308,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
}, []);
const handleComplete = async () => {
if (!template) return;
if (!selectedTemplate) return;
setLoading(true);
try {
@@ -297,7 +318,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
const qualityData: QualityCheckData = {
batchId: batch.id,
checkType: template.id,
checkType: selectedTemplate.id,
inspector: currentTenant?.name || 'Inspector',
startTime: new Date().toISOString(),
results: Object.values(results),
@@ -316,7 +337,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
// Create quality check via API
const checkData = {
batch_id: batch.id,
check_type: template.id,
check_type: selectedTemplate.id,
check_time: new Date().toISOString(),
quality_score: overallScore / 10, // Convert to 0-1 scale
pass_fail: passed,
@@ -434,7 +455,7 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
const overallScore = calculateOverallScore();
const criticalFailures = getCriticalFailures();
const isComplete = template?.criteria.filter(c => c.required).every(c => results[c.id]) || false;
const isComplete = selectedTemplate?.criteria.filter(c => c.required).every(c => results[c.id]) || false;
const statusIndicator: StatusIndicatorConfig = {
color: statusColors.inProgress.primary,
@@ -484,11 +505,11 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
</TabsList>
<TabsContent value="inspection" className="space-y-6">
{template && (
{selectedTemplate && (
<>
{/* Progress Indicator */}
<div className="grid grid-cols-6 gap-2">
{template.criteria.map((criterion, index) => {
{selectedTemplate.criteria.map((criterion, index) => {
const result = results[criterion.id];
const isCompleted = !!result;
const isCurrent = index === currentCriteriaIndex;
@@ -559,8 +580,8 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
</Button>
<Button
variant="primary"
onClick={() => setCurrentCriteriaIndex(Math.min(template.criteria.length - 1, currentCriteriaIndex + 1))}
disabled={currentCriteriaIndex === template.criteria.length - 1}
onClick={() => setCurrentCriteriaIndex(Math.min(selectedTemplate.criteria.length - 1, currentCriteriaIndex + 1))}
disabled={currentCriteriaIndex === selectedTemplate.criteria.length - 1}
>
Siguiente
</Button>

View File

@@ -0,0 +1,467 @@
import React, { useState, useMemo } from 'react';
import {
Plus,
Search,
Filter,
Edit,
Copy,
Trash2,
Eye,
CheckCircle,
AlertTriangle,
Settings,
Tag,
Thermometer,
Scale,
Timer,
FileCheck
} from 'lucide-react';
import {
Button,
Input,
Card,
Badge,
Select,
StatusCard,
Modal,
StatsGrid
} from '../../ui';
import { LoadingSpinner } from '../../shared';
import { PageHeader } from '../../layout';
import { useCurrentTenant } from '../../../stores/tenant.store';
import {
useQualityTemplates,
useCreateQualityTemplate,
useUpdateQualityTemplate,
useDeleteQualityTemplate,
useDuplicateQualityTemplate
} from '../../../api/hooks/qualityTemplates';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplate,
type QualityCheckTemplateCreate,
type QualityCheckTemplateUpdate
} from '../../../api/types/qualityTemplates';
import { CreateQualityTemplateModal } from './CreateQualityTemplateModal';
import { EditQualityTemplateModal } from './EditQualityTemplateModal';
interface QualityTemplateManagerProps {
className?: string;
}
const QUALITY_CHECK_TYPE_CONFIG = {
[QualityCheckType.VISUAL]: {
icon: Eye,
label: 'Visual',
color: 'bg-blue-500',
description: 'Inspección visual'
},
[QualityCheckType.MEASUREMENT]: {
icon: Settings,
label: 'Medición',
color: 'bg-green-500',
description: 'Mediciones precisas'
},
[QualityCheckType.TEMPERATURE]: {
icon: Thermometer,
label: 'Temperatura',
color: 'bg-red-500',
description: 'Control de temperatura'
},
[QualityCheckType.WEIGHT]: {
icon: Scale,
label: 'Peso',
color: 'bg-purple-500',
description: 'Control de peso'
},
[QualityCheckType.BOOLEAN]: {
icon: CheckCircle,
label: 'Sí/No',
color: 'bg-gray-500',
description: 'Verificación binaria'
},
[QualityCheckType.TIMING]: {
icon: Timer,
label: 'Tiempo',
color: 'bg-orange-500',
description: 'Control de tiempo'
}
};
const PROCESS_STAGE_LABELS = {
[ProcessStage.MIXING]: 'Mezclado',
[ProcessStage.PROOFING]: 'Fermentación',
[ProcessStage.SHAPING]: 'Formado',
[ProcessStage.BAKING]: 'Horneado',
[ProcessStage.COOLING]: 'Enfriado',
[ProcessStage.PACKAGING]: 'Empaquetado',
[ProcessStage.FINISHING]: 'Acabado'
};
export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
className = ''
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCheckType, setSelectedCheckType] = useState<QualityCheckType | ''>('');
const [selectedStage, setSelectedStage] = useState<ProcessStage | ''>('');
const [showActiveOnly, setShowActiveOnly] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// API hooks
const {
data: templatesData,
isLoading,
error
} = useQualityTemplates(tenantId, {
check_type: selectedCheckType || undefined,
stage: selectedStage || undefined,
is_active: showActiveOnly,
search: searchTerm || undefined
});
const createTemplateMutation = useCreateQualityTemplate(tenantId);
const updateTemplateMutation = useUpdateQualityTemplate(tenantId);
const deleteTemplateMutation = useDeleteQualityTemplate(tenantId);
const duplicateTemplateMutation = useDuplicateQualityTemplate(tenantId);
// Filtered templates
const filteredTemplates = useMemo(() => {
if (!templatesData?.templates) return [];
return templatesData.templates.filter(template => {
const matchesSearch = !searchTerm ||
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.category?.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
});
}, [templatesData?.templates, searchTerm]);
// Statistics
const templateStats = useMemo(() => {
const templates = templatesData?.templates || [];
return {
total: templates.length,
active: templates.filter(t => t.is_active).length,
critical: templates.filter(t => t.is_critical).length,
required: templates.filter(t => t.is_required).length,
byType: Object.values(QualityCheckType).map(type => ({
type,
count: templates.filter(t => t.check_type === type).length
}))
};
}, [templatesData?.templates]);
// Event handlers
const handleCreateTemplate = async (templateData: QualityCheckTemplateCreate) => {
try {
await createTemplateMutation.mutateAsync(templateData);
setShowCreateModal(false);
} catch (error) {
console.error('Error creating template:', error);
}
};
const handleUpdateTemplate = async (templateData: QualityCheckTemplateUpdate) => {
if (!selectedTemplate) return;
try {
await updateTemplateMutation.mutateAsync({
templateId: selectedTemplate.id,
templateData
});
setShowEditModal(false);
setSelectedTemplate(null);
} catch (error) {
console.error('Error updating template:', error);
}
};
const handleDeleteTemplate = async (templateId: string) => {
if (!confirm('¿Estás seguro de que quieres eliminar esta plantilla?')) return;
try {
await deleteTemplateMutation.mutateAsync(templateId);
} catch (error) {
console.error('Error deleting template:', error);
}
};
const handleDuplicateTemplate = async (templateId: string) => {
try {
await duplicateTemplateMutation.mutateAsync(templateId);
} catch (error) {
console.error('Error duplicating template:', error);
}
};
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
const typeConfig = QUALITY_CHECK_TYPE_CONFIG[template.check_type];
return {
color: template.is_active ? typeConfig.color : '#6b7280',
text: typeConfig.label,
icon: typeConfig.icon
};
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando plantillas de calidad..." />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar las plantillas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{error?.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return (
<div className={`space-y-6 ${className}`}>
<PageHeader
title="Plantillas de Control de Calidad"
description="Gestiona las plantillas de control de calidad para tus procesos de producción"
actions={[
{
id: "new",
label: "Nueva Plantilla",
variant: "primary" as const,
icon: Plus,
onClick: () => setShowCreateModal(true)
}
]}
/>
{/* Statistics */}
<StatsGrid
stats={[
{
title: 'Total Plantillas',
value: templateStats.total,
variant: 'default',
icon: FileCheck
},
{
title: 'Activas',
value: templateStats.active,
variant: 'success',
icon: CheckCircle
},
{
title: 'Críticas',
value: templateStats.critical,
variant: 'error',
icon: AlertTriangle
},
{
title: 'Requeridas',
value: templateStats.required,
variant: 'warning',
icon: Tag
}
]}
columns={4}
/>
{/* Filters */}
<Card className="p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-tertiary)]" />
<Input
placeholder="Buscar plantillas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select
value={selectedCheckType}
onChange={(e) => setSelectedCheckType(e.target.value as QualityCheckType | '')}
placeholder="Tipo de control"
>
<option value="">Todos los tipos</option>
{Object.entries(QUALITY_CHECK_TYPE_CONFIG).map(([type, config]) => (
<option key={type} value={type}>
{config.label}
</option>
))}
</Select>
<Select
value={selectedStage}
onChange={(e) => setSelectedStage(e.target.value as ProcessStage | '')}
placeholder="Etapa del proceso"
>
<option value="">Todas las etapas</option>
{Object.entries(PROCESS_STAGE_LABELS).map(([stage, label]) => (
<option key={stage} value={stage}>
{label}
</option>
))}
</Select>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="activeOnly"
checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)}
className="rounded border-[var(--border-primary)]"
/>
<label htmlFor="activeOnly" className="text-sm text-[var(--text-secondary)]">
Solo activas
</label>
</div>
</div>
</Card>
{/* Templates Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredTemplates.map((template) => {
const statusConfig = getTemplateStatusConfig(template);
const stageLabels = template.applicable_stages?.map(stage =>
PROCESS_STAGE_LABELS[stage]
) || ['Todas las etapas'];
return (
<StatusCard
key={template.id}
id={template.id}
statusIndicator={statusConfig}
title={template.name}
subtitle={template.category || 'Sin categoría'}
primaryValue={template.weight}
primaryValueLabel="peso"
secondaryInfo={{
label: 'Etapas',
value: stageLabels.length > 2
? `${stageLabels.slice(0, 2).join(', ')}...`
: stageLabels.join(', ')
}}
metadata={[
template.description || 'Sin descripción',
`${template.is_required ? 'Requerido' : 'Opcional'}`,
`${template.is_critical ? 'Crítico' : 'Normal'}`
]}
actions={[
{
label: 'Ver',
icon: Eye,
variant: 'primary',
priority: 'primary',
onClick: () => {
setSelectedTemplate(template);
// Could open a view modal here
}
},
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedTemplate(template);
setShowEditModal(true);
}
},
{
label: 'Duplicar',
icon: Copy,
priority: 'secondary',
onClick: () => handleDuplicateTemplate(template.id)
},
{
label: 'Eliminar',
icon: Trash2,
variant: 'danger',
priority: 'secondary',
onClick: () => handleDeleteTemplate(template.id)
}
]}
>
{/* Additional badges */}
<div className="flex flex-wrap gap-1 mt-2">
{template.is_active && (
<Badge variant="success" size="sm">Activa</Badge>
)}
{template.is_required && (
<Badge variant="warning" size="sm">Requerida</Badge>
)}
{template.is_critical && (
<Badge variant="error" size="sm">Crítica</Badge>
)}
</div>
</StatusCard>
);
})}
</div>
{/* Empty State */}
{filteredTemplates.length === 0 && (
<div className="text-center py-12">
<FileCheck className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron plantillas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{templatesData?.templates?.length === 0
? 'No hay plantillas de calidad creadas. Crea la primera plantilla para comenzar.'
: 'Intenta ajustar los filtros de búsqueda o crear una nueva plantilla'
}
</p>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Nueva Plantilla
</Button>
</div>
)}
{/* Create Template Modal */}
<CreateQualityTemplateModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onCreateTemplate={handleCreateTemplate}
isLoading={createTemplateMutation.isPending}
/>
{/* Edit Template Modal */}
{selectedTemplate && (
<EditQualityTemplateModal
isOpen={showEditModal}
onClose={() => {
setShowEditModal(false);
setSelectedTemplate(null);
}}
template={selectedTemplate}
onUpdateTemplate={handleUpdateTemplate}
isLoading={updateTemplateMutation.isPending}
/>
)}
</div>
);
};
export default QualityTemplateManager;

View File

@@ -6,6 +6,11 @@ export { default as EquipmentManager } from './EquipmentManager';
export { CreateProductionBatchModal } from './CreateProductionBatchModal';
export { default as ProductionStatusCard } from './ProductionStatusCard';
export { default as QualityCheckModal } from './QualityCheckModal';
export { default as ProcessStageTracker } from './ProcessStageTracker';
export { default as CompactProcessStageTracker } from './CompactProcessStageTracker';
export { default as QualityTemplateManager } from './QualityTemplateManager';
export { CreateQualityTemplateModal } from './CreateQualityTemplateModal';
export { EditQualityTemplateModal } from './EditQualityTemplateModal';
// Export component props types
export type { ProductionScheduleProps } from './ProductionSchedule';

View File

@@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import {
CheckCircle,
AlertTriangle,
Settings,
Plus,
Trash2,
Save
} from 'lucide-react';
import {
Modal,
Button,
Card,
Badge,
Select
} from '../../ui';
import { LoadingSpinner } from '../../shared';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useQualityTemplatesForRecipe } from '../../../api/hooks/qualityTemplates';
import {
ProcessStage,
type QualityCheckTemplate,
type RecipeQualityConfiguration,
type ProcessStageQualityConfig
} from '../../../api/types/qualityTemplates';
import type { RecipeResponse } from '../../../api/types/recipes';
interface QualityCheckConfigurationModalProps {
isOpen: boolean;
onClose: () => void;
recipe: RecipeResponse;
onSaveConfiguration: (config: RecipeQualityConfiguration) => Promise<void>;
isLoading?: boolean;
}
const PROCESS_STAGE_LABELS = {
[ProcessStage.MIXING]: 'Mezclado',
[ProcessStage.PROOFING]: 'Fermentación',
[ProcessStage.SHAPING]: 'Formado',
[ProcessStage.BAKING]: 'Horneado',
[ProcessStage.COOLING]: 'Enfriado',
[ProcessStage.PACKAGING]: 'Empaquetado',
[ProcessStage.FINISHING]: 'Acabado'
};
export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationModalProps> = ({
isOpen,
onClose,
recipe,
onSaveConfiguration,
isLoading = false
}) => {
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// Initialize configuration from recipe or create default
const [configuration, setConfiguration] = useState<RecipeQualityConfiguration>(() => {
const existing = recipe.quality_check_configuration as RecipeQualityConfiguration;
return existing || {
stages: {},
global_parameters: {},
default_templates: []
};
});
// Fetch available templates
const {
data: templatesByStage,
isLoading: templatesLoading,
error: templatesError
} = useQualityTemplatesForRecipe(tenantId, recipe.id);
// Reset configuration when recipe changes
useEffect(() => {
const existing = recipe.quality_check_configuration as RecipeQualityConfiguration;
setConfiguration(existing || {
stages: {},
global_parameters: {},
default_templates: []
});
}, [recipe]);
const handleStageToggle = (stage: ProcessStage) => {
setConfiguration(prev => {
const newStages = { ...prev.stages };
if (newStages[stage]) {
// Remove stage configuration
delete newStages[stage];
} else {
// Add default stage configuration
newStages[stage] = {
stage,
template_ids: [],
custom_parameters: {},
is_required: true,
blocking: true
};
}
return {
...prev,
stages: newStages
};
});
};
const handleTemplateToggle = (stage: ProcessStage, templateId: string) => {
setConfiguration(prev => {
const stageConfig = prev.stages[stage];
if (!stageConfig) return prev;
const templateIds = stageConfig.template_ids.includes(templateId)
? stageConfig.template_ids.filter(id => id !== templateId)
: [...stageConfig.template_ids, templateId];
return {
...prev,
stages: {
...prev.stages,
[stage]: {
...stageConfig,
template_ids: templateIds
}
}
};
});
};
const handleStageSettingChange = (
stage: ProcessStage,
setting: keyof ProcessStageQualityConfig,
value: boolean
) => {
setConfiguration(prev => {
const stageConfig = prev.stages[stage];
if (!stageConfig) return prev;
return {
...prev,
stages: {
...prev.stages,
[stage]: {
...stageConfig,
[setting]: value
}
}
};
});
};
const handleSave = async () => {
try {
await onSaveConfiguration(configuration);
onClose();
} catch (error) {
console.error('Error saving quality configuration:', error);
}
};
const getConfiguredStagesCount = () => {
return Object.keys(configuration.stages).length;
};
const getTotalTemplatesCount = () => {
return Object.values(configuration.stages).reduce(
(total, stage) => total + stage.template_ids.length,
0
);
};
if (templatesLoading) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Configuración de Control de Calidad" size="xl">
<div className="flex items-center justify-center py-12">
<LoadingSpinner text="Cargando plantillas de calidad..." />
</div>
</Modal>
);
}
if (templatesError) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Configuración de Control de Calidad" size="xl">
<div className="text-center py-12">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar plantillas
</h3>
<p className="text-[var(--text-secondary)]">
No se pudieron cargar las plantillas de control de calidad
</p>
</div>
</Modal>
);
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Control de Calidad - ${recipe.name}`}
size="xl"
>
<div className="space-y-6">
{/* Summary */}
<Card className="p-4 bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-[var(--text-primary)]">
Resumen de Configuración
</h4>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{getConfiguredStagesCount()} etapas configuradas {getTotalTemplatesCount()} controles asignados
</p>
</div>
<div className="flex gap-2">
<Badge variant="info">
{recipe.category || 'Sin categoría'}
</Badge>
<Badge variant={recipe.status === 'active' ? 'success' : 'warning'}>
{recipe.status}
</Badge>
</div>
</div>
</Card>
{/* Process Stages Configuration */}
<div className="space-y-4">
<h4 className="font-medium text-[var(--text-primary)] flex items-center gap-2">
<Settings className="w-5 h-5" />
Configuración por Etapas del Proceso
</h4>
{Object.values(ProcessStage).map(stage => {
const stageConfig = configuration.stages[stage];
const isConfigured = !!stageConfig;
const availableTemplates = templatesByStage?.[stage] || [];
return (
<Card key={stage} className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={isConfigured}
onChange={() => handleStageToggle(stage)}
className="rounded border-[var(--border-primary)]"
/>
<span className="font-medium">
{PROCESS_STAGE_LABELS[stage]}
</span>
</label>
{isConfigured && (
<Badge variant="success" size="sm">
{stageConfig.template_ids.length} controles
</Badge>
)}
</div>
{isConfigured && (
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={stageConfig.is_required}
onChange={(e) => handleStageSettingChange(
stage,
'is_required',
e.target.checked
)}
className="rounded border-[var(--border-primary)]"
/>
Requerido
</label>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={stageConfig.blocking}
onChange={(e) => handleStageSettingChange(
stage,
'blocking',
e.target.checked
)}
className="rounded border-[var(--border-primary)]"
/>
Bloqueante
</label>
</div>
)}
</div>
{isConfigured && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Plantillas de Control de Calidad
</label>
{availableTemplates.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{availableTemplates.map(template => (
<label
key={template.id}
className="flex items-center gap-2 p-2 border rounded cursor-pointer hover:bg-[var(--bg-secondary)]"
>
<input
type="checkbox"
checked={stageConfig.template_ids.includes(template.id)}
onChange={() => handleTemplateToggle(stage, template.id)}
className="rounded border-[var(--border-primary)]"
/>
<div className="flex-1">
<div className="font-medium text-sm">
{template.name}
</div>
<div className="text-xs text-[var(--text-secondary)]">
{template.check_type} {template.category || 'Sin categoría'}
</div>
</div>
{template.is_critical && (
<Badge variant="error" size="sm">Crítico</Badge>
)}
{template.is_required && (
<Badge variant="warning" size="sm">Req.</Badge>
)}
</label>
))}
</div>
) : (
<p className="text-sm text-[var(--text-secondary)] py-4 text-center border border-dashed rounded">
No hay plantillas disponibles para esta etapa.
<br />
<a
href="/app/operations/production/quality-templates"
className="text-[var(--color-primary)] hover:underline"
>
Crear nueva plantilla
</a>
</p>
)}
</div>
</div>
)}
</Card>
);
})}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={isLoading}
isLoading={isLoading}
>
<Save className="w-4 h-4 mr-2" />
Guardar Configuración
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -1 +1,2 @@
export { CreateRecipeModal } from './CreateRecipeModal';
export { CreateRecipeModal } from './CreateRecipeModal';
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';

View File

@@ -156,6 +156,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
'/app/operations': 'navigation.operations',
'/app/operations/procurement': 'navigation.procurement',
'/app/operations/production': 'navigation.production',
'/app/operations/maquinaria': 'navigation.equipment',
'/app/operations/pos': 'navigation.pos',
'/app/bakery': 'navigation.bakery',
'/app/bakery/recipes': 'navigation.recipes',

View File

@@ -25,11 +25,12 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
...props
}, ref) => {
const baseClasses = [
'inline-flex items-center justify-center font-medium',
'inline-flex items-center justify-center font-semibold tracking-wide',
'transition-all duration-200 ease-in-out',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'border rounded-lg'
'border rounded-xl shadow-sm',
'hover:shadow-md active:shadow-sm'
];
const variantClasses = {
@@ -78,11 +79,11 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
};
const sizeClasses = {
xs: 'px-2 py-1 text-xs gap-1 min-h-6',
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-8 sm:min-h-10', // Better touch target on mobile
md: 'px-4 py-2 text-sm gap-2 min-h-10 sm:min-h-12',
lg: 'px-6 py-2.5 text-base gap-2 min-h-12 sm:min-h-14',
xl: 'px-8 py-3 text-lg gap-3 min-h-14 sm:min-h-16'
xs: 'px-3 py-1.5 text-xs gap-1.5 min-h-7',
sm: 'px-4 py-2 text-sm gap-2 min-h-9 sm:min-h-10',
md: 'px-6 py-3 text-sm gap-2.5 min-h-11 sm:min-h-12',
lg: 'px-8 py-3.5 text-base gap-3 min-h-13 sm:min-h-14',
xl: 'px-10 py-4 text-lg gap-3.5 min-h-15 sm:min-h-16'
};
const loadingSpinner = (
@@ -128,13 +129,13 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
>
{isLoading && loadingSpinner}
{!isLoading && leftIcon && (
<span className="flex-shrink-0">{leftIcon}</span>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{leftIcon}</span>
)}
<span>
<span className="relative">
{isLoading && loadingText ? loadingText : children}
</span>
{!isLoading && rightIcon && (
<span className="flex-shrink-0">{rightIcon}</span>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{rightIcon}</span>
)}
</button>
);

View File

@@ -0,0 +1,121 @@
import React from 'react';
export interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: 'sm' | 'md' | 'lg';
label?: string;
description?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
className?: string;
}
const Toggle: React.FC<ToggleProps> = ({
checked,
onChange,
disabled = false,
size = 'md',
label,
description,
leftIcon,
rightIcon,
className = ''
}) => {
const sizeClasses = {
sm: 'w-8 h-4',
md: 'w-11 h-6',
lg: 'w-14 h-8'
};
const thumbSizeClasses = {
sm: 'h-3 w-3',
md: 'h-5 w-5',
lg: 'h-7 w-7'
};
const thumbPositionClasses = {
sm: 'after:top-[2px] after:left-[2px] peer-checked:after:translate-x-4',
md: 'after:top-[2px] after:left-[2px] peer-checked:after:translate-x-full',
lg: 'after:top-[2px] after:left-[2px] peer-checked:after:translate-x-6'
};
const handleToggle = () => {
if (!disabled) {
onChange(!checked);
}
};
return (
<div className={`flex items-center ${className}`}>
{leftIcon && (
<div className="mr-3 flex-shrink-0">
{leftIcon}
</div>
)}
<div className="flex items-center flex-1">
<div className="flex items-center">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={handleToggle}
disabled={disabled}
className="sr-only peer"
/>
<div
className={`
${sizeClasses[size]}
bg-[var(--bg-tertiary)]
peer-focus:outline-none
rounded-full
peer
${thumbPositionClasses[size]}
peer-checked:after:border-white
after:content-['']
after:absolute
${thumbSizeClasses[size]}
after:bg-white
after:rounded-full
after:transition-all
peer-checked:bg-[var(--color-primary)]
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
</label>
{(label || description) && (
<div className="ml-3">
{label && (
<span className={`
text-sm font-medium text-[var(--text-primary)]
${disabled ? 'opacity-50' : ''}
`}>
{label}
</span>
)}
{description && (
<p className={`
text-xs text-[var(--text-secondary)]
${disabled ? 'opacity-50' : ''}
`}>
{description}
</p>
)}
</div>
)}
</div>
</div>
{rightIcon && (
<div className="ml-3 flex-shrink-0">
{rightIcon}
</div>
)}
</div>
);
};
export default Toggle;

View File

@@ -0,0 +1,2 @@
export { default as Toggle } from './Toggle';
export type { ToggleProps } from './Toggle';

View File

@@ -12,6 +12,7 @@ export { default as Select } from './Select';
export { default as DatePicker } from './DatePicker';
export { Tabs } from './Tabs';
export { ThemeToggle } from './ThemeToggle';
export { Toggle } from './Toggle';
export { ProgressBar } from './ProgressBar';
export { StatusIndicator } from './StatusIndicator';
export { ListItem } from './ListItem';
@@ -34,6 +35,7 @@ export type { SelectProps, SelectOption } from './Select';
export type { DatePickerProps } from './DatePicker';
export type { TabsProps, TabItem } from './Tabs';
export type { ThemeToggleProps } from './ThemeToggle';
export type { ToggleProps } from './Toggle';
export type { ProgressBarProps } from './ProgressBar';
export type { StatusIndicatorProps } from './StatusIndicator';
export type { ListItemProps } from './ListItem';

View File

@@ -4,6 +4,7 @@
"operations": "Operations",
"inventory": "Inventory",
"production": "Production",
"equipment": "Equipment",
"recipes": "Recipes",
"orders": "Orders",
"procurement": "Procurement",

View File

@@ -0,0 +1,90 @@
{
"title": "Equipment",
"subtitle": "Manage and monitor your production equipment",
"equipment_status": {
"operational": "Operational",
"maintenance": "Maintenance",
"down": "Out of Service",
"warning": "Warning"
},
"equipment_type": {
"oven": "Oven",
"mixer": "Mixer",
"proofer": "Proofing Chamber",
"freezer": "Freezer",
"packaging": "Packaging Machine",
"other": "Other"
},
"fields": {
"name": "Name",
"type": "Type",
"model": "Model",
"serial_number": "Serial Number",
"location": "Location",
"status": "Status",
"install_date": "Installation Date",
"last_maintenance": "Last Maintenance",
"next_maintenance": "Next Maintenance",
"maintenance_interval": "Maintenance Interval",
"efficiency": "Efficiency",
"uptime": "Uptime",
"energy_usage": "Energy Usage",
"temperature": "Temperature",
"target_temperature": "Target Temperature",
"power": "Power",
"capacity": "Capacity",
"weight": "Weight",
"parts": "Parts"
},
"actions": {
"add_equipment": "Add Equipment",
"edit_equipment": "Edit Equipment",
"delete_equipment": "Delete Equipment",
"schedule_maintenance": "Schedule Maintenance",
"view_maintenance_history": "View Maintenance History",
"acknowledge_alert": "Acknowledge Alert",
"view_details": "View Details",
"view_history": "View History",
"close": "Close",
"cost": "Cost"
},
"labels": {
"total_equipment": "Total Equipment",
"operational": "Operational",
"avg_efficiency": "Average Efficiency",
"active_alerts": "Active Alerts",
"maintenance_due": "Maintenance Due",
"overdue_maintenance": "Overdue Maintenance",
"low_efficiency": "Low Efficiency"
},
"descriptions": {
"equipment_efficiency": "Current equipment efficiency percentage",
"uptime_percentage": "Percentage of uptime",
"active_alerts_count": "Number of active alerts not acknowledged"
},
"maintenance": {
"title": "Maintenance",
"last": "Last",
"next": "Next",
"interval": "Interval",
"history": "History",
"records": "records",
"overdue": "Overdue",
"scheduled": "Scheduled",
"type": {
"preventive": "Preventive",
"corrective": "Corrective",
"emergency": "Emergency"
}
},
"alerts": {
"title": "Alerts",
"unread_alerts": "unread alerts",
"acknowledged": "Acknowledged",
"new": "New",
"acknowledge": "Acknowledge",
"critical": "Critical",
"warning": "Warning",
"info": "Information"
}
}

View File

@@ -4,6 +4,7 @@
"operations": "Operaciones",
"inventory": "Inventario",
"production": "Producción",
"equipment": "Maquinaria",
"recipes": "Recetas",
"orders": "Pedidos",
"procurement": "Compras",

View File

@@ -0,0 +1,87 @@
{
"title": "Maquinaria",
"subtitle": "Gestiona y monitorea tus equipos de producción",
"equipment_status": {
"operational": "Operacional",
"maintenance": "Mantenimiento",
"down": "Fuera de Servicio",
"warning": "Advertencia"
},
"equipment_type": {
"oven": "Horno",
"mixer": "Batidora",
"proofer": "Cámara de Fermentación",
"freezer": "Congelador",
"packaging": "Empaquetadora",
"other": "Otro"
},
"fields": {
"name": "Nombre",
"type": "Tipo",
"model": "Modelo",
"serial_number": "Número de Serie",
"location": "Ubicación",
"status": "Estado",
"install_date": "Fecha de Instalación",
"last_maintenance": "Último Mantenimiento",
"next_maintenance": "Próximo Mantenimiento",
"maintenance_interval": "Intervalo de Mantenimiento",
"efficiency": "Eficiencia",
"uptime": "Tiempo de Funcionamiento",
"energy_usage": "Consumo Energético",
"temperature": "Temperatura",
"target_temperature": "Temperatura Objetivo",
"power": "Potencia",
"capacity": "Capacidad",
"weight": "Peso"
},
"actions": {
"add_equipment": "Agregar Equipo",
"edit_equipment": "Editar Equipo",
"delete_equipment": "Eliminar Equipo",
"schedule_maintenance": "Programar Mantenimiento",
"view_maintenance_history": "Ver Historial de Mantenimiento",
"acknowledge_alert": "Reconocer Alerta",
"view_details": "Ver Detalles",
"view_history": "Ver Historial"
},
"labels": {
"total_equipment": "Total de Equipos",
"operational": "Operacionales",
"avg_efficiency": "Eficiencia Promedio",
"active_alerts": "Alertas Activas",
"maintenance_due": "Mantenimiento Próximo",
"overdue_maintenance": "Mantenimiento Atrasado",
"low_efficiency": "Baja Eficiencia"
},
"descriptions": {
"equipment_efficiency": "Porcentaje de eficiencia actual de los equipos",
"uptime_percentage": "Porcentaje de tiempo de funcionamiento",
"active_alerts_count": "Número de alertas activas sin reconocer"
},
"maintenance": {
"title": "Mantenimiento",
"last": "Último",
"next": "Próximo",
"interval": "Intervalo",
"history": "Historial",
"records": "registros",
"overdue": "Atrasado",
"scheduled": "Programado",
"type": {
"preventive": "Preventivo",
"corrective": "Correctivo",
"emergency": "Emergencia"
}
},
"alerts": {
"title": "Alertas",
"unread_alerts": "alertas no leídas",
"acknowledged": "Reconocido",
"new": "Nuevo",
"acknowledge": "Reconocer",
"critical": "Crítico",
"warning": "Advertencia",
"info": "Información"
}
}

View File

@@ -76,5 +76,9 @@
"production_efficiency": "Porcentaje de eficiencia en la producción actual",
"quality_average": "Puntuación promedio de calidad en los últimos lotes",
"waste_reduction": "Reducción de desperdicio comparado con el mes anterior"
},
"equipment": {
"title": "Equipos",
"subtitle": "Gestión de maquinaria de producción"
}
}

View File

@@ -1,18 +1,19 @@
{
"navigation": {
"dashboard": "Aginte Panela",
"dashboard": "Aginte-panela",
"operations": "Eragiketak",
"inventory": "Inbentarioa",
"production": "Ekoizpena",
"equipment": "Makinaria",
"recipes": "Errezetak",
"orders": "Eskaerak",
"procurement": "Erosketak",
"pos": "Salmenta Puntua",
"pos": "Salmenta-puntua",
"analytics": "Analisiak",
"forecasting": "Aurreikuspena",
"forecasting": "Aurreikuspenak",
"sales": "Salmentak",
"performance": "Errendimendua",
"insights": "AI Jakintza",
"insights": "AA ikuspegiak",
"data": "Datuak",
"weather": "Eguraldia",
"traffic": "Trafikoa",
@@ -20,13 +21,13 @@
"communications": "Komunikazioak",
"notifications": "Jakinarazpenak",
"alerts": "Alertak",
"preferences": "Lehentasunak",
"preferences": "Hobespenak",
"settings": "Ezarpenak",
"team": "Taldea",
"bakery": "Okindegi",
"training": "Trebakuntza",
"bakery": "Ogindegi",
"training": "Trebatzea",
"system": "Sistema",
"onboarding": "Hasierako Konfigurazioa"
"onboarding": "Hasierako konfigurazioa"
},
"actions": {
"save": "Gorde",

View File

@@ -0,0 +1,90 @@
{
"title": "Makinaria",
"subtitle": "Kudeatu eta monitorizatu zure ekoizpen-makina",
"equipment_status": {
"operational": "Funtzionamenduan",
"maintenance": "Mantentzea",
"down": "Zerbitzuetik kanpo",
"warning": "Abisua"
},
"equipment_type": {
"oven": "Labean",
"mixer": "Nahaste-makina",
"proofer": "Igoera-gela",
"freezer": "Izozkailua",
"packaging": "Paketatze-makina",
"other": "Bestelakoa"
},
"fields": {
"name": "Izena",
"type": "Mota",
"model": "Modeloa",
"serial_number": "Serie-zenbakia",
"location": "Kokapena",
"status": "Egoera",
"install_date": "Instalazio-data",
"last_maintenance": "Azken mantentzea",
"next_maintenance": "Hurrengo mantentzea",
"maintenance_interval": "Mantentze-tartea",
"efficiency": "Eraginkortasuna",
"uptime": "Funtzionamendu-denbora",
"energy_usage": "Energia-kontsumoa",
"temperature": "Tenperatura",
"target_temperature": "Helburuko tenperatura",
"power": "Potentzia",
"capacity": "Edukiera",
"weight": "Pisua",
"parts": "Piezak"
},
"actions": {
"add_equipment": "Gehitu makina",
"edit_equipment": "Editatu makina",
"delete_equipment": "Ezabatu makina",
"schedule_maintenance": "Antolatu mantentzea",
"view_maintenance_history": "Ikusi mantentze-historia",
"acknowledge_alert": "Berretsi alerta",
"view_details": "Ikusi xehetasunak",
"view_history": "Ikusi historia",
"close": "Itxi",
"cost": "Kostua"
},
"labels": {
"total_equipment": "Makina guztira",
"operational": "Funtzionamenduan",
"avg_efficiency": "Batez besteko eraginkortasuna",
"active_alerts": "Alerta aktiboak",
"maintenance_due": "Mantentzea egiteko",
"overdue_maintenance": "Mantentzea atzeratuta",
"low_efficiency": "Eraginkortasun baxua"
},
"descriptions": {
"equipment_efficiency": "Uneko makinaren eraginkortasun-ehunekoa",
"uptime_percentage": "Funtzionamendu-denboraren ehunekoa",
"active_alerts_count": "Berretsi gabeko alerta kopurua"
},
"maintenance": {
"title": "Mantentzea",
"last": "Azkena",
"next": "Hurrengoa",
"interval": "Tartea",
"history": "Historia",
"records": "erregistro",
"overdue": "Atzeratuta",
"scheduled": "Antolatuta",
"type": {
"preventive": "Prebentiboa",
"corrective": "Zuzentzailea",
"emergency": "Larria"
}
},
"alerts": {
"title": "Alertak",
"unread_alerts": "irakurri gabeko alertak",
"acknowledged": "Berretsita",
"new": "Berria",
"acknowledge": "Berretsi",
"critical": "Larria",
"warning": "Abisua",
"info": "Informazioa"
}
}

View File

@@ -1,14 +1,14 @@
{
"title": "Ekoizpena",
"subtitle": "Zure okindegiaren ekoizpena kudeatu",
"subtitle": "Kudeatu zure ogindegiko ekoizpena",
"production_status": {
"PENDING": "Zain",
"PENDING": "Itxaroten",
"IN_PROGRESS": "Abian",
"COMPLETED": "Amaituta",
"CANCELLED": "Bertan behera utzi",
"COMPLETED": "Osatuta",
"CANCELLED": "Ezeztatuta",
"ON_HOLD": "Pausatuta",
"QUALITY_CHECK": "Kalitate Kontrola",
"FAILED": "Huts egin"
"QUALITY_CHECK": "Kalitate-kontrola",
"FAILED": "Huts egin du"
},
"production_priority": {
"LOW": "Baxua",
@@ -19,62 +19,66 @@
"batch_status": {
"PLANNED": "Planifikatuta",
"IN_PROGRESS": "Abian",
"COMPLETED": "Amaituta",
"CANCELLED": "Bertan behera utzi",
"COMPLETED": "Osatuta",
"CANCELLED": "Ezeztatuta",
"ON_HOLD": "Pausatuta"
},
"quality_check_status": {
"PENDING": "Zain",
"PENDING": "Itxaroten",
"IN_PROGRESS": "Abian",
"PASSED": "Onartuta",
"FAILED": "Baztertuta",
"REQUIRES_ATTENTION": "Arreta Behar du"
"PASSED": "Gaindituta",
"FAILED": "Huts egin du",
"REQUIRES_ATTENTION": "Arreta behar du"
},
"fields": {
"batch_number": "Lote Zenbakia",
"production_date": "Ekoizpen Data",
"planned_quantity": "Planifikatutako Kantitatea",
"actual_quantity": "Benetako Kantitatea",
"yield_percentage": "Errendimendu Ehunekoa",
"batch_number": "Sorta-zenbakia",
"production_date": "Ekoizpen-data",
"planned_quantity": "Planifikatutako kantitatea",
"actual_quantity": "Benetako kantitatea",
"yield_percentage": "Errendimendu-ehunekoa",
"priority": "Lehentasuna",
"assigned_staff": "Esleitutako Langilea",
"production_notes": "Ekoizpen Oharrak",
"quality_score": "Kalitate Puntuazioa",
"quality_notes": "Kalitate Oharrak",
"defect_rate": "Akats Tasa",
"rework_required": "Berrlana Behar",
"waste_quantity": "Hondakin Kantitatea",
"waste_reason": "Hondakin Arrazoia",
"assigned_staff": "Esleitutako langilea",
"production_notes": "Ekoizpen-oharrak",
"quality_score": "Kalitate-puntua",
"quality_notes": "Kalitate-oharrak",
"defect_rate": "Akats-tasa",
"rework_required": "Berregitea behar da",
"waste_quantity": "Hondakin-kantitatea",
"waste_reason": "Hondakinaren arrazoia",
"efficiency": "Eraginkortasuna",
"material_cost": "Material Kostua",
"labor_cost": "Lan Kostua",
"overhead_cost": "Kostu Orokorra",
"total_cost": "Kostu Osoa",
"cost_per_unit": "Unitateko Kostua"
"material_cost": "Material-kostua",
"labor_cost": "Lan-kostua",
"overhead_cost": "Indirektoko kostua",
"total_cost": "Kostu osoa",
"cost_per_unit": "Unitateko kostua"
},
"actions": {
"start_production": "Ekoizpena Hasi",
"complete_batch": "Lotea Amaitu",
"pause_production": "Ekoizpena Pausatu",
"cancel_batch": "Lotea Ezeztatu",
"quality_check": "Kalitate Kontrola",
"create_batch": "Lotea Sortu",
"view_details": "Xehetasunak Ikusi",
"edit_batch": "Lotea Editatu",
"duplicate_batch": "Lotea Bikoiztu"
"start_production": "Hasi ekoizpena",
"complete_batch": "Osatu sorta",
"pause_production": "Pausatu ekoizpena",
"cancel_batch": "Ezeztatu sorta",
"quality_check": "Kalitate-kontrola",
"create_batch": "Sortu sorta",
"view_details": "Ikusi xehetasunak",
"edit_batch": "Editatu sorta",
"duplicate_batch": "Bikoiztu sorta"
},
"labels": {
"current_production": "Uneko Ekoizpena",
"production_queue": "Ekoizpen Ilara",
"completed_today": "Gaur Amaitutakoak",
"efficiency_rate": "Eraginkortasun Tasa",
"quality_score": "Kalitate Puntuazioa",
"active_batches": "Lote Aktiboak",
"pending_quality_checks": "Kalitate Kontrol Zain"
"current_production": "Uneko ekoizpena",
"production_queue": "Ekoizpen-ilara",
"completed_today": "Gaur osatuta",
"efficiency_rate": "Eraginkortasun-tasa",
"quality_score": "Kalitate-puntua",
"active_batches": "Sorta aktiboak",
"pending_quality_checks": "Kalitate-kontrolak itxaroten"
},
"descriptions": {
"production_efficiency": "Uneko ekoizpenaren eraginkortasun ehunekoa",
"quality_average": "Azken loteen batez besteko kalitate puntuazioa",
"waste_reduction": "Hondakin murrizketa aurreko hilarekin alderatuta"
"production_efficiency": "Uneko ekoizpenaren eraginkortasun-ehunekoa",
"quality_average": "Azken sorten batez besteko kalitate-puntua",
"waste_reduction": "Aurreko hilabetearekin alderatutako hondakin-murrizketa"
},
"equipment": {
"title": "Makinen egoera",
"subtitle": "Monitorizatu makinen osasuna eta errendimendua"
}
}

View File

@@ -9,6 +9,7 @@ import recipesEs from './es/recipes.json';
import errorsEs from './es/errors.json';
import dashboardEs from './es/dashboard.json';
import productionEs from './es/production.json';
import equipmentEs from './es/equipment.json';
// English translations
import commonEn from './en/common.json';
@@ -21,6 +22,7 @@ import recipesEn from './en/recipes.json';
import errorsEn from './en/errors.json';
import dashboardEn from './en/dashboard.json';
import productionEn from './en/production.json';
import equipmentEn from './en/equipment.json';
// Basque translations
import commonEu from './eu/common.json';
@@ -33,6 +35,7 @@ import recipesEu from './eu/recipes.json';
import errorsEu from './eu/errors.json';
import dashboardEu from './eu/dashboard.json';
import productionEu from './eu/production.json';
import equipmentEu from './eu/equipment.json';
// Translation resources by language
export const resources = {
@@ -47,6 +50,7 @@ export const resources = {
errors: errorsEs,
dashboard: dashboardEs,
production: productionEs,
equipment: equipmentEs,
},
en: {
common: commonEn,
@@ -59,6 +63,7 @@ export const resources = {
errors: errorsEn,
dashboard: dashboardEn,
production: productionEn,
equipment: equipmentEn,
},
eu: {
common: commonEu,
@@ -71,6 +76,7 @@ export const resources = {
errors: errorsEu,
dashboard: dashboardEu,
production: productionEu,
equipment: equipmentEu,
},
};
@@ -107,7 +113,7 @@ export const languageConfig = {
};
// Namespaces available in translations
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production'] as const;
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment'] as const;
export type Namespace = typeof namespaces[number];
// Helper function to get language display name
@@ -121,7 +127,7 @@ export const isSupportedLanguage = (language: string): language is SupportedLang
};
// Export individual language modules for direct imports
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, recipesEs, errorsEs };
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, recipesEs, errorsEs, equipmentEs };
// Default export with all translations
export default resources;

View File

@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { TrendingUp, DollarSign, Activity, AlertTriangle, Settings } from 'lucide-react';
import { Card, StatsGrid, Button } from '../../../components/ui';
import { PageHeader } from '../../../components/layout';
import { QualityDashboard, EquipmentManager } from '../../../components/domain/production';
// Production Cost Monitor Component (placeholder)
const ProductionCostMonitor: React.FC = () => {
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Monitor de Costos de Producción</h3>
<DollarSign className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-[var(--surface-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)]">Costo por unidad</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">2.45</p>
<p className="text-xs text-green-600">-8% vs mes anterior</p>
</div>
<div className="p-4 bg-[var(--surface-secondary)] rounded-lg">
<p className="text-sm text-[var(--text-secondary)]">Eficiencia de costos</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">92%</p>
<p className="text-xs text-green-600">+3% vs objetivo</p>
</div>
</div>
<div className="h-64 bg-[var(--surface-secondary)] rounded-lg flex items-center justify-center">
<p className="text-[var(--text-secondary)]">Gráfico de tendencias de costos</p>
</div>
</div>
</Card>
);
};
// AI Insights Component (placeholder)
const AIInsights: React.FC = () => {
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Insights de IA</h3>
<Activity className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div className="space-y-4">
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<TrendingUp className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900">Optimización de Producción</h4>
<p className="text-sm text-blue-700">Se detectó que producir croissants los martes reduce costos en 15%</p>
</div>
</div>
</div>
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<h4 className="font-medium text-amber-900">Predicción de Demanda</h4>
<p className="text-sm text-amber-700">Aumento del 25% en demanda de pan integral previsto para el fin de semana</p>
</div>
</div>
</div>
</div>
</Card>
);
};
// Equipment Status Component (placeholder)
const EquipmentStatus: React.FC = () => {
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Estado de Equipos</h3>
<Settings className="w-5 h-5 text-[var(--color-primary)]" />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
<div>
<p className="font-medium text-green-900">Horno Principal</p>
<p className="text-sm text-green-700">Funcionando óptimamente</p>
</div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<div className="flex items-center justify-between p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div>
<p className="font-medium text-yellow-900">Mezcladora #2</p>
<p className="text-sm text-yellow-700">Mantenimiento requerido en 3 días</p>
</div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
<div>
<p className="font-medium text-green-900">Refrigerador</p>
<p className="text-sm text-green-700">Temperatura estable</p>
</div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
</div>
</Card>
);
};
const ProductionAnalyticsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState('overview');
return (
<div className="space-y-6">
<PageHeader
title="Análisis de Producción"
description="Análisis avanzado y insights para profesionales y empresas"
/>
{/* Key Metrics */}
<StatsGrid
stats={[
{
title: 'Eficiencia General',
value: '94%',
variant: 'success' as const,
icon: TrendingUp,
},
{
title: 'Costo Promedio',
value: '€2.45',
variant: 'info' as const,
icon: DollarSign,
},
{
title: 'Equipos Activos',
value: '8/9',
variant: 'warning' as const,
icon: Settings,
},
{
title: 'Calidad Promedio',
value: '9.2/10',
variant: 'success' as const,
icon: Activity,
}
]}
columns={4}
/>
{/* Tabs Navigation */}
<div className="border-b border-[var(--border-primary)]">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Resumen
</button>
<button
onClick={() => setActiveTab('costs')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'costs'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Costos
</button>
<button
onClick={() => setActiveTab('ai-insights')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'ai-insights'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
IA Insights
</button>
<button
onClick={() => setActiveTab('equipment')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'equipment'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Equipos
</button>
<button
onClick={() => setActiveTab('quality')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'quality'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Calidad
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid gap-6 lg:grid-cols-2">
<ProductionCostMonitor />
<AIInsights />
<EquipmentStatus />
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Resumen de Calidad</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Productos aprobados</span>
<span className="font-medium">96%</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Rechazos por calidad</span>
<span className="font-medium">2%</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Reprocesos</span>
<span className="font-medium">2%</span>
</div>
</div>
</Card>
</div>
)}
{activeTab === 'costs' && (
<div className="grid gap-6">
<ProductionCostMonitor />
</div>
)}
{activeTab === 'ai-insights' && (
<div className="grid gap-6">
<AIInsights />
</div>
)}
{activeTab === 'equipment' && (
<div className="grid gap-6">
<EquipmentStatus />
<EquipmentManager />
</div>
)}
{activeTab === 'quality' && (
<QualityDashboard />
)}
</div>
);
};
export default ProductionAnalyticsPage;

View File

@@ -4,4 +4,5 @@ export * from './recipes';
export * from './procurement';
export * from './orders';
export * from './suppliers';
export * from './pos';
export * from './pos';
export * from './maquinaria';

View File

@@ -0,0 +1,604 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, AlertTriangle, Settings, CheckCircle, Eye, Clock, Zap, Wrench, Thermometer, Activity, Search, Filter, Download, TrendingUp, Bell, History, Calendar, Edit, Trash2 } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/shared';
import { PageHeader } from '../../../../components/layout';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { Equipment } from '../../../../types/equipment';
const MOCK_EQUIPMENT: Equipment[] = [
{
id: '1',
name: 'Horno Principal #1',
type: 'oven',
model: 'Miwe Condo CO 4.1212',
serialNumber: 'MCO-2021-001',
location: 'Área de Horneado - Zona A',
status: 'operational',
installDate: '2021-03-15',
lastMaintenance: '2024-01-15',
nextMaintenance: '2024-04-15',
maintenanceInterval: 90,
temperature: 220,
targetTemperature: 220,
efficiency: 92,
uptime: 98.5,
energyUsage: 45.2,
utilizationToday: 87,
alerts: [],
maintenanceHistory: [
{
id: '1',
date: '2024-01-15',
type: 'preventive',
description: 'Limpieza general y calibración de termostatos',
technician: 'Juan Pérez',
cost: 150,
downtime: 2,
partsUsed: ['Filtros de aire', 'Sellos de puerta']
}
],
specifications: {
power: 45,
capacity: 24,
dimensions: { width: 200, height: 180, depth: 120 },
weight: 850
}
},
{
id: '2',
name: 'Batidora Industrial #2',
type: 'mixer',
model: 'Hobart HL800',
serialNumber: 'HHL-2020-002',
location: 'Área de Preparación - Zona B',
status: 'warning',
installDate: '2020-08-10',
lastMaintenance: '2024-01-20',
nextMaintenance: '2024-02-20',
maintenanceInterval: 30,
efficiency: 88,
uptime: 94.2,
energyUsage: 12.8,
utilizationToday: 76,
alerts: [
{
id: '1',
type: 'warning',
message: 'Vibración inusual detectada en el motor',
timestamp: '2024-01-23T10:30:00Z',
acknowledged: false
},
{
id: '2',
type: 'info',
message: 'Mantenimiento programado en 5 días',
timestamp: '2024-01-23T08:00:00Z',
acknowledged: true
}
],
maintenanceHistory: [
{
id: '1',
date: '2024-01-20',
type: 'corrective',
description: 'Reemplazo de correas de transmisión',
technician: 'María González',
cost: 85,
downtime: 4,
partsUsed: ['Correa tipo V', 'Rodamientos']
}
],
specifications: {
power: 15,
capacity: 80,
dimensions: { width: 120, height: 150, depth: 80 },
weight: 320
}
},
{
id: '3',
name: 'Cámara de Fermentación #1',
type: 'proofer',
model: 'Bongard EUROPA 16.18',
serialNumber: 'BEU-2022-001',
location: 'Área de Fermentación',
status: 'maintenance',
installDate: '2022-06-20',
lastMaintenance: '2024-01-23',
nextMaintenance: '2024-01-24',
maintenanceInterval: 60,
temperature: 32,
targetTemperature: 35,
efficiency: 0,
uptime: 85.1,
energyUsage: 0,
utilizationToday: 0,
alerts: [
{
id: '1',
type: 'info',
message: 'En mantenimiento programado',
timestamp: '2024-01-23T06:00:00Z',
acknowledged: true
}
],
maintenanceHistory: [
{
id: '1',
date: '2024-01-23',
type: 'preventive',
description: 'Mantenimiento programado - sistema de humidificación',
technician: 'Carlos Rodríguez',
cost: 200,
downtime: 8,
partsUsed: ['Sensor de humedad', 'Válvulas']
}
],
specifications: {
power: 8,
capacity: 16,
dimensions: { width: 180, height: 200, depth: 100 },
weight: 450
}
}
];
const MaquinariaPage: React.FC = () => {
const { t } = useTranslation(['equipment', 'common']);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<Equipment['status'] | 'all'>('all');
const [selectedItem, setSelectedItem] = useState<Equipment | null>(null);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// Mock functions for equipment actions - these would be replaced with actual API calls
const handleCreateEquipment = () => {
console.log('Create new equipment');
// Implementation would go here
};
const handleEditEquipment = (equipmentId: string) => {
console.log('Edit equipment:', equipmentId);
// Implementation would go here
};
const handleScheduleMaintenance = (equipmentId: string) => {
console.log('Schedule maintenance for equipment:', equipmentId);
// Implementation would go here
};
const handleAcknowledgeAlert = (equipmentId: string, alertId: string) => {
console.log('Acknowledge alert:', alertId, 'for equipment:', equipmentId);
// Implementation would go here
};
const handleViewMaintenanceHistory = (equipmentId: string) => {
console.log('View maintenance history for equipment:', equipmentId);
// Implementation would go here
};
const filteredEquipment = useMemo(() => {
return MOCK_EQUIPMENT.filter(eq => {
const matchesSearch = !searchTerm ||
eq.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
eq.location.toLowerCase().includes(searchTerm.toLowerCase()) ||
eq.type.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || eq.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [MOCK_EQUIPMENT, searchTerm, statusFilter]);
const equipmentStats = useMemo(() => {
const total = MOCK_EQUIPMENT.length;
const operational = MOCK_EQUIPMENT.filter(e => e.status === 'operational').length;
const warning = MOCK_EQUIPMENT.filter(e => e.status === 'warning').length;
const maintenance = MOCK_EQUIPMENT.filter(e => e.status === 'maintenance').length;
const down = MOCK_EQUIPMENT.filter(e => e.status === 'down').length;
const avgEfficiency = MOCK_EQUIPMENT.reduce((sum, e) => sum + e.efficiency, 0) / total;
const avgUptime = MOCK_EQUIPMENT.reduce((sum, e) => sum + e.uptime, 0) / total;
const totalAlerts = MOCK_EQUIPMENT.reduce((sum, e) => sum + e.alerts.filter(a => !a.acknowledged).length, 0);
return {
total,
operational,
warning,
maintenance,
down,
avgEfficiency,
avgUptime,
totalAlerts
};
}, [MOCK_EQUIPMENT]);
const getStatusConfig = (status: Equipment['status']) => {
const configs = {
operational: { color: getStatusColor('completed'), text: t('equipment_status.operational'), icon: CheckCircle },
warning: { color: getStatusColor('warning'), text: t('equipment_status.warning'), icon: AlertTriangle },
maintenance: { color: getStatusColor('info'), text: t('equipment_status.maintenance'), icon: Wrench },
down: { color: getStatusColor('error'), text: t('equipment_status.down'), icon: AlertTriangle }
};
return configs[status];
};
const getTypeIcon = (type: Equipment['type']) => {
const icons = {
oven: Thermometer,
mixer: Activity,
proofer: Settings,
freezer: Zap,
packaging: Settings,
other: Settings
};
return icons[type];
};
const getStatusColor = (status: string): string => {
const statusColors: { [key: string]: string } = {
operational: '#10B981', // green-500
warning: '#F59E0B', // amber-500
maintenance: '#3B82F6', // blue-500
down: '#EF4444' // red-500
};
return statusColors[status] || '#6B7280'; // gray-500 as default
};
const stats = [
{
title: t('labels.total_equipment'),
value: equipmentStats.total,
icon: Settings,
variant: 'default' as const
},
{
title: t('labels.operational'),
value: equipmentStats.operational,
icon: CheckCircle,
variant: 'success' as const,
subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%`
},
{
title: t('labels.avg_efficiency'),
value: `${equipmentStats.avgEfficiency.toFixed(1)}%`,
icon: TrendingUp,
variant: equipmentStats.avgEfficiency >= 90 ? 'success' as const : 'warning' as const
},
{
title: t('labels.active_alerts'),
value: equipmentStats.totalAlerts,
icon: Bell,
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const
}
];
const handleShowMaintenanceDetails = (equipment: Equipment) => {
setSelectedItem(equipment);
setShowMaintenanceModal(true);
};
const handleCloseMaintenanceModal = () => {
setShowMaintenanceModal(false);
setSelectedItem(null);
};
// Loading state
if (!tenantId) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando datos..." />
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={[
{
id: "add-new-equipment",
label: t('actions.add_equipment'),
variant: "primary" as const,
icon: Plus,
onClick: handleCreateEquipment,
tooltip: t('actions.add_equipment'),
size: "md"
}
]}
/>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
{/* Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
placeholder={t('common:forms.search_placeholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-[var(--text-tertiary)]" />
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as Equipment['status'] | 'all')}
className="px-3 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="all">{t('common:forms.select_option')}</option>
<option value="operational">{t('equipment_status.operational')}</option>
<option value="warning">{t('equipment_status.warning')}</option>
<option value="maintenance">{t('equipment_status.maintenance')}</option>
<option value="down">{t('equipment_status.down')}</option>
</select>
</div>
</div>
</Card>
{/* Equipment Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredEquipment.map((equipment) => {
const statusConfig = getStatusConfig(equipment.status);
const TypeIcon = getTypeIcon(equipment.type);
// Calculate maintenance status
const nextMaintenanceDate = new Date(equipment.nextMaintenance);
const daysUntilMaintenance = Math.ceil((nextMaintenanceDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
const isOverdue = daysUntilMaintenance < 0;
return (
<StatusCard
key={equipment.id}
id={equipment.id}
statusIndicator={statusConfig}
title={equipment.name}
subtitle={equipment.location}
primaryValue={`${equipment.efficiency}%`}
primaryValueLabel={t('fields.efficiency')}
secondaryInfo={{
label: t('fields.uptime'),
value: `${equipment.uptime.toFixed(1)}%`
}}
actions={[
{
label: t('actions.view_details'),
icon: Eye,
variant: 'primary',
priority: 'primary',
onClick: () => handleShowMaintenanceDetails(equipment)
},
{
label: t('actions.view_history'),
icon: History,
priority: 'secondary',
onClick: () => handleViewMaintenanceHistory(equipment.id)
},
{
label: t('actions.schedule_maintenance'),
icon: Wrench,
priority: 'secondary',
onClick: () => handleScheduleMaintenance(equipment.id)
}
]}
/>
);
})}
</div>
{/* Empty State */}
{filteredEquipment.length === 0 && (
<div className="text-center py-12">
<Settings className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
{t('common:forms.no_results')}
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{t('common:forms.empty_state')}
</p>
<Button
onClick={handleCreateEquipment}
variant="primary"
size="md"
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
>
<Plus className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
<span className="text-sm sm:text-base">{t('actions.add_equipment')}</span>
</Button>
</div>
)}
{/* Maintenance Details Modal */}
{selectedItem && showMaintenanceModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-[var(--bg-primary)] rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto my-8">
<div className="p-4 sm:p-6">
<div className="flex items-center justify-between mb-4 sm:mb-6">
<div>
<h2 className="text-lg sm:text-xl font-semibold text-[var(--text-primary)]">
{selectedItem.name}
</h2>
<p className="text-[var(--text-secondary)] text-sm">
{selectedItem.model} - {selectedItem.serialNumber}
</p>
</div>
<button
onClick={handleCloseMaintenanceModal}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1"
>
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4 sm:space-y-6">
{/* Equipment Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
<h3 className="font-medium text-[var(--text-primary)] mb-2 text-sm sm:text-base">{t('fields.status')}</h3>
<div className="flex items-center space-x-2">
<div
className="w-2 h-2 sm:w-3 sm:h-3 rounded-full"
style={{ backgroundColor: getStatusConfig(selectedItem.status).color }}
/>
<span className="text-[var(--text-primary)] text-sm sm:text-base">
{t(`equipment_status.${selectedItem.status}`)}
</span>
</div>
</div>
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
<h3 className="font-medium text-[var(--text-primary)] mb-2 text-sm sm:text-base">{t('fields.efficiency')}</h3>
<div className="text-lg sm:text-2xl font-bold text-[var(--text-primary)]">
{selectedItem.efficiency}%
</div>
</div>
</div>
{/* Maintenance Information */}
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('maintenance.title')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<div>
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.last')}</p>
<p className="text-[var(--text-primary)] text-sm sm:text-base">
{new Date(selectedItem.lastMaintenance).toLocaleDateString('es-ES')}
</p>
</div>
<div>
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.next')}</p>
<p className={`font-medium ${(new Date(selectedItem.nextMaintenance).getTime() < new Date().getTime()) ? 'text-red-500' : 'text-[var(--text-primary)]'} text-sm sm:text-base`}>
{new Date(selectedItem.nextMaintenance).toLocaleDateString('es-ES')}
</p>
</div>
<div>
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">{t('maintenance.interval')}</p>
<p className="text-[var(--text-primary)] text-sm sm:text-base">
{selectedItem.maintenanceInterval} {t('common:units.days')}
</p>
</div>
</div>
{new Date(selectedItem.nextMaintenance).getTime() < new Date().getTime() && (
<div className="mt-3 p-2 bg-red-50 dark:bg-red-900/20 rounded border-l-2 border-red-500">
<div className="flex items-center space-x-2">
<AlertTriangle className="w-4 h-4 text-red-500" />
<span className="text-xs sm:text-sm font-medium text-red-700 dark:text-red-300">
{t('maintenance.overdue')}
</span>
</div>
</div>
)}
</div>
{/* Active Alerts */}
{selectedItem.alerts.filter(a => !a.acknowledged).length > 0 && (
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('alerts.title')}</h3>
<div className="space-y-2 sm:space-y-3">
{selectedItem.alerts.filter(a => !a.acknowledged).map((alert) => (
<div
key={alert.id}
className={`p-2 sm:p-3 rounded border-l-2 ${
alert.type === 'critical' ? 'bg-red-50 border-red-500 dark:bg-red-900/20' :
alert.type === 'warning' ? 'bg-orange-50 border-orange-500 dark:bg-orange-900/20' :
'bg-blue-50 border-blue-500 dark:bg-blue-900/20'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<AlertTriangle className={`w-3 h-3 sm:w-4 sm:h-4 ${
alert.type === 'critical' ? 'text-red-500' :
alert.type === 'warning' ? 'text-orange-500' : 'text-blue-500'
}`} />
<span className="font-medium text-[var(--text-primary)] text-xs sm:text-sm">
{alert.message}
</span>
</div>
<span className="text-xs text-[var(--text-secondary)] hidden sm:block">
{new Date(alert.timestamp).toLocaleString('es-ES')}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Maintenance History */}
<div className="p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg">
<h3 className="font-medium text-[var(--text-primary)] mb-3 sm:mb-4 text-sm sm:text-base">{t('maintenance.history')}</h3>
<div className="space-y-3 sm:space-y-4">
{selectedItem.maintenanceHistory.map((history) => (
<div key={history.id} className="border-b border-[var(--border-primary)] pb-2 sm:pb-3 last:border-0 last:pb-0">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium text-[var(--text-primary)] text-sm">{history.description}</h4>
<p className="text-xs text-[var(--text-secondary)]">
{new Date(history.date).toLocaleDateString('es-ES')} - {history.technician}
</p>
</div>
<span className="px-1.5 py-0.5 sm:px-2 sm:py-1 bg-[var(--bg-tertiary)] text-xs rounded">
{t(`maintenance.type.${history.type}`)}
</span>
</div>
<div className="mt-1 sm:mt-2 flex flex-wrap gap-2">
<span className="text-xs">
<span className="text-[var(--text-secondary)]">{t('common:actions.cost')}:</span>
<span className="font-medium text-[var(--text-primary)]"> {history.cost}</span>
</span>
<span className="text-xs">
<span className="text-[var(--text-secondary)]">{t('fields.uptime')}:</span>
<span className="font-medium text-[var(--text-primary)]"> {history.downtime}h</span>
</span>
</div>
{history.partsUsed.length > 0 && (
<div className="mt-1 sm:mt-2">
<span className="text-xs text-[var(--text-secondary)]">{t('fields.parts')}:</span>
<div className="flex flex-wrap gap-1 mt-1">
{history.partsUsed.map((part, index) => (
<span key={index} className="px-1.5 py-0.5 sm:px-2 sm:py-1 bg-[var(--bg-tertiary)] text-xs rounded">
{part}
</span>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
<div className="flex justify-end space-x-2 sm:space-x-3 mt-4 sm:mt-6">
<Button variant="outline" size="sm" onClick={handleCloseMaintenanceModal}>
{t('common:actions.close')}
</Button>
<Button variant="primary" size="sm" onClick={() => selectedItem && handleScheduleMaintenance(selectedItem.id)}>
{t('actions.schedule_maintenance')}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default MaquinariaPage;

View File

@@ -0,0 +1 @@
export { default as MaquinariaPage } from './MaquinariaPage';

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Plus, ShoppingCart, Truck, Euro, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play } from 'lucide-react';
import { Plus, ShoppingCart, Truck, Euro, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play, Zap, User } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
@@ -28,6 +28,7 @@ const ProcurementPage: React.FC = () => {
const [selectedRequirement, setSelectedRequirement] = useState<any>(null);
const [showCreatePurchaseOrderModal, setShowCreatePurchaseOrderModal] = useState(false);
const [selectedRequirementsForPO, setSelectedRequirementsForPO] = useState<any[]>([]);
const [isAIMode, setIsAIMode] = useState(true);
const [generatePlanForm, setGeneratePlanForm] = useState({
plan_date: new Date().toISOString().split('T')[0],
planning_horizon_days: 14,
@@ -253,28 +254,63 @@ const ProcurementPage: React.FC = () => {
return (
<div className="space-y-6">
<PageHeader
title="Planificación de Compras"
description="Administra planes de compras, requerimientos y análisis de procurement"
actions={[
{
id: "create-po",
label: "Crear Orden de Compra",
variant: "primary" as const,
icon: Plus,
onClick: () => {
// Open the purchase order modal with empty requirements
// This allows manual creation of purchase orders without procurement plans
setSelectedRequirementsForPO([]);
setShowCreatePurchaseOrderModal(true);
}
},
{
id: "trigger",
label: triggerSchedulerMutation.isPending ? "Ejecutando..." : "Ejecutar Programador",
variant: "outline" as const,
icon: triggerSchedulerMutation.isPending ? Loader : Calendar,
onClick: () => {
<div className="flex items-center justify-between mb-6">
<PageHeader
title="Planificación de Compras"
description="Administra planes de compras, requerimientos y análisis de procurement"
/>
<div className="flex items-center gap-4">
{/* AI/Manual Mode Segmented Control */}
<div className="inline-flex p-1 bg-[var(--surface-secondary)] rounded-xl border border-[var(--border-primary)] shadow-sm">
<button
onClick={() => setIsAIMode(true)}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out
${isAIMode
? 'bg-[var(--color-primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-tertiary)]'
}
`}
>
<Zap className="w-4 h-4" />
Automático IA
</button>
<button
onClick={() => setIsAIMode(false)}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out
${!isAIMode
? 'bg-[var(--color-primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-tertiary)]'
}
`}
>
<User className="w-4 h-4" />
Manual
</button>
</div>
{/* Action Buttons */}
{!isAIMode && (
<Button
variant="primary"
onClick={() => {
// Open the purchase order modal with empty requirements
// This allows manual creation of purchase orders without procurement plans
setSelectedRequirementsForPO([]);
setShowCreatePurchaseOrderModal(true);
}}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Crear Orden de Compra
</Button>
)}
{/* Testing button - keep for development */}
<Button
variant="outline"
onClick={() => {
triggerSchedulerMutation.mutate(tenantId, {
onSuccess: (data) => {
// Scheduler executed successfully
@@ -287,11 +323,19 @@ const ProcurementPage: React.FC = () => {
// toast.error('Error ejecutando el programador de compras');
}
})
},
disabled: triggerSchedulerMutation.isPending
}
]}
/>
}}
disabled={triggerSchedulerMutation.isPending}
className="flex items-center gap-2"
>
{triggerSchedulerMutation.isPending ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Calendar className="w-4 h-4" />
)}
{triggerSchedulerMutation.isPending ? "Ejecutando..." : "Ejecutar Programador"}
</Button>
</div>
</div>
{/* Stats Grid */}
{isDashboardLoading ? (

View File

@@ -1,11 +1,11 @@
import React, { useState, useMemo } from 'react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusModal } from '../../../../components/ui';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, Zap, User, PlusCircle } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusModal, Toggle } from '../../../../components/ui';
import { statusColors } from '../../../../styles/colors';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { LoadingSpinner } from '../../../../components/shared';
import { PageHeader } from '../../../../components/layout';
import { ProductionSchedule, BatchTracker, QualityDashboard, EquipmentManager, CreateProductionBatchModal, ProductionStatusCard, QualityCheckModal } from '../../../../components/domain/production';
import { ProductionSchedule, BatchTracker, QualityDashboard, EquipmentManager, CreateProductionBatchModal, ProductionStatusCard, QualityCheckModal, ProcessStageTracker, CompactProcessStageTracker } from '../../../../components/domain/production';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import {
useProductionDashboard,
@@ -24,6 +24,7 @@ import {
ProductionPriorityEnum
} from '../../../../api';
import { useProductionEnums } from '../../../../utils/enumHelpers';
import { ProcessStage } from '../../../../api/types/qualityTemplates';
const ProductionPage: React.FC = () => {
const [activeTab, setActiveTab] = useState('schedule');
@@ -33,6 +34,7 @@ const ProductionPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showQualityModal, setShowQualityModal] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [isAIMode, setIsAIMode] = useState(true);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
@@ -68,6 +70,133 @@ const ProductionPage: React.FC = () => {
}
};
// Stage management handlers
const handleStageAdvance = async (batchId: string, currentStage: ProcessStage) => {
const stages = Object.values(ProcessStage);
const currentIndex = stages.indexOf(currentStage);
const nextStage = stages[currentIndex + 1];
if (nextStage) {
try {
await updateBatchStatusMutation.mutateAsync({
batchId,
updates: {
current_process_stage: nextStage,
process_stage_history: {
[currentStage]: { end_time: new Date().toISOString() },
[nextStage]: { start_time: new Date().toISOString() }
}
}
});
} catch (error) {
console.error('Error advancing stage:', error);
}
} else {
// Final stage - mark as completed
await updateBatchStatusMutation.mutateAsync({
batchId,
updates: { status: ProductionStatusEnum.COMPLETED }
});
}
};
const handleStageStart = async (batchId: string, stage: ProcessStage) => {
try {
await updateBatchStatusMutation.mutateAsync({
batchId,
updates: {
status: ProductionStatusEnum.IN_PROGRESS,
current_process_stage: stage,
process_stage_history: {
[stage]: { start_time: new Date().toISOString() }
}
}
});
} catch (error) {
console.error('Error starting stage:', error);
}
};
const handleQualityCheckForStage = (batch: ProductionBatchResponse, stage: ProcessStage) => {
setSelectedBatch(batch);
setShowQualityModal(true);
// The QualityCheckModal should be enhanced to handle stage-specific checks
};
// Helper function to generate mock process stage data for the selected batch
const generateMockProcessStageData = (batch: ProductionBatchResponse) => {
// Mock data based on batch status - this would come from the API in real implementation
const mockProcessStage = {
current: batch.status === ProductionStatusEnum.PENDING ? 'mixing' as const :
batch.status === ProductionStatusEnum.IN_PROGRESS ? 'baking' as const :
batch.status === ProductionStatusEnum.QUALITY_CHECK ? 'cooling' as const :
'finishing' as const,
history: batch.status !== ProductionStatusEnum.PENDING ? [
{ stage: 'mixing' as const, timestamp: batch.actual_start_time || batch.planned_start_time, duration: 30 },
...(batch.status === ProductionStatusEnum.IN_PROGRESS || batch.status === ProductionStatusEnum.QUALITY_CHECK || batch.status === ProductionStatusEnum.COMPLETED ? [
{ stage: 'proofing' as const, timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), duration: 90 },
{ stage: 'shaping' as const, timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(), duration: 15 }
] : []),
...(batch.status === ProductionStatusEnum.QUALITY_CHECK || batch.status === ProductionStatusEnum.COMPLETED ? [
{ stage: 'baking' as const, timestamp: new Date(Date.now() - 30 * 60 * 1000).toISOString(), duration: 45 }
] : [])
] : [],
pendingQualityChecks: batch.status === ProductionStatusEnum.IN_PROGRESS ? [
{
id: 'qc1',
name: 'Control de temperatura interna',
stage: 'baking' as const,
isRequired: true,
isCritical: true,
status: 'pending' as const,
checkType: 'temperature' as const
}
] : batch.status === ProductionStatusEnum.QUALITY_CHECK ? [
{
id: 'qc2',
name: 'Inspección visual final',
stage: 'cooling' as const,
isRequired: true,
isCritical: false,
status: 'pending' as const,
checkType: 'visual' as const
}
] : [],
completedQualityChecks: batch.status === ProductionStatusEnum.COMPLETED ? [
{
id: 'qc1',
name: 'Control de temperatura interna',
stage: 'baking' as const,
isRequired: true,
isCritical: true,
status: 'completed' as const,
checkType: 'temperature' as const
},
{
id: 'qc2',
name: 'Inspección visual final',
stage: 'cooling' as const,
isRequired: true,
isCritical: false,
status: 'completed' as const,
checkType: 'visual' as const
}
] : batch.status === ProductionStatusEnum.IN_PROGRESS ? [
{
id: 'qc3',
name: 'Verificación de masa',
stage: 'mixing' as const,
isRequired: true,
isCritical: false,
status: 'completed' as const,
checkType: 'visual' as const
}
] : []
};
return mockProcessStage;
};
const batches = activeBatchesData?.batches || [];
@@ -139,19 +268,55 @@ const ProductionPage: React.FC = () => {
return (
<div className="space-y-6">
<PageHeader
title="Gestión de Producción"
description="Planifica y controla la producción diaria de tu panadería"
actions={[
{
id: "new",
label: "Nueva Orden de Producción",
variant: "primary" as const,
icon: Plus,
onClick: () => setShowCreateModal(true)
}
]}
/>
<div className="flex items-center justify-between mb-6">
<PageHeader
title="Gestión de Producción"
description="Planifica y controla la producción diaria de tu panadería"
/>
<div className="flex items-center gap-4">
{/* AI/Manual Mode Segmented Control */}
<div className="inline-flex p-1 bg-[var(--surface-secondary)] rounded-xl border border-[var(--border-primary)] shadow-sm">
<button
onClick={() => setIsAIMode(true)}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out
${isAIMode
? 'bg-[var(--color-primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-tertiary)]'
}
`}
>
<Zap className="w-4 h-4" />
Automático IA
</button>
<button
onClick={() => setIsAIMode(false)}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ease-in-out
${!isAIMode
? 'bg-[var(--color-primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-tertiary)]'
}
`}
>
<User className="w-4 h-4" />
Manual
</button>
</div>
{!isAIMode && (
<Button
variant="primary"
size="md"
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2.5 px-6 py-3 text-sm font-semibold tracking-wide shadow-lg hover:shadow-xl transition-all duration-200"
>
<PlusCircle className="w-5 h-5" />
Nueva Orden de Producción
</Button>
)}
</div>
</div>
{/* Production Stats */}
<StatsGrid
@@ -209,26 +374,6 @@ const ProductionPage: React.FC = () => {
>
Programación
</button>
<button
onClick={() => setActiveTab('quality-dashboard')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'quality-dashboard'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Dashboard Calidad
</button>
<button
onClick={() => setActiveTab('equipment')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'equipment'
? 'border-orange-500 text-[var(--color-primary)]'
: 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] hover:border-[var(--border-secondary)]'
}`}
>
Equipos
</button>
</nav>
</div>
@@ -342,13 +487,6 @@ const ProductionPage: React.FC = () => {
)}
{activeTab === 'quality-dashboard' && (
<QualityDashboard />
)}
{activeTab === 'equipment' && (
<EquipmentManager />
)}
{/* Production Batch Modal */}
{showBatchModal && selectedBatch && (
@@ -436,36 +574,23 @@ const ProductionPage: React.FC = () => {
]
},
{
title: 'Etapas de Producción',
title: 'Seguimiento de Proceso',
icon: Timer,
fields: [
{
label: 'Etapa Actual',
value: selectedBatch.status === ProductionStatusEnum.IN_PROGRESS
? 'En progreso'
: selectedBatch.status === ProductionStatusEnum.QUALITY_CHECK
? 'Control de calidad'
: productionEnums.getProductionStatusLabel(selectedBatch.status)
},
{
label: 'Progreso del Lote',
value: selectedBatch.status === ProductionStatusEnum.COMPLETED
? '100%'
: selectedBatch.status === ProductionStatusEnum.PENDING
? '0%'
: '50%'
},
{
label: 'Tiempo Transcurrido',
value: selectedBatch.actual_start_time
? `${Math.round((new Date().getTime() - new Date(selectedBatch.actual_start_time).getTime()) / (1000 * 60))} minutos`
: 'No iniciado'
},
{
label: 'Tiempo Estimado Restante',
value: selectedBatch.planned_end_time && selectedBatch.actual_start_time
? `${Math.max(0, Math.round((new Date(selectedBatch.planned_end_time).getTime() - new Date().getTime()) / (1000 * 60)))} minutos`
: 'Calculando...'
label: '',
value: (
<CompactProcessStageTracker
processStage={generateMockProcessStageData(selectedBatch)}
onAdvanceStage={(currentStage) => handleStageAdvance(selectedBatch.id, currentStage)}
onQualityCheck={(checkId) => {
setShowQualityModal(true);
console.log('Opening quality check:', checkId);
}}
className="w-full"
/>
),
span: 2
}
]
},

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer } from 'lucide-react';
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
@@ -7,7 +7,10 @@ import { PageHeader } from '../../../../components/layout';
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import type { RecipeResponse, RecipeCreate, MeasurementUnit } from '../../../../api/types/recipes';
import { useQualityTemplatesForRecipe } from '../../../../api/hooks/qualityTemplates';
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
import { CreateRecipeModal } from '../../../../components/domain/recipes';
import { QualityCheckConfigurationModal } from '../../../../components/domain/recipes/QualityCheckConfigurationModal';
const RecipesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
@@ -15,6 +18,7 @@ const RecipesPage: React.FC = () => {
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showQualityConfigModal, setShowQualityConfigModal] = useState(false);
const [editedRecipe, setEditedRecipe] = useState<Partial<RecipeResponse>>({});
const currentTenant = useCurrentTenant();
@@ -76,6 +80,29 @@ const RecipesPage: React.FC = () => {
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
};
const getQualityConfigSummary = (config: any) => {
if (!config || !config.stages) return 'No configurado';
const stageLabels = {
[ProcessStage.MIXING]: 'Mezclado',
[ProcessStage.PROOFING]: 'Fermentación',
[ProcessStage.SHAPING]: 'Formado',
[ProcessStage.BAKING]: 'Horneado',
[ProcessStage.COOLING]: 'Enfriado',
[ProcessStage.PACKAGING]: 'Empaquetado',
[ProcessStage.FINISHING]: 'Acabado'
};
const configuredStages = Object.keys(config.stages)
.filter(stage => config.stages[stage]?.template_ids?.length > 0)
.map(stage => stageLabels[stage as ProcessStage] || stage);
if (configuredStages.length === 0) return 'No configurado';
if (configuredStages.length <= 2) return `Configurado para: ${configuredStages.join(', ')}`;
return `Configurado para ${configuredStages.length} etapas`;
};
const filteredRecipes = useMemo(() => {
if (!searchTerm) return recipes;
@@ -222,6 +249,31 @@ const RecipesPage: React.FC = () => {
}
};
// Handle saving quality configuration
const handleSaveQualityConfiguration = async (config: RecipeQualityConfiguration) => {
if (!selectedRecipe) return;
try {
await updateRecipeMutation.mutateAsync({
id: selectedRecipe.id,
data: {
quality_check_configuration: config
}
});
// Update local state
setSelectedRecipe({
...selectedRecipe,
quality_check_configuration: config
});
console.log('Quality configuration updated successfully');
} catch (error) {
console.error('Error updating quality configuration:', error);
throw error;
}
};
// Get current value for field (edited value or original)
const getFieldValue = (originalValue: any, fieldKey: string) => {
return editedRecipe[fieldKey as keyof RecipeResponse] !== undefined
@@ -356,6 +408,26 @@ const RecipesPage: React.FC = () => {
readonly: true // For now, ingredients editing can be complex, so we'll keep it read-only
}
]
},
{
title: 'Control de Calidad',
icon: CheckCircle,
fields: [
{
label: 'Configuración de controles',
value: selectedRecipe.quality_check_configuration
? getQualityConfigSummary(selectedRecipe.quality_check_configuration)
: 'No configurado',
type: modalMode === 'edit' ? 'button' : 'text',
span: 2,
buttonText: modalMode === 'edit' ? 'Configurar Controles de Calidad' : undefined,
onButtonClick: modalMode === 'edit' ? () => {
// Open quality check configuration modal
setShowQualityConfigModal(true);
} : undefined,
readonly: modalMode !== 'edit'
}
]
}
];
};
@@ -509,6 +581,17 @@ const RecipesPage: React.FC = () => {
onClose={() => setShowCreateModal(false)}
onCreateRecipe={handleCreateRecipe}
/>
{/* Quality Check Configuration Modal */}
{selectedRecipe && (
<QualityCheckConfigurationModal
isOpen={showQualityConfigModal}
onClose={() => setShowQualityConfigModal(false)}
recipe={selectedRecipe}
onSaveConfiguration={handleSaveQualityConfiguration}
isLoading={updateRecipeMutation.isPending}
/>
)}
</div>
);
};

View File

@@ -18,8 +18,10 @@ const ProcurementPage = React.lazy(() => import('../pages/app/operations/procure
const SuppliersPage = React.lazy(() => import('../pages/app/operations/suppliers/SuppliersPage'));
const OrdersPage = React.lazy(() => import('../pages/app/operations/orders/OrdersPage'));
const POSPage = React.lazy(() => import('../pages/app/operations/pos/POSPage'));
const MaquinariaPage = React.lazy(() => import('../pages/app/operations/maquinaria/MaquinariaPage'));
// Analytics pages
const ProductionAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProductionAnalyticsPage'));
const ForecastingPage = React.lazy(() => import('../pages/app/analytics/forecasting/ForecastingPage'));
const SalesAnalyticsPage = React.lazy(() => import('../pages/app/analytics/sales-analytics/SalesAnalyticsPage'));
const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insights/AIInsightsPage'));
@@ -188,17 +190,37 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/database/maquinaria"
element={
<ProtectedRoute>
<AppShell>
<MaquinariaPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Analytics Routes */}
<Route
path="/app/analytics/forecasting"
<Route
path="/app/analytics/production"
element={
<ProtectedRoute>
<AppShell>
<ProductionAnalyticsPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route
path="/app/analytics/forecasting"
element={
<ProtectedRoute>
<AppShell>
<ForecastingPage />
</AppShell>
</ProtectedRoute>
}
}
/>
<Route
path="/app/analytics/sales"

View File

@@ -61,6 +61,7 @@ export const ROUTES = {
PRODUCTION_SCHEDULE: '/production/schedule',
PRODUCTION_QUALITY: '/production/quality',
PRODUCTION_REPORTS: '/production/reports',
PRODUCTION_ANALYTICS: '/app/analytics/production',
// Sales & Analytics
SALES: '/sales',
@@ -310,6 +311,16 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/maquinaria',
name: 'Maquinaria',
component: 'MaquinariaPage',
title: 'Maquinaria',
icon: 'production',
requiresAuth: true,
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/database/bakery-config',
name: 'BakeryConfig',
@@ -350,13 +361,25 @@ export const routesConfig: RouteConfig[] = [
path: '/app/analytics',
name: 'Analytics',
component: 'AnalyticsPage',
title: 'Analytics',
title: 'Análisis',
icon: 'sales',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'basic',
showInNavigation: true,
children: [
{
path: '/app/analytics/production',
name: 'ProductionAnalytics',
component: 'ProductionAnalyticsPage',
title: 'Análisis de Producción',
icon: 'production',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'advanced',
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/analytics/forecasting',
name: 'Forecasting',

View File

@@ -0,0 +1,54 @@
// Types for equipment management
export interface EquipmentAlert {
id: string;
type: 'warning' | 'critical' | 'info';
message: string;
timestamp: string;
acknowledged: boolean;
}
export interface MaintenanceHistory {
id: string;
date: string;
type: 'preventive' | 'corrective' | 'emergency';
description: string;
technician: string;
cost: number;
downtime: number; // hours
partsUsed: string[];
}
export interface EquipmentSpecifications {
power: number; // kW
capacity: number;
dimensions: {
width: number;
height: number;
depth: number;
};
weight: number;
}
export interface Equipment {
id: string;
name: string;
type: 'oven' | 'mixer' | 'proofer' | 'freezer' | 'packaging' | 'other';
model: string;
serialNumber: string;
location: string;
status: 'operational' | 'maintenance' | 'down' | 'warning';
installDate: string;
lastMaintenance: string;
nextMaintenance: string;
maintenanceInterval: number; // days
temperature?: number;
targetTemperature?: number;
efficiency: number;
uptime: number;
energyUsage: number;
utilizationToday: number;
alerts: EquipmentAlert[];
maintenanceHistory: MaintenanceHistory[];
specifications: EquipmentSpecifications;
}