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

View File

@@ -22,6 +22,7 @@ from app.schemas.production import (
ProductionStatusEnum
)
from app.core.config import settings
from .quality_templates import router as quality_templates_router
logger = structlog.get_logger()

View File

@@ -0,0 +1,174 @@
# services/production/app/api/quality_templates.py
"""
Quality Check Template API endpoints for production service
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from uuid import UUID
from ..core.database import get_db
from ..models.production import QualityCheckTemplate, ProcessStage
from ..services.quality_template_service import QualityTemplateService
from ..schemas.quality_templates import (
QualityCheckTemplateCreate,
QualityCheckTemplateUpdate,
QualityCheckTemplateResponse,
QualityCheckTemplateList
)
from shared.auth.tenant_access import get_current_tenant_id
router = APIRouter(prefix="/quality-templates", tags=["quality-templates"])
@router.post("", response_model=QualityCheckTemplateResponse, status_code=status.HTTP_201_CREATED)
async def create_quality_template(
template_data: QualityCheckTemplateCreate,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Create a new quality check template"""
try:
service = QualityTemplateService(db)
template = await service.create_template(tenant_id, template_data)
return QualityCheckTemplateResponse.from_orm(template)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("", response_model=QualityCheckTemplateList)
async def get_quality_templates(
tenant_id: str = Depends(get_current_tenant_id),
stage: Optional[ProcessStage] = Query(None, description="Filter by process stage"),
check_type: Optional[str] = Query(None, description="Filter by check type"),
is_active: Optional[bool] = Query(True, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
):
"""Get quality check templates for tenant"""
try:
service = QualityTemplateService(db)
templates, total = await service.get_templates(
tenant_id=tenant_id,
stage=stage,
check_type=check_type,
is_active=is_active,
skip=skip,
limit=limit
)
return QualityCheckTemplateList(
templates=[QualityCheckTemplateResponse.from_orm(t) for t in templates],
total=total,
skip=skip,
limit=limit
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{template_id}", response_model=QualityCheckTemplateResponse)
async def get_quality_template(
template_id: UUID,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Get a specific quality check template"""
try:
service = QualityTemplateService(db)
template = await service.get_template(tenant_id, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return QualityCheckTemplateResponse.from_orm(template)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.put("/{template_id}", response_model=QualityCheckTemplateResponse)
async def update_quality_template(
template_id: UUID,
template_data: QualityCheckTemplateUpdate,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Update a quality check template"""
try:
service = QualityTemplateService(db)
template = await service.update_template(tenant_id, template_id, template_data)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return QualityCheckTemplateResponse.from_orm(template)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_quality_template(
template_id: UUID,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Delete a quality check template"""
try:
service = QualityTemplateService(db)
success = await service.delete_template(tenant_id, template_id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/stages/{stage}", response_model=QualityCheckTemplateList)
async def get_templates_for_stage(
stage: ProcessStage,
tenant_id: str = Depends(get_current_tenant_id),
is_active: Optional[bool] = Query(True, description="Filter by active status"),
db: Session = Depends(get_db)
):
"""Get quality check templates applicable to a specific process stage"""
try:
service = QualityTemplateService(db)
templates = await service.get_templates_for_stage(tenant_id, stage, is_active)
return QualityCheckTemplateList(
templates=[QualityCheckTemplateResponse.from_orm(t) for t in templates],
total=len(templates),
skip=0,
limit=len(templates)
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/{template_id}/duplicate", response_model=QualityCheckTemplateResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_quality_template(
template_id: UUID,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Duplicate an existing quality check template"""
try:
service = QualityTemplateService(db)
template = await service.duplicate_template(tenant_id, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return QualityCheckTemplateResponse.from_orm(template)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))

View File

@@ -14,6 +14,7 @@ import structlog
from app.core.config import settings
from app.core.database import init_database, get_db_health
from app.api.production import router as production_router
from app.api.quality_templates import router as quality_templates_router
from app.services.production_alert_service import ProductionAlertService
# Configure logging
@@ -73,6 +74,7 @@ app.add_middleware(
# Include routers
app.include_router(production_router, prefix="/api/v1")
app.include_router(quality_templates_router, prefix="/api/v1")
@app.get("/health")

View File

@@ -35,6 +35,22 @@ class ProductionPriority(str, enum.Enum):
URGENT = "URGENT"
class EquipmentStatus(str, enum.Enum):
"""Equipment status enumeration"""
OPERATIONAL = "operational"
MAINTENANCE = "maintenance"
DOWN = "down"
WARNING = "warning"
class EquipmentType(str, enum.Enum):
"""Equipment type enumeration"""
OVEN = "oven"
MIXER = "mixer"
PROOFER = "proofer"
FREEZER = "freezer"
PACKAGING = "packaging"
OTHER = "other"
class ProductionBatch(Base):
@@ -56,16 +72,22 @@ class ProductionBatch(Base):
planned_end_time = Column(DateTime(timezone=True), nullable=False)
planned_quantity = Column(Float, nullable=False)
planned_duration_minutes = Column(Integer, nullable=False)
# Actual production tracking
actual_start_time = Column(DateTime(timezone=True), nullable=True)
actual_end_time = Column(DateTime(timezone=True), nullable=True)
actual_quantity = Column(Float, nullable=True)
actual_duration_minutes = Column(Integer, nullable=True)
# Status and priority
status = Column(SQLEnum(ProductionStatus), nullable=False, default=ProductionStatus.PENDING, index=True)
priority = Column(SQLEnum(ProductionPriority), nullable=False, default=ProductionPriority.MEDIUM)
# Process stage tracking
current_process_stage = Column(SQLEnum(ProcessStage), nullable=True, index=True)
process_stage_history = Column(JSON, nullable=True) # Track stage transitions with timestamps
pending_quality_checks = Column(JSON, nullable=True) # Required quality checks for current stage
completed_quality_checks = Column(JSON, nullable=True) # Completed quality checks by stage
# Cost tracking
estimated_cost = Column(Float, nullable=True)
@@ -307,48 +329,138 @@ class ProductionCapacity(Base):
}
class ProcessStage(str, enum.Enum):
"""Production process stages where quality checks can occur"""
MIXING = "mixing"
PROOFING = "proofing"
SHAPING = "shaping"
BAKING = "baking"
COOLING = "cooling"
PACKAGING = "packaging"
FINISHING = "finishing"
class QualityCheckTemplate(Base):
"""Quality check templates for tenant-specific quality standards"""
__tablename__ = "quality_check_templates"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Template identification
name = Column(String(255), nullable=False)
template_code = Column(String(100), nullable=True, index=True)
check_type = Column(String(50), nullable=False) # visual, measurement, temperature, weight, boolean
category = Column(String(100), nullable=True) # appearance, structure, texture, etc.
# Template configuration
description = Column(Text, nullable=True)
instructions = Column(Text, nullable=True)
parameters = Column(JSON, nullable=True) # Dynamic check parameters
thresholds = Column(JSON, nullable=True) # Pass/fail criteria
scoring_criteria = Column(JSON, nullable=True) # Scoring methodology
# Configurability settings
is_active = Column(Boolean, default=True)
is_required = Column(Boolean, default=False)
is_critical = Column(Boolean, default=False) # Critical failures block production
weight = Column(Float, default=1.0) # Weight in overall quality score
# Measurement specifications
min_value = Column(Float, nullable=True)
max_value = Column(Float, nullable=True)
target_value = Column(Float, nullable=True)
unit = Column(String(20), nullable=True)
tolerance_percentage = Column(Float, nullable=True)
# Process stage applicability
applicable_stages = Column(JSON, nullable=True) # List of ProcessStage values
# Metadata
created_by = Column(UUID(as_uuid=True), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary following shared pattern"""
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"name": self.name,
"template_code": self.template_code,
"check_type": self.check_type,
"category": self.category,
"description": self.description,
"instructions": self.instructions,
"parameters": self.parameters,
"thresholds": self.thresholds,
"scoring_criteria": self.scoring_criteria,
"is_active": self.is_active,
"is_required": self.is_required,
"is_critical": self.is_critical,
"weight": self.weight,
"min_value": self.min_value,
"max_value": self.max_value,
"target_value": self.target_value,
"unit": self.unit,
"tolerance_percentage": self.tolerance_percentage,
"applicable_stages": self.applicable_stages,
"created_by": str(self.created_by),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class QualityCheck(Base):
"""Quality check model for tracking production quality metrics"""
"""Quality check model for tracking production quality metrics with stage support"""
__tablename__ = "quality_checks"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
batch_id = Column(UUID(as_uuid=True), nullable=False, index=True) # FK to ProductionBatch
template_id = Column(UUID(as_uuid=True), nullable=True, index=True) # FK to QualityCheckTemplate
# Check information
check_type = Column(String(50), nullable=False) # visual, weight, temperature, etc.
process_stage = Column(SQLEnum(ProcessStage), nullable=True, index=True) # Stage when check was performed
check_time = Column(DateTime(timezone=True), nullable=False)
checker_id = Column(String(100), nullable=True) # Staff member who performed check
# Quality metrics
quality_score = Column(Float, nullable=False) # 1-10 scale
pass_fail = Column(Boolean, nullable=False)
defect_count = Column(Integer, nullable=False, default=0)
defect_types = Column(JSON, nullable=True) # List of defect categories
# Measurements
measured_weight = Column(Float, nullable=True)
measured_temperature = Column(Float, nullable=True)
measured_moisture = Column(Float, nullable=True)
measured_dimensions = Column(JSON, nullable=True)
stage_specific_data = Column(JSON, nullable=True) # Stage-specific measurements
# Standards comparison
target_weight = Column(Float, nullable=True)
target_temperature = Column(Float, nullable=True)
target_moisture = Column(Float, nullable=True)
tolerance_percentage = Column(Float, nullable=True)
# Results
within_tolerance = Column(Boolean, nullable=True)
corrective_action_needed = Column(Boolean, default=False)
corrective_actions = Column(JSON, nullable=True)
# Template-based results
template_results = Column(JSON, nullable=True) # Results from template-based checks
criteria_scores = Column(JSON, nullable=True) # Individual criteria scores
# Notes and documentation
check_notes = Column(Text, nullable=True)
photos_urls = Column(JSON, nullable=True) # URLs to quality check photos
certificate_url = Column(String(500), nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -385,3 +497,81 @@ class QualityCheck(Base):
}
class Equipment(Base):
"""Equipment model for tracking production equipment"""
__tablename__ = "equipment"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Equipment identification
name = Column(String(255), nullable=False)
type = Column(SQLEnum(EquipmentType), nullable=False)
model = Column(String(100), nullable=True)
serial_number = Column(String(100), nullable=True)
location = Column(String(255), nullable=True)
# Status tracking
status = Column(SQLEnum(EquipmentStatus), nullable=False, default=EquipmentStatus.OPERATIONAL)
# Dates
install_date = Column(DateTime(timezone=True), nullable=True)
last_maintenance_date = Column(DateTime(timezone=True), nullable=True)
next_maintenance_date = Column(DateTime(timezone=True), nullable=True)
maintenance_interval_days = Column(Integer, nullable=True) # Maintenance interval in days
# Performance metrics
efficiency_percentage = Column(Float, nullable=True) # Current efficiency
uptime_percentage = Column(Float, nullable=True) # Overall equipment effectiveness
energy_usage_kwh = Column(Float, nullable=True) # Current energy usage
# Specifications
power_kw = Column(Float, nullable=True) # Power in kilowatts
capacity = Column(Float, nullable=True) # Capacity (units depend on equipment type)
weight_kg = Column(Float, nullable=True) # Weight in kilograms
# Temperature monitoring
current_temperature = Column(Float, nullable=True) # Current temperature reading
target_temperature = Column(Float, nullable=True) # Target temperature
# Status
is_active = Column(Boolean, default=True)
# Notes
notes = Column(Text, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary following shared pattern"""
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"name": self.name,
"type": self.type.value if self.type else None,
"model": self.model,
"serial_number": self.serial_number,
"location": self.location,
"status": self.status.value if self.status else None,
"install_date": self.install_date.isoformat() if self.install_date else None,
"last_maintenance_date": self.last_maintenance_date.isoformat() if self.last_maintenance_date else None,
"next_maintenance_date": self.next_maintenance_date.isoformat() if self.next_maintenance_date else None,
"maintenance_interval_days": self.maintenance_interval_days,
"efficiency_percentage": self.efficiency_percentage,
"uptime_percentage": self.uptime_percentage,
"energy_usage_kwh": self.energy_usage_kwh,
"power_kw": self.power_kw,
"capacity": self.capacity,
"weight_kg": self.weight_kg,
"current_temperature": self.current_temperature,
"target_temperature": self.target_temperature,
"is_active": self.is_active,
"notes": self.notes,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -0,0 +1,179 @@
# services/production/app/schemas/quality_templates.py
"""
Quality Check Template Pydantic schemas for validation and serialization
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any, Union
from uuid import UUID
from datetime import datetime
from enum import Enum
from ..models.production import ProcessStage
class QualityCheckType(str, Enum):
"""Quality check types"""
VISUAL = "visual"
MEASUREMENT = "measurement"
TEMPERATURE = "temperature"
WEIGHT = "weight"
BOOLEAN = "boolean"
TIMING = "timing"
class QualityCheckTemplateBase(BaseModel):
"""Base schema for quality check templates"""
name: str = Field(..., min_length=1, max_length=255, description="Template name")
template_code: Optional[str] = Field(None, max_length=100, description="Template code for reference")
check_type: QualityCheckType = Field(..., description="Type of quality check")
category: Optional[str] = Field(None, max_length=100, description="Check category (e.g., appearance, structure)")
description: Optional[str] = Field(None, description="Template description")
instructions: Optional[str] = Field(None, description="Check instructions for staff")
# Configuration
parameters: Optional[Dict[str, Any]] = Field(None, description="Dynamic check parameters")
thresholds: Optional[Dict[str, Any]] = Field(None, description="Pass/fail criteria")
scoring_criteria: Optional[Dict[str, Any]] = Field(None, description="Scoring methodology")
# Settings
is_active: bool = Field(True, description="Whether template is active")
is_required: bool = Field(False, description="Whether check is required")
is_critical: bool = Field(False, description="Whether failure blocks production")
weight: float = Field(1.0, ge=0.0, le=10.0, description="Weight in overall quality score")
# Measurement specifications
min_value: Optional[float] = Field(None, description="Minimum acceptable value")
max_value: Optional[float] = Field(None, description="Maximum acceptable value")
target_value: Optional[float] = Field(None, description="Target value")
unit: Optional[str] = Field(None, max_length=20, description="Unit of measurement")
tolerance_percentage: Optional[float] = Field(None, ge=0.0, le=100.0, description="Tolerance percentage")
# Process stage applicability
applicable_stages: Optional[List[ProcessStage]] = Field(None, description="Applicable process stages")
@validator('applicable_stages')
def validate_stages(cls, v):
if v is not None:
# Ensure all values are valid ProcessStage enums
for stage in v:
if stage not in ProcessStage:
raise ValueError(f"Invalid process stage: {stage}")
return v
@validator('min_value', 'max_value', 'target_value')
def validate_measurement_values(cls, v, values):
if v is not None and values.get('check_type') not in [QualityCheckType.MEASUREMENT, QualityCheckType.TEMPERATURE, QualityCheckType.WEIGHT]:
return None # Clear values for non-measurement types
return v
class QualityCheckTemplateCreate(QualityCheckTemplateBase):
"""Schema for creating quality check templates"""
created_by: UUID = Field(..., description="User ID who created the template")
class QualityCheckTemplateUpdate(BaseModel):
"""Schema for updating quality check templates"""
name: Optional[str] = Field(None, min_length=1, max_length=255)
template_code: Optional[str] = Field(None, max_length=100)
check_type: Optional[QualityCheckType] = None
category: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
instructions: Optional[str] = None
parameters: Optional[Dict[str, Any]] = None
thresholds: Optional[Dict[str, Any]] = None
scoring_criteria: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None
is_required: Optional[bool] = None
is_critical: Optional[bool] = None
weight: Optional[float] = Field(None, ge=0.0, le=10.0)
min_value: Optional[float] = None
max_value: Optional[float] = None
target_value: Optional[float] = None
unit: Optional[str] = Field(None, max_length=20)
tolerance_percentage: Optional[float] = Field(None, ge=0.0, le=100.0)
applicable_stages: Optional[List[ProcessStage]] = None
class QualityCheckTemplateResponse(QualityCheckTemplateBase):
"""Schema for quality check template responses"""
id: UUID
tenant_id: UUID
created_by: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class QualityCheckTemplateList(BaseModel):
"""Schema for paginated quality check template lists"""
templates: List[QualityCheckTemplateResponse]
total: int
skip: int
limit: int
class QualityCheckCriterion(BaseModel):
"""Individual quality check criterion within a template"""
id: str = Field(..., description="Unique criterion identifier")
name: str = Field(..., description="Criterion name")
description: str = Field(..., description="Criterion description")
check_type: QualityCheckType = Field(..., description="Type of check")
required: bool = Field(True, description="Whether criterion is required")
weight: float = Field(1.0, ge=0.0, le=10.0, description="Weight in template score")
acceptable_criteria: str = Field(..., description="Description of acceptable criteria")
min_value: Optional[float] = None
max_value: Optional[float] = None
unit: Optional[str] = None
is_critical: bool = Field(False, description="Whether failure is critical")
class QualityCheckResult(BaseModel):
"""Result of a quality check criterion"""
criterion_id: str = Field(..., description="Criterion identifier")
value: Union[float, str, bool] = Field(..., description="Check result value")
score: float = Field(..., ge=0.0, le=10.0, description="Score for this criterion")
notes: Optional[str] = Field(None, description="Additional notes")
photos: Optional[List[str]] = Field(None, description="Photo URLs")
pass_check: bool = Field(..., description="Whether criterion passed")
timestamp: datetime = Field(..., description="When check was performed")
class QualityCheckExecutionRequest(BaseModel):
"""Schema for executing a quality check using a template"""
template_id: UUID = Field(..., description="Quality check template ID")
batch_id: UUID = Field(..., description="Production batch ID")
process_stage: ProcessStage = Field(..., description="Current process stage")
checker_id: Optional[str] = Field(None, description="Staff member performing check")
results: List[QualityCheckResult] = Field(..., description="Check results")
final_notes: Optional[str] = Field(None, description="Final notes")
photos: Optional[List[str]] = Field(None, description="Additional photo URLs")
class QualityCheckExecutionResponse(BaseModel):
"""Schema for quality check execution results"""
check_id: UUID = Field(..., description="Created quality check ID")
overall_score: float = Field(..., ge=0.0, le=10.0, description="Overall quality score")
overall_pass: bool = Field(..., description="Whether check passed overall")
critical_failures: List[str] = Field(..., description="List of critical failures")
corrective_actions: List[str] = Field(..., description="Recommended corrective actions")
timestamp: datetime = Field(..., description="When check was completed")
class ProcessStageQualityConfig(BaseModel):
"""Configuration for quality checks at a specific process stage"""
stage: ProcessStage = Field(..., description="Process stage")
template_ids: List[UUID] = Field(..., description="Required template IDs")
custom_parameters: Optional[Dict[str, Any]] = Field(None, description="Stage-specific parameters")
is_required: bool = Field(True, description="Whether stage requires quality checks")
blocking: bool = Field(True, description="Whether stage blocks on failed checks")
class RecipeQualityConfiguration(BaseModel):
"""Quality check configuration for a recipe"""
stages: Dict[str, ProcessStageQualityConfig] = Field(..., description="Stage configurations")
global_parameters: Optional[Dict[str, Any]] = Field(None, description="Global quality parameters")
default_templates: Optional[List[UUID]] = Field(None, description="Default template IDs")

View File

@@ -49,14 +49,14 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
max_instances=1
)
# Equipment monitoring - disabled (equipment tables not available in production database)
# self.scheduler.add_job(
# self.check_equipment_status,
# CronTrigger(minute='*/3'),
# id='equipment_check',
# misfire_grace_time=30,
# max_instances=1
# )
# Equipment monitoring - check equipment status for maintenance alerts
self.scheduler.add_job(
self.check_equipment_status,
CronTrigger(minute='*/30'), # Check every 30 minutes
id='equipment_check',
misfire_grace_time=30,
max_instances=1
)
# Efficiency recommendations - every 30 minutes (recommendations)
self.scheduler.add_job(
@@ -394,19 +394,61 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
error=str(e))
async def check_equipment_status(self):
"""Check equipment status and failures (alerts)"""
# Equipment tables don't exist in production database - skip this check
logger.debug("Equipment check skipped - equipment tables not available in production database")
return
"""Check equipment status and maintenance requirements (alerts)"""
try:
self._checks_performed += 1
# Query equipment that needs attention
query = """
SELECT
e.id, e.tenant_id, e.name, e.type, e.status,
e.efficiency_percentage, e.uptime_percentage,
e.last_maintenance_date, e.next_maintenance_date,
e.maintenance_interval_days,
EXTRACT(DAYS FROM (e.next_maintenance_date - NOW())) as days_to_maintenance,
COUNT(ea.id) as active_alerts
FROM equipment e
LEFT JOIN alerts ea ON ea.equipment_id = e.id
AND ea.is_active = true
AND ea.is_resolved = false
WHERE e.is_active = true
AND e.tenant_id = $1
GROUP BY e.id, e.tenant_id, e.name, e.type, e.status,
e.efficiency_percentage, e.uptime_percentage,
e.last_maintenance_date, e.next_maintenance_date,
e.maintenance_interval_days
ORDER BY e.next_maintenance_date ASC
"""
tenants = await self.get_active_tenants()
for tenant_id in tenants:
try:
from sqlalchemy import text
async with self.db_manager.get_session() as session:
result = await session.execute(text(query), {"tenant_id": tenant_id})
equipment_list = result.fetchall()
for equipment in equipment_list:
await self._process_equipment_issue(equipment)
except Exception as e:
logger.error("Error checking equipment status",
tenant_id=str(tenant_id),
error=str(e))
except Exception as e:
logger.error("Equipment status check failed", error=str(e))
self._errors_count += 1
async def _process_equipment_issue(self, equipment: Dict[str, Any]):
"""Process equipment issue"""
try:
status = equipment['status']
efficiency = equipment.get('efficiency_percent', 100)
efficiency = equipment.get('efficiency_percentage', 100)
days_to_maintenance = equipment.get('days_to_maintenance', 30)
if status == 'error':
if status == 'down':
template_data = self.format_spanish_message(
'equipment_failure',
equipment_name=equipment['name']
@@ -422,41 +464,52 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
'equipment_id': str(equipment['id']),
'equipment_name': equipment['name'],
'equipment_type': equipment['type'],
'error_count': equipment.get('error_count', 0),
'last_reading': equipment.get('last_reading').isoformat() if equipment.get('last_reading') else None
'efficiency': efficiency
}
}, item_type='alert')
elif status == 'maintenance_required' or days_to_maintenance <= 1:
severity = 'high' if days_to_maintenance <= 1 else 'medium'
elif status == 'maintenance' or (days_to_maintenance is not None and days_to_maintenance <= 3):
severity = 'high' if (days_to_maintenance is not None and days_to_maintenance <= 1) else 'medium'
template_data = self.format_spanish_message(
'maintenance_required',
equipment_name=equipment['name'],
days_until_maintenance=max(0, int(days_to_maintenance)) if days_to_maintenance is not None else 3
)
await self.publish_item(equipment['tenant_id'], {
'type': 'maintenance_required',
'severity': severity,
'title': f'🔧 Mantenimiento Requerido: {equipment["name"]}',
'message': f'Equipo {equipment["name"]} requiere mantenimiento en {days_to_maintenance} días.',
'actions': ['Programar mantenimiento', 'Revisar historial', 'Preparar repuestos', 'Planificar parada'],
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'metadata': {
'equipment_id': str(equipment['id']),
'equipment_name': equipment['name'],
'days_to_maintenance': days_to_maintenance,
'last_maintenance': equipment.get('last_maintenance').isoformat() if equipment.get('last_maintenance') else None
'last_maintenance': equipment.get('last_maintenance_date')
}
}, item_type='alert')
elif efficiency < 80:
elif efficiency is not None and efficiency < 80:
severity = 'medium' if efficiency < 70 else 'low'
template_data = self.format_spanish_message(
'low_equipment_efficiency',
equipment_name=equipment['name'],
efficiency_percent=round(efficiency, 1)
)
await self.publish_item(equipment['tenant_id'], {
'type': 'low_equipment_efficiency',
'severity': severity,
'title': f'📉 Baja Eficiencia: {equipment["name"]}',
'message': f'Eficiencia del {equipment["name"]} bajó a {efficiency:.1f}%. Revisar funcionamiento.',
'actions': ['Revisar configuración', 'Limpiar equipo', 'Calibrar sensores', 'Revisar mantenimiento'],
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'metadata': {
'equipment_id': str(equipment['id']),
'efficiency_percent': float(efficiency),
'temperature': equipment.get('temperature'),
'vibration_level': equipment.get('vibration_level')
'equipment_name': equipment['name'],
'efficiency_percent': float(efficiency)
}
}, item_type='alert')

View File

@@ -0,0 +1,306 @@
# services/production/app/services/quality_template_service.py
"""
Quality Check Template Service for business logic and data operations
"""
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from typing import List, Optional, Tuple
from uuid import UUID, uuid4
from datetime import datetime, timezone
from ..models.production import QualityCheckTemplate, ProcessStage
from ..schemas.quality_templates import QualityCheckTemplateCreate, QualityCheckTemplateUpdate
class QualityTemplateService:
"""Service for managing quality check templates"""
def __init__(self, db: Session):
self.db = db
async def create_template(
self,
tenant_id: str,
template_data: QualityCheckTemplateCreate
) -> QualityCheckTemplate:
"""Create a new quality check template"""
# Validate template code uniqueness if provided
if template_data.template_code:
existing = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.template_code == template_data.template_code
)
).first()
if existing:
raise ValueError(f"Template code '{template_data.template_code}' already exists")
# Create template
template = QualityCheckTemplate(
id=uuid4(),
tenant_id=UUID(tenant_id),
**template_data.dict()
)
self.db.add(template)
self.db.commit()
self.db.refresh(template)
return template
async def get_templates(
self,
tenant_id: str,
stage: Optional[ProcessStage] = None,
check_type: Optional[str] = None,
is_active: Optional[bool] = True,
skip: int = 0,
limit: int = 100
) -> Tuple[List[QualityCheckTemplate], int]:
"""Get quality check templates with filtering and pagination"""
query = self.db.query(QualityCheckTemplate).filter(
QualityCheckTemplate.tenant_id == tenant_id
)
# Apply filters
if is_active is not None:
query = query.filter(QualityCheckTemplate.is_active == is_active)
if check_type:
query = query.filter(QualityCheckTemplate.check_type == check_type)
if stage:
# Filter by applicable stages (JSON array contains stage)
query = query.filter(
func.json_contains(
QualityCheckTemplate.applicable_stages,
f'"{stage.value}"'
)
)
# Get total count
total = query.count()
# Apply pagination and ordering
templates = query.order_by(
QualityCheckTemplate.is_critical.desc(),
QualityCheckTemplate.is_required.desc(),
QualityCheckTemplate.name
).offset(skip).limit(limit).all()
return templates, total
async def get_template(
self,
tenant_id: str,
template_id: UUID
) -> Optional[QualityCheckTemplate]:
"""Get a specific quality check template"""
return self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.id == template_id
)
).first()
async def update_template(
self,
tenant_id: str,
template_id: UUID,
template_data: QualityCheckTemplateUpdate
) -> Optional[QualityCheckTemplate]:
"""Update a quality check template"""
template = await self.get_template(tenant_id, template_id)
if not template:
return None
# Validate template code uniqueness if being updated
if template_data.template_code and template_data.template_code != template.template_code:
existing = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.template_code == template_data.template_code,
QualityCheckTemplate.id != template_id
)
).first()
if existing:
raise ValueError(f"Template code '{template_data.template_code}' already exists")
# Update fields
update_data = template_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(template, field, value)
template.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(template)
return template
async def delete_template(
self,
tenant_id: str,
template_id: UUID
) -> bool:
"""Delete a quality check template"""
template = await self.get_template(tenant_id, template_id)
if not template:
return False
# Check if template is in use (you might want to add this check)
# For now, we'll allow deletion but in production you might want to:
# 1. Soft delete by setting is_active = False
# 2. Check for dependent quality checks
# 3. Prevent deletion if in use
self.db.delete(template)
self.db.commit()
return True
async def get_templates_for_stage(
self,
tenant_id: str,
stage: ProcessStage,
is_active: Optional[bool] = True
) -> List[QualityCheckTemplate]:
"""Get all quality check templates applicable to a specific process stage"""
query = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
or_(
# Templates that specify applicable stages
func.json_contains(
QualityCheckTemplate.applicable_stages,
f'"{stage.value}"'
),
# Templates that don't specify stages (applicable to all)
QualityCheckTemplate.applicable_stages.is_(None)
)
)
)
if is_active is not None:
query = query.filter(QualityCheckTemplate.is_active == is_active)
return query.order_by(
QualityCheckTemplate.is_critical.desc(),
QualityCheckTemplate.is_required.desc(),
QualityCheckTemplate.weight.desc(),
QualityCheckTemplate.name
).all()
async def duplicate_template(
self,
tenant_id: str,
template_id: UUID
) -> Optional[QualityCheckTemplate]:
"""Duplicate an existing quality check template"""
original = await self.get_template(tenant_id, template_id)
if not original:
return None
# Create duplicate with modified name and code
duplicate_data = {
'name': f"{original.name} (Copy)",
'template_code': f"{original.template_code}_copy" if original.template_code else None,
'check_type': original.check_type,
'category': original.category,
'description': original.description,
'instructions': original.instructions,
'parameters': original.parameters,
'thresholds': original.thresholds,
'scoring_criteria': original.scoring_criteria,
'is_active': original.is_active,
'is_required': original.is_required,
'is_critical': original.is_critical,
'weight': original.weight,
'min_value': original.min_value,
'max_value': original.max_value,
'target_value': original.target_value,
'unit': original.unit,
'tolerance_percentage': original.tolerance_percentage,
'applicable_stages': original.applicable_stages,
'created_by': original.created_by
}
create_data = QualityCheckTemplateCreate(**duplicate_data)
return await self.create_template(tenant_id, create_data)
async def get_templates_by_recipe_config(
self,
tenant_id: str,
stage: ProcessStage,
recipe_quality_config: dict
) -> List[QualityCheckTemplate]:
"""Get quality check templates based on recipe configuration"""
# Extract template IDs from recipe configuration for the specific stage
stage_config = recipe_quality_config.get('stages', {}).get(stage.value)
if not stage_config:
return []
template_ids = stage_config.get('template_ids', [])
if not template_ids:
return []
# Get templates by IDs
templates = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.id.in_([UUID(tid) for tid in template_ids]),
QualityCheckTemplate.is_active == True
)
).order_by(
QualityCheckTemplate.is_critical.desc(),
QualityCheckTemplate.is_required.desc(),
QualityCheckTemplate.weight.desc()
).all()
return templates
async def validate_template_configuration(
self,
tenant_id: str,
template_data: dict
) -> Tuple[bool, List[str]]:
"""Validate quality check template configuration"""
errors = []
# Validate check type specific requirements
check_type = template_data.get('check_type')
if check_type in ['measurement', 'temperature', 'weight']:
if not template_data.get('unit'):
errors.append(f"Unit is required for {check_type} checks")
min_val = template_data.get('min_value')
max_val = template_data.get('max_value')
if min_val is not None and max_val is not None and min_val >= max_val:
errors.append("Minimum value must be less than maximum value")
# Validate scoring criteria
scoring = template_data.get('scoring_criteria', {})
if check_type == 'visual' and not scoring:
errors.append("Visual checks require scoring criteria")
# Validate process stages
stages = template_data.get('applicable_stages', [])
if stages:
valid_stages = [stage.value for stage in ProcessStage]
invalid_stages = [s for s in stages if s not in valid_stages]
if invalid_stages:
errors.append(f"Invalid process stages: {invalid_stages}")
return len(errors) == 0, errors

View File

@@ -0,0 +1,41 @@
"""Add quality check configuration to recipes
Revision ID: 004
Revises: 003
Create Date: 2024-01-15 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '004'
down_revision = '003'
branch_labels = None
depends_on = None
def upgrade():
"""Upgrade database schema to add quality check configuration"""
# Add quality_check_configuration column to recipes table
op.add_column('recipes', sa.Column('quality_check_configuration', postgresql.JSONB, nullable=True))
# Create index for better performance on quality configuration queries
op.create_index(
'ix_recipes_quality_check_configuration',
'recipes',
['quality_check_configuration'],
postgresql_using='gin'
)
def downgrade():
"""Downgrade database schema"""
# Drop index
op.drop_index('ix_recipes_quality_check_configuration')
# Remove quality_check_configuration column
op.drop_column('recipes', 'quality_check_configuration')

View File

@@ -112,6 +112,7 @@ class Recipe(Base):
# Quality control
quality_check_points = Column(JSONB, nullable=True) # Key checkpoints during production
quality_check_configuration = Column(JSONB, nullable=True) # Stage-based quality check config
common_issues = Column(JSONB, nullable=True) # Known issues and solutions
# Status and lifecycle
@@ -180,6 +181,7 @@ class Recipe(Base):
'optimal_production_temperature': self.optimal_production_temperature,
'optimal_humidity': self.optimal_humidity,
'quality_check_points': self.quality_check_points,
'quality_check_configuration': self.quality_check_configuration,
'common_issues': self.common_issues,
'status': self.status.value if self.status else None,
'is_seasonal': self.is_seasonal,

View File

@@ -52,6 +52,20 @@ ITEM_TEMPLATES = {
'actions': ['Parar producción', 'Llamar mantenimiento', 'Usar equipo alternativo', 'Documentar fallo']
}
},
'maintenance_required': {
'es': {
'title': '🔧 Mantenimiento Requerido: {equipment_name}',
'message': 'Equipo {equipment_name} requiere mantenimiento en {days_until_maintenance} días.',
'actions': ['Programar mantenimiento', 'Revisar historial', 'Preparar repuestos', 'Planificar parada']
}
},
'low_equipment_efficiency': {
'es': {
'title': '📉 Baja Eficiencia: {equipment_name}',
'message': 'Eficiencia del {equipment_name} bajó a {efficiency_percent}%. Revisar funcionamiento.',
'actions': ['Revisar configuración', 'Limpiar equipo', 'Calibrar sensores', 'Revisar mantenimiento']
}
},
'order_overload': {
'es': {
'title': '📋 Sobrecarga de Pedidos',