Add POI feature and imporve the overall backend implementation
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user