Fix some UI issues 2
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
AuthHealthResponse
|
||||
} from '../types/auth';
|
||||
import { ApiError } from '../client';
|
||||
import { useAuthStore } from '../../stores/auth.store';
|
||||
|
||||
// Query Keys
|
||||
export const authKeys = {
|
||||
@@ -149,6 +150,11 @@ export const useUpdateProfile = (
|
||||
onSuccess: (data) => {
|
||||
// Update the profile cache
|
||||
queryClient.setQueryData(authKeys.profile(), data);
|
||||
// Update the auth store user to maintain consistency
|
||||
const authStore = useAuthStore.getState();
|
||||
if (authStore.user) {
|
||||
authStore.updateUser(data);
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse,
|
||||
RecipeCategoriesResponse,
|
||||
RecipeQualityConfiguration,
|
||||
RecipeQualityConfigurationUpdate,
|
||||
} from '../types/recipes';
|
||||
|
||||
/**
|
||||
@@ -139,6 +141,65 @@ export class RecipesService {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.get<RecipeCategoriesResponse>(`${baseUrl}/categories/list`);
|
||||
}
|
||||
|
||||
// Quality Configuration Methods
|
||||
|
||||
/**
|
||||
* Get quality configuration for a recipe
|
||||
*/
|
||||
async getRecipeQualityConfiguration(
|
||||
tenantId: string,
|
||||
recipeId: string
|
||||
): Promise<RecipeQualityConfiguration> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.get<RecipeQualityConfiguration>(`${baseUrl}/${recipeId}/quality-configuration`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update quality configuration for a recipe
|
||||
*/
|
||||
async updateRecipeQualityConfiguration(
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
qualityConfig: RecipeQualityConfigurationUpdate
|
||||
): Promise<RecipeQualityConfiguration> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.put<RecipeQualityConfiguration>(
|
||||
`${baseUrl}/${recipeId}/quality-configuration`,
|
||||
qualityConfig
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add quality templates to a recipe stage
|
||||
*/
|
||||
async addQualityTemplatesToStage(
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
stage: string,
|
||||
templateIds: string[]
|
||||
): Promise<{ message: string }> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.post<{ message: string }>(
|
||||
`${baseUrl}/${recipeId}/quality-configuration/stages/${stage}/templates`,
|
||||
templateIds
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a quality template from a recipe stage
|
||||
*/
|
||||
async removeQualityTemplateFromStage(
|
||||
tenantId: string,
|
||||
recipeId: string,
|
||||
stage: string,
|
||||
templateId: string
|
||||
): Promise<{ message: string }> {
|
||||
const baseUrl = this.getBaseUrl(tenantId);
|
||||
return apiClient.delete<{ message: string }>(
|
||||
`${baseUrl}/${recipeId}/quality-configuration/stages/${stage}/templates/${templateId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface User {
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
avatar?: string; // User avatar image URL
|
||||
tenant_id?: string;
|
||||
role?: GlobalUserRole;
|
||||
}
|
||||
@@ -66,6 +67,7 @@ export interface UserResponse {
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
avatar?: string; // User avatar image URL
|
||||
tenant_id?: string;
|
||||
role?: GlobalUserRole;
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export interface UserUpdate {
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface TokenVerificationResponse {
|
||||
|
||||
@@ -34,6 +34,31 @@ export enum ProductionStatus {
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
// Quality Template Association Types
|
||||
export interface QualityStageConfiguration {
|
||||
template_ids: string[];
|
||||
required_checks: string[];
|
||||
optional_checks: string[];
|
||||
blocking_on_failure: boolean;
|
||||
min_quality_score?: number | null;
|
||||
}
|
||||
|
||||
export interface RecipeQualityConfiguration {
|
||||
stages: Record<string, QualityStageConfiguration>;
|
||||
overall_quality_threshold: number;
|
||||
critical_stage_blocking: boolean;
|
||||
auto_create_quality_checks: boolean;
|
||||
quality_manager_approval_required: boolean;
|
||||
}
|
||||
|
||||
export interface RecipeQualityConfigurationUpdate {
|
||||
stages?: Record<string, QualityStageConfiguration>;
|
||||
overall_quality_threshold?: number;
|
||||
critical_stage_blocking?: boolean;
|
||||
auto_create_quality_checks?: boolean;
|
||||
quality_manager_approval_required?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export interface RecipeIngredientCreate {
|
||||
ingredient_id: string;
|
||||
@@ -106,6 +131,7 @@ export interface RecipeCreate {
|
||||
preparation_notes?: string | null;
|
||||
storage_instructions?: string | null;
|
||||
quality_standards?: string | null;
|
||||
quality_check_configuration?: RecipeQualityConfiguration | null;
|
||||
serves_count?: number | null;
|
||||
nutritional_info?: Record<string, any> | null;
|
||||
allergen_info?: Record<string, any> | null;
|
||||
@@ -143,6 +169,7 @@ export interface RecipeUpdate {
|
||||
preparation_notes?: string | null;
|
||||
storage_instructions?: string | null;
|
||||
quality_standards?: string | null;
|
||||
quality_check_configuration?: RecipeQualityConfiguration | null;
|
||||
serves_count?: number | null;
|
||||
nutritional_info?: Record<string, any> | null;
|
||||
allergen_info?: Record<string, any> | null;
|
||||
@@ -189,6 +216,7 @@ export interface RecipeResponse {
|
||||
preparation_notes?: string | null;
|
||||
storage_instructions?: string | null;
|
||||
quality_standards?: string | null;
|
||||
quality_check_configuration?: RecipeQualityConfiguration | null;
|
||||
serves_count?: number | null;
|
||||
nutritional_info?: Record<string, any> | null;
|
||||
allergen_info?: Record<string, any> | null;
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button, Input, Card, Select, Avatar, Modal } from '../../ui';
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
import { useToast } from '../../../hooks/ui/useToast';
|
||||
import { useUpdateProfile, useChangePassword, useAuthProfile } from '../../../api/hooks/auth';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -16,7 +18,7 @@ interface ProfileFormData {
|
||||
phone: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
avatar_url?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,16 +50,39 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'preferences' | 'notifications'>(initialTab);
|
||||
|
||||
// Mock data for profile
|
||||
const updateProfileMutation = useUpdateProfile();
|
||||
const changePasswordMutation = useChangePassword();
|
||||
const { data: userProfile, isLoading: profileLoading } = useAuthProfile();
|
||||
|
||||
const [profileData, setProfileData] = useState<ProfileFormData>({
|
||||
first_name: 'María',
|
||||
last_name: 'González Pérez',
|
||||
email: 'admin@bakery.com',
|
||||
phone: '+34 612 345 678',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
avatar_url: 'https://images.unsplash.com/photo-1494790108755-2616b612b372?w=150&h=150&fit=crop&crop=face'
|
||||
avatar: '' // Using empty string to allow Avatar to show default bakery-themed icon
|
||||
});
|
||||
|
||||
const originalProfileDataRef = useRef<ProfileFormData | null>(null);
|
||||
|
||||
// Load profile data from API when available
|
||||
useEffect(() => {
|
||||
if (userProfile) {
|
||||
const profileFormData: ProfileFormData = {
|
||||
first_name: userProfile.full_name?.split(' ')[0] || '',
|
||||
last_name: userProfile.full_name?.split(' ').slice(1).join(' ') || '',
|
||||
email: userProfile.email || '',
|
||||
phone: userProfile.phone || '',
|
||||
language: userProfile.language || 'es',
|
||||
timezone: userProfile.timezone || 'Europe/Madrid',
|
||||
avatar: userProfile.avatar || ''
|
||||
};
|
||||
|
||||
setProfileData(profileFormData);
|
||||
originalProfileDataRef.current = profileFormData; // Store original data for reset
|
||||
}
|
||||
}, [userProfile]);
|
||||
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
|
||||
email_notifications: true,
|
||||
@@ -106,25 +131,7 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
{ value: 'Europe/Rome', label: 'Roma (CET/CEST)' }
|
||||
];
|
||||
|
||||
// Mock update profile function
|
||||
const updateProfile = async (data: any): Promise<boolean> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Simulate successful update
|
||||
console.log('Profile updated:', data);
|
||||
setIsLoading(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError('Error updating profile');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Profile picture upload handler
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -165,7 +172,7 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const newImageUrl = URL.createObjectURL(file); // Temporary URL
|
||||
setProfileData(prev => ({ ...prev, avatar_url: newImageUrl }));
|
||||
setProfileData(prev => ({ ...prev, avatar: newImageUrl }));
|
||||
setHasChanges(prev => ({ ...prev, profile: true }));
|
||||
|
||||
showToast({
|
||||
@@ -186,7 +193,7 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setProfileData(prev => ({ ...prev, avatar_url: '' }));
|
||||
setProfileData(prev => ({ ...prev, avatar: '' }));
|
||||
setHasChanges(prev => ({ ...prev, profile: true }));
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
@@ -268,27 +275,26 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await updateProfile(profileData);
|
||||
if (success) {
|
||||
setHasChanges(false);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Perfil actualizado',
|
||||
message: 'Tu información ha sido guardada correctamente'
|
||||
});
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error al actualizar',
|
||||
message: error || 'No se pudo actualizar tu perfil'
|
||||
});
|
||||
}
|
||||
await updateProfileMutation.mutateAsync({
|
||||
full_name: `${profileData.first_name} ${profileData.last_name}`.trim() || profileData.first_name,
|
||||
email: profileData.email,
|
||||
phone: profileData.phone,
|
||||
language: profileData.language,
|
||||
timezone: profileData.timezone,
|
||||
});
|
||||
|
||||
setHasChanges(false);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Perfil actualizado',
|
||||
message: 'Tu información ha sido guardada correctamente'
|
||||
});
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error de conexión',
|
||||
message: 'No se pudo conectar con el servidor'
|
||||
title: 'Error al actualizar',
|
||||
message: 'No se pudo actualizar tu perfil'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -300,19 +306,30 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: This would typically call a separate password change endpoint
|
||||
// For now, we'll show a placeholder message
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Contraseña actualizada',
|
||||
message: 'Tu contraseña ha sido cambiada correctamente'
|
||||
});
|
||||
|
||||
setPasswordData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmNewPassword: ''
|
||||
});
|
||||
try {
|
||||
await changePasswordMutation.mutateAsync({
|
||||
current_password: passwordData.currentPassword,
|
||||
new_password: passwordData.newPassword,
|
||||
});
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Contraseña actualizada',
|
||||
message: 'Tu contraseña ha sido cambiada correctamente'
|
||||
});
|
||||
|
||||
setPasswordData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmNewPassword: ''
|
||||
});
|
||||
} catch (error) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error al cambiar contraseña',
|
||||
message: 'No se pudo cambiar tu contraseña. Por favor, verifica tu contraseña actual.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileInputChange = (field: keyof ProfileFormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -353,13 +370,13 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
{ id: 'preferences' as const, label: 'Preferencias', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }
|
||||
];
|
||||
|
||||
// Mock user data for display
|
||||
const mockUser = {
|
||||
first_name: 'María',
|
||||
last_name: 'González',
|
||||
email: 'admin@bakery.com',
|
||||
bakery_name: 'Panadería San Miguel',
|
||||
avatar_url: 'https://images.unsplash.com/photo-1494790108755-2616b612b372?w=150&h=150&fit=crop&crop=face'
|
||||
// User data for display - based on actual profile data
|
||||
const displayUser = {
|
||||
first_name: profileData.first_name || '',
|
||||
last_name: profileData.last_name || '',
|
||||
email: profileData.email || '',
|
||||
bakery_name: user?.tenant_id ? 'Current Bakery' : 'No Bakery Assigned',
|
||||
avatar: profileData.avatar || ''
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -369,8 +386,9 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={mockUser.avatar_url}
|
||||
name={`${mockUser.first_name} ${mockUser.last_name}`}
|
||||
src={displayUser.avatar || undefined}
|
||||
alt={`${displayUser.first_name} ${displayUser.last_name}` || 'Usuario'}
|
||||
name={displayUser.avatar ? `${displayUser.first_name} ${displayUser.last_name}` : undefined}
|
||||
size="xl"
|
||||
className="w-20 h-20 border-4 border-background-primary shadow-lg"
|
||||
/>
|
||||
@@ -378,14 +396,14 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
||||
{mockUser.first_name} {mockUser.last_name}
|
||||
{displayUser.first_name} {displayUser.last_name}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-lg mb-1">{mockUser.email}</p>
|
||||
<p className="text-text-secondary text-lg mb-1">{displayUser.email}</p>
|
||||
<p className="text-sm text-text-tertiary flex items-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
Trabajando en {mockUser.bakery_name}
|
||||
Trabajando en {displayUser.bakery_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -428,8 +446,9 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
<div className="flex items-center space-x-8 mb-8 p-6 bg-background-secondary rounded-lg">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={profileData.avatar_url}
|
||||
name={`${profileData.first_name} ${profileData.last_name}`}
|
||||
src={profileData.avatar || undefined}
|
||||
alt={`${profileData.first_name} ${profileData.last_name}` || 'Usuario'}
|
||||
name={profileData.avatar ? `${profileData.first_name} ${profileData.last_name}` : undefined}
|
||||
size="xl"
|
||||
className="w-24 h-24"
|
||||
/>
|
||||
@@ -556,15 +575,10 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setProfileData({
|
||||
first_name: 'María',
|
||||
last_name: 'González Pérez',
|
||||
email: 'admin@bakery.com',
|
||||
phone: '+34 612 345 678',
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
avatar_url: 'https://images.unsplash.com/photo-1494790108755-2616b612b372?w=150&h=150&fit=crop&crop=face'
|
||||
});
|
||||
// Reset to original profile data
|
||||
if (originalProfileDataRef.current) {
|
||||
setProfileData(originalProfileDataRef.current);
|
||||
}
|
||||
setHasChanges(prev => ({ ...prev, profile: false }));
|
||||
setErrors({});
|
||||
}}
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface ProfileFormData {
|
||||
phone: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
avatar_url?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface BakeryFormData {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
Modal,
|
||||
@@ -9,18 +9,47 @@ import {
|
||||
Badge,
|
||||
Card
|
||||
} from '../../ui';
|
||||
import { LoadingSpinner } from '../../shared';
|
||||
import {
|
||||
QualityCheckType,
|
||||
ProcessStage,
|
||||
type QualityCheckTemplateCreate
|
||||
} from '../../../api/types/qualityTemplates';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { recipesService } from '../../../api/services/recipes';
|
||||
import type { RecipeResponse } from '../../../api/types/recipes';
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Settings,
|
||||
Link as LinkIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CreateQualityTemplateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateTemplate: (templateData: QualityCheckTemplateCreate) => Promise<void>;
|
||||
onCreateTemplate: (templateData: QualityCheckTemplateCreate, recipeAssociations?: RecipeAssociation[]) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
initialRecipe?: RecipeResponse; // Pre-select recipe if coming from recipe page
|
||||
}
|
||||
|
||||
interface RecipeAssociation {
|
||||
recipeId: string;
|
||||
recipeName: string;
|
||||
stages: ProcessStage[];
|
||||
}
|
||||
|
||||
interface StageConfiguration {
|
||||
stage: ProcessStage;
|
||||
isRequired: boolean;
|
||||
isOptional: boolean;
|
||||
blockingOnFailure: boolean;
|
||||
minQualityScore?: number;
|
||||
}
|
||||
|
||||
const QUALITY_CHECK_TYPE_OPTIONS = [
|
||||
@@ -58,10 +87,22 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateTemplate,
|
||||
isLoading = false
|
||||
isLoading = false,
|
||||
initialRecipe
|
||||
}) => {
|
||||
const currentTenant = useCurrentTenant();
|
||||
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>([]);
|
||||
const [recipeAssociations, setRecipeAssociations] = useState<RecipeAssociation[]>([]);
|
||||
const [stageConfigurations, setStageConfigurations] = useState<StageConfiguration[]>([]);
|
||||
const [showRecipeAssociation, setShowRecipeAssociation] = useState(false);
|
||||
const [showAdvancedConfig, setShowAdvancedConfig] = useState(false);
|
||||
|
||||
// Fetch available recipes for association
|
||||
const { data: recipes, isLoading: recipesLoading } = useQuery({
|
||||
queryKey: ['recipes', currentTenant?.id],
|
||||
queryFn: () => recipesService.searchRecipes(currentTenant?.id || '', { limit: 1000 }),
|
||||
enabled: isOpen && !!currentTenant?.id && showRecipeAssociation
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -94,6 +135,33 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
QualityCheckType.WEIGHT
|
||||
].includes(checkType);
|
||||
|
||||
// Initialize with recipe if provided
|
||||
useEffect(() => {
|
||||
if (initialRecipe && isOpen) {
|
||||
setRecipeAssociations([{
|
||||
recipeId: initialRecipe.id,
|
||||
recipeName: initialRecipe.name,
|
||||
stages: []
|
||||
}]);
|
||||
setShowRecipeAssociation(true);
|
||||
}
|
||||
}, [initialRecipe, isOpen]);
|
||||
|
||||
// Update stage configurations when selected stages change
|
||||
useEffect(() => {
|
||||
const newConfigs = selectedStages.map(stage => {
|
||||
const existing = stageConfigurations.find(c => c.stage === stage);
|
||||
return existing || {
|
||||
stage,
|
||||
isRequired: false,
|
||||
isOptional: true,
|
||||
blockingOnFailure: true,
|
||||
minQualityScore: undefined
|
||||
};
|
||||
});
|
||||
setStageConfigurations(newConfigs);
|
||||
}, [selectedStages]);
|
||||
|
||||
const handleStageToggle = (stage: ProcessStage) => {
|
||||
const newStages = selectedStages.includes(stage)
|
||||
? selectedStages.filter(s => s !== stage)
|
||||
@@ -102,17 +170,78 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
setSelectedStages(newStages);
|
||||
};
|
||||
|
||||
const handleAddRecipeAssociation = () => {
|
||||
const newAssociation: RecipeAssociation = {
|
||||
recipeId: '',
|
||||
recipeName: '',
|
||||
stages: []
|
||||
};
|
||||
setRecipeAssociations([...recipeAssociations, newAssociation]);
|
||||
};
|
||||
|
||||
const handleRemoveRecipeAssociation = (index: number) => {
|
||||
setRecipeAssociations(recipeAssociations.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleRecipeAssociationChange = (index: number, field: keyof RecipeAssociation, value: any) => {
|
||||
const updated = [...recipeAssociations];
|
||||
if (field === 'recipeId' && recipes) {
|
||||
const recipe = recipes.find(r => r.id === value);
|
||||
if (recipe) {
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
recipeId: value,
|
||||
recipeName: recipe.name
|
||||
};
|
||||
}
|
||||
} else {
|
||||
(updated[index] as any)[field] = value;
|
||||
}
|
||||
setRecipeAssociations(updated);
|
||||
};
|
||||
|
||||
const handleStageConfigChange = (stage: ProcessStage, field: keyof StageConfiguration, value: any) => {
|
||||
setStageConfigurations(configs =>
|
||||
configs.map(config =>
|
||||
config.stage === stage ? { ...config, [field]: value } : config
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: QualityCheckTemplateCreate) => {
|
||||
try {
|
||||
// Validate recipe associations if any
|
||||
const validRecipeAssociations = recipeAssociations
|
||||
.filter(association => association.recipeId && association.stages.length > 0)
|
||||
.map(association => ({
|
||||
...association,
|
||||
stageConfigurations: stageConfigurations
|
||||
.filter(config => association.stages.includes(config.stage))
|
||||
.reduce((acc, config) => {
|
||||
acc[config.stage] = {
|
||||
template_ids: [], // Will be populated by backend
|
||||
required_checks: config.isRequired ? [data.name] : [],
|
||||
optional_checks: !config.isRequired ? [data.name] : [],
|
||||
blocking_on_failure: config.blockingOnFailure,
|
||||
min_quality_score: config.minQualityScore || null
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, any>)
|
||||
}));
|
||||
|
||||
await onCreateTemplate({
|
||||
...data,
|
||||
applicable_stages: selectedStages.length > 0 ? selectedStages : undefined,
|
||||
created_by: currentTenant?.id || ''
|
||||
});
|
||||
}, validRecipeAssociations.length > 0 ? validRecipeAssociations : undefined);
|
||||
|
||||
// Reset form
|
||||
reset();
|
||||
setSelectedStages([]);
|
||||
setRecipeAssociations([]);
|
||||
setStageConfigurations([]);
|
||||
setShowRecipeAssociation(false);
|
||||
setShowAdvancedConfig(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error);
|
||||
}
|
||||
@@ -121,6 +250,10 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
setSelectedStages([]);
|
||||
setRecipeAssociations([]);
|
||||
setStageConfigurations([]);
|
||||
setShowRecipeAssociation(false);
|
||||
setShowAdvancedConfig(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -296,16 +429,171 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recipe Association */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<LinkIcon className="h-5 w-5 text-[var(--text-secondary)]" />
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
Asociación con Recetas
|
||||
</h4>
|
||||
<Badge variant="outline" size="sm">
|
||||
Nuevo
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowRecipeAssociation(!showRecipeAssociation)}
|
||||
>
|
||||
{showRecipeAssociation ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showRecipeAssociation && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-2 p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<p className="font-medium text-[var(--text-primary)] mb-1">Asociación Automática con Recetas</p>
|
||||
<p>
|
||||
Al asociar esta plantilla con recetas, se aplicará automáticamente a los lotes de producción
|
||||
creados a partir de esas recetas. Esto asegura consistencia en los controles de calidad.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipeAssociations.map((association, index) => {
|
||||
const isComplete = association.recipeId && association.stages.length > 0;
|
||||
return (
|
||||
<div key={index} className={`p-4 border rounded-lg space-y-3 ${
|
||||
isComplete ? 'border-green-200 bg-green-50/50' : 'border-[var(--border-primary)]'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h5 className="font-medium text-[var(--text-primary)]">
|
||||
Receta {index + 1}
|
||||
</h5>
|
||||
{isComplete && (
|
||||
<Badge variant="success" size="sm">Configurada</Badge>
|
||||
)}
|
||||
</div>
|
||||
{recipeAssociations.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveRecipeAssociation(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Seleccionar Receta
|
||||
</label>
|
||||
{recipesLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-sm text-[var(--text-secondary)]">Cargando recetas...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={association.recipeId}
|
||||
onChange={(e) => handleRecipeAssociationChange(index, 'recipeId', e.target.value)}
|
||||
>
|
||||
<option value="">Seleccionar receta</option>
|
||||
{recipes?.map(recipe => (
|
||||
<option key={recipe.id} value={recipe.id}>
|
||||
{recipe.name} {recipe.category && `(${recipe.category})`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{association.recipeId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Etapas donde aplicar en esta receta
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 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={association.stages.includes(stage.value)}
|
||||
onChange={(e) => {
|
||||
const newStages = e.target.checked
|
||||
? [...association.stages, stage.value]
|
||||
: association.stages.filter(s => s !== stage.value);
|
||||
handleRecipeAssociationChange(index, 'stages', newStages);
|
||||
}}
|
||||
className="rounded border-[var(--border-primary)]"
|
||||
/>
|
||||
<span className="text-sm">{stage.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{association.recipeId && association.stages.length === 0 && (
|
||||
<div className="flex items-start space-x-2 p-2 bg-yellow-50/50 border border-yellow-200 rounded">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-yellow-700">
|
||||
Selecciona al menos una etapa para completar la configuración de esta receta.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddRecipeAssociation}
|
||||
disabled={recipesLoading}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Agregar Receta
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Process Stages */}
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-4">
|
||||
Etapas del Proceso Aplicables
|
||||
</h4>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
Etapas del Proceso Aplicables
|
||||
</h4>
|
||||
{selectedStages.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedConfig(!showAdvancedConfig)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{showAdvancedConfig ? 'Ocultar' : 'Configurar'} Avanzado
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
{PROCESS_STAGE_OPTIONS.map(stage => (
|
||||
<label
|
||||
key={stage.value}
|
||||
@@ -321,6 +609,81 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Advanced Stage Configuration */}
|
||||
{showAdvancedConfig && selectedStages.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-start space-x-2 mb-4">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500 mt-0.5" />
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<p className="font-medium text-[var(--text-primary)] mb-1">Configuración Avanzada por Etapa</p>
|
||||
<p>
|
||||
Configura comportamientos específicos para cada etapa seleccionada. Esta configuración se aplicará
|
||||
cuando se use la plantilla en recetas asociadas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{stageConfigurations.map(config => (
|
||||
<div key={config.stage} className="p-3 bg-white rounded-lg border">
|
||||
<h5 className="font-medium text-[var(--text-primary)] mb-3">
|
||||
{PROCESS_STAGE_OPTIONS.find(s => s.value === config.stage)?.label}
|
||||
</h5>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.isRequired}
|
||||
onChange={(e) => handleStageConfigChange(config.stage, 'isRequired', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)]"
|
||||
/>
|
||||
<span className="text-sm">Control requerido</span>
|
||||
<Badge variant="info" size="sm">Obligatorio</Badge>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.blockingOnFailure}
|
||||
onChange={(e) => handleStageConfigChange(config.stage, 'blockingOnFailure', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)]"
|
||||
/>
|
||||
<span className="text-sm">Bloquear si falla</span>
|
||||
<Badge variant="error" size="sm">Crítico</Badge>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Puntuación Mínima (opcional)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.minQualityScore || ''}
|
||||
onChange={(e) => handleStageConfigChange(
|
||||
config.stage,
|
||||
'minQualityScore',
|
||||
e.target.value ? parseFloat(e.target.value) : undefined
|
||||
)}
|
||||
placeholder="ej: 7.0"
|
||||
className="w-24"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Puntuación mínima requerida (0-10)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Settings */}
|
||||
|
||||
@@ -515,12 +515,15 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
level > 0 && 'pl-6',
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={clsx(
|
||||
'relative',
|
||||
isCollapsed && level === 0 && 'flex items-center justify-center'
|
||||
)}>
|
||||
{ItemIcon && (
|
||||
<ItemIcon
|
||||
className={clsx(
|
||||
'flex-shrink-0 transition-colors duration-200',
|
||||
isCollapsed && level === 0 ? 'w-5 h-5' : 'w-4 h-4 mr-3',
|
||||
isCollapsed && level === 0 ? 'w-4 h-4' : 'w-4 h-4 mr-3',
|
||||
isActive
|
||||
? 'text-[var(--color-primary)]'
|
||||
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
@@ -529,7 +532,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
)}
|
||||
|
||||
{/* Submenu indicator for collapsed sidebar */}
|
||||
{isCollapsed && hasChildren && level === 0 && (
|
||||
{isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0 && (
|
||||
<div className="absolute -bottom-1 -right-1 w-2 h-2 bg-[var(--color-primary)] rounded-full opacity-75" />
|
||||
)}
|
||||
</div>
|
||||
@@ -585,12 +588,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
disabled={item.disabled}
|
||||
data-path={item.path}
|
||||
onMouseEnter={() => {
|
||||
if (isCollapsed && hasChildren && level === 0) {
|
||||
if (isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0) {
|
||||
setHoveredItem(item.id);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (isCollapsed && hasChildren && level === 0) {
|
||||
if (isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0) {
|
||||
setHoveredItem(null);
|
||||
}
|
||||
}}
|
||||
@@ -600,7 +603,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
isActive && 'bg-[var(--color-primary)]/10 border-l-2 border-[var(--color-primary)]',
|
||||
!isActive && 'hover:bg-[var(--bg-secondary)]',
|
||||
item.disabled && 'opacity-50 cursor-not-allowed',
|
||||
isCollapsed && level === 0 ? 'flex justify-center items-center p-3 aspect-square' : 'p-3'
|
||||
isCollapsed && level === 0 ? 'flex justify-center items-center p-2 h-10 w-10 min-w-10 max-w-10' : 'p-3'
|
||||
)}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
@@ -610,8 +613,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
</button>
|
||||
|
||||
{/* Submenu overlay for collapsed sidebar */}
|
||||
{isCollapsed && hasChildren && level === 0 && isHovered && (
|
||||
<div
|
||||
{isCollapsed && hasChildren && level === 0 && isHovered && item.children && item.children.length > 0 && (
|
||||
<div
|
||||
className="absolute left-full top-0 ml-2 z-[var(--z-popover)]"
|
||||
onMouseEnter={() => setHoveredItem(item.id)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
@@ -710,22 +713,22 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className={clsx('flex-1 overflow-y-auto', isCollapsed ? 'px-2 py-4' : 'p-4')}>
|
||||
<ul className={clsx(isCollapsed ? 'space-y-2' : 'space-y-2')}>
|
||||
<nav className={clsx('flex-1 overflow-y-auto overflow-x-hidden', isCollapsed ? 'px-1 py-4' : 'p-4')}>
|
||||
<ul className={clsx(isCollapsed ? 'space-y-1 flex flex-col items-center' : 'space-y-2')}>
|
||||
{visibleItems.map(item => renderItem(item))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Profile section */}
|
||||
{user && (
|
||||
<div className="border-t border-[var(--border-primary)]">
|
||||
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed && 'p-1 flex justify-center')}>
|
||||
<div className="relative" data-profile-menu>
|
||||
<button
|
||||
onClick={handleProfileMenuToggle}
|
||||
className={clsx(
|
||||
'w-full flex items-center transition-all duration-200',
|
||||
'hover:bg-[var(--bg-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
isCollapsed ? 'justify-center p-3' : 'p-4 gap-3',
|
||||
isCollapsed ? 'justify-center p-2 h-10 w-10 mx-auto rounded-lg' : 'p-4 gap-3',
|
||||
isProfileMenuOpen && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
aria-label="Menú de perfil"
|
||||
@@ -733,9 +736,9 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
src={user.avatar || undefined}
|
||||
alt={user.full_name || 'Usuario'}
|
||||
name={user.avatar ? user.full_name : undefined}
|
||||
size={isCollapsed ? "sm" : "md"}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
@@ -802,14 +805,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
|
||||
{/* Collapse button */}
|
||||
{showCollapseButton && (
|
||||
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-2' : 'p-4')}>
|
||||
<div className={clsx('border-t border-[var(--border-primary)]', isCollapsed ? 'p-1 flex justify-center' : 'p-4')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleCollapse}
|
||||
className={clsx(
|
||||
'w-full flex items-center transition-colors duration-200',
|
||||
isCollapsed ? 'justify-center p-3 aspect-square' : 'justify-start px-4 py-2'
|
||||
'flex items-center transition-colors duration-200',
|
||||
isCollapsed ? 'justify-center h-10 w-10 p-2 rounded-lg' : 'w-full justify-start px-4 py-2'
|
||||
)}
|
||||
aria-label={isCollapsed ? t('common:actions.expand', 'Expand sidebar') : t('common:actions.collapse', 'Collapse sidebar')}
|
||||
>
|
||||
@@ -932,9 +935,9 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
name={user.name}
|
||||
src={user.avatar || undefined}
|
||||
alt={user.full_name || 'Usuario'}
|
||||
name={user.avatar ? user.full_name : undefined}
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
@@ -213,7 +213,7 @@ const Avatar = forwardRef<HTMLDivElement, AvatarProps>(({
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Default bakery-themed chef icon
|
||||
// Default professional user icon
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-primary-dark)] text-white">
|
||||
<svg
|
||||
@@ -222,16 +222,8 @@ const Avatar = forwardRef<HTMLDivElement, AvatarProps>(({
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Chef hat */}
|
||||
<path d="M18.5 7c-1.4 0-2.5-1.1-2.5-2.5S17.1 2 18.5 2s2.5 1.1 2.5 2.5S19.9 7 18.5 7z"/>
|
||||
<path d="M5.5 7C4.1 7 3 5.9 3 4.5S4.1 2 5.5 2 8 3.1 8 4.5 6.9 7 5.5 7z"/>
|
||||
<path d="M12 6c-1.4 0-2.5-1.1-2.5-2.5S10.6 1 12 1s2.5 1.1 2.5 2.5S13.4 6 12 6z"/>
|
||||
<path d="M19 8H5c-1.1 0-2 .9-2 2v1c0 .6.4 1 1 1h16c.6 0 1-.4 1-1v-1c0-1.1-.9-2-2-2z"/>
|
||||
<path d="M18 12H6v8c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2v-8z"/>
|
||||
{/* Chef details */}
|
||||
<circle cx="9" cy="16" r="0.5" fill="currentColor" opacity="0.7"/>
|
||||
<circle cx="15" cy="16" r="0.5" fill="currentColor" opacity="0.7"/>
|
||||
<path d="M10 18h4" stroke="currentColor" strokeWidth="0.5" strokeLinecap="round" opacity="0.7"/>
|
||||
{/* Professional user silhouette */}
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -99,14 +99,14 @@ export const NotificationPanel: React.FC<NotificationPanelProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[9998] transition-all duration-300 ${
|
||||
isMobile ? 'bg-black/50 backdrop-blur-sm' : 'bg-black/20'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Backdrop - Only on mobile */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-sm transition-all duration-300"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ProfileFormData {
|
||||
phone: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface PasswordData {
|
||||
@@ -66,7 +67,8 @@ const ProfilePage: React.FC = () => {
|
||||
email: profile.email || '',
|
||||
phone: profile.phone || '',
|
||||
language: profile.language || 'es',
|
||||
timezone: profile.timezone || 'Europe/Madrid'
|
||||
timezone: profile.timezone || 'Europe/Madrid',
|
||||
avatar: profile.avatar || ''
|
||||
});
|
||||
|
||||
// Update notification preferences with profile data
|
||||
@@ -381,8 +383,9 @@ const ProfilePage: React.FC = () => {
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={profile?.avatar_url}
|
||||
name={`${profileData.first_name} ${profileData.last_name}`}
|
||||
src={profile?.avatar || undefined}
|
||||
alt={profile?.full_name || `${profileData.first_name} ${profileData.last_name}` || 'Usuario'}
|
||||
name={profile?.avatar ? (profile?.full_name || `${profileData.first_name} ${profileData.last_name}`) : undefined}
|
||||
size="xl"
|
||||
className="w-20 h-20"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface User {
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
avatar?: string; // User avatar image URL
|
||||
tenant_id?: string;
|
||||
role?: GlobalUserRole;
|
||||
}
|
||||
|
||||
@@ -93,13 +93,17 @@ class ProductionService:
|
||||
batch_dict = batch_data.model_dump()
|
||||
batch_dict["tenant_id"] = tenant_id
|
||||
|
||||
# Validate recipe exists if provided
|
||||
# Validate recipe exists and get quality configuration
|
||||
recipe_quality_config = None
|
||||
if batch_data.recipe_id:
|
||||
recipe_details = await self.recipes_client.get_recipe_by_id(
|
||||
str(tenant_id), str(batch_data.recipe_id)
|
||||
)
|
||||
if not recipe_details:
|
||||
raise ValueError(f"Recipe {batch_data.recipe_id} not found")
|
||||
|
||||
# Extract quality configuration from recipe
|
||||
recipe_quality_config = recipe_details.get("quality_check_configuration")
|
||||
|
||||
# Check ingredient availability
|
||||
if batch_data.recipe_id:
|
||||
@@ -118,10 +122,15 @@ class ProductionService:
|
||||
|
||||
# Create the batch
|
||||
batch = await batch_repo.create_batch(batch_dict)
|
||||
|
||||
logger.info("Production batch created",
|
||||
batch_id=str(batch.id), tenant_id=str(tenant_id))
|
||||
|
||||
|
||||
# Inherit quality templates from recipe if configured
|
||||
if recipe_quality_config and recipe_quality_config.get("auto_create_quality_checks", True):
|
||||
await self._setup_batch_quality_checks(session, batch, recipe_quality_config, tenant_id)
|
||||
|
||||
logger.info("Production batch created with quality inheritance",
|
||||
batch_id=str(batch.id), tenant_id=str(tenant_id),
|
||||
has_quality_config=bool(recipe_quality_config))
|
||||
|
||||
return batch
|
||||
|
||||
except Exception as e:
|
||||
@@ -129,6 +138,132 @@ class ProductionService:
|
||||
error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def _setup_batch_quality_checks(
|
||||
self,
|
||||
session,
|
||||
batch: ProductionBatch,
|
||||
quality_config: Dict[str, Any],
|
||||
tenant_id: UUID
|
||||
):
|
||||
"""Set up quality checks for a production batch based on recipe configuration"""
|
||||
try:
|
||||
# Initialize pending and completed quality checks structures
|
||||
pending_quality_checks = {}
|
||||
completed_quality_checks = {}
|
||||
|
||||
# Process each stage configuration
|
||||
stages = quality_config.get("stages", {})
|
||||
for stage_name, stage_config in stages.items():
|
||||
template_ids = stage_config.get("template_ids", [])
|
||||
required_checks = stage_config.get("required_checks", [])
|
||||
optional_checks = stage_config.get("optional_checks", [])
|
||||
min_quality_score = stage_config.get("min_quality_score")
|
||||
blocking_on_failure = stage_config.get("blocking_on_failure", True)
|
||||
|
||||
# Set up pending checks for this stage
|
||||
if template_ids or required_checks or optional_checks:
|
||||
pending_quality_checks[stage_name] = {
|
||||
"template_ids": [str(tid) for tid in template_ids],
|
||||
"required_checks": required_checks,
|
||||
"optional_checks": optional_checks,
|
||||
"min_quality_score": min_quality_score,
|
||||
"blocking_on_failure": blocking_on_failure,
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
# Initialize completed structure for this stage
|
||||
completed_quality_checks[stage_name] = {
|
||||
"checks": [],
|
||||
"overall_score": None,
|
||||
"passed": None,
|
||||
"completed_at": None
|
||||
}
|
||||
|
||||
# Update batch with quality check configuration
|
||||
batch.pending_quality_checks = pending_quality_checks
|
||||
batch.completed_quality_checks = completed_quality_checks
|
||||
|
||||
# Save the updated batch
|
||||
await session.commit()
|
||||
|
||||
logger.info("Quality checks setup completed for batch",
|
||||
batch_id=str(batch.id),
|
||||
stages_configured=list(stages.keys()),
|
||||
total_templates=sum(len(stage.get("template_ids", [])) for stage in stages.values()))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error setting up batch quality checks",
|
||||
error=str(e), batch_id=str(batch.id))
|
||||
raise
|
||||
|
||||
async def update_batch_stage_with_quality_checks(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
batch_id: UUID,
|
||||
new_stage: str
|
||||
) -> ProductionBatch:
|
||||
"""Update batch stage and create required quality checks"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as session:
|
||||
batch_repo = ProductionBatchRepository(session)
|
||||
|
||||
# Get the batch
|
||||
batch = await batch_repo.get_batch(tenant_id, batch_id)
|
||||
if not batch:
|
||||
raise ValueError(f"Batch {batch_id} not found")
|
||||
|
||||
# Update current stage
|
||||
old_stage = batch.current_process_stage
|
||||
batch.current_process_stage = new_stage
|
||||
|
||||
# Check if there are pending quality checks for this stage
|
||||
pending_checks = batch.pending_quality_checks or {}
|
||||
stage_checks = pending_checks.get(new_stage)
|
||||
|
||||
if stage_checks and stage_checks.get("template_ids"):
|
||||
# Create quality check records from templates
|
||||
from app.repositories.quality_template_repository import QualityTemplateRepository
|
||||
|
||||
template_repo = QualityTemplateRepository(session)
|
||||
quality_repo = QualityCheckRepository(session)
|
||||
|
||||
template_ids = [UUID(tid) for tid in stage_checks["template_ids"]]
|
||||
templates = await template_repo.get_templates_by_ids(str(tenant_id), template_ids)
|
||||
|
||||
# Create quality checks for each template
|
||||
for template in templates:
|
||||
quality_check_data = {
|
||||
"tenant_id": tenant_id,
|
||||
"batch_id": batch_id,
|
||||
"template_id": template.id,
|
||||
"check_type": template.check_type,
|
||||
"process_stage": new_stage,
|
||||
"check_time": datetime.utcnow(),
|
||||
"quality_score": 0.0, # To be filled when check is performed
|
||||
"pass_fail": False, # To be updated when check is performed
|
||||
"defect_count": 0,
|
||||
"target_weight": template.target_value,
|
||||
"target_temperature": template.target_value if template.check_type == "temperature" else None,
|
||||
"tolerance_percentage": template.tolerance_percentage
|
||||
}
|
||||
|
||||
await quality_repo.create_quality_check(quality_check_data)
|
||||
|
||||
logger.info("Created quality checks for batch stage transition",
|
||||
batch_id=str(batch_id),
|
||||
stage=new_stage,
|
||||
checks_created=len(templates))
|
||||
|
||||
# Save batch changes
|
||||
await session.commit()
|
||||
|
||||
return batch
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error updating batch stage with quality checks",
|
||||
error=str(e), batch_id=str(batch_id), new_stage=new_stage)
|
||||
raise
|
||||
|
||||
async def get_production_batches_list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
|
||||
@@ -18,7 +18,9 @@ from ..schemas.recipes import (
|
||||
RecipeSearchRequest,
|
||||
RecipeDuplicateRequest,
|
||||
RecipeFeasibilityResponse,
|
||||
RecipeStatisticsResponse
|
||||
RecipeStatisticsResponse,
|
||||
RecipeQualityConfiguration,
|
||||
RecipeQualityConfigurationUpdate
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -350,4 +352,136 @@ async def get_recipe_categories(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipe categories: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# Quality Configuration Endpoints
|
||||
|
||||
@router.get("/{tenant_id}/recipes/{recipe_id}/quality-configuration", response_model=RecipeQualityConfiguration)
|
||||
async def get_recipe_quality_configuration(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get quality configuration for a specific recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Get recipe with quality configuration
|
||||
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
# Return quality configuration or default structure
|
||||
quality_config = recipe.get("quality_check_configuration")
|
||||
if not quality_config:
|
||||
quality_config = {
|
||||
"stages": {},
|
||||
"overall_quality_threshold": 7.0,
|
||||
"critical_stage_blocking": True,
|
||||
"auto_create_quality_checks": True,
|
||||
"quality_manager_approval_required": False
|
||||
}
|
||||
|
||||
return quality_config
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipe quality configuration: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.put("/{tenant_id}/recipes/{recipe_id}/quality-configuration", response_model=RecipeQualityConfiguration)
|
||||
async def update_recipe_quality_configuration(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
quality_config: RecipeQualityConfigurationUpdate,
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update quality configuration for a specific recipe"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Verify recipe exists and belongs to tenant
|
||||
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
# Update recipe with quality configuration
|
||||
updated_recipe = await recipe_service.update_recipe_quality_configuration(
|
||||
tenant_id, recipe_id, quality_config.dict(exclude_unset=True), user_id
|
||||
)
|
||||
|
||||
return updated_recipe["quality_check_configuration"]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe quality configuration: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates")
|
||||
async def add_quality_templates_to_stage(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
stage: str,
|
||||
template_ids: List[UUID],
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add quality templates to a specific recipe stage"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Verify recipe exists
|
||||
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
# Add templates to stage
|
||||
await recipe_service.add_quality_templates_to_stage(
|
||||
tenant_id, recipe_id, stage, template_ids, user_id
|
||||
)
|
||||
|
||||
return {"message": f"Added {len(template_ids)} templates to {stage} stage"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding quality templates to recipe stage: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/{tenant_id}/recipes/{recipe_id}/quality-configuration/stages/{stage}/templates/{template_id}")
|
||||
async def remove_quality_template_from_stage(
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
stage: str,
|
||||
template_id: UUID,
|
||||
user_id: UUID = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove a quality template from a specific recipe stage"""
|
||||
try:
|
||||
recipe_service = RecipeService(db)
|
||||
|
||||
# Verify recipe exists
|
||||
recipe = await recipe_service.get_recipe(tenant_id, recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found")
|
||||
|
||||
# Remove template from stage
|
||||
await recipe_service.remove_quality_template_from_stage(
|
||||
tenant_id, recipe_id, stage, template_id, user_id
|
||||
)
|
||||
|
||||
return {"message": f"Removed template from {stage} stage"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing quality template from recipe stage: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
@@ -12,6 +12,34 @@ from enum import Enum
|
||||
from ..models.recipes import RecipeStatus, MeasurementUnit
|
||||
|
||||
|
||||
# Quality Template Association Schemas
|
||||
class QualityStageConfiguration(BaseModel):
|
||||
"""Schema for quality checks configuration per production stage"""
|
||||
template_ids: List[UUID] = Field(default_factory=list, description="Quality template IDs for this stage")
|
||||
required_checks: List[str] = Field(default_factory=list, description="Required quality check types")
|
||||
optional_checks: List[str] = Field(default_factory=list, description="Optional quality check types")
|
||||
blocking_on_failure: bool = Field(default=True, description="Block stage progression on critical failures")
|
||||
min_quality_score: Optional[float] = Field(None, ge=0, le=10, description="Minimum quality score to pass stage")
|
||||
|
||||
|
||||
class RecipeQualityConfiguration(BaseModel):
|
||||
"""Schema for recipe quality configuration across all stages"""
|
||||
stages: Dict[str, QualityStageConfiguration] = Field(default_factory=dict, description="Quality configuration per stage")
|
||||
overall_quality_threshold: float = Field(default=7.0, ge=0, le=10, description="Overall quality threshold for batch")
|
||||
critical_stage_blocking: bool = Field(default=True, description="Block progression if critical checks fail")
|
||||
auto_create_quality_checks: bool = Field(default=True, description="Automatically create quality checks for batches")
|
||||
quality_manager_approval_required: bool = Field(default=False, description="Require quality manager approval")
|
||||
|
||||
|
||||
class RecipeQualityConfigurationUpdate(BaseModel):
|
||||
"""Schema for updating recipe quality configuration"""
|
||||
stages: Optional[Dict[str, QualityStageConfiguration]] = None
|
||||
overall_quality_threshold: Optional[float] = Field(None, ge=0, le=10)
|
||||
critical_stage_blocking: Optional[bool] = None
|
||||
auto_create_quality_checks: Optional[bool] = None
|
||||
quality_manager_approval_required: Optional[bool] = None
|
||||
|
||||
|
||||
class RecipeIngredientCreate(BaseModel):
|
||||
"""Schema for creating recipe ingredients"""
|
||||
ingredient_id: UUID
|
||||
@@ -90,6 +118,7 @@ class RecipeCreate(BaseModel):
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
quality_check_configuration: Optional[RecipeQualityConfiguration] = None
|
||||
serves_count: Optional[int] = Field(None, ge=1)
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
allergen_info: Optional[Dict[str, Any]] = None
|
||||
@@ -128,6 +157,7 @@ class RecipeUpdate(BaseModel):
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
quality_check_configuration: Optional[RecipeQualityConfigurationUpdate] = None
|
||||
serves_count: Optional[int] = Field(None, ge=1)
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
allergen_info: Optional[Dict[str, Any]] = None
|
||||
@@ -175,6 +205,7 @@ class RecipeResponse(BaseModel):
|
||||
preparation_notes: Optional[str] = None
|
||||
storage_instructions: Optional[str] = None
|
||||
quality_standards: Optional[str] = None
|
||||
quality_check_configuration: Optional[RecipeQualityConfiguration] = None
|
||||
serves_count: Optional[int] = None
|
||||
nutritional_info: Optional[Dict[str, Any]] = None
|
||||
allergen_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -261,4 +261,125 @@ class RecipeService:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
}
|
||||
|
||||
# Quality Configuration Methods
|
||||
|
||||
async def update_recipe_quality_configuration(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
quality_config_update: Dict[str, Any],
|
||||
user_id: UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""Update quality configuration for a recipe"""
|
||||
try:
|
||||
# Get current recipe
|
||||
recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id)
|
||||
if not recipe:
|
||||
raise ValueError("Recipe not found")
|
||||
|
||||
# Get existing quality configuration or create default
|
||||
current_config = recipe.get("quality_check_configuration", {
|
||||
"stages": {},
|
||||
"overall_quality_threshold": 7.0,
|
||||
"critical_stage_blocking": True,
|
||||
"auto_create_quality_checks": True,
|
||||
"quality_manager_approval_required": False
|
||||
})
|
||||
|
||||
# Merge with updates
|
||||
if "stages" in quality_config_update:
|
||||
current_config["stages"].update(quality_config_update["stages"])
|
||||
|
||||
for key in ["overall_quality_threshold", "critical_stage_blocking",
|
||||
"auto_create_quality_checks", "quality_manager_approval_required"]:
|
||||
if key in quality_config_update:
|
||||
current_config[key] = quality_config_update[key]
|
||||
|
||||
# Update recipe with new configuration
|
||||
recipe_update = RecipeUpdate(quality_check_configuration=current_config)
|
||||
await self.recipe_repo.update_recipe(tenant_id, recipe_id, recipe_update, user_id)
|
||||
|
||||
# Return updated recipe
|
||||
updated_recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id)
|
||||
return updated_recipe
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating recipe quality configuration: {e}")
|
||||
raise
|
||||
|
||||
async def add_quality_templates_to_stage(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
stage: str,
|
||||
template_ids: List[UUID],
|
||||
user_id: UUID
|
||||
):
|
||||
"""Add quality templates to a specific recipe stage"""
|
||||
try:
|
||||
# Get current recipe
|
||||
recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id)
|
||||
if not recipe:
|
||||
raise ValueError("Recipe not found")
|
||||
|
||||
# Get existing quality configuration
|
||||
quality_config = recipe.get("quality_check_configuration", {"stages": {}})
|
||||
|
||||
# Initialize stage if it doesn't exist
|
||||
if stage not in quality_config["stages"]:
|
||||
quality_config["stages"][stage] = {
|
||||
"template_ids": [],
|
||||
"required_checks": [],
|
||||
"optional_checks": [],
|
||||
"blocking_on_failure": True,
|
||||
"min_quality_score": None
|
||||
}
|
||||
|
||||
# Add template IDs (avoid duplicates)
|
||||
stage_config = quality_config["stages"][stage]
|
||||
existing_ids = set(stage_config.get("template_ids", []))
|
||||
new_ids = [str(tid) for tid in template_ids if str(tid) not in existing_ids]
|
||||
stage_config["template_ids"].extend(new_ids)
|
||||
|
||||
# Update recipe
|
||||
recipe_update = RecipeUpdate(quality_check_configuration=quality_config)
|
||||
await self.recipe_repo.update_recipe(tenant_id, recipe_id, recipe_update, user_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding quality templates to stage: {e}")
|
||||
raise
|
||||
|
||||
async def remove_quality_template_from_stage(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
recipe_id: UUID,
|
||||
stage: str,
|
||||
template_id: UUID,
|
||||
user_id: UUID
|
||||
):
|
||||
"""Remove a quality template from a specific recipe stage"""
|
||||
try:
|
||||
# Get current recipe
|
||||
recipe = await self.recipe_repo.get_recipe(tenant_id, recipe_id)
|
||||
if not recipe:
|
||||
raise ValueError("Recipe not found")
|
||||
|
||||
# Get existing quality configuration
|
||||
quality_config = recipe.get("quality_check_configuration", {"stages": {}})
|
||||
|
||||
# Remove template ID from stage
|
||||
if stage in quality_config["stages"]:
|
||||
stage_config = quality_config["stages"][stage]
|
||||
template_ids = stage_config.get("template_ids", [])
|
||||
template_ids = [tid for tid in template_ids if str(tid) != str(template_id)]
|
||||
stage_config["template_ids"] = template_ids
|
||||
|
||||
# Update recipe
|
||||
recipe_update = RecipeUpdate(quality_check_configuration=quality_config)
|
||||
await self.recipe_repo.update_recipe(tenant_id, recipe_id, recipe_update, user_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing quality template from stage: {e}")
|
||||
raise
|
||||
Reference in New Issue
Block a user