Add POI feature and imporve the overall backend implementation
This commit is contained in:
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
256
frontend/src/components/domain/pos/POSSyncStatus.tsx
Normal file
256
frontend/src/components/domain/pos/POSSyncStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
231
frontend/src/components/domain/settings/POICategoryAccordion.tsx
Normal file
231
frontend/src/components/domain/settings/POICategoryAccordion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
314
frontend/src/components/domain/settings/POIContextView.tsx
Normal file
314
frontend/src/components/domain/settings/POIContextView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
201
frontend/src/components/domain/settings/POIMap.tsx
Normal file
201
frontend/src/components/domain/settings/POIMap.tsx
Normal 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='© <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>
|
||||
);
|
||||
};
|
||||
185
frontend/src/components/domain/settings/POISummaryCard.tsx
Normal file
185
frontend/src/components/domain/settings/POISummaryCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user