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

@@ -11,6 +11,7 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
import {
BakeryTypeSelectionStep,
RegisterTenantStep,
POIDetectionStep,
FileUploadStep,
InventoryReviewStep,
ProductCategorizationStep,
@@ -74,6 +75,15 @@ const OnboardingWizardContent: React.FC = () => {
isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null,
},
// Phase 2b: POI Detection
{
id: 'poi-detection',
title: t('onboarding:steps.poi_detection.title', 'Detección de Ubicación'),
description: t('onboarding:steps.poi_detection.description', 'Analizar puntos de interés cercanos'),
component: POIDetectionStep,
isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null && ctx.state.bakeryLocation !== undefined,
},
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
{
id: 'upload-sales-data',
@@ -325,6 +335,10 @@ const OnboardingWizardContent: React.FC = () => {
}
if (currentStep.id === 'inventory-review') {
wizardContext.markStepComplete('inventoryReviewCompleted');
// Store inventory items in context for the next step
if (data?.inventoryItems) {
wizardContext.updateInventoryItems(data.inventoryItems);
}
}
if (currentStep.id === 'product-categorization' && data?.categorizedProducts) {
wizardContext.updateCategorizedProducts(data.categorizedProducts);
@@ -339,6 +353,11 @@ const OnboardingWizardContent: React.FC = () => {
}
if (currentStep.id === 'setup' && data?.tenant) {
setCurrentTenant(data.tenant);
// If tenant info and location are available in data, update the wizard context
if (data.tenantId && data.bakeryLocation) {
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
}
}
// Mark step as completed in backend
@@ -531,6 +550,24 @@ const OnboardingWizardContent: React.FC = () => {
uploadedFileName: wizardContext.state.uploadedFileName || '',
uploadedFileSize: wizardContext.state.uploadedFileSize || 0,
}
: // Pass inventory items to InitialStockEntryStep
currentStep.id === 'initial-stock-entry' && wizardContext.state.inventoryItems
? {
productsWithStock: wizardContext.state.inventoryItems.map(item => ({
id: item.id,
name: item.name,
type: item.product_type === 'ingredient' ? 'ingredient' : 'finished_product',
category: item.category,
unit: item.unit_of_measure,
initialStock: undefined,
}))
}
: // Pass tenant info to POI detection step
currentStep.id === 'poi-detection'
? {
tenantId: wizardContext.state.tenantId,
bakeryLocation: wizardContext.state.bakeryLocation,
}
: undefined
}
/>

View File

@@ -16,11 +16,34 @@ export interface AISuggestion {
isAccepted?: boolean;
}
// Inventory item structure from InventoryReviewStep
export interface InventoryItemForm {
id: string;
name: string;
product_type: string;
category: string;
unit_of_measure: string;
isSuggested?: boolean;
confidence_score?: number;
sales_data?: {
total_quantity: number;
total_revenue: number;
average_price: number;
};
}
export interface WizardState {
// Discovery Phase
bakeryType: BakeryType;
dataSource: DataSource;
// Core Setup Data
tenantId?: string;
bakeryLocation?: {
latitude: number;
longitude: number;
};
// AI-Assisted Path Data
uploadedFile?: File; // NEW: The actual file object needed for sales import API
uploadedFileName?: string;
@@ -30,6 +53,7 @@ export interface WizardState {
aiAnalysisComplete: boolean;
categorizedProducts?: any[]; // Products with type classification
productsWithStock?: any[]; // Products with initial stock levels
inventoryItems?: InventoryItemForm[]; // NEW: Inventory items created in InventoryReviewStep
// Setup Progress
categorizationCompleted: boolean;
@@ -55,11 +79,15 @@ export interface WizardContextValue {
state: WizardState;
updateBakeryType: (type: BakeryType) => void;
updateDataSource: (source: DataSource) => void;
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
updateLocation: (location: { latitude: number; longitude: number }) => void;
updateTenantId: (tenantId: string) => void;
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
setAIAnalysisComplete: (complete: boolean) => void;
updateCategorizedProducts: (products: any[]) => void;
updateProductsWithStock: (products: any[]) => void;
updateInventoryItems: (items: InventoryItemForm[]) => void; // NEW: Store inventory items
markStepComplete: (step: keyof WizardState) => void;
getVisibleSteps: () => string[];
shouldShowStep: (stepId: string) => boolean;
@@ -126,6 +154,28 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
setState(prev => ({ ...prev, dataSource: source }));
};
const updateTenantInfo = (tenantId: string, location: { latitude: number; longitude: number }) => {
setState(prev => ({
...prev,
tenantId,
bakeryLocation: location
}));
};
const updateLocation = (location: { latitude: number; longitude: number }) => {
setState(prev => ({
...prev,
bakeryLocation: location
}));
};
const updateTenantId = (tenantId: string) => {
setState(prev => ({
...prev,
tenantId
}));
};
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
};
@@ -152,6 +202,10 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
setState(prev => ({ ...prev, productsWithStock: products }));
};
const updateInventoryItems = (items: InventoryItemForm[]) => {
setState(prev => ({ ...prev, inventoryItems: items }));
};
const markStepComplete = (step: keyof WizardState) => {
setState(prev => ({ ...prev, [step]: true }));
};
@@ -244,11 +298,15 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
state,
updateBakeryType,
updateDataSource,
updateTenantInfo,
updateLocation,
updateTenantId,
updateAISuggestions,
updateUploadedFile,
setAIAnalysisComplete,
updateCategorizedProducts,
updateProductsWithStock,
updateInventoryItems,
markStepComplete,
getVisibleSteps,
shouldShowStep,

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';

View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import { RefreshCw, CheckCircle, AlertCircle, Clock, TrendingUp, Loader2 } from 'lucide-react';
import { Card } from '../../ui';
import { showToast } from '../../../utils/toast';
import { posService } from '../../../api/services/pos';
interface POSSyncStatusProps {
tenantId: string;
onSyncComplete?: () => void;
}
interface SyncStatus {
total_completed_transactions: number;
synced_to_sales: number;
pending_sync: number;
sync_rate: number;
}
export const POSSyncStatus: React.FC<POSSyncStatusProps> = ({ tenantId, onSyncComplete }) => {
const [status, setStatus] = useState<SyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchSyncStatus = async () => {
setLoading(true);
try {
const response = await fetch(
`/api/v1/pos/tenants/${tenantId}/pos/transactions/sync-status`,
{
headers: {
'Content-Type': 'application/json',
// Add auth headers as needed
},
}
);
if (!response.ok) {
throw new Error('Failed to fetch sync status');
}
const data = await response.json();
setStatus(data);
setLastUpdated(new Date());
} catch (error: any) {
console.error('Error fetching sync status:', error);
showToast.error('Error al obtener estado de sincronización');
} finally {
setLoading(false);
}
};
const triggerSync = async () => {
setSyncing(true);
try {
const response = await fetch(
`/api/v1/pos/tenants/${tenantId}/pos/transactions/sync-all-to-sales`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Add auth headers as needed
},
}
);
if (!response.ok) {
throw new Error('Sync failed');
}
const result = await response.json();
showToast.success(
`Sincronización completada: ${result.synced} de ${result.total_transactions} transacciones`
);
// Refresh status after sync
await fetchSyncStatus();
if (onSyncComplete) {
onSyncComplete();
}
} catch (error: any) {
console.error('Error during sync:', error);
showToast.error('Error al sincronizar transacciones');
} finally {
setSyncing(false);
}
};
React.useEffect(() => {
if (tenantId) {
fetchSyncStatus();
// Auto-refresh every 30 seconds
const interval = setInterval(fetchSyncStatus, 30000);
return () => clearInterval(interval);
}
}, [tenantId]);
if (loading && !status) {
return (
<Card className="p-6">
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Cargando estado...</span>
</div>
</Card>
);
}
if (!status) {
return null;
}
const hasPendingSync = status.pending_sync > 0;
return (
<Card className="p-6">
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<RefreshCw className="w-5 h-5 mr-2 text-blue-500" />
Estado de Sincronización POS Ventas
</h3>
<button
onClick={fetchSyncStatus}
disabled={loading}
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors disabled:opacity-50"
title="Actualizar estado"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Total Transactions */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[var(--text-secondary)]">Total Transacciones</span>
<Clock className="w-4 h-4 text-gray-400" />
</div>
<div className="text-2xl font-bold text-[var(--text-primary)]">
{status.total_completed_transactions}
</div>
</div>
{/* Synced */}
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-green-700 dark:text-green-400">Sincronizadas</span>
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div className="text-2xl font-bold text-green-800 dark:text-green-300">
{status.synced_to_sales}
</div>
</div>
{/* Pending */}
<div
className={`p-4 rounded-lg ${
hasPendingSync
? 'bg-orange-50 dark:bg-orange-900/20'
: 'bg-gray-50 dark:bg-gray-800'
}`}
>
<div className="flex items-center justify-between mb-2">
<span
className={`text-sm ${
hasPendingSync
? 'text-orange-700 dark:text-orange-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
Pendientes
</span>
<AlertCircle
className={`w-4 h-4 ${
hasPendingSync
? 'text-orange-600 dark:text-orange-400'
: 'text-gray-400'
}`}
/>
</div>
<div
className={`text-2xl font-bold ${
hasPendingSync
? 'text-orange-800 dark:text-orange-300'
: 'text-gray-600 dark:text-gray-300'
}`}
>
{status.pending_sync}
</div>
</div>
</div>
{/* Sync Rate */}
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-[var(--text-secondary)]">Tasa de Sincronización</span>
<TrendingUp className="w-4 h-4 text-blue-500" />
</div>
<div className="flex items-center">
<div className="flex-1">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${status.sync_rate}%` }}
/>
</div>
</div>
<span className="ml-3 text-lg font-semibold text-[var(--text-primary)]">
{status.sync_rate.toFixed(1)}%
</span>
</div>
</div>
{/* Actions */}
{hasPendingSync && (
<div className="flex items-center justify-between pt-4 border-t border-[var(--border-primary)]">
<div className="text-sm text-[var(--text-secondary)]">
{status.pending_sync} transacción{status.pending_sync !== 1 ? 'es' : ''} esperando
sincronización
</div>
<button
onClick={triggerSync}
disabled={syncing}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors inline-flex items-center gap-2"
>
{syncing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sincronizando...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Sincronizar Ahora
</>
)}
</button>
</div>
)}
{/* Last Updated */}
{lastUpdated && (
<div className="text-xs text-[var(--text-tertiary)] text-center">
Última actualización: {lastUpdated.toLocaleTimeString('es-ES')}
</div>
)}
</div>
</Card>
);
};

View File

@@ -0,0 +1,499 @@
// ================================================================
// frontend/src/components/domain/procurement/DeliveryReceiptModal.tsx
// ================================================================
/**
* Delivery Receipt Modal
*
* Modal for recording delivery receipt with:
* - Item-by-item quantity verification
* - Batch/lot number entry
* - Expiration date entry
* - Quality inspection toggle
* - Rejection reasons for damaged/incorrect items
*/
import React, { useState, useMemo } from 'react';
import {
X,
Package,
Calendar,
Hash,
CheckCircle2,
AlertTriangle,
Truck,
ClipboardCheck,
} from 'lucide-react';
// Define delivery item type
interface DeliveryItemInput {
purchase_order_item_id: string;
inventory_product_id: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
delivered_quantity: number;
accepted_quantity: number;
rejected_quantity: number;
batch_lot_number?: string;
expiry_date?: string;
quality_issues?: string;
rejection_reason?: string;
}
interface DeliveryReceiptModalProps {
isOpen: boolean;
onClose: () => void;
purchaseOrder: {
id: string;
po_number: string;
supplier_id: string;
supplier_name?: string;
items: Array<{
id: string;
inventory_product_id: string;
product_name: string;
ordered_quantity: number;
unit_of_measure: string;
received_quantity: number;
}>;
};
onSubmit: (deliveryData: {
purchase_order_id: string;
supplier_id: string;
items: DeliveryItemInput[];
inspection_passed: boolean;
inspection_notes?: string;
notes?: string;
}) => Promise<void>;
loading?: boolean;
}
export function DeliveryReceiptModal({
isOpen,
onClose,
purchaseOrder,
onSubmit,
loading = false,
}: DeliveryReceiptModalProps) {
const [items, setItems] = useState<DeliveryItemInput[]>(() =>
purchaseOrder.items.map(item => ({
purchase_order_item_id: item.id,
inventory_product_id: item.inventory_product_id,
product_name: item.product_name,
ordered_quantity: item.ordered_quantity,
unit_of_measure: item.unit_of_measure,
delivered_quantity: item.ordered_quantity - item.received_quantity, // Remaining qty
accepted_quantity: item.ordered_quantity - item.received_quantity,
rejected_quantity: 0,
batch_lot_number: '',
expiry_date: '',
quality_issues: '',
rejection_reason: '',
}))
);
const [inspectionPassed, setInspectionPassed] = useState(true);
const [inspectionNotes, setInspectionNotes] = useState('');
const [generalNotes, setGeneralNotes] = useState('');
// Calculate summary statistics
const summary = useMemo(() => {
const totalOrdered = items.reduce((sum, item) => sum + item.ordered_quantity, 0);
const totalDelivered = items.reduce((sum, item) => sum + item.delivered_quantity, 0);
const totalAccepted = items.reduce((sum, item) => sum + item.accepted_quantity, 0);
const totalRejected = items.reduce((sum, item) => sum + item.rejected_quantity, 0);
const hasIssues = items.some(item => item.rejected_quantity > 0 || item.quality_issues);
return {
totalOrdered,
totalDelivered,
totalAccepted,
totalRejected,
hasIssues,
completionRate: totalOrdered > 0 ? (totalAccepted / totalOrdered) * 100 : 0,
};
}, [items]);
const updateItem = (index: number, field: keyof DeliveryItemInput, value: any) => {
setItems(prevItems => {
const newItems = [...prevItems];
newItems[index] = { ...newItems[index], [field]: value };
// Auto-calculate accepted quantity when delivered or rejected changes
if (field === 'delivered_quantity' || field === 'rejected_quantity') {
const delivered = field === 'delivered_quantity' ? value : newItems[index].delivered_quantity;
const rejected = field === 'rejected_quantity' ? value : newItems[index].rejected_quantity;
newItems[index].accepted_quantity = Math.max(0, delivered - rejected);
}
return newItems;
});
};
const handleSubmit = async () => {
// Validate that all items have required fields
const hasErrors = items.some(item =>
item.delivered_quantity < 0 ||
item.accepted_quantity < 0 ||
item.rejected_quantity < 0 ||
item.delivered_quantity < item.rejected_quantity
);
if (hasErrors) {
alert('Please fix validation errors before submitting');
return;
}
const deliveryData = {
purchase_order_id: purchaseOrder.id,
supplier_id: purchaseOrder.supplier_id,
items: items.filter(item => item.delivered_quantity > 0), // Only include delivered items
inspection_passed: inspectionPassed,
inspection_notes: inspectionNotes || undefined,
notes: generalNotes || undefined,
};
await onSubmit(deliveryData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
{/* Header */}
<div
className="p-6 border-b flex items-center justify-between"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<Truck className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
<div>
<h2 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>
Record Delivery Receipt
</h2>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
PO #{purchaseOrder.po_number} {purchaseOrder.supplier_name}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-opacity-80 transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
disabled={loading}
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* Summary Stats */}
<div
className="p-4 border-b grid grid-cols-4 gap-4"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)'
}}
>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Ordered
</p>
<p className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
{summary.totalOrdered.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Delivered
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-info-600)' }}>
{summary.totalDelivered.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Accepted
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-success-600)' }}>
{summary.totalAccepted.toFixed(1)}
</p>
</div>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Rejected
</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-error-600)' }}>
{summary.totalRejected.toFixed(1)}
</p>
</div>
</div>
{/* Items List */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{items.map((item, index) => (
<div
key={item.purchase_order_item_id}
className="border rounded-lg p-4 space-y-3"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.rejected_quantity > 0
? 'var(--color-error-300)'
: 'var(--border-primary)',
}}
>
{/* Item Header */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-2 flex-1">
<Package className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
<div>
<p className="font-semibold" style={{ color: 'var(--text-primary)' }}>
{item.product_name}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Ordered: {item.ordered_quantity} {item.unit_of_measure}
</p>
</div>
</div>
</div>
{/* Quantity Inputs */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Delivered Qty *
</label>
<input
type="number"
value={item.delivered_quantity}
onChange={(e) => updateItem(index, 'delivered_quantity', parseFloat(e.target.value) || 0)}
min="0"
step="0.01"
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Rejected Qty
</label>
<input
type="number"
value={item.rejected_quantity}
onChange={(e) => updateItem(index, 'rejected_quantity', parseFloat(e.target.value) || 0)}
min="0"
step="0.01"
max={item.delivered_quantity}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: item.rejected_quantity > 0 ? 'var(--color-error-300)' : 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
Accepted Qty
</label>
<input
type="number"
value={item.accepted_quantity}
readOnly
className="w-full px-3 py-2 rounded border text-sm font-semibold"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--color-success-600)',
}}
/>
</div>
</div>
{/* Batch & Expiry */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Hash className="w-3 h-3" />
Batch/Lot Number
</label>
<input
type="text"
value={item.batch_lot_number}
onChange={(e) => updateItem(index, 'batch_lot_number', e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
<Calendar className="w-3 h-3" />
Expiration Date
</label>
<input
type="date"
value={item.expiry_date}
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
</div>
{/* Quality Issues / Rejection Reason */}
{item.rejected_quantity > 0 && (
<div>
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--color-error-600)' }}>
<AlertTriangle className="w-3 h-3" />
Rejection Reason *
</label>
<textarea
value={item.rejection_reason}
onChange={(e) => updateItem(index, 'rejection_reason', e.target.value)}
placeholder="Why was this item rejected? (damaged, wrong product, quality issues, etc.)"
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--color-error-300)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
)}
</div>
))}
</div>
{/* Quality Inspection */}
<div
className="p-4 border-t space-y-3"
style={{ borderColor: 'var(--border-primary)' }}
>
<div className="flex items-center gap-3">
<ClipboardCheck className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={inspectionPassed}
onChange={(e) => setInspectionPassed(e.target.checked)}
className="w-4 h-4"
disabled={loading}
/>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
Quality inspection passed
</span>
</label>
</div>
{!inspectionPassed && (
<textarea
value={inspectionNotes}
onChange={(e) => setInspectionNotes(e.target.value)}
placeholder="Describe quality inspection issues..."
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--color-warning-300)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
)}
<textarea
value={generalNotes}
onChange={(e) => setGeneralNotes(e.target.value)}
placeholder="General delivery notes (optional)"
rows={2}
className="w-full px-3 py-2 rounded border text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
disabled={loading}
/>
</div>
{/* Footer Actions */}
<div
className="p-6 border-t flex items-center justify-between"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)'
}}
>
<div>
{summary.hasIssues && (
<p className="text-sm flex items-center gap-2" style={{ color: 'var(--color-warning-700)' }}>
<AlertTriangle className="w-4 h-4" />
This delivery has quality issues or rejections
</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg font-medium transition-colors"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-secondary)',
border: '1px solid var(--border-primary)',
}}
disabled={loading}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || summary.totalDelivered === 0}
className="px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2"
style={{
backgroundColor: loading ? 'var(--color-info-300)' : 'var(--color-info-600)',
color: 'white',
opacity: loading || summary.totalDelivered === 0 ? 0.6 : 1,
cursor: loading || summary.totalDelivered === 0 ? 'not-allowed' : 'pointer',
}}
>
{loading ? (
<>Processing...</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Record Delivery
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
// Procurement Components - Components for procurement and purchase order management
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
export { DeliveryReceiptModal } from './DeliveryReceiptModal';

View File

@@ -0,0 +1,231 @@
/**
* POI Category Accordion Component
*
* Expandable accordion showing detailed POI information by category
*/
import React, { useState } from 'react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/Accordion';
import { Badge } from '@/components/ui/Badge';
import { Progress } from '@/components/ui/Progress';
import type { POIContext, POICategoryData } from '@/types/poi';
import { POI_CATEGORY_METADATA, formatDistance, getImpactLevel, IMPACT_LEVELS } from '@/types/poi';
interface POICategoryAccordionProps {
poiContext: POIContext;
selectedCategory?: string | null;
onCategorySelect?: (category: string | null) => void;
}
export const POICategoryAccordion: React.FC<POICategoryAccordionProps> = ({
poiContext,
selectedCategory,
onCategorySelect
}) => {
// Sort categories by proximity score (descending)
const sortedCategories = Object.entries(poiContext.poi_detection_results)
.filter(([_, data]) => data.count > 0)
.sort((a, b) => b[1].features.proximity_score - a[1].features.proximity_score);
const renderCategoryDetails = (category: string, data: POICategoryData) => {
const { features } = data;
const impactLevel = getImpactLevel(features.proximity_score);
const impactConfig = IMPACT_LEVELS[impactLevel];
return (
<div className="space-y-4">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-xs text-gray-600 mb-1">Total Count</div>
<div className="text-2xl font-bold">{features.total_count}</div>
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Proximity Score</div>
<div className="text-2xl font-bold text-blue-600">
{features.proximity_score.toFixed(2)}
</div>
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Nearest</div>
<div className="text-lg font-semibold">
{formatDistance(features.distance_to_nearest_m)}
</div>
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Impact Level</div>
<Badge
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
>
{impactConfig.label}
</Badge>
</div>
</div>
{/* Distance Distribution */}
<div>
<div className="text-sm font-semibold text-gray-700 mb-3">
Distance Distribution
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span>0-100m (Immediate)</span>
<span className="font-medium">{features.count_0_100m}</span>
</div>
<Progress
value={(features.count_0_100m / features.total_count) * 100}
className="h-2"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>100-300m (Primary)</span>
<span className="font-medium">{features.count_100_300m}</span>
</div>
<Progress
value={(features.count_100_300m / features.total_count) * 100}
className="h-2"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>300-500m (Secondary)</span>
<span className="font-medium">{features.count_300_500m}</span>
</div>
<Progress
value={(features.count_300_500m / features.total_count) * 100}
className="h-2"
/>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span>500-1000m (Tertiary)</span>
<span className="font-medium">{features.count_500_1000m}</span>
</div>
<Progress
value={(features.count_500_1000m / features.total_count) * 100}
className="h-2"
/>
</div>
</div>
</div>
{/* POI List */}
{data.pois.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-2">
Locations ({data.pois.length})
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{data.pois.map((poi, index) => (
<div
key={`${poi.osm_id}-${index}`}
className="p-2 border border-gray-200 rounded hover:bg-gray-50 transition-colors"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="font-medium text-sm">{poi.name}</div>
{poi.tags.addr_street && (
<div className="text-xs text-gray-600 mt-1">
{poi.tags.addr_street}
{poi.tags.addr_housenumber && ` ${poi.tags.addr_housenumber}`}
</div>
)}
</div>
{poi.distance_m !== undefined && (
<Badge variant="outline" className="text-xs ml-2">
{formatDistance(poi.distance_m)}
</Badge>
)}
</div>
{poi.zone && (
<div className="text-xs text-gray-500 mt-1">
Zone: {poi.zone.replace('_', ' ')}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Error Message */}
{data.error && (
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
Error: {data.error}
</div>
)}
</div>
);
};
if (sortedCategories.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No POIs detected in any category
</div>
);
}
return (
<div className="space-y-2">
<Accordion type="single" collapsible className="w-full">
{sortedCategories.map(([category, data]) => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
const impactLevel = getImpactLevel(data.features.proximity_score);
const impactConfig = IMPACT_LEVELS[impactLevel];
const isSelected = selectedCategory === category;
return (
<AccordionItem key={category} value={category}>
<AccordionTrigger
className={`hover:no-underline ${isSelected ? 'bg-blue-50' : ''}`}
onClick={() => onCategorySelect?.(isSelected ? null : category)}
>
<div className="flex items-center justify-between w-full pr-4">
<div className="flex items-center gap-3">
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
<div className="text-left">
<div className="font-semibold">{metadata.displayName}</div>
<div className="text-xs text-gray-600 font-normal">
{data.count} {data.count === 1 ? 'location' : 'locations'}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
>
{impactConfig.label}
</Badge>
<div className="text-right">
<div className="text-sm font-medium">
Score: {data.features.proximity_score.toFixed(2)}
</div>
<div className="text-xs text-gray-600">
Nearest: {formatDistance(data.features.distance_to_nearest_m)}
</div>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pt-4 pb-2 px-2">
{renderCategoryDetails(category, data)}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
);
};

View File

@@ -0,0 +1,314 @@
/**
* POI Context View Component
*
* Main view for POI detection results and management
* Displays map, summary, and detailed category information
*/
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Alert, AlertDescription } from '@/components/ui/Alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { RefreshCw, AlertCircle, CheckCircle, MapPin } from 'lucide-react';
import { POIMap } from './POIMap';
import { POISummaryCard } from './POISummaryCard';
import { POICategoryAccordion } from './POICategoryAccordion';
import { usePOIContext } from '@/hooks/usePOIContext';
import { Loader } from '@/components/ui/Loader';
interface POIContextViewProps {
tenantId: string;
bakeryLocation?: {
latitude: number;
longitude: number;
};
}
export const POIContextView: React.FC<POIContextViewProps> = ({
tenantId,
bakeryLocation
}) => {
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('overview');
const {
poiContext,
isLoading,
isRefreshing,
error,
isStale,
needsRefresh,
competitorAnalysis,
competitiveInsights,
detectPOIs,
refreshPOIs,
fetchContext,
fetchCompetitorAnalysis
} = usePOIContext({ tenantId, autoFetch: true });
// Fetch competitor analysis when POI context is available
useEffect(() => {
if (poiContext && !competitorAnalysis) {
fetchCompetitorAnalysis();
}
}, [poiContext, competitorAnalysis, fetchCompetitorAnalysis]);
// Handle initial POI detection if no context exists
const handleInitialDetection = async () => {
if (!bakeryLocation) {
return;
}
await detectPOIs(bakeryLocation.latitude, bakeryLocation.longitude);
};
// Handle POI refresh
const handleRefresh = async () => {
await refreshPOIs();
};
if (isLoading && !poiContext) {
return (
<Card>
<CardContent className="py-12">
<Loader size="large" text="Loading POI context..." />
</CardContent>
</Card>
);
}
// No POI context - show detection prompt
if (!poiContext && !error) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Location Context
</CardTitle>
<CardDescription>
Detect nearby points of interest to enhance demand forecasting accuracy
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
POI detection has not been run for this location. Click the button below to
automatically detect nearby schools, offices, transport hubs, and other
points of interest that may affect bakery demand.
</AlertDescription>
</Alert>
{bakeryLocation && (
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">
Bakery Location
</div>
<div className="text-sm text-gray-600">
Latitude: {bakeryLocation.latitude.toFixed(6)}
</div>
<div className="text-sm text-gray-600">
Longitude: {bakeryLocation.longitude.toFixed(6)}
</div>
</div>
)}
<Button
onClick={handleInitialDetection}
disabled={!bakeryLocation || isLoading}
size="lg"
className="w-full"
>
{isLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Detecting POIs...
</>
) : (
<>
<MapPin className="mr-2 h-4 w-4" />
Detect Points of Interest
</>
)}
</Button>
{!bakeryLocation && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Bakery location is required for POI detection. Please ensure your
bakery address has been geocoded.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
);
}
// Error state
if (error && !poiContext) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
Error Loading POI Context
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button onClick={fetchContext} className="mt-4">
Retry
</Button>
</CardContent>
</Card>
);
}
if (!poiContext) return null;
return (
<div className="space-y-6">
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Location Context & POI Analysis
</CardTitle>
<CardDescription>
Detected {poiContext.total_pois_detected} points of interest around your bakery
</CardDescription>
</div>
<div className="flex gap-2">
{(isStale || needsRefresh) && (
<Alert variant="warning" className="mb-0">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
POI data may be outdated
</AlertDescription>
</Alert>
)}
<Button
onClick={handleRefresh}
disabled={isRefreshing}
variant="outline"
size="sm"
>
{isRefreshing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Refreshing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh POI Data
</>
)}
</Button>
</div>
</div>
</CardHeader>
</Card>
{/* Competitive Insights */}
{competitiveInsights && competitiveInsights.length > 0 && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">Competitive Analysis</div>
<ul className="space-y-1 text-sm">
{competitiveInsights.map((insight, index) => (
<li key={index}>{insight}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{/* Main Content */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="overview">Overview & Map</TabsTrigger>
<TabsTrigger value="categories">Detailed Categories</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Map */}
<div className="lg:col-span-2 h-[600px]">
<POIMap
poiContext={poiContext}
selectedCategory={selectedCategory}
/>
</div>
{/* Summary */}
<div className="lg:col-span-1">
<POISummaryCard
poiContext={poiContext}
onCategorySelect={setSelectedCategory}
/>
</div>
</div>
</TabsContent>
<TabsContent value="categories" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>POI Categories</CardTitle>
<CardDescription>
Detailed breakdown of detected points of interest by category
</CardDescription>
</CardHeader>
<CardContent>
<POICategoryAccordion
poiContext={poiContext}
selectedCategory={selectedCategory}
onCategorySelect={setSelectedCategory}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Detection Metadata */}
<Card>
<CardHeader>
<CardTitle className="text-sm">Detection Metadata</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-gray-600">Detection Date</div>
<div className="font-medium">
{new Date(poiContext.detection_timestamp).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-gray-600">Source</div>
<div className="font-medium capitalize">{poiContext.detection_source}</div>
</div>
<div>
<div className="text-gray-600">Relevant Categories</div>
<div className="font-medium">{poiContext.relevant_categories?.length || 0}</div>
</div>
<div>
<div className="text-gray-600">ML Features</div>
<div className="font-medium">
{Object.keys(poiContext.ml_features || {}).length}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,201 @@
/**
* POI Map Component
*
* Interactive map visualization of POIs around bakery location
* Uses Leaflet for mapping and displays POIs with color-coded markers
*/
import React, { useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Circle, Popup, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { POIContext, POI } from '@/types/poi';
import { POI_CATEGORY_METADATA, formatDistance } from '@/types/poi';
// Fix for default marker icons in Leaflet
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
});
interface POIMapProps {
poiContext: POIContext;
selectedCategory?: string | null;
}
// Helper component to create custom colored icons
function createColoredIcon(color: string, emoji: string): L.DivIcon {
return L.divIcon({
className: 'custom-poi-marker',
html: `<div style="background-color: ${color}; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3); font-size: 18px;">${emoji}</div>`,
iconSize: [32, 32],
iconAnchor: [16, 16]
});
}
function createBakeryIcon(): L.DivIcon {
return L.divIcon({
className: 'bakery-marker',
html: `<div style="background-color: #dc2626; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 3px solid white; box-shadow: 0 3px 6px rgba(0,0,0,0.4); font-size: 24px;">🏪</div>`,
iconSize: [40, 40],
iconAnchor: [20, 20]
});
}
// Component to recenter map when location changes
function MapRecenter({ center }: { center: [number, number] }) {
const map = useMap();
React.useEffect(() => {
map.setView(center, map.getZoom());
}, [center, map]);
return null;
}
export const POIMap: React.FC<POIMapProps> = ({ poiContext, selectedCategory }) => {
const center: [number, number] = [
poiContext.location.latitude,
poiContext.location.longitude
];
// Filter POIs by selected category
const poisToDisplay = useMemo(() => {
const pois: Array<{ category: string; poi: POI }> = [];
Object.entries(poiContext.poi_detection_results).forEach(([category, data]) => {
if (selectedCategory && selectedCategory !== category) {
return; // Skip if category filter is active and doesn't match
}
data.pois.forEach(poi => {
pois.push({ category, poi });
});
});
return pois;
}, [poiContext, selectedCategory]);
return (
<div className="relative w-full h-full rounded-lg overflow-hidden border border-gray-200">
<MapContainer
center={center}
zoom={15}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
>
<MapRecenter center={center} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Bakery marker */}
<Marker position={center} icon={createBakeryIcon()}>
<Popup>
<div className="text-center">
<div className="font-semibold text-base">Your Bakery</div>
<div className="text-sm text-gray-600 mt-1">
{center[0].toFixed(6)}, {center[1].toFixed(6)}
</div>
</div>
</Popup>
</Marker>
{/* Distance rings */}
<Circle
center={center}
radius={100}
pathOptions={{
color: '#22c55e',
fillColor: '#22c55e',
fillOpacity: 0.05,
weight: 2,
dashArray: '5, 5'
}}
/>
<Circle
center={center}
radius={300}
pathOptions={{
color: '#f59e0b',
fillColor: '#f59e0b',
fillOpacity: 0.03,
weight: 2,
dashArray: '5, 5'
}}
/>
<Circle
center={center}
radius={500}
pathOptions={{
color: '#ef4444',
fillColor: '#ef4444',
fillOpacity: 0.02,
weight: 2,
dashArray: '5, 5'
}}
/>
{/* POI markers */}
{poisToDisplay.map(({ category, poi }, index) => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
return (
<Marker
key={`${category}-${poi.osm_id}-${index}`}
position={[poi.lat, poi.lon]}
icon={createColoredIcon(metadata.color, metadata.icon)}
>
<Popup>
<div className="min-w-[200px]">
<div className="flex items-center gap-2 mb-2">
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
<div>
<div className="font-semibold">{poi.name}</div>
<div className="text-xs text-gray-600">{metadata.displayName}</div>
</div>
</div>
{poi.distance_m && (
<div className="text-sm text-gray-700 mt-1">
Distance: <span className="font-medium">{formatDistance(poi.distance_m)}</span>
</div>
)}
{poi.zone && (
<div className="text-sm text-gray-700">
Zone: <span className="font-medium capitalize">{poi.zone.replace('_', ' ')}</span>
</div>
)}
<div className="text-xs text-gray-500 mt-2">
OSM ID: {poi.osm_id}
</div>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
{/* Legend */}
<div className="absolute bottom-4 right-4 bg-white rounded-lg shadow-lg p-3 max-w-xs z-[1000]">
<div className="font-semibold text-sm mb-2">Distance Rings</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 border-t-2 border-green-500 border-dashed"></div>
<span>100m - Immediate</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 border-t-2 border-orange-500 border-dashed"></div>
<span>300m - Primary</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 border-t-2 border-red-500 border-dashed"></div>
<span>500m - Secondary</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,185 @@
/**
* POI Summary Card Component
*
* Displays summary statistics and high-impact categories
*/
import React from 'react';
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { POIContext } from '@/types/poi';
import { POI_CATEGORY_METADATA, getImpactLevel, IMPACT_LEVELS } from '@/types/poi';
interface POISummaryCardProps {
poiContext: POIContext;
onCategorySelect?: (category: string) => void;
}
export const POISummaryCard: React.FC<POISummaryCardProps> = ({
poiContext,
onCategorySelect
}) => {
const highImpactCategories = poiContext.high_impact_categories || [];
const relevantCategories = poiContext.relevant_categories || [];
// Calculate category impact levels
const categoryImpacts = Object.entries(poiContext.poi_detection_results)
.map(([category, data]) => ({
category,
proximityScore: data.features.proximity_score,
count: data.count,
impactLevel: getImpactLevel(data.features.proximity_score)
}))
.filter(item => item.count > 0)
.sort((a, b) => b.proximityScore - a.proximityScore);
const detectionDate = poiContext.detection_timestamp
? new Date(poiContext.detection_timestamp).toLocaleDateString()
: 'Unknown';
const needsRefresh = poiContext.next_refresh_date
? new Date(poiContext.next_refresh_date) < new Date()
: false;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>POI Summary</span>
{needsRefresh && (
<Badge variant="warning" className="text-xs">
Refresh Recommended
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Total POIs */}
<div>
<div className="text-sm text-gray-600 mb-1">Total POIs Detected</div>
<div className="text-3xl font-bold text-gray-900">
{poiContext.total_pois_detected}
</div>
</div>
{/* Detection Info */}
<div className="space-y-1">
<div className="text-sm text-gray-600">Detection Date</div>
<div className="text-sm font-medium">{detectionDate}</div>
</div>
{/* Detection Status */}
<div className="space-y-1">
<div className="text-sm text-gray-600">Status</div>
<Badge
variant={
poiContext.detection_status === 'completed'
? 'success'
: poiContext.detection_status === 'partial'
? 'warning'
: 'destructive'
}
>
{poiContext.detection_status}
</Badge>
</div>
{/* Impact Categories */}
{categoryImpacts.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-2">
Impact by Category
</div>
<div className="space-y-2">
{categoryImpacts.map(({ category, count, proximityScore, impactLevel }) => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
const impactConfig = IMPACT_LEVELS[impactLevel];
return (
<div
key={category}
className="flex items-center justify-between p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => onCategorySelect?.(category)}
>
<div className="flex items-center gap-2">
<span style={{ fontSize: '20px' }}>{metadata.icon}</span>
<div>
<div className="text-sm font-medium">
{metadata.displayName}
</div>
<div className="text-xs text-gray-600">
{count} {count === 1 ? 'location' : 'locations'}
</div>
</div>
</div>
<div className="text-right">
<Badge
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
className="text-xs"
>
{impactConfig.label}
</Badge>
<div className="text-xs text-gray-600 mt-1">
Score: {proximityScore.toFixed(2)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* High Impact Highlights */}
{highImpactCategories.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-2">
High Impact Factors
</div>
<div className="flex flex-wrap gap-2">
{highImpactCategories.map(category => {
const metadata = POI_CATEGORY_METADATA[category];
if (!metadata) return null;
return (
<Badge
key={category}
variant="success"
className="cursor-pointer"
onClick={() => onCategorySelect?.(category)}
>
{metadata.icon} {metadata.displayName}
</Badge>
);
})}
</div>
</div>
)}
{/* ML Features Count */}
<div className="pt-3 border-t border-gray-200">
<div className="text-sm text-gray-600">ML Features Generated</div>
<div className="text-2xl font-bold text-blue-600">
{Object.keys(poiContext.ml_features || {}).length}
</div>
<div className="text-xs text-gray-500 mt-1">
Used for demand forecasting
</div>
</div>
{/* Location Coordinates */}
<div className="pt-3 border-t border-gray-200 text-xs text-gray-500">
<div className="font-semibold mb-1">Location</div>
<div>
Lat: {poiContext.location.latitude.toFixed(6)}
</div>
<div>
Lon: {poiContext.location.longitude.toFixed(6)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -720,6 +720,7 @@ const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
// Create individual sales records for each item
for (const item of data.salesItems) {
const salesData = {
inventory_product_id: item.productId || null, // Include inventory product ID for stock tracking
product_name: item.product,
product_category: 'general', // Could be enhanced with category selection
quantity_sold: item.quantity,