Add POI feature and imporve the overall backend implementation

This commit is contained in:
Urtzi Alfaro
2025-11-12 15:34:10 +01:00
parent e8096cd979
commit 5783c7ed05
173 changed files with 16862 additions and 9078 deletions

View File

@@ -330,10 +330,11 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
}
}
// Complete the step with metadata
// Complete the step with metadata and inventory items
onComplete({
inventoryItemsCreated: inventoryItems.length,
salesDataImported: salesImported,
inventoryItems: inventoryItems, // Pass the created items to the next step
});
} catch (error) {
console.error('Error creating inventory items:', error);

View File

@@ -0,0 +1,346 @@
/**
* POI Detection Onboarding Step
*
* Onboarding wizard step for automatic POI detection during bakery registration
*/
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
import { Alert, AlertDescription } from '@/components/ui/Alert';
import { Progress } from '@/components/ui/Progress';
import { CheckCircle, MapPin, AlertCircle, Loader2, ArrowRight } from 'lucide-react';
import { poiContextApi } from '@/services/api/poiContextApi';
import { POI_CATEGORY_METADATA } from '@/types/poi';
import type { POIDetectionResponse } from '@/types/poi';
import { useWizardContext } from '../context';
interface POIDetectionStepProps {
onNext?: () => void;
onPrevious?: () => void;
onComplete?: (data?: any) => void;
onUpdate?: (data: any) => void;
isFirstStep?: boolean;
isLastStep?: boolean;
initialData?: any;
}
export const POIDetectionStep: React.FC<POIDetectionStepProps> = ({
onComplete,
onUpdate,
initialData,
}) => {
const [isDetecting, setIsDetecting] = useState(false);
const [detectionResult, setDetectionResult] = useState<POIDetectionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const wizardContext = useWizardContext();
// Extract tenantId and location from context or initialData
// Prioritize initialData (from previous step completion), fall back to context
const tenantId = initialData?.tenantId || wizardContext.state.tenantId;
const bakeryLocation = initialData?.bakeryLocation || wizardContext.state.bakeryLocation;
// Auto-detect POIs when both tenantId and location are available
useEffect(() => {
if (tenantId && bakeryLocation?.latitude && bakeryLocation?.longitude) {
handleDetectPOIs();
} else {
// If we don't have the required data, show a message
setError('Location data not available. Please complete the previous step first.');
}
}, [tenantId, bakeryLocation]);
const handleDetectPOIs = async () => {
if (!tenantId || !bakeryLocation?.latitude || !bakeryLocation?.longitude) {
setError('Tenant ID and location are required for POI detection.');
return;
}
setIsDetecting(true);
setError(null);
setProgress(10);
try {
// Simulate progress updates
const progressInterval = setInterval(() => {
setProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
return 90;
}
return prev + 10;
});
}, 500);
const result = await poiContextApi.detectPOIs(
tenantId,
bakeryLocation.latitude,
bakeryLocation.longitude,
false
);
clearInterval(progressInterval);
setProgress(100);
setDetectionResult(result);
} catch (err: any) {
setError(err.message || 'Failed to detect POIs');
console.error('POI detection error:', err);
} finally {
setIsDetecting(false);
}
};
// Loading state
if (isDetecting) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Detecting Nearby Points of Interest
</CardTitle>
<CardDescription>
Analyzing your bakery's location to identify nearby schools, offices, transport hubs, and more...
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mb-4" />
<div className="text-center">
<div className="text-lg font-medium mb-2">
Scanning OpenStreetMap data...
</div>
<div className="text-sm text-gray-600">
This may take a few moments
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Detection Progress</span>
<span>{progress}%</span>
</div>
<Progress value={progress} className="h-2" />
</div>
<div className="grid grid-cols-3 gap-3">
{Object.values(POI_CATEGORY_METADATA).slice(0, 9).map(category => (
<div key={category.name} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<span style={{ fontSize: '20px' }}>{category.icon}</span>
<span className="text-xs text-gray-700">{category.displayName}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// Error state
if (error && !detectionResult) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
POI Detection Failed
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
<div className="flex gap-3">
<Button
onClick={handleDetectPOIs}
variant="outline"
className="flex-1"
disabled={isDetecting}
>
{isDetecting ? 'Detecting...' : 'Try Again'}
</Button>
<Button
onClick={() => onComplete?.({ poi_detection_skipped: true })}
variant="ghost"
className="flex-1"
>
Skip for Now
</Button>
</div>
</CardContent>
</Card>
);
}
// Success state
if (detectionResult) {
const { poi_context, competitive_insights } = detectionResult;
const categoriesWithPOIs = Object.entries(poi_context.poi_detection_results)
.filter(([_, data]) => data.count > 0)
.sort((a, b) => (b[1].features?.proximity_score || 0) - (a[1].features?.proximity_score || 0));
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-5 w-5" />
POI Detection Complete
</CardTitle>
<CardDescription>
Successfully detected {poi_context.total_pois_detected} points of interest around your bakery
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-lg text-center">
<div className="text-3xl font-bold text-blue-600">
{poi_context.total_pois_detected}
</div>
<div className="text-sm text-gray-700 mt-1">Total POIs</div>
</div>
<div className="p-4 bg-green-50 rounded-lg text-center">
<div className="text-3xl font-bold text-green-600">
{poi_context.relevant_categories?.length || 0}
</div>
<div className="text-sm text-gray-700 mt-1">Relevant Categories</div>
</div>
<div className="p-4 bg-purple-50 rounded-lg text-center">
<div className="text-3xl font-bold text-purple-600">
{Object.keys(poi_context.ml_features || {}).length}
</div>
<div className="text-sm text-gray-700 mt-1">ML Features</div>
</div>
</div>
{/* Competitive Insights */}
{competitive_insights && competitive_insights.length > 0 && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">Location Insights</div>
<ul className="space-y-1 text-sm">
{competitive_insights.map((insight, index) => (
<li key={index}>{insight}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* High Impact Categories */}
{poi_context.high_impact_categories && poi_context.high_impact_categories.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-3">
High Impact Factors for Your Location
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{poi_context.high_impact_categories.map(category => {
const metadata = (POI_CATEGORY_METADATA as Record<string, any>)[category];
if (!metadata) return null;
const categoryData = poi_context.poi_detection_results[category];
return (
<div
key={category}
className="p-3 border border-green-200 bg-green-50 rounded-lg"
>
<div className="flex items-center gap-2 mb-1">
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
<span className="font-medium text-sm">{metadata.displayName}</span>
</div>
<div className="text-xs text-gray-700">
{categoryData.count} {categoryData.count === 1 ? 'location' : 'locations'}
</div>
</div>
);
})}
</div>
</div>
)}
{/* All Categories */}
{categoriesWithPOIs.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-3">
All Detected Categories
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{categoriesWithPOIs.map(([category, data]) => {
const metadata = (POI_CATEGORY_METADATA as Record<string, any>)[category];
if (!metadata) return null;
return (
<div
key={category}
className="flex items-center gap-2 p-2 bg-gray-50 rounded"
>
<span style={{ fontSize: '20px' }}>{metadata.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{metadata.displayName}
</div>
<div className="text-xs text-gray-600">
{data.count} nearby
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Actions */}
<div className="pt-4 border-t border-gray-200">
<Button
onClick={() => onComplete?.({
poi_detection_completed: true,
total_pois_detected: poi_context.total_pois_detected,
relevant_categories: poi_context.relevant_categories,
high_impact_categories: poi_context.high_impact_categories,
})}
size="lg"
className="w-full"
>
Continue to Next Step
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<p className="text-xs text-center text-gray-600 mt-3">
These location-based features will enhance your demand forecasting accuracy
</p>
</div>
</CardContent>
</Card>
);
}
// Default state - show loading or instruction if needed
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Preparing POI Detection
</CardTitle>
<CardDescription>
Preparing to analyze nearby points of interest around your bakery
</CardDescription>
</CardHeader>
<CardContent className="text-center py-8">
<div className="text-gray-600 mb-4">
{error || 'Waiting for location data...'}
</div>
{error && !tenantId && !bakeryLocation && (
<p className="text-sm text-gray-600">
Please complete the previous step to provide location information.
</p>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,10 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Button } from '../../../ui/Button';
import { Input } from '../../../ui/Input';
import React, { useState } from 'react';
import { Button, Input } from '../../../ui';
import { AddressAutocomplete } from '../../../ui/AddressAutocomplete';
import { useRegisterBakery } from '../../../../api/hooks/tenant';
import { BakeryRegistration } from '../../../../api/types/tenant';
import { nominatimService, NominatimResult } from '../../../../api/services/nominatim';
import { debounce } from 'lodash';
import { AddressResult } from '../../../../services/api/geocodingApi';
import { useWizardContext } from '../context';
interface RegisterTenantStepProps {
onNext: () => void;
@@ -18,6 +18,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
onComplete,
isFirstStep
}) => {
const wizardContext = useWizardContext();
const [formData, setFormData] = useState<BakeryRegistration>({
name: '',
address: '',
@@ -29,51 +30,14 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [addressSuggestions, setAddressSuggestions] = useState<NominatimResult[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const registerBakery = useRegisterBakery();
// Debounced address search
const searchAddress = useCallback(
debounce(async (query: string) => {
if (query.length < 3) {
setAddressSuggestions([]);
return;
}
setIsSearching(true);
try {
const results = await nominatimService.searchAddress(query);
setAddressSuggestions(results);
setShowSuggestions(true);
} catch (error) {
console.error('Address search failed:', error);
} finally {
setIsSearching(false);
}
}, 500),
[]
);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
searchAddress.cancel();
};
}, [searchAddress]);
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Trigger address search when address field changes
if (field === 'address') {
searchAddress(value);
}
if (errors[field]) {
setErrors(prev => ({
...prev,
@@ -82,18 +46,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
}
};
const handleAddressSelect = (result: NominatimResult) => {
const parsed = nominatimService.parseAddress(result);
const handleAddressSelect = (address: AddressResult) => {
setFormData(prev => ({
...prev,
address: parsed.street,
city: parsed.city,
postal_code: parsed.postalCode,
address: address.display_name,
city: address.address.city || address.address.municipality || address.address.suburb || prev.city,
postal_code: address.address.postcode || prev.postal_code,
}));
};
setShowSuggestions(false);
setAddressSuggestions([]);
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 = () => {
@@ -145,7 +111,14 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
try {
const tenant = await registerBakery.mutateAsync(formData);
onComplete({ tenant });
// Update the wizard context with tenant info and pass the bakeryLocation coordinates
// that were captured during address selection to the next step (POI Detection)
onComplete({
tenant,
tenantId: tenant.id,
bakeryLocation: wizardContext.state.bakeryLocation
});
} catch (error) {
console.error('Error registering bakery:', error);
setErrors({ submit: 'Error al registrar la panadería. Por favor, inténtalo de nuevo.' });
@@ -174,41 +147,24 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
isRequired
/>
<div className="md:col-span-2 relative">
<Input
label="Dirección"
placeholder="Calle Principal 123, Madrid"
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dirección <span className="text-red-500">*</span>
</label>
<AddressAutocomplete
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
onFocus={() => {
if (addressSuggestions.length > 0) {
setShowSuggestions(true);
}
placeholder="Enter bakery address..."
onAddressSelect={(address) => {
console.log('Selected:', address.display_name);
handleAddressSelect(address);
}}
onBlur={() => {
setTimeout(() => setShowSuggestions(false), 200);
}}
error={errors.address}
isRequired
onCoordinatesChange={handleCoordinatesChange}
countryCode="es"
required
/>
{isSearching && (
<div className="absolute right-3 top-10 text-gray-400">
Buscando...
</div>
)}
{showSuggestions && addressSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{addressSuggestions.map((result) => (
<div
key={result.place_id}
className="px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0"
onClick={() => handleAddressSelect(result)}
>
<div className="text-sm font-medium text-gray-900">
{nominatimService.formatAddress(result)}
</div>
</div>
))}
{errors.address && (
<div className="mt-1 text-sm text-red-600">
{errors.address}
</div>
)}
</div>

View File

@@ -4,6 +4,7 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
// Core Onboarding Steps
export { RegisterTenantStep } from './RegisterTenantStep';
export { POIDetectionStep } from './POIDetectionStep';
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
export { FileUploadStep } from './FileUploadStep';