Add improved production UI 3
This commit is contained in:
275
frontend/src/api/hooks/qualityTemplates.ts
Normal file
275
frontend/src/api/hooks/qualityTemplates.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
205
frontend/src/api/services/qualityTemplates.ts
Normal file
205
frontend/src/api/services/qualityTemplates.ts
Normal 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();
|
||||
173
frontend/src/api/types/qualityTemplates.ts
Normal file
173
frontend/src/api/types/qualityTemplates.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
121
frontend/src/components/ui/Toggle/Toggle.tsx
Normal file
121
frontend/src/components/ui/Toggle/Toggle.tsx
Normal 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;
|
||||
2
frontend/src/components/ui/Toggle/index.ts
Normal file
2
frontend/src/components/ui/Toggle/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Toggle } from './Toggle';
|
||||
export type { ToggleProps } from './Toggle';
|
||||
@@ -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';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"operations": "Operations",
|
||||
"inventory": "Inventory",
|
||||
"production": "Production",
|
||||
"equipment": "Equipment",
|
||||
"recipes": "Recipes",
|
||||
"orders": "Orders",
|
||||
"procurement": "Procurement",
|
||||
|
||||
90
frontend/src/locales/en/equipment.json
Normal file
90
frontend/src/locales/en/equipment.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"operations": "Operaciones",
|
||||
"inventory": "Inventario",
|
||||
"production": "Producción",
|
||||
"equipment": "Maquinaria",
|
||||
"recipes": "Recetas",
|
||||
"orders": "Pedidos",
|
||||
"procurement": "Compras",
|
||||
|
||||
87
frontend/src/locales/es/equipment.json
Normal file
87
frontend/src/locales/es/equipment.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
90
frontend/src/locales/eu/equipment.json
Normal file
90
frontend/src/locales/eu/equipment.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
252
frontend/src/pages/app/analytics/ProductionAnalyticsPage.tsx
Normal file
252
frontend/src/pages/app/analytics/ProductionAnalyticsPage.tsx
Normal 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;
|
||||
@@ -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';
|
||||
604
frontend/src/pages/app/operations/maquinaria/MaquinariaPage.tsx
Normal file
604
frontend/src/pages/app/operations/maquinaria/MaquinariaPage.tsx
Normal 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;
|
||||
1
frontend/src/pages/app/operations/maquinaria/index.ts
Normal file
1
frontend/src/pages/app/operations/maquinaria/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as MaquinariaPage } from './MaquinariaPage';
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
54
frontend/src/types/equipment.ts
Normal file
54
frontend/src/types/equipment.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user