Add traslations
This commit is contained in:
@@ -86,8 +86,7 @@ export class TenantService {
|
|||||||
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/${parentTenantId}/children`);
|
return apiClient.get<TenantResponse[]>(`${this.baseUrl}/${parentTenantId}/children`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkCreateChildTenants(request: {
|
async bulkCreateChildTenants(parentTenantId: string, request: {
|
||||||
parent_tenant_id: string;
|
|
||||||
child_tenants: Array<{
|
child_tenants: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
city: string;
|
city: string;
|
||||||
@@ -99,6 +98,10 @@ export class TenantService {
|
|||||||
longitude?: number;
|
longitude?: number;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
business_type?: string;
|
||||||
|
business_model?: string;
|
||||||
|
timezone?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
}>;
|
}>;
|
||||||
auto_configure_distribution?: boolean;
|
auto_configure_distribution?: boolean;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -109,7 +112,7 @@ export class TenantService {
|
|||||||
failed_tenants: Array<{ name: string; location_code: string; error: string }>;
|
failed_tenants: Array<{ name: string; location_code: string; error: string }>;
|
||||||
distribution_configured: boolean;
|
distribution_configured: boolean;
|
||||||
}> {
|
}> {
|
||||||
return apiClient.post(`${this.baseUrl}/bulk-children`, request);
|
return apiClient.post(`${this.baseUrl}/${parentTenantId}/bulk-children`, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.acceptTerms) {
|
if (!formData.acceptTerms) {
|
||||||
newErrors.acceptTerms = t('auth:validation.terms_required', 'Debes aceptar los términos y condiciones');
|
newErrors.acceptTerms = t('auth:validation.terms_required', 'Debes aceptar los términos y condiciones y la política de privacidad');
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@@ -235,7 +235,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
|
|
||||||
const handlePaymentError = (errorMessage: string) => {
|
const handlePaymentError = (errorMessage: string) => {
|
||||||
showToast.error(errorMessage, {
|
showToast.error(errorMessage, {
|
||||||
title: 'Error en el pago'
|
title: t('auth:alerts.payment_error', 'Error en el pago')
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,13 +252,13 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
// Show 2 steps if plan is pre-selected, 3 steps otherwise
|
// Show 2 steps if plan is pre-selected, 3 steps otherwise
|
||||||
const steps = preSelectedPlan
|
const steps = preSelectedPlan
|
||||||
? [
|
? [
|
||||||
{ key: 'basic_info', label: 'Información', number: 1, time: '2 min' },
|
{ key: 'basic_info', label: t('auth:steps.info', 'Información'), number: 1, time: '2 min' },
|
||||||
{ key: 'payment', label: 'Pago', number: 2, time: '2 min' }
|
{ key: 'payment', label: t('auth:steps.payment', 'Pago'), number: 2, time: '2 min' }
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: 'basic_info', label: 'Información', number: 1, time: '2 min' },
|
{ key: 'basic_info', label: t('auth:steps.info', 'Información'), number: 1, time: '2 min' },
|
||||||
{ key: 'subscription', label: 'Plan', number: 2, time: '1 min' },
|
{ key: 'subscription', label: t('auth:steps.subscription', 'Plan'), number: 2, time: '1 min' },
|
||||||
{ key: 'payment', label: 'Pago', number: 3, time: '2 min' }
|
{ key: 'payment', label: t('auth:steps.payment', 'Pago'), number: 3, time: '2 min' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStepIndex = (step: RegistrationStep) => {
|
const getStepIndex = (step: RegistrationStep) => {
|
||||||
@@ -499,14 +499,8 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="acceptTerms" className="text-sm text-text-secondary cursor-pointer">
|
<label htmlFor="acceptTerms" className="text-sm text-text-secondary cursor-pointer">
|
||||||
Acepto los{' '}
|
{t('auth:register.accept_terms_and_privacy', 'Acepto los términos y condiciones y la política de privacidad')}
|
||||||
<a href="/terms" target="_blank" rel="noopener noreferrer" className="text-color-primary hover:text-color-primary-dark underline">
|
{' '}
|
||||||
términos y condiciones
|
|
||||||
</a>{' '}
|
|
||||||
y la{' '}
|
|
||||||
<a href="/privacy" target="_blank" rel="noopener noreferrer" className="text-color-primary hover:text-color-primary-dark underline">
|
|
||||||
política de privacidad
|
|
||||||
</a>{' '}
|
|
||||||
<span className="text-color-error">*</span>
|
<span className="text-color-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -524,7 +518,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="marketingConsent" className="text-sm text-text-secondary cursor-pointer">
|
<label htmlFor="marketingConsent" className="text-sm text-text-secondary cursor-pointer">
|
||||||
Deseo recibir comunicaciones de marketing y promociones
|
{t('auth:register.marketing_consent', 'Deseo recibir comunicaciones de marketing y promociones')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -538,7 +532,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="analyticsConsent" className="text-sm text-text-secondary cursor-pointer">
|
<label htmlFor="analyticsConsent" className="text-sm text-text-secondary cursor-pointer">
|
||||||
Acepto el uso de cookies analíticas para mejorar la experiencia
|
{t('auth:register.analytics_consent', 'Acepto el uso de cookies analíticas para mejorar la experiencia')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +545,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
|
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
|
||||||
className="w-full sm:w-48"
|
className="w-full sm:w-48"
|
||||||
>
|
>
|
||||||
Siguiente
|
{t('auth:register.next_button', 'Siguiente')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -587,7 +581,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full sm:w-48 order-2 sm:order-1"
|
className="w-full sm:w-48 order-2 sm:order-1"
|
||||||
>
|
>
|
||||||
Anterior
|
{t('auth:register.previous_button', 'Anterior')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -596,7 +590,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full sm:w-48 order-1 sm:order-2"
|
className="w-full sm:w-48 order-1 sm:order-2"
|
||||||
>
|
>
|
||||||
Siguiente
|
{t('auth:register.next_button', 'Siguiente')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,36 +613,36 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-2 border-blue-200 dark:border-blue-800">
|
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-2 border-blue-200 dark:border-blue-800">
|
||||||
<h3 className="text-lg font-bold text-text-primary mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-bold text-text-primary mb-4 flex items-center gap-2">
|
||||||
<CheckCircle className="w-5 h-5 text-color-primary" />
|
<CheckCircle className="w-5 h-5 text-color-primary" />
|
||||||
Resumen de tu Plan
|
{t('auth:payment.payment_summary', 'Resumen de tu Plan')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-text-secondary">Plan seleccionado:</span>
|
<span className="text-text-secondary">{t('auth:payment.selected_plan', 'Plan seleccionado:')}</span>
|
||||||
<span className="font-bold text-color-primary text-lg">{selectedPlanMetadata.name}</span>
|
<span className="font-bold text-color-primary text-lg">{selectedPlanMetadata.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-text-secondary">Precio mensual:</span>
|
<span className="text-text-secondary">{t('auth:payment.monthly_price', 'Precio mensual:')}</span>
|
||||||
<span className="font-semibold text-text-primary">
|
<span className="font-semibold text-text-primary">
|
||||||
{subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)}/mes
|
{subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)}/mes
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{useTrial && (
|
{useTrial && (
|
||||||
<div className="flex justify-between items-center pt-3 border-t border-blue-200 dark:border-blue-800">
|
<div className="flex justify-between items-center pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||||
<span className="text-green-700 dark:text-green-400 font-medium">Período de prueba:</span>
|
<span className="text-green-700 dark:text-green-400 font-medium">{t('auth:payment.trial_period', 'Período de prueba:')}</span>
|
||||||
<span className="font-bold text-green-700 dark:text-green-400">
|
<span className="font-bold text-green-700 dark:text-green-400">
|
||||||
{isPilot ? `${trialMonths} meses GRATIS` : '14 días gratis'}
|
{isPilot ? t('auth:payment.free_months', {count: trialMonths}) : t('auth:payment.free_days', '14 días gratis')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="pt-3 border-t border-blue-200 dark:border-blue-800">
|
<div className="pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-text-tertiary">Total hoy:</span>
|
<span className="text-text-tertiary">{t('auth:payment.total_today', 'Total hoy:')}</span>
|
||||||
<span className="font-bold text-xl text-color-success">€0.00</span>
|
<span className="font-bold text-xl text-color-success">€0.00</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-tertiary mt-2 text-center">
|
<p className="text-xs text-text-tertiary mt-2 text-center">
|
||||||
{useTrial
|
{useTrial
|
||||||
? `Se te cobrará ${subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)} después del período de prueba`
|
? t('auth:payment.billing_message', {price: subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)})
|
||||||
: 'Tarjeta requerida para validación'
|
: t('auth:payment.payment_required', 'Tarjeta requerida para validación')
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -675,7 +669,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-full sm:w-48"
|
className="w-full sm:w-48"
|
||||||
>
|
>
|
||||||
Anterior
|
{t('auth:register.previous_button', 'Anterior')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -701,7 +695,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
{onLoginClick && currentStep === 'basic_info' && (
|
{onLoginClick && currentStep === 'basic_info' && (
|
||||||
<div className="mt-8 text-center border-t border-border-primary pt-6">
|
<div className="mt-8 text-center border-t border-border-primary pt-6">
|
||||||
<p className="text-text-secondary mb-4">
|
<p className="text-text-secondary mb-4">
|
||||||
¿Ya tienes una cuenta?
|
{t('auth:register.have_account', '¿Ya tienes una cuenta?')}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -709,7 +703,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="text-color-primary hover:text-color-primary-dark"
|
className="text-color-primary hover:text-color-primary-dark"
|
||||||
>
|
>
|
||||||
Iniciar Sesión
|
{t('auth:register.sign_in_link', 'Iniciar Sesión')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -422,8 +422,7 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
throw new Error('Parent tenant not registered');
|
throw new Error('Parent tenant not registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await tenantService.bulkCreateChildTenants({
|
const response = await tenantService.bulkCreateChildTenants(parentTenantId, {
|
||||||
parent_tenant_id: parentTenantId,
|
|
||||||
child_tenants: data.childTenants.map((ct: any) => ({
|
child_tenants: data.childTenants.map((ct: any) => ({
|
||||||
name: ct.name,
|
name: ct.name,
|
||||||
city: ct.city,
|
city: ct.city,
|
||||||
@@ -435,6 +434,10 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
longitude: ct.longitude,
|
longitude: ct.longitude,
|
||||||
phone: ct.phone,
|
phone: ct.phone,
|
||||||
email: ct.email,
|
email: ct.email,
|
||||||
|
business_type: ct.business_type,
|
||||||
|
business_model: ct.business_model,
|
||||||
|
timezone: ct.timezone,
|
||||||
|
metadata: ct.metadata,
|
||||||
})),
|
})),
|
||||||
auto_configure_distribution: true,
|
auto_configure_distribution: true,
|
||||||
});
|
});
|
||||||
@@ -446,6 +449,11 @@ const OnboardingWizardContent: React.FC = () => {
|
|||||||
|
|
||||||
if (response.failed_count > 0) {
|
if (response.failed_count > 0) {
|
||||||
console.warn('⚠️ Some child tenants failed to create:', response.failed_tenants);
|
console.warn('⚠️ Some child tenants failed to create:', response.failed_tenants);
|
||||||
|
|
||||||
|
// Show specific errors for each failed tenant
|
||||||
|
response.failed_tenants.forEach(failed => {
|
||||||
|
console.error(`Failed to create tenant ${failed.name} (${failed.location_code}):`, failed.error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (childTenantError) {
|
} catch (childTenantError) {
|
||||||
console.error('❌ Failed to create child tenants:', childTenantError);
|
console.error('❌ Failed to create child tenants:', childTenantError);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Plus, Store, MapPin, Trash2, Edit2, Building2, X } from 'lucide-react';
|
import { Plus, Store, MapPin, Trash2, Edit2, Building2, X, Phone, Mail, Clock, Tag, Globe } from 'lucide-react';
|
||||||
import Button from '../../../ui/Button/Button';
|
import Button from '../../../ui/Button/Button';
|
||||||
import Card from '../../../ui/Card/Card';
|
import Card from '../../../ui/Card/Card';
|
||||||
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../../../ui/Modal';
|
import { Modal, ModalHeader, ModalBody, ModalFooter } from '../../../ui/Modal';
|
||||||
import { Input } from '../../../ui/Input';
|
import { Input } from '../../../ui/Input';
|
||||||
|
import { Select } from '../../../ui/Select';
|
||||||
|
import { AddressAutocomplete } from '../../../ui/AddressAutocomplete';
|
||||||
|
|
||||||
export interface ChildTenantSetupStepProps {
|
export interface ChildTenantSetupStepProps {
|
||||||
onUpdate?: (data: { childTenants: ChildTenant[]; canContinue: boolean }) => void;
|
onUpdate?: (data: { childTenants: ChildTenant[]; canContinue: boolean }) => void;
|
||||||
@@ -22,6 +24,14 @@ export interface ChildTenant {
|
|||||||
address: string;
|
address: string;
|
||||||
postal_code: string;
|
postal_code: string;
|
||||||
location_code: string;
|
location_code: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
business_type?: string;
|
||||||
|
business_model?: string;
|
||||||
|
timezone?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
||||||
@@ -42,6 +52,14 @@ export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
|||||||
address: '',
|
address: '',
|
||||||
postal_code: '',
|
postal_code: '',
|
||||||
location_code: '',
|
location_code: '',
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
business_type: 'bakery',
|
||||||
|
business_model: 'retail_bakery',
|
||||||
|
timezone: 'Europe/Madrid',
|
||||||
|
metadata: {},
|
||||||
});
|
});
|
||||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -67,11 +85,35 @@ export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
|||||||
}
|
}
|
||||||
if (!formData.postal_code?.trim()) {
|
if (!formData.postal_code?.trim()) {
|
||||||
errors.postal_code = 'El código postal es requerido';
|
errors.postal_code = 'El código postal es requerido';
|
||||||
|
} else if (!/^\d{5}$/.test(formData.postal_code)) {
|
||||||
|
errors.postal_code = 'El código postal debe tener exactamente 5 dígitos';
|
||||||
}
|
}
|
||||||
if (!formData.location_code?.trim()) {
|
if (!formData.location_code?.trim()) {
|
||||||
errors.location_code = 'El código de ubicación es requerido';
|
errors.location_code = 'El código de ubicación es requerido';
|
||||||
} else if (formData.location_code.length > 10) {
|
} else if (formData.location_code.length > 10) {
|
||||||
errors.location_code = 'El código no debe exceder 10 caracteres';
|
errors.location_code = 'El código no debe exceder 10 caracteres';
|
||||||
|
} else if (!/^[A-Z0-9\-_.]+$/.test(formData.location_code)) {
|
||||||
|
errors.location_code = 'Solo se permiten letras mayúsculas, números y guiones/guiones bajos';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone validation
|
||||||
|
if (formData.phone && formData.phone.trim()) {
|
||||||
|
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))) {
|
||||||
|
errors.phone = 'Introduce un número de teléfono español válido';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email validation
|
||||||
|
if (formData.email && formData.email.trim()) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(formData.email)) {
|
||||||
|
errors.email = 'Introduce un correo electrónico válido';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormErrors(errors);
|
setFormErrors(errors);
|
||||||
@@ -122,6 +164,14 @@ export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
|||||||
address: formData.address!,
|
address: formData.address!,
|
||||||
postal_code: formData.postal_code!,
|
postal_code: formData.postal_code!,
|
||||||
location_code: formData.location_code!.toUpperCase(),
|
location_code: formData.location_code!.toUpperCase(),
|
||||||
|
latitude: formData.latitude,
|
||||||
|
longitude: formData.longitude,
|
||||||
|
phone: formData.phone || undefined,
|
||||||
|
email: formData.email || undefined,
|
||||||
|
business_type: formData.business_type || 'bakery',
|
||||||
|
business_model: formData.business_model || 'retail_bakery',
|
||||||
|
timezone: formData.timezone || 'Europe/Madrid',
|
||||||
|
metadata: formData.metadata || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingTenant) {
|
if (editingTenant) {
|
||||||
@@ -236,9 +286,16 @@ export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
|||||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||||
{tenant.name}
|
{tenant.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
<span className="text-xs text-[var(--text-tertiary)] font-mono">
|
<span className="text-xs text-[var(--text-tertiary)] font-mono">
|
||||||
{tenant.location_code}
|
{tenant.location_code}
|
||||||
</span>
|
</span>
|
||||||
|
{tenant.zone && (
|
||||||
|
<span className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
• {tenant.zone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -336,7 +393,142 @@ export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* City and Zone */}
|
{/* Business Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
Tipo de Negocio
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={formData.business_type || 'bakery'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, business_type: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="bakery">Panadería</option>
|
||||||
|
<option value="coffee_shop">Cafetería</option>
|
||||||
|
<option value="pastry_shop">Pastelería</option>
|
||||||
|
<option value="restaurant">Restaurante</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Business Model */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
Modelo de Negocio
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={formData.business_model || 'retail_bakery'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, business_model: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="retail_bakery">Panadería Minorista</option>
|
||||||
|
<option value="central_baker_satellite">Obrador Central + Sucursales</option>
|
||||||
|
<option value="hybrid_bakery">Modelo Híbrido</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
Teléfono
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-secondary)]" />
|
||||||
|
<Input
|
||||||
|
value={formData.phone || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
placeholder="ej. +34 123 456 789"
|
||||||
|
error={formErrors.phone}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-secondary)]" />
|
||||||
|
<Input
|
||||||
|
value={formData.email || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder="ej. contacto@panaderia.com"
|
||||||
|
error={formErrors.email}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timezone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
Zona Horaria
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Clock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--text-secondary)]" />
|
||||||
|
<Select
|
||||||
|
value={formData.timezone || 'Europe/Madrid'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, timezone: e.target.value })}
|
||||||
|
className="pl-10"
|
||||||
|
>
|
||||||
|
<option value="Europe/Madrid">Europe/Madrid (UTC+1/UTC+2)</option>
|
||||||
|
<option value="Europe/Paris">Europe/Paris (UTC+1/UTC+2)</option>
|
||||||
|
<option value="Europe/London">Europe/London (UTC+0/UTC+1)</option>
|
||||||
|
<option value="America/New_York">America/New_York (UTC-5/UTC-4)</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
Zona / Barrio (opcional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.zone || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, zone: e.target.value })}
|
||||||
|
placeholder="ej. Salamanca, Chamberí, Centro"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Zona o barrio específico dentro de la ciudad
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address with Autocomplete */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
|
Dirección *
|
||||||
|
</label>
|
||||||
|
<AddressAutocomplete
|
||||||
|
value={formData.address || ''}
|
||||||
|
placeholder="ej. Calle de Serrano, 48"
|
||||||
|
onAddressSelect={(address) => {
|
||||||
|
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,
|
||||||
|
latitude: address.lat,
|
||||||
|
longitude: address.lon,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onCoordinatesChange={(lat, lon) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
countryCode="es"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{formErrors.address && (
|
||||||
|
<div className="mt-1 text-sm text-[var(--color-error)]">
|
||||||
|
{formErrors.address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City and Postal Code */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
@@ -349,32 +541,6 @@ export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
|||||||
error={formErrors.city}
|
error={formErrors.city}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
||||||
Zona / Barrio
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={formData.zone || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, zone: e.target.value })}
|
|
||||||
placeholder="ej. Salamanca"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
||||||
Dirección *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={formData.address || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
|
||||||
placeholder="ej. Calle de Serrano, 48"
|
|
||||||
error={formErrors.address}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Postal Code */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||||
Código Postal *
|
Código Postal *
|
||||||
@@ -384,9 +550,11 @@ export const ChildTenantsSetupStep: React.FC<ChildTenantSetupStepProps> = ({
|
|||||||
onChange={(e) => setFormData({ ...formData, postal_code: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, postal_code: e.target.value })}
|
||||||
placeholder="ej. 28001"
|
placeholder="ej. 28001"
|
||||||
error={formErrors.postal_code}
|
error={formErrors.postal_code}
|
||||||
|
maxLength={5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter justify="end">
|
<ModalFooter justify="end">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -35,7 +35,9 @@
|
|||||||
"passwords_dont_match": "Passwords don't match",
|
"passwords_dont_match": "Passwords don't match",
|
||||||
"accept_terms": "I accept the terms and conditions",
|
"accept_terms": "I accept the terms and conditions",
|
||||||
"accept_privacy": "I accept the privacy policy",
|
"accept_privacy": "I accept the privacy policy",
|
||||||
|
"accept_terms_and_privacy": "I accept the terms and conditions and privacy policy",
|
||||||
"marketing_consent": "I want to receive newsletters and updates (optional)",
|
"marketing_consent": "I want to receive newsletters and updates (optional)",
|
||||||
|
"analytics_consent": "I agree to the use of analytical cookies to improve the experience",
|
||||||
"register_button": "Create account",
|
"register_button": "Create account",
|
||||||
"registering": "Creating account...",
|
"registering": "Creating account...",
|
||||||
"have_account": "Already have an account?",
|
"have_account": "Already have an account?",
|
||||||
@@ -44,7 +46,48 @@
|
|||||||
"privacy_link": "Privacy Policy",
|
"privacy_link": "Privacy Policy",
|
||||||
"step_of": "Step {current} of {total}",
|
"step_of": "Step {current} of {total}",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"back": "Back"
|
"back": "Back",
|
||||||
|
"next_button": "Next",
|
||||||
|
"previous_button": "Previous",
|
||||||
|
"have_account": "Already have an account?",
|
||||||
|
"sign_in_link": "Sign in"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"info": "Information",
|
||||||
|
"subscription": "Plan",
|
||||||
|
"payment": "Payment"
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"select_plan": "Select your plan",
|
||||||
|
"choose_plan": "Choose the plan that best fits your business"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"payment_summary": "Your Plan Summary",
|
||||||
|
"selected_plan": "Selected plan:",
|
||||||
|
"monthly_price": "Monthly price:",
|
||||||
|
"trial_period": "Trial period:",
|
||||||
|
"total_today": "Total today:",
|
||||||
|
"payment_required": "Payment required for validation",
|
||||||
|
"billing_message": "You will be charged {price} after the trial period",
|
||||||
|
"free_months": "{count} months FREE",
|
||||||
|
"free_days": "14 days free",
|
||||||
|
"payment_info": "Payment information",
|
||||||
|
"secure_payment": "Your payment information is protected with end-to-end encryption",
|
||||||
|
"dev_mode": "Development Mode",
|
||||||
|
"cardholder_name": "Cardholder name",
|
||||||
|
"email": "Email address",
|
||||||
|
"address_line1": "Address",
|
||||||
|
"city": "City",
|
||||||
|
"state": "State/Province",
|
||||||
|
"postal_code": "Postal Code",
|
||||||
|
"country": "Country",
|
||||||
|
"card_details": "Card details",
|
||||||
|
"card_info_secure": "Your card information is secure"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success_create": "Account created successfully",
|
||||||
|
"error_create": "Error creating account",
|
||||||
|
"payment_error": "Payment error"
|
||||||
},
|
},
|
||||||
"forgot_password": {
|
"forgot_password": {
|
||||||
"title": "Reset password",
|
"title": "Reset password",
|
||||||
@@ -124,14 +167,14 @@
|
|||||||
"email_required": "Email address is required",
|
"email_required": "Email address is required",
|
||||||
"email_invalid": "Please enter a valid email address",
|
"email_invalid": "Please enter a valid email address",
|
||||||
"password_required": "Password is required",
|
"password_required": "Password is required",
|
||||||
"password_min_length": "Password must be at least {{min}} characters",
|
"password_min_length": "Password must be at least {min} characters",
|
||||||
"password_weak": "Password is too weak",
|
"password_weak": "Password is too weak",
|
||||||
"passwords_must_match": "Passwords must match",
|
"passwords_must_match": "Passwords must match",
|
||||||
"first_name_required": "First name is required",
|
"first_name_required": "First name is required",
|
||||||
"last_name_required": "Last name is required",
|
"last_name_required": "Last name is required",
|
||||||
"phone_required": "Phone is required",
|
"phone_required": "Phone is required",
|
||||||
"company_name_required": "Company name is required",
|
"company_name_required": "Company name is required",
|
||||||
"terms_required": "You must accept the terms and conditions",
|
"terms_required": "You must accept the terms and conditions and privacy policy",
|
||||||
"field_required": "This field is required",
|
"field_required": "This field is required",
|
||||||
"invalid_format": "Invalid format"
|
"invalid_format": "Invalid format"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -441,5 +441,31 @@
|
|||||||
"features": "Features",
|
"features": "Features",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"contact": "Contact"
|
"contact": "Contact"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Saved successfully",
|
||||||
|
"created": "Created successfully",
|
||||||
|
"updated": "Updated successfully",
|
||||||
|
"deleted": "Deleted successfully",
|
||||||
|
"sent": "Sent successfully",
|
||||||
|
"imported": "Imported successfully",
|
||||||
|
"exported": "Exported successfully",
|
||||||
|
"logged_in": "Logged in successfully",
|
||||||
|
"logged_out": "Logged out successfully"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"required_field": "This field is required",
|
||||||
|
"invalid_email": "Invalid email address",
|
||||||
|
"invalid_phone": "Invalid phone number",
|
||||||
|
"weak_password": "Password must be stronger",
|
||||||
|
"passwords_not_match": "Passwords do not match",
|
||||||
|
"network_error": "Network error",
|
||||||
|
"server_error": "Server error",
|
||||||
|
"unauthorized": "Unauthorized",
|
||||||
|
"forbidden": "Access denied",
|
||||||
|
"not_found": "Not found",
|
||||||
|
"validation_error": "Validation error",
|
||||||
|
"file_too_large": "File too large",
|
||||||
|
"invalid_file_type": "Invalid file type"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,9 @@
|
|||||||
"passwords_dont_match": "Las contraseñas no coinciden",
|
"passwords_dont_match": "Las contraseñas no coinciden",
|
||||||
"accept_terms": "Acepto los términos y condiciones",
|
"accept_terms": "Acepto los términos y condiciones",
|
||||||
"accept_privacy": "Acepto la política de privacidad",
|
"accept_privacy": "Acepto la política de privacidad",
|
||||||
|
"accept_terms_and_privacy": "Acepto los términos y condiciones y la política de privacidad",
|
||||||
"marketing_consent": "Quiero recibir newsletters y novedades (opcional)",
|
"marketing_consent": "Quiero recibir newsletters y novedades (opcional)",
|
||||||
|
"analytics_consent": "Acepto el uso de cookies analíticas para mejorar la experiencia",
|
||||||
"register_button": "Crear cuenta",
|
"register_button": "Crear cuenta",
|
||||||
"registering": "Creando cuenta...",
|
"registering": "Creando cuenta...",
|
||||||
"have_account": "¿Ya tienes cuenta?",
|
"have_account": "¿Ya tienes cuenta?",
|
||||||
@@ -44,7 +46,44 @@
|
|||||||
"privacy_link": "Política de Privacidad",
|
"privacy_link": "Política de Privacidad",
|
||||||
"step_of": "Paso {current} de {total}",
|
"step_of": "Paso {current} de {total}",
|
||||||
"continue": "Continuar",
|
"continue": "Continuar",
|
||||||
"back": "Atrás"
|
"back": "Atrás",
|
||||||
|
"next_button": "Siguiente",
|
||||||
|
"previous_button": "Anterior",
|
||||||
|
"have_account": "¿Ya tienes una cuenta?",
|
||||||
|
"sign_in_link": "Iniciar sesión"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"info": "Información",
|
||||||
|
"subscription": "Plan",
|
||||||
|
"payment": "Pago"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"payment_summary": "Resumen de tu Plan",
|
||||||
|
"selected_plan": "Plan seleccionado:",
|
||||||
|
"monthly_price": "Precio mensual:",
|
||||||
|
"trial_period": "Período de prueba:",
|
||||||
|
"total_today": "Total hoy:",
|
||||||
|
"payment_required": "Tarjeta requerida para validación",
|
||||||
|
"billing_message": "Se te cobrará {{price}} después del período de prueba",
|
||||||
|
"free_months": "{{count}} meses GRATIS",
|
||||||
|
"free_days": "14 días gratis",
|
||||||
|
"payment_info": "Información de Pago",
|
||||||
|
"secure_payment": "Tu información de pago está protegida con encriptación de extremo a extremo",
|
||||||
|
"dev_mode": "Modo Desarrollo",
|
||||||
|
"cardholder_name": "Nombre del titular",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"address_line1": "Dirección",
|
||||||
|
"city": "Ciudad",
|
||||||
|
"state": "Estado/Provincia",
|
||||||
|
"postal_code": "Código Postal",
|
||||||
|
"country": "País",
|
||||||
|
"card_details": "Detalles de la tarjeta",
|
||||||
|
"card_info_secure": "Tu información de tarjeta está segura"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success_create": "Cuenta creada exitosamente",
|
||||||
|
"error_create": "Error al crear la cuenta",
|
||||||
|
"payment_error": "Error en el pago"
|
||||||
},
|
},
|
||||||
"forgot_password": {
|
"forgot_password": {
|
||||||
"title": "Recuperar contraseña",
|
"title": "Recuperar contraseña",
|
||||||
@@ -131,7 +170,7 @@
|
|||||||
"last_name_required": "El apellido es requerido",
|
"last_name_required": "El apellido es requerido",
|
||||||
"phone_required": "El teléfono es requerido",
|
"phone_required": "El teléfono es requerido",
|
||||||
"company_name_required": "El nombre de la empresa es requerido",
|
"company_name_required": "El nombre de la empresa es requerido",
|
||||||
"terms_required": "Debes aceptar los términos y condiciones",
|
"terms_required": "Debes aceptar los términos y condiciones y la política de privacidad",
|
||||||
"field_required": "Este campo es requerido",
|
"field_required": "Este campo es requerido",
|
||||||
"invalid_format": "Formato inválido"
|
"invalid_format": "Formato inválido"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -463,5 +463,31 @@
|
|||||||
"features": "Funcionalidades",
|
"features": "Funcionalidades",
|
||||||
"about": "Nosotros",
|
"about": "Nosotros",
|
||||||
"contact": "Contacto"
|
"contact": "Contacto"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Guardado correctamente",
|
||||||
|
"created": "Creado correctamente",
|
||||||
|
"updated": "Actualizado correctamente",
|
||||||
|
"deleted": "Eliminado correctamente",
|
||||||
|
"sent": "Enviado correctamente",
|
||||||
|
"imported": "Importado correctamente",
|
||||||
|
"exported": "Exportado correctamente",
|
||||||
|
"logged_in": "Sesión iniciada correctamente",
|
||||||
|
"logged_out": "Sesión cerrada correctamente"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"required_field": "Este campo es obligatorio",
|
||||||
|
"invalid_email": "Email no válido",
|
||||||
|
"invalid_phone": "Teléfono no válido",
|
||||||
|
"weak_password": "La contraseña debe ser más segura",
|
||||||
|
"passwords_not_match": "Las contraseñas no coinciden",
|
||||||
|
"network_error": "Error de red",
|
||||||
|
"server_error": "Error del servidor",
|
||||||
|
"unauthorized": "No autorizado",
|
||||||
|
"forbidden": "Acceso denegado",
|
||||||
|
"not_found": "No encontrado",
|
||||||
|
"validation_error": "Error de validación",
|
||||||
|
"file_too_large": "Archivo demasiado grande",
|
||||||
|
"invalid_file_type": "Tipo de archivo no válido"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,9 @@
|
|||||||
"passwords_dont_match": "Pasahitzak ez datoz bat",
|
"passwords_dont_match": "Pasahitzak ez datoz bat",
|
||||||
"accept_terms": "Baldintza eta baldintzak onartzen ditut",
|
"accept_terms": "Baldintza eta baldintzak onartzen ditut",
|
||||||
"accept_privacy": "Pribatutasun politika onartzen dut",
|
"accept_privacy": "Pribatutasun politika onartzen dut",
|
||||||
|
"accept_terms_and_privacy": "Baldintzak eta pribatutasun politika onartzen ditut",
|
||||||
"marketing_consent": "Newsletter eta berriak jaso nahi ditut (aukerakoa)",
|
"marketing_consent": "Newsletter eta berriak jaso nahi ditut (aukerakoa)",
|
||||||
|
"analytics_consent": "Ados nago analitikarako cookieak erabiltzearekin esperientzia hobetzeko",
|
||||||
"register_button": "Sortu kontua",
|
"register_button": "Sortu kontua",
|
||||||
"registering": "Kontua sortzen...",
|
"registering": "Kontua sortzen...",
|
||||||
"have_account": "Dagoeneko baduzu kontua?",
|
"have_account": "Dagoeneko baduzu kontua?",
|
||||||
@@ -44,7 +46,75 @@
|
|||||||
"privacy_link": "Pribatutasun politika",
|
"privacy_link": "Pribatutasun politika",
|
||||||
"step_of": "{current}. urratsa {total}-tik",
|
"step_of": "{current}. urratsa {total}-tik",
|
||||||
"continue": "Jarraitu",
|
"continue": "Jarraitu",
|
||||||
"back": "Atzera"
|
"back": "Atzera",
|
||||||
|
"next_button": "Hurrengoa",
|
||||||
|
"previous_button": "Aurrekoa"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"info": "Informazioa",
|
||||||
|
"subscription": "Harpidetza",
|
||||||
|
"payment": "Ordainketa"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"payment_summary": "Zure planaren laburpena",
|
||||||
|
"selected_plan": "Hautatutako plana:",
|
||||||
|
"monthly_price": "Hileroko prezioa:",
|
||||||
|
"trial_period": "Proba epea:",
|
||||||
|
"total_today": "Gaurko totala:",
|
||||||
|
"payment_required": "Ordainketa beharrezkoa balidaziorako",
|
||||||
|
"billing_message": "{{price}} kobratuko zaizu proba epea ondoren",
|
||||||
|
"free_months": "{{count}} hilabete DOAN",
|
||||||
|
"free_days": "14 egun doan",
|
||||||
|
"payment_info": "Ordainketaren informazioa",
|
||||||
|
"secure_payment": "Zure ordainketa informazioa babespetuta dago amaieratik amaierarako zifratzearekin",
|
||||||
|
"dev_mode": "Garapen Modua",
|
||||||
|
"cardholder_name": "Txartelaren titularra",
|
||||||
|
"email": "Helbide elektronikoa",
|
||||||
|
"address_line1": "Helbidea",
|
||||||
|
"city": "Hiria",
|
||||||
|
"state": "Estatua/Probintzia",
|
||||||
|
"postal_code": "Posta kodea",
|
||||||
|
"country": "Herrialdea",
|
||||||
|
"card_details": "Txartelaren xehetasunak",
|
||||||
|
"card_info_secure": "Zure txartelaren informazioa segurua da",
|
||||||
|
"process_payment": "Prozesatu Ordainketa",
|
||||||
|
"payment_bypassed_title": "Ordainketa Saltatua",
|
||||||
|
"payment_bypassed_description": "Ordainketa prozesua saltatu da garapen moduan. Erregistratzea modu normalean jarraituko du.",
|
||||||
|
"continue_registration": "Erregistratzearekin Jarraitu",
|
||||||
|
"payment_bypassed": "Ordainketa Saltatua",
|
||||||
|
"bypass_payment": "Saltatu Ordainketa"
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"select_plan": "Hautatu zure plana",
|
||||||
|
"choose_plan": "Aukeratu zure negozioari egokiena den plana"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"payment_summary": "Zure planaren laburpena",
|
||||||
|
"selected_plan": "Hautatutako plana:",
|
||||||
|
"monthly_price": "Hileroko prezioa:",
|
||||||
|
"trial_period": "Proba epea:",
|
||||||
|
"total_today": "Gaurko totala:",
|
||||||
|
"payment_required": "Ordainketa beharrezkoa balidaziorako",
|
||||||
|
"billing_message": "{price} kobratuko zaizu proba epea ondoren",
|
||||||
|
"free_months": "{count} hilabete DOAN",
|
||||||
|
"free_days": "14 egun doan",
|
||||||
|
"payment_info": "Ordainketa informazioa",
|
||||||
|
"secure_payment": "Zure ordainketa informazioa babespetuta dago amaieratik amaierarako zifratzearekin",
|
||||||
|
"dev_mode": "Garapen Modua",
|
||||||
|
"cardholder_name": "Txartelaren titularra",
|
||||||
|
"email": "Helbide elektronikoa",
|
||||||
|
"address_line1": "Helbidea",
|
||||||
|
"city": "Hiria",
|
||||||
|
"state": "Estatua/Probintzia",
|
||||||
|
"postal_code": "Posta kodea",
|
||||||
|
"country": "Herrialdea",
|
||||||
|
"card_details": "Txartelaren xehetasunak",
|
||||||
|
"card_info_secure": "Zure txartelaren informazioa segurua da"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success_create": "Kontua behar bezala sortu da",
|
||||||
|
"error_create": "Errorea kontua sortzean",
|
||||||
|
"payment_error": "Ordainketa errorea"
|
||||||
},
|
},
|
||||||
"forgot_password": {
|
"forgot_password": {
|
||||||
"title": "Berrezarri pasahitza",
|
"title": "Berrezarri pasahitza",
|
||||||
@@ -124,14 +194,14 @@
|
|||||||
"email_required": "Helbide elektronikoa beharrezkoa da",
|
"email_required": "Helbide elektronikoa beharrezkoa da",
|
||||||
"email_invalid": "Mesedez, sartu baliozko helbide elektroniko bat",
|
"email_invalid": "Mesedez, sartu baliozko helbide elektroniko bat",
|
||||||
"password_required": "Pasahitza beharrezkoa da",
|
"password_required": "Pasahitza beharrezkoa da",
|
||||||
"password_min_length": "Pasahitzak gutxienez {{min}} karaktere izan behar ditu",
|
"password_min_length": "Pasahitzak gutxienez {min} karaktere izan behar ditu",
|
||||||
"password_weak": "Pasahitza ahulegia da",
|
"password_weak": "Pasahitza ahulegia da",
|
||||||
"passwords_must_match": "Pasahitzak bat etorri behar dira",
|
"passwords_must_match": "Pasahitzak bat etorri behar dira",
|
||||||
"first_name_required": "Izena beharrezkoa da",
|
"first_name_required": "Izena beharrezkoa da",
|
||||||
"last_name_required": "Abizena beharrezkoa da",
|
"last_name_required": "Abizena beharrezkoa da",
|
||||||
"phone_required": "Telefonoa beharrezkoa da",
|
"phone_required": "Telefonoa beharrezkoa da",
|
||||||
"company_name_required": "Enpresaren izena beharrezkoa da",
|
"company_name_required": "Enpresaren izena beharrezkoa da",
|
||||||
"terms_required": "Baldintza eta baldintzak onartu behar dituzu",
|
"terms_required": "Baldintzak eta pribatutasun politika onartu behar dituzu",
|
||||||
"field_required": "Eremu hau beharrezkoa da",
|
"field_required": "Eremu hau beharrezkoa da",
|
||||||
"invalid_format": "Formatu baliogabea"
|
"invalid_format": "Formatu baliogabea"
|
||||||
},
|
},
|
||||||
@@ -162,5 +232,10 @@
|
|||||||
"manage_sales": "Kudeatu salmentak",
|
"manage_sales": "Kudeatu salmentak",
|
||||||
"view_reports": "Ikusi txostenak",
|
"view_reports": "Ikusi txostenak",
|
||||||
"manage_settings": "Kudeatu ezarpenak"
|
"manage_settings": "Kudeatu ezarpenak"
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"success_create": "Kontua behar bezala sortu da",
|
||||||
|
"error_create": "Errorea kontua sortzean",
|
||||||
|
"payment_error": "Ordainketa errorea"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,5 +437,31 @@
|
|||||||
"features": "Ezaugarriak",
|
"features": "Ezaugarriak",
|
||||||
"about": "Guri buruz",
|
"about": "Guri buruz",
|
||||||
"contact": "Harremanetan jarri"
|
"contact": "Harremanetan jarri"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Ondo gordeta",
|
||||||
|
"created": "Ondo sortuta",
|
||||||
|
"updated": "Ondo eguneratuta",
|
||||||
|
"deleted": "Ondo ezabatuta",
|
||||||
|
"sent": "Ondo bidalia",
|
||||||
|
"imported": "Ondo inportatuta",
|
||||||
|
"exported": "Ondo esportatuta",
|
||||||
|
"logged_in": "Saioa ondo hasita",
|
||||||
|
"logged_out": "Saioa ondo itxita"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"required_field": "Eremu hau beharrezkoa da",
|
||||||
|
"invalid_email": "Email baliogabea",
|
||||||
|
"invalid_phone": "Telefono baliogabea",
|
||||||
|
"weak_password": "Pasahitza indartsuagoa izan behar da",
|
||||||
|
"passwords_not_match": "Pasahitzak ez datoz bat",
|
||||||
|
"network_error": "Sare-errorea",
|
||||||
|
"server_error": "Zerbitzari-errorea",
|
||||||
|
"unauthorized": "Baimenik gabe",
|
||||||
|
"forbidden": "Sarbidea ukatuta",
|
||||||
|
"not_found": "Ez da aurkitu",
|
||||||
|
"validation_error": "Balidazio-errorea",
|
||||||
|
"file_too_large": "Fitxategia handiegia da",
|
||||||
|
"invalid_file_type": "Fitxategi mota baliogabea"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,30 +339,30 @@ export const ERROR_CODES = {
|
|||||||
|
|
||||||
// Success messages
|
// Success messages
|
||||||
export const SUCCESS_MESSAGES = {
|
export const SUCCESS_MESSAGES = {
|
||||||
SAVED: 'Guardado correctamente',
|
SAVED: 'common:messages.saved',
|
||||||
CREATED: 'Creado correctamente',
|
CREATED: 'common:messages.created',
|
||||||
UPDATED: 'Actualizado correctamente',
|
UPDATED: 'common:messages.updated',
|
||||||
DELETED: 'Eliminado correctamente',
|
DELETED: 'common:messages.deleted',
|
||||||
SENT: 'Enviado correctamente',
|
SENT: 'common:messages.sent',
|
||||||
IMPORTED: 'Importado correctamente',
|
IMPORTED: 'common:messages.imported',
|
||||||
EXPORTED: 'Exportado correctamente',
|
EXPORTED: 'common:messages.exported',
|
||||||
LOGGED_IN: 'Sesión iniciada',
|
LOGGED_IN: 'common:messages.logged_in',
|
||||||
LOGGED_OUT: 'Sesión cerrada',
|
LOGGED_OUT: 'common:messages.logged_out',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Error messages
|
// Error messages
|
||||||
export const ERROR_MESSAGES = {
|
export const ERROR_MESSAGES = {
|
||||||
REQUIRED_FIELD: 'Este campo es obligatorio',
|
REQUIRED_FIELD: 'common:errors.required_field',
|
||||||
INVALID_EMAIL: 'Email no válido',
|
INVALID_EMAIL: 'common:errors.invalid_email',
|
||||||
INVALID_PHONE: 'Teléfono no válido',
|
INVALID_PHONE: 'common:errors.invalid_phone',
|
||||||
WEAK_PASSWORD: 'La contraseña debe ser más segura',
|
WEAK_PASSWORD: 'common:errors.weak_password',
|
||||||
PASSWORDS_NOT_MATCH: 'Las contraseñas no coinciden',
|
PASSWORDS_NOT_MATCH: 'common:errors.passwords_not_match',
|
||||||
NETWORK_ERROR: 'Error de conexión',
|
NETWORK_ERROR: 'common:errors.network_error',
|
||||||
SERVER_ERROR: 'Error del servidor',
|
SERVER_ERROR: 'common:errors.server_error',
|
||||||
UNAUTHORIZED: 'No autorizado',
|
UNAUTHORIZED: 'common:errors.unauthorized',
|
||||||
FORBIDDEN: 'Acceso denegado',
|
FORBIDDEN: 'common:errors.forbidden',
|
||||||
NOT_FOUND: 'No encontrado',
|
NOT_FOUND: 'common:errors.not_found',
|
||||||
VALIDATION_ERROR: 'Error de validación',
|
VALIDATION_ERROR: 'common:errors.validation_error',
|
||||||
FILE_TOO_LARGE: 'Archivo demasiado grande',
|
FILE_TOO_LARGE: 'common:errors.file_too_large',
|
||||||
INVALID_FILE_TYPE: 'Tipo de archivo no válido',
|
INVALID_FILE_TYPE: 'common:errors.invalid_file_type',
|
||||||
} as const;
|
} as const;
|
||||||
32
frontend/src/utils/translationHelpers.ts
Normal file
32
frontend/src/utils/translationHelpers.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SUCCESS_MESSAGES, ERROR_MESSAGES } from '../utils/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get translated success messages
|
||||||
|
*/
|
||||||
|
export const useSuccessMessages = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getMessage = (key: keyof typeof SUCCESS_MESSAGES): string => {
|
||||||
|
const translationKey = SUCCESS_MESSAGES[key];
|
||||||
|
const [namespace, path] = translationKey.split(':');
|
||||||
|
return t(`${namespace}:${path}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getMessage };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get translated error messages
|
||||||
|
*/
|
||||||
|
export const useErrorMessages = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getMessage = (key: keyof typeof ERROR_MESSAGES): string => {
|
||||||
|
const translationKey = ERROR_MESSAGES[key];
|
||||||
|
const [namespace, path] = translationKey.split(':');
|
||||||
|
return t(`${namespace}:${path}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getMessage };
|
||||||
|
};
|
||||||
@@ -49,10 +49,10 @@ async def get_tenant_children(request: Request, tenant_id: str = Path(...)):
|
|||||||
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/children")
|
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/children")
|
||||||
|
|
||||||
|
|
||||||
@router.api_route("/bulk-children", methods=["POST", "OPTIONS"])
|
@router.api_route("/{tenant_id}/bulk-children", methods=["POST", "OPTIONS"])
|
||||||
async def proxy_bulk_children(request: Request):
|
async def proxy_bulk_children(request: Request, tenant_id: str = Path(...)):
|
||||||
"""Proxy bulk children creation requests to tenant service"""
|
"""Proxy bulk children creation requests to tenant service"""
|
||||||
return await _proxy_to_tenant_service(request, "/api/v1/tenants/bulk-children")
|
return await _proxy_to_tenant_service(request, f"/api/v1/tenants/{tenant_id}/bulk-children")
|
||||||
|
|
||||||
@router.api_route("/{tenant_id}/children/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
@router.api_route("/{tenant_id}/children/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
async def proxy_tenant_children(request: Request, tenant_id: str = Path(...), path: str = ""):
|
async def proxy_tenant_children(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
|
|||||||
@@ -219,10 +219,11 @@ async def get_tenant_children_count(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(route_builder.build_base_route("bulk-children", include_tenant_prefix=False), response_model=BulkChildTenantsResponse)
|
@router.post("/api/v1/tenants/{tenant_id}/bulk-children", response_model=BulkChildTenantsResponse)
|
||||||
@track_endpoint_metrics("bulk_create_child_tenants")
|
@track_endpoint_metrics("bulk_create_child_tenants")
|
||||||
async def bulk_create_child_tenants(
|
async def bulk_create_child_tenants(
|
||||||
request: BulkChildTenantsCreate,
|
request: BulkChildTenantsCreate,
|
||||||
|
tenant_id: str = Path(..., description="Parent tenant ID"),
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||||
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
|
||||||
):
|
):
|
||||||
@@ -242,7 +243,7 @@ async def bulk_create_child_tenants(
|
|||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Bulk child tenant creation request received",
|
"Bulk child tenant creation request received",
|
||||||
parent_tenant_id=request.parent_tenant_id,
|
parent_tenant_id=tenant_id,
|
||||||
child_count=len(request.child_tenants),
|
child_count=len(request.child_tenants),
|
||||||
user_id=current_user.get("user_id")
|
user_id=current_user.get("user_id")
|
||||||
)
|
)
|
||||||
@@ -252,7 +253,7 @@ async def bulk_create_child_tenants(
|
|||||||
from app.models.tenants import Tenant
|
from app.models.tenants import Tenant
|
||||||
tenant_repo = TenantRepository(Tenant, session)
|
tenant_repo = TenantRepository(Tenant, session)
|
||||||
|
|
||||||
parent_tenant = await tenant_repo.get_by_id(request.parent_tenant_id)
|
parent_tenant = await tenant_repo.get_by_id(tenant_id)
|
||||||
if not parent_tenant:
|
if not parent_tenant:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -262,7 +263,7 @@ async def bulk_create_child_tenants(
|
|||||||
# Verify user has access to parent tenant (owners/admins only)
|
# Verify user has access to parent tenant (owners/admins only)
|
||||||
access_info = await tenant_service.verify_user_access(
|
access_info = await tenant_service.verify_user_access(
|
||||||
current_user["user_id"],
|
current_user["user_id"],
|
||||||
request.parent_tenant_id
|
tenant_id
|
||||||
)
|
)
|
||||||
if not access_info.has_access or access_info.role not in ["owner", "admin"]:
|
if not access_info.has_access or access_info.role not in ["owner", "admin"]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -271,8 +272,8 @@ async def bulk_create_child_tenants(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify parent is enterprise tier
|
# Verify parent is enterprise tier
|
||||||
parent_subscription_tier = await tenant_service.get_subscription_tier(request.parent_tenant_id)
|
parent_subscription = await tenant_service.subscription_repo.get_active_subscription(tenant_id)
|
||||||
if parent_subscription_tier != "enterprise":
|
if not parent_subscription or parent_subscription.plan != "enterprise":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Only enterprise tier tenants can have child tenants"
|
detail="Only enterprise tier tenants can have child tenants"
|
||||||
@@ -290,13 +291,16 @@ async def bulk_create_child_tenants(
|
|||||||
failed_tenants = []
|
failed_tenants = []
|
||||||
|
|
||||||
for child_data in request.child_tenants:
|
for child_data in request.child_tenants:
|
||||||
|
# Create a nested transaction (savepoint) for each child tenant
|
||||||
|
# This allows us to rollback individual child tenant creation without affecting others
|
||||||
|
async with session.begin_nested():
|
||||||
try:
|
try:
|
||||||
# Create child tenant
|
# Create child tenant with full tenant model fields
|
||||||
child_tenant = Tenant(
|
child_tenant = Tenant(
|
||||||
name=child_data.name,
|
name=child_data.name,
|
||||||
subdomain=None, # Child tenants typically don't have subdomains
|
subdomain=None, # Child tenants typically don't have subdomains
|
||||||
business_type=parent_tenant.business_type,
|
business_type=child_data.business_type or parent_tenant.business_type,
|
||||||
business_model="retail_bakery", # Child outlets are typically retail
|
business_model=child_data.business_model or "retail_bakery", # Child outlets are typically retail
|
||||||
address=child_data.address,
|
address=child_data.address,
|
||||||
city=child_data.city,
|
city=child_data.city,
|
||||||
postal_code=child_data.postal_code,
|
postal_code=child_data.postal_code,
|
||||||
@@ -304,48 +308,53 @@ async def bulk_create_child_tenants(
|
|||||||
longitude=child_data.longitude,
|
longitude=child_data.longitude,
|
||||||
phone=child_data.phone or parent_tenant.phone,
|
phone=child_data.phone or parent_tenant.phone,
|
||||||
email=child_data.email or parent_tenant.email,
|
email=child_data.email or parent_tenant.email,
|
||||||
timezone=parent_tenant.timezone,
|
timezone=child_data.timezone or parent_tenant.timezone,
|
||||||
owner_id=parent_tenant.owner_id,
|
owner_id=parent_tenant.owner_id,
|
||||||
parent_tenant_id=parent_tenant.id,
|
parent_tenant_id=parent_tenant.id,
|
||||||
tenant_type="child",
|
tenant_type="child",
|
||||||
hierarchy_path=f"{parent_tenant.hierarchy_path}/{str(parent_tenant.id)}",
|
hierarchy_path=f"{parent_tenant.hierarchy_path}", # Will be updated after flush
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_demo=parent_tenant.is_demo,
|
is_demo=parent_tenant.is_demo,
|
||||||
demo_session_id=parent_tenant.demo_session_id,
|
demo_session_id=parent_tenant.demo_session_id,
|
||||||
demo_expires_at=parent_tenant.demo_expires_at
|
demo_expires_at=parent_tenant.demo_expires_at,
|
||||||
|
metadata_={
|
||||||
|
"location_code": child_data.location_code,
|
||||||
|
"zone": child_data.zone,
|
||||||
|
**(child_data.metadata or {})
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(child_tenant)
|
session.add(child_tenant)
|
||||||
await session.flush() # Get the ID without committing
|
await session.flush() # Get the ID without committing
|
||||||
|
|
||||||
# Create TenantLocation record for the child with location_code
|
# Update hierarchy_path now that we have the child tenant ID
|
||||||
|
child_tenant.hierarchy_path = f"{parent_tenant.hierarchy_path}.{str(child_tenant.id)}"
|
||||||
|
|
||||||
|
# Create TenantLocation record for the child
|
||||||
from app.models.tenant_location import TenantLocation
|
from app.models.tenant_location import TenantLocation
|
||||||
location = TenantLocation(
|
location = TenantLocation(
|
||||||
tenant_id=child_tenant.id,
|
tenant_id=child_tenant.id,
|
||||||
name=child_data.name,
|
name=child_data.name,
|
||||||
location_code=child_data.location_code,
|
|
||||||
city=child_data.city,
|
city=child_data.city,
|
||||||
zone=child_data.zone,
|
|
||||||
address=child_data.address,
|
address=child_data.address,
|
||||||
postal_code=child_data.postal_code,
|
postal_code=child_data.postal_code,
|
||||||
latitude=child_data.latitude,
|
latitude=child_data.latitude,
|
||||||
longitude=child_data.longitude,
|
longitude=child_data.longitude,
|
||||||
status="ACTIVE",
|
is_active=True,
|
||||||
is_primary=True,
|
|
||||||
enterprise_location=True,
|
|
||||||
location_type="retail"
|
location_type="retail"
|
||||||
)
|
)
|
||||||
session.add(location)
|
session.add(location)
|
||||||
|
|
||||||
# Inherit subscription from parent
|
# Inherit subscription from parent
|
||||||
from app.models.tenants import Subscription
|
from app.models.tenants import Subscription
|
||||||
parent_subscription = await session.execute(
|
from sqlalchemy import select
|
||||||
session.query(Subscription).filter(
|
parent_subscription_result = await session.execute(
|
||||||
|
select(Subscription).where(
|
||||||
Subscription.tenant_id == parent_tenant.id,
|
Subscription.tenant_id == parent_tenant.id,
|
||||||
Subscription.status == "active"
|
Subscription.status == "active"
|
||||||
).statement
|
|
||||||
)
|
)
|
||||||
parent_sub = parent_subscription.scalar_one_or_none()
|
)
|
||||||
|
parent_sub = parent_subscription_result.scalar_one_or_none()
|
||||||
|
|
||||||
if parent_sub:
|
if parent_sub:
|
||||||
child_subscription = Subscription(
|
child_subscription = Subscription(
|
||||||
@@ -353,12 +362,15 @@ async def bulk_create_child_tenants(
|
|||||||
plan=parent_sub.plan,
|
plan=parent_sub.plan,
|
||||||
status="active",
|
status="active",
|
||||||
billing_cycle=parent_sub.billing_cycle,
|
billing_cycle=parent_sub.billing_cycle,
|
||||||
price=0, # Child tenants don't pay separately
|
monthly_price=0, # Child tenants don't pay separately
|
||||||
trial_ends_at=parent_sub.trial_ends_at
|
trial_ends_at=parent_sub.trial_ends_at
|
||||||
)
|
)
|
||||||
session.add(child_subscription)
|
session.add(child_subscription)
|
||||||
|
|
||||||
await session.commit()
|
# Commit the nested transaction (savepoint)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Refresh objects to get their final state
|
||||||
await session.refresh(child_tenant)
|
await session.refresh(child_tenant)
|
||||||
await session.refresh(location)
|
await session.refresh(location)
|
||||||
|
|
||||||
@@ -381,8 +393,8 @@ async def bulk_create_child_tenants(
|
|||||||
last_training_date=child_tenant.last_training_date,
|
last_training_date=child_tenant.last_training_date,
|
||||||
owner_id=str(child_tenant.owner_id),
|
owner_id=str(child_tenant.owner_id),
|
||||||
created_at=child_tenant.created_at,
|
created_at=child_tenant.created_at,
|
||||||
location_code=location.location_code,
|
location_code=child_data.location_code,
|
||||||
zone=location.zone,
|
zone=child_data.zone,
|
||||||
hierarchy_path=child_tenant.hierarchy_path
|
hierarchy_path=child_tenant.hierarchy_path
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -404,7 +416,11 @@ async def bulk_create_child_tenants(
|
|||||||
"location_code": child_data.location_code,
|
"location_code": child_data.location_code,
|
||||||
"error": str(child_error)
|
"error": str(child_error)
|
||||||
})
|
})
|
||||||
await session.rollback()
|
# Nested transaction will automatically rollback on exception
|
||||||
|
# This only rolls back the current child tenant, not the entire batch
|
||||||
|
|
||||||
|
# Commit all successful child tenant creations
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
# TODO: Configure distribution routes if requested
|
# TODO: Configure distribution routes if requested
|
||||||
distribution_configured = False
|
distribution_configured = False
|
||||||
@@ -414,7 +430,7 @@ async def bulk_create_child_tenants(
|
|||||||
# For now, we'll skip this and just log
|
# For now, we'll skip this and just log
|
||||||
logger.info(
|
logger.info(
|
||||||
"Distribution route configuration requested",
|
"Distribution route configuration requested",
|
||||||
parent_tenant_id=request.parent_tenant_id,
|
parent_tenant_id=tenant_id,
|
||||||
child_count=len(created_tenants)
|
child_count=len(created_tenants)
|
||||||
)
|
)
|
||||||
# distribution_configured = await configure_distribution_routes(...)
|
# distribution_configured = await configure_distribution_routes(...)
|
||||||
@@ -426,13 +442,13 @@ async def bulk_create_child_tenants(
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Bulk child tenant creation completed",
|
"Bulk child tenant creation completed",
|
||||||
parent_tenant_id=request.parent_tenant_id,
|
parent_tenant_id=tenant_id,
|
||||||
created_count=len(created_tenants),
|
created_count=len(created_tenants),
|
||||||
failed_count=len(failed_tenants)
|
failed_count=len(failed_tenants)
|
||||||
)
|
)
|
||||||
|
|
||||||
return BulkChildTenantsResponse(
|
return BulkChildTenantsResponse(
|
||||||
parent_tenant_id=request.parent_tenant_id,
|
parent_tenant_id=tenant_id,
|
||||||
created_count=len(created_tenants),
|
created_count=len(created_tenants),
|
||||||
failed_count=len(failed_tenants),
|
failed_count=len(failed_tenants),
|
||||||
created_tenants=created_tenants,
|
created_tenants=created_tenants,
|
||||||
@@ -445,7 +461,7 @@ async def bulk_create_child_tenants(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Bulk child tenant creation failed",
|
"Bulk child tenant creation failed",
|
||||||
parent_tenant_id=request.parent_tenant_id,
|
parent_tenant_id=tenant_id,
|
||||||
user_id=current_user.get("user_id"),
|
user_id=current_user.get("user_id"),
|
||||||
error=str(e)
|
error=str(e)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class TenantStatsResponse(BaseModel):
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class ChildTenantCreate(BaseModel):
|
class ChildTenantCreate(BaseModel):
|
||||||
"""Schema for creating a child tenant in enterprise hierarchy"""
|
"""Schema for creating a child tenant in enterprise hierarchy - Updated to match tenant model"""
|
||||||
name: str = Field(..., min_length=2, max_length=200, description="Child tenant name (e.g., 'Madrid - Salamanca')")
|
name: str = Field(..., min_length=2, max_length=200, description="Child tenant name (e.g., 'Madrid - Salamanca')")
|
||||||
city: str = Field(..., min_length=2, max_length=100, description="City where the outlet is located")
|
city: str = Field(..., min_length=2, max_length=100, description="City where the outlet is located")
|
||||||
zone: Optional[str] = Field(None, max_length=100, description="Zone or neighborhood")
|
zone: Optional[str] = Field(None, max_length=100, description="Zone or neighborhood")
|
||||||
@@ -212,14 +212,24 @@ class ChildTenantCreate(BaseModel):
|
|||||||
postal_code: str = Field(..., pattern=r"^\d{5}$", description="5-digit postal code")
|
postal_code: str = Field(..., pattern=r"^\d{5}$", description="5-digit postal code")
|
||||||
location_code: str = Field(..., min_length=1, max_length=10, description="Short location code (e.g., MAD, BCN)")
|
location_code: str = Field(..., min_length=1, max_length=10, description="Short location code (e.g., MAD, BCN)")
|
||||||
|
|
||||||
# Optional coordinates (can be geocoded from address if not provided)
|
# Coordinates (can be geocoded from address if not provided)
|
||||||
latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate")
|
latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate")
|
||||||
longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate")
|
longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate")
|
||||||
|
|
||||||
# Optional contact info (inherits from parent if not provided)
|
# Contact info (inherits from parent if not provided)
|
||||||
phone: Optional[str] = Field(None, min_length=9, max_length=20, description="Contact phone")
|
phone: Optional[str] = Field(None, min_length=9, max_length=20, description="Contact phone")
|
||||||
email: Optional[str] = Field(None, description="Contact email")
|
email: Optional[str] = Field(None, description="Contact email")
|
||||||
|
|
||||||
|
# Business info
|
||||||
|
business_type: Optional[str] = Field(None, max_length=100, description="Type of business")
|
||||||
|
business_model: Optional[str] = Field(None, max_length=100, description="Business model")
|
||||||
|
|
||||||
|
# Timezone configuration
|
||||||
|
timezone: Optional[str] = Field(None, max_length=50, description="Timezone for scheduling")
|
||||||
|
|
||||||
|
# Additional metadata
|
||||||
|
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata for the child tenant")
|
||||||
|
|
||||||
@field_validator('location_code')
|
@field_validator('location_code')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_location_code(cls, v):
|
def validate_location_code(cls, v):
|
||||||
@@ -243,10 +253,42 @@ class ChildTenantCreate(BaseModel):
|
|||||||
raise ValueError('Invalid Spanish phone number')
|
raise ValueError('Invalid Spanish phone number')
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('business_type')
|
||||||
|
@classmethod
|
||||||
|
def validate_business_type(cls, v):
|
||||||
|
"""Validate business type if provided"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
valid_types = ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant']
|
||||||
|
if v not in valid_types:
|
||||||
|
raise ValueError(f'Business type must be one of: {valid_types}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('business_model')
|
||||||
|
@classmethod
|
||||||
|
def validate_business_model(cls, v):
|
||||||
|
"""Validate business model if provided"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
valid_models = ['individual_bakery', 'central_baker_satellite', 'retail_bakery', 'hybrid_bakery']
|
||||||
|
if v not in valid_models:
|
||||||
|
raise ValueError(f'Business model must be one of: {valid_models}')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('timezone')
|
||||||
|
@classmethod
|
||||||
|
def validate_timezone(cls, v):
|
||||||
|
"""Validate timezone if provided"""
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
# Basic timezone validation - should match common timezone formats
|
||||||
|
if not re.match(r'^[A-Za-z_+/]+$', v):
|
||||||
|
raise ValueError('Invalid timezone format')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class BulkChildTenantsCreate(BaseModel):
|
class BulkChildTenantsCreate(BaseModel):
|
||||||
"""Schema for bulk creating child tenants during onboarding"""
|
"""Schema for bulk creating child tenants during onboarding"""
|
||||||
parent_tenant_id: str = Field(..., description="ID of the parent (central baker) tenant")
|
|
||||||
child_tenants: List[ChildTenantCreate] = Field(
|
child_tenants: List[ChildTenantCreate] = Field(
|
||||||
...,
|
...,
|
||||||
min_length=1,
|
min_length=1,
|
||||||
|
|||||||
Reference in New Issue
Block a user