Files
bakery-ia/frontend/src/components/domain/onboarding/steps/RegisterTenantStep.tsx
2026-01-12 22:15:11 +01:00

408 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../../../ui';
import { AddressAutocomplete } from '../../../ui/AddressAutocomplete';
import { useRegisterBakery, useTenant, useUpdateTenant } from '../../../../api/hooks/tenant';
import { BakeryRegistration, TenantUpdate } from '../../../../api/types/tenant';
import { AddressResult } from '../../../../services/api/geocodingApi';
import { useWizardContext } from '../context';
import { poiContextApi } from '../../../../services/api/poiContextApi';
interface RegisterTenantStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
// Map bakeryType to business_model
const getBakeryBusinessModel = (bakeryType: string | null): string => {
switch (bakeryType) {
case 'production':
return 'central_baker_satellite'; // Production-focused bakery
case 'retail':
return 'retail_bakery'; // Retail/finishing bakery
case 'mixed':
return 'hybrid_bakery'; // Mixed model (enterprise or hybrid)
default:
return 'individual_bakery'; // Default fallback
}
};
export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
onComplete,
isFirstStep
}) => {
const { t } = useTranslation();
const wizardContext = useWizardContext();
const tenantId = wizardContext.state.tenantId;
// Check if user is enterprise tier for conditional labels
const subscriptionTier = localStorage.getItem('subscription_tier');
const isEnterprise = subscriptionTier === 'enterprise';
// Get business_model from wizard context's bakeryType
const businessModel = getBakeryBusinessModel(wizardContext.state.bakeryType);
const [formData, setFormData] = useState<BakeryRegistration>({
name: '',
address: '',
postal_code: '',
phone: '',
city: 'Madrid',
business_type: 'bakery',
business_model: businessModel
});
// Fetch existing tenant data if we have a tenantId (persistence)
const { data: existingTenant, isLoading: isLoadingTenant } = useTenant(tenantId || '');
// Update formData when existing tenant data is fetched
useEffect(() => {
if (existingTenant) {
console.log('🔄 Populating RegisterTenantStep with existing data:', existingTenant);
setFormData({
name: existingTenant.name,
address: existingTenant.address,
postal_code: existingTenant.postal_code,
phone: existingTenant.phone || '',
city: existingTenant.city,
business_type: existingTenant.business_type,
business_model: existingTenant.business_model || businessModel
});
// Update location in context if available from tenant
// Note: Backend might not store lat/lon directly in Tenant table in all versions,
// but if we had them or if we want to re-trigger geocoding, we'd handle it here.
}
}, [existingTenant, businessModel]);
// Update business_model when bakeryType changes in context
useEffect(() => {
const newBusinessModel = getBakeryBusinessModel(wizardContext.state.bakeryType);
if (newBusinessModel !== formData.business_model) {
setFormData(prev => ({
...prev,
business_model: newBusinessModel
}));
}
}, [wizardContext.state.bakeryType, formData.business_model]);
const [errors, setErrors] = useState<Record<string, string>>({});
const registerBakery = useRegisterBakery();
const updateTenant = useUpdateTenant();
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ''
}));
}
};
const handleAddressSelect = (address: AddressResult) => {
setFormData(prev => ({
...prev,
address: address.display_name,
city: address.address.city || address.address.municipality || address.address.suburb || prev.city,
postal_code: address.address.postcode || prev.postal_code,
}));
};
const handleCoordinatesChange = (lat: number, lon: number) => {
// Store coordinates in the wizard context immediately
// This allows the POI detection step to access location information when it's available
wizardContext.updateLocation({ latitude: lat, longitude: lon });
console.log('Coordinates captured and stored:', { latitude: lat, longitude: lon });
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
// Required fields according to backend BakeryRegistration schema
if (!formData.name.trim()) {
newErrors.name = t('onboarding:steps.tenant_registration.validation.name_required');
} else if (formData.name.length < 2 || formData.name.length > 200) {
newErrors.name = t('onboarding:steps.tenant_registration.validation.name_length');
}
if (!formData.address.trim()) {
newErrors.address = t('onboarding:steps.tenant_registration.validation.address_required');
} else if (formData.address.length < 10 || formData.address.length > 500) {
newErrors.address = t('onboarding:steps.tenant_registration.validation.address_length');
}
if (!formData.postal_code.trim()) {
newErrors.postal_code = t('onboarding:steps.tenant_registration.validation.postal_code_required');
} else if (!/^\d{5}$/.test(formData.postal_code)) {
newErrors.postal_code = t('onboarding:steps.tenant_registration.validation.postal_code_format');
}
if (!formData.phone.trim()) {
newErrors.phone = t('onboarding:steps.tenant_registration.validation.phone_required');
} else if (formData.phone.length < 9 || formData.phone.length > 20) {
newErrors.phone = t('onboarding:steps.tenant_registration.validation.phone_length');
} else {
// Basic Spanish phone validation
const phone = formData.phone.replace(/[\s\-\(\)]/g, '');
const patterns = [
/^(\+34|0034|34)?[6789]\d{8}$/, // Mobile
/^(\+34|0034|34)?9\d{8}$/ // Landline
];
if (!patterns.some(pattern => pattern.test(phone))) {
newErrors.phone = t('onboarding:steps.tenant_registration.validation.phone_format');
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
console.log('📝 Submitting tenant data:', {
isUpdate: !!tenantId,
bakeryType: wizardContext.state.bakeryType,
business_model: formData.business_model,
formData
});
try {
let tenant;
if (tenantId) {
// Update existing tenant
const updateData: TenantUpdate = {
name: formData.name,
address: formData.address,
phone: formData.phone,
business_type: formData.business_type,
business_model: formData.business_model
};
tenant = await updateTenant.mutateAsync({ tenantId, updateData });
console.log('✅ Tenant updated successfully:', tenant.id);
} else {
// Create new tenant
tenant = await registerBakery.mutateAsync(formData);
console.log('✅ Tenant registered successfully:', tenant.id);
}
// Trigger POI detection in the background (non-blocking)
// This replaces the removed POI Detection step
// POI detection will be cached for 90 days and reused during training
const bakeryLocation = wizardContext.state.bakeryLocation;
if (bakeryLocation?.latitude && bakeryLocation?.longitude && tenant.id) {
console.log(`🔍 Triggering background POI detection for tenant ${tenant.id}...`);
// Run POI detection asynchronously without blocking the wizard flow
// This ensures POI data is ready before the training step
poiContextApi.detectPOIs(
tenant.id,
bakeryLocation.latitude,
bakeryLocation.longitude,
false // force_refresh = false, will use cache if available
).then((result) => {
const source = result.source || 'unknown';
console.log(`✅ POI detection completed for tenant ${tenant.id} (source: ${source})`);
if (result.poi_context) {
const totalPois = result.poi_context.total_pois_detected || 0;
const relevantCategories = result.poi_context.relevant_categories?.length || 0;
console.log(`📍 POI Summary: ${totalPois} POIs detected, ${relevantCategories} relevant categories`);
}
// Phase 3: Handle calendar suggestion if available
if (result.calendar_suggestion) {
const suggestion = result.calendar_suggestion;
console.log(`📊 Calendar suggestion available:`, {
calendar: suggestion.calendar_name,
confidence: `${suggestion.confidence_percentage}%`,
should_auto_assign: suggestion.should_auto_assign
});
// Store suggestion in wizard context for later use
// Frontend can show this in settings or a notification later
if (suggestion.confidence_percentage >= 75) {
console.log(`✅ High confidence suggestion: ${suggestion.calendar_name} (${suggestion.confidence_percentage}%)`);
// TODO: Show notification to admin about high-confidence suggestion
} else {
console.log(`📋 Lower confidence suggestion: ${suggestion.calendar_name} (${suggestion.confidence_percentage}%)`);
// TODO: Store for later review in settings
}
}
}).catch((error) => {
console.warn('⚠️ Background POI detection failed (non-blocking):', error);
console.warn('Training will continue without POI features if detection is not complete.');
// This is non-critical - training service will continue without POI features
});
} else {
console.warn('⚠️ Cannot trigger POI detection: missing location data or tenant ID');
}
// Update the wizard context with tenant info
onComplete({
tenant,
tenantId: tenant.id,
bakeryLocation: wizardContext.state.bakeryLocation
});
} catch (error) {
console.error('Error registering bakery:', error);
setErrors({ submit: t('onboarding:steps.tenant_registration.errors.register') });
}
};
return (
<div className="space-y-6 md:space-y-8">
{/* Informational header */}
<div className="bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 border-l-4 border-[var(--color-primary)] rounded-lg p-4 md:p-5">
<div className="flex items-start gap-3">
<div className="text-2xl flex-shrink-0">{isEnterprise ? '🏭' : '🏪'}</div>
<div>
<h3 className="font-semibold text-[var(--text-primary)] mb-1">
{t(isEnterprise
? 'onboarding:steps.tenant_registration.header.title_enterprise'
: 'onboarding:steps.tenant_registration.header.title'
)}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t(isEnterprise
? 'onboarding:steps.tenant_registration.header.description_enterprise'
: 'onboarding:steps.tenant_registration.header.description'
)}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 md:gap-6">
<div className="transform transition-all duration-200 hover:scale-[1.01]">
<Input
label={t(isEnterprise
? 'onboarding:steps.tenant_registration.fields.business_name_enterprise'
: 'onboarding:steps.tenant_registration.fields.business_name'
)}
placeholder={t(isEnterprise
? 'onboarding:steps.tenant_registration.placeholders.business_name_enterprise'
: 'onboarding:steps.tenant_registration.placeholders.business_name'
)}
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
isRequired
/>
</div>
<div className="transform transition-all duration-200 hover:scale-[1.01]">
<Input
label={t('onboarding:steps.tenant_registration.fields.phone')}
type="tel"
placeholder={t('onboarding:steps.tenant_registration.placeholders.phone')}
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
error={errors.phone}
isRequired
/>
</div>
<div className="md:col-span-2 transform transition-all duration-200 hover:scale-[1.01] relative z-20">
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<span className="text-lg">📍</span>
{t('onboarding:steps.tenant_registration.fields.address')} <span className="text-red-500">*</span>
</label>
<AddressAutocomplete
value={formData.address}
placeholder={t(isEnterprise
? 'onboarding:steps.tenant_registration.placeholders.address_enterprise'
: 'onboarding:steps.tenant_registration.placeholders.address'
)}
onAddressSelect={(address) => {
console.log('Selected:', address.display_name);
handleAddressSelect(address);
}}
onCoordinatesChange={handleCoordinatesChange}
countryCode="es"
required
/>
{errors.address && (
<div className="mt-2 text-sm text-red-600 flex items-center gap-1.5 animate-shake">
<span></span>
{errors.address}
</div>
)}
</div>
<div className="transform transition-all duration-200 hover:scale-[1.01]">
<Input
label={t('onboarding:steps.tenant_registration.fields.postal_code')}
placeholder={t('onboarding:steps.tenant_registration.placeholders.postal_code')}
value={formData.postal_code}
onChange={(e) => handleInputChange('postal_code', e.target.value)}
error={errors.postal_code}
isRequired
maxLength={5}
/>
</div>
<div className="transform transition-all duration-200 hover:scale-[1.01]">
<Input
label={t('onboarding:steps.tenant_registration.fields.city')}
placeholder={t('onboarding:steps.tenant_registration.placeholders.city')}
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
error={errors.city}
/>
</div>
</div>
{errors.submit && (
<div className="bg-gradient-to-r from-[var(--color-error)]/10 to-[var(--color-error)]/5 border-l-4 border-[var(--color-error)] rounded-lg p-4 animate-shake">
<div className="flex items-start gap-3">
<span className="text-2xl flex-shrink-0"></span>
<div>
<h4 className="font-semibold text-[var(--color-error)] mb-1">
{t('onboarding:steps.tenant_registration.errors.register_title')}
</h4>
<p className="text-sm text-[var(--text-secondary)]">{errors.submit}</p>
</div>
</div>
</div>
)}
<div className="flex justify-end pt-4 border-t-2 border-[var(--border-color)]/50">
<Button
onClick={handleSubmit}
isLoading={registerBakery.isPending || updateTenant.isPending}
loadingText={t(tenantId
? 'onboarding:steps.tenant_registration.loading.updating'
: (isEnterprise
? 'onboarding:steps.tenant_registration.loading.creating_enterprise'
: 'onboarding:steps.tenant_registration.loading.creating'
)
)}
size="lg"
className="w-full sm:w-auto sm:min-w-[280px] text-base font-semibold transform transition-all duration-300 hover:scale-105 shadow-lg"
>
{t(tenantId
? (isEnterprise
? 'onboarding:steps.tenant_registration.buttons.update_enterprise'
: 'onboarding:steps.tenant_registration.buttons.update'
)
: (isEnterprise
? 'onboarding:steps.tenant_registration.buttons.create_enterprise'
: 'onboarding:steps.tenant_registration.buttons.create'
)
)}
</Button>
</div>
</div>
);
};