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

@@ -19,17 +19,25 @@ import {
Euro,
ChevronDown,
ChevronUp,
X,
Package,
Building2,
Calendar,
Truck,
} from 'lucide-react';
import { ActionItem, ActionQueue } from '../../api/hooks/newDashboard';
import { useReasoningFormatter } from '../../hooks/useReasoningTranslation';
import { useTranslation } from 'react-i18next';
import { usePurchaseOrder } from '../../api/hooks/purchase-orders';
interface ActionQueueCardProps {
actionQueue: ActionQueue;
loading?: boolean;
onApprove?: (actionId: string) => void;
onReject?: (actionId: string, reason: string) => void;
onViewDetails?: (actionId: string) => void;
onModify?: (actionId: string) => void;
tenantId?: string;
}
const urgencyConfig = {
@@ -62,20 +70,34 @@ const urgencyConfig = {
function ActionItemCard({
action,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: {
action: ActionItem;
onApprove?: (id: string) => void;
onReject?: (id: string, reason: string) => void;
onViewDetails?: (id: string) => void;
onModify?: (id: string) => void;
tenantId?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [rejectionReason, setRejectionReason] = useState('');
const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal;
const UrgencyIcon = config.icon;
const { formatPOAction } = useReasoningFormatter();
const { t } = useTranslation('reasoning');
// Fetch PO details if this is a PO action and details are expanded
const { data: poDetail } = usePurchaseOrder(
tenantId || '',
action.id,
{ enabled: !!tenantId && showDetails && action.type === 'po_approval' }
);
// Translate reasoning_data (or fallback to deprecated text fields)
// Memoize to prevent undefined values from being created on each render
const { reasoning, consequence, severity } = useMemo(() => {
@@ -166,6 +188,157 @@ function ActionItemCard({
</>
)}
{/* Inline PO Details (expandable) */}
{action.type === 'po_approval' && (
<>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-2 text-sm font-medium transition-colors mb-3 w-full"
style={{ color: 'var(--color-info-700)' }}
>
{showDetails ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<Package className="w-4 h-4" />
<span>View Order Details</span>
</button>
{showDetails && poDetail && (
<div
className="border rounded-md p-4 mb-3 space-y-3"
style={{
backgroundColor: 'var(--bg-primary)',
borderColor: 'var(--border-primary)',
}}
>
{/* Supplier Info */}
<div className="flex items-start gap-2">
<Building2 className="w-5 h-5 flex-shrink-0" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{poDetail.supplier?.name || 'Supplier'}
</p>
{poDetail.supplier?.contact_person && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Contact: {poDetail.supplier.contact_person}
</p>
)}
{poDetail.supplier?.email && (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{poDetail.supplier.email}
</p>
)}
</div>
</div>
{/* Delivery Date & Tracking */}
{poDetail.required_delivery_date && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5" style={{ color: 'var(--color-warning-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Required Delivery
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.required_delivery_date).toLocaleDateString()}
</p>
</div>
</div>
{/* Estimated Delivery Date (shown after approval) */}
{poDetail.estimated_delivery_date && (
<div className="flex items-center gap-2 ml-7">
<Truck className="w-4 h-4" style={{ color: 'var(--color-info-600)' }} />
<div className="flex-1">
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
Expected Arrival
</p>
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
{new Date(poDetail.estimated_delivery_date).toLocaleDateString()}
</p>
</div>
{(() => {
const now = new Date();
const estimatedDate = new Date(poDetail.estimated_delivery_date);
const daysUntil = Math.ceil((estimatedDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let statusColor = 'var(--color-success-600)';
let statusText = 'On Track';
if (daysUntil < 0) {
statusColor = 'var(--color-error-600)';
statusText = `${Math.abs(daysUntil)}d Overdue`;
} else if (daysUntil === 0) {
statusColor = 'var(--color-warning-600)';
statusText = 'Due Today';
} else if (daysUntil <= 2) {
statusColor = 'var(--color-warning-600)';
statusText = `${daysUntil}d Left`;
} else {
statusText = `${daysUntil}d Left`;
}
return (
<span
className="px-2 py-1 rounded text-xs font-semibold"
style={{
backgroundColor: statusColor.replace('600', '100'),
color: statusColor,
}}
>
{statusText}
</span>
);
})()}
</div>
)}
</div>
)}
{/* Line Items */}
{poDetail.items && poDetail.items.length > 0 && (
<div>
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>
Order Items ({poDetail.items.length})
</p>
<div className="space-y-2 max-h-48 overflow-y-auto">
{poDetail.items.map((item, idx) => (
<div
key={idx}
className="flex justify-between items-start p-2 rounded"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{item.product_name || item.product_code || 'Product'}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{item.ordered_quantity} {item.unit_of_measure} × {parseFloat(item.unit_price).toFixed(2)}
</p>
</div>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>
{parseFloat(item.line_total).toFixed(2)}
</p>
</div>
))}
</div>
</div>
)}
{/* Total Amount */}
<div
className="border-t pt-2 flex justify-between items-center"
style={{ borderColor: 'var(--border-primary)' }}
>
<p className="text-sm font-bold" style={{ color: 'var(--text-primary)' }}>Total Amount</p>
<p className="text-lg font-bold" style={{ color: 'var(--color-info-700)' }}>
{parseFloat(poDetail.total_amount).toFixed(2)}
</p>
</div>
</div>
)}
</>
)}
{/* Time Estimate */}
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
<Clock className="w-4 h-4" />
@@ -174,6 +347,79 @@ function ActionItemCard({
</span>
</div>
{/* Rejection Modal */}
{showRejectModal && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={() => setShowRejectModal(false)}
>
<div
className="rounded-lg p-6 max-w-md w-full"
style={{ backgroundColor: 'var(--bg-primary)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
Reject Purchase Order
</h3>
<button
onClick={() => setShowRejectModal(false)}
className="p-1 rounded hover:bg-opacity-10 hover:bg-black"
>
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
Please provide a reason for rejecting this purchase order:
</p>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
placeholder="Enter rejection reason..."
className="w-full p-3 border rounded-lg mb-4 min-h-24"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border-primary)',
color: 'var(--text-primary)',
}}
/>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowRejectModal(false)}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
}}
>
Cancel
</button>
<button
onClick={() => {
if (onReject && rejectionReason.trim()) {
onReject(action.id, rejectionReason);
setShowRejectModal(false);
setRejectionReason('');
}
}}
disabled={!rejectionReason.trim()}
className="px-4 py-2 rounded-lg font-semibold transition-colors"
style={{
backgroundColor: rejectionReason.trim() ? 'var(--color-error-600)' : 'var(--bg-quaternary)',
color: rejectionReason.trim() ? 'white' : 'var(--text-tertiary)',
cursor: rejectionReason.trim() ? 'pointer' : 'not-allowed',
}}
>
Reject Order
</button>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{(action.actions || []).map((button, index) => {
@@ -210,6 +456,8 @@ function ActionItemCard({
const handleClick = () => {
if (button.action === 'approve' && onApprove) {
onApprove(action.id);
} else if (button.action === 'reject') {
setShowRejectModal(true);
} else if (button.action === 'view_details' && onViewDetails) {
onViewDetails(action.id);
} else if (button.action === 'modify' && onModify) {
@@ -232,6 +480,7 @@ function ActionItemCard({
style={currentStyle}
>
{button.action === 'approve' && <CheckCircle2 className="w-4 h-4" />}
{button.action === 'reject' && <X className="w-4 h-4" />}
{button.action === 'view_details' && <Eye className="w-4 h-4" />}
{button.action === 'modify' && <Edit className="w-4 h-4" />}
{button.label}
@@ -247,8 +496,10 @@ export function ActionQueueCard({
actionQueue,
loading,
onApprove,
onReject,
onViewDetails,
onModify,
tenantId,
}: ActionQueueCardProps) {
const [showAll, setShowAll] = useState(false);
const { t } = useTranslation('reasoning');
@@ -338,8 +589,10 @@ export function ActionQueueCard({
key={action.id}
action={action}
onApprove={onApprove}
onReject={onReject}
onViewDetails={onViewDetails}
onModify={onModify}
tenantId={tenantId}
/>
))}
</div>

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,

View File

@@ -588,6 +588,14 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
);
};
// Get tour attribute for navigation item
const getTourAttribute = (path: string): string | undefined => {
if (path === '/app/database') return 'sidebar-database';
if (path === '/app/operations') return 'sidebar-operations';
if (path === '/app/analytics') return 'sidebar-analytics';
return undefined;
};
// Render navigation item
const renderItem = (item: NavigationItem, level = 0) => {
const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/');
@@ -595,6 +603,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const hasChildren = item.children && item.children.length > 0;
const isHovered = hoveredItem === item.id;
const ItemIcon = item.icon;
const tourAttr = getTourAttribute(item.path);
const itemContent = (
<div
@@ -676,6 +685,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
onClick={() => handleItemClick(item)}
disabled={item.disabled}
data-path={item.path}
data-tour={tourAttr}
onMouseEnter={() => {
if (isCollapsed && hasChildren && level === 0 && item.children && item.children.length > 0) {
setHoveredItem(item.id);

View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1 @@
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion';

View File

@@ -0,0 +1,191 @@
/**
* Address Autocomplete Component
*
* Provides autocomplete functionality for address input with geocoding
*/
import React, { useRef, useEffect, useState } from 'react';
import { MapPin, Loader2, X, Check } from 'lucide-react';
import { Input, Button, Card, CardBody } from '@/components/ui';
import { useAddressAutocomplete } from '@/hooks/useAddressAutocomplete';
import { AddressResult } from '@/services/api/geocodingApi';
interface AddressAutocompleteProps {
value?: string;
placeholder?: string;
onAddressSelect?: (address: AddressResult) => void;
onCoordinatesChange?: (lat: number, lon: number) => void;
className?: string;
disabled?: boolean;
countryCode?: string;
required?: boolean;
}
export const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
value,
placeholder = 'Enter bakery address...',
onAddressSelect,
onCoordinatesChange,
className = '',
disabled = false,
countryCode = 'es',
required = false
}) => {
const {
query,
setQuery,
results,
isLoading,
error,
selectedAddress,
selectAddress,
clearSelection
} = useAddressAutocomplete({ countryCode });
const [showResults, setShowResults] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
// Initialize query from value prop
useEffect(() => {
if (value && !query) {
setQuery(value);
}
}, [value, query, setQuery]);
// Close results dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setShowResults(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setQuery(newQuery);
setShowResults(true);
};
const handleSelectAddress = (address: AddressResult) => {
selectAddress(address);
setShowResults(false);
// Notify parent components
if (onAddressSelect) {
onAddressSelect(address);
}
if (onCoordinatesChange) {
onCoordinatesChange(address.lat, address.lon);
}
};
const handleClear = () => {
clearSelection();
setShowResults(false);
};
const handleInputFocus = () => {
if (results.length > 0) {
setShowResults(true);
}
};
return (
<div ref={wrapperRef} className={`relative ${className}`}>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
value={query}
onChange={handleInputChange}
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
required={required}
className={`pl-10 pr-10 ${selectedAddress ? 'border-green-500' : ''}`}
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
{isLoading && (
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
)}
{selectedAddress && !isLoading && (
<Check className="h-4 w-4 text-green-600" />
)}
{query && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
className="h-6 w-6 p-0 hover:bg-gray-100"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* Error message */}
{error && (
<div className="mt-1 text-sm text-red-600">
{error}
</div>
)}
{/* Results dropdown */}
{showResults && results.length > 0 && (
<Card className="absolute z-50 w-full mt-1 max-h-80 overflow-y-auto shadow-lg">
<CardBody className="p-0">
<div className="divide-y divide-gray-100">
{results.map((result) => (
<button
key={result.place_id}
type="button"
onClick={() => handleSelectAddress(result)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 focus:bg-gray-50 focus:outline-none transition-colors"
>
<div className="flex items-start gap-3">
<MapPin className="h-4 w-4 text-blue-600 mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{result.address.road && result.address.house_number
? `${result.address.road}, ${result.address.house_number}`
: result.address.road || result.display_name}
</div>
<div className="text-xs text-gray-600 truncate mt-0.5">
{result.address.city || result.address.municipality || result.address.suburb}
{result.address.postcode && `, ${result.address.postcode}`}
</div>
<div className="text-xs text-gray-400 mt-1">
{result.lat.toFixed(6)}, {result.lon.toFixed(6)}
</div>
</div>
</div>
</button>
))}
</div>
</CardBody>
</Card>
)}
{/* No results message */}
{showResults && !isLoading && query.length >= 3 && results.length === 0 && !error && (
<Card className="absolute z-50 w-full mt-1 shadow-lg">
<CardBody className="p-4">
<div className="text-sm text-gray-600 text-center">
No addresses found for "{query}"
</div>
</CardBody>
</Card>
)}
</div>
);
};

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'border-amber-500/50 text-amber-700 dark:border-amber-500 [&>svg]:text-amber-500',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1 @@
export { Alert, AlertTitle, AlertDescription } from './Alert';

View File

@@ -0,0 +1,101 @@
import React, { useEffect, useRef, useState } from 'react';
import { motion, useInView, useSpring, useTransform } from 'framer-motion';
export interface AnimatedCounterProps {
/** The target value to count to */
value: number;
/** Duration of the animation in seconds */
duration?: number;
/** Number of decimal places to display */
decimals?: number;
/** Prefix to display before the number (e.g., "€", "$") */
prefix?: string;
/** Suffix to display after the number (e.g., "%", "/mes") */
suffix?: string;
/** Additional CSS classes */
className?: string;
/** Delay before animation starts (in seconds) */
delay?: number;
/** Whether to animate on mount or when in view */
animateOnMount?: boolean;
}
/**
* AnimatedCounter - Animates numbers counting up from 0 to target value
*
* Features:
* - Smooth spring-based animation
* - Configurable duration and delay
* - Support for decimals, prefix, and suffix
* - Triggers animation when scrolling into view
* - Accessible with proper number formatting
*
* @example
* <AnimatedCounter value={2000} prefix="€" suffix="/mes" />
* <AnimatedCounter value={92} suffix="%" decimals={0} />
*/
export const AnimatedCounter: React.FC<AnimatedCounterProps> = ({
value,
duration = 2,
decimals = 0,
prefix = '',
suffix = '',
className = '',
delay = 0,
animateOnMount = false,
}) => {
const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.5 });
const [hasAnimated, setHasAnimated] = useState(false);
const shouldAnimate = animateOnMount || isInView;
// Spring animation for smooth counting
const spring = useSpring(0, {
damping: 30,
stiffness: 50,
duration: duration * 1000,
});
const display = useTransform(spring, (current) =>
current.toFixed(decimals)
);
useEffect(() => {
if (shouldAnimate && !hasAnimated) {
const timer = setTimeout(() => {
spring.set(value);
setHasAnimated(true);
}, delay * 1000);
return () => clearTimeout(timer);
}
}, [shouldAnimate, hasAnimated, value, spring, delay]);
const [displayValue, setDisplayValue] = useState('0');
useEffect(() => {
const unsubscribe = display.on('change', (latest) => {
setDisplayValue(latest);
});
return unsubscribe;
}, [display]);
return (
<motion.span
ref={ref}
className={className}
initial={{ opacity: 0, y: 20 }}
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.5, delay: delay }}
aria-live="polite"
aria-atomic="true"
>
{prefix}
{displayValue}
{suffix}
</motion.span>
);
};
export default AnimatedCounter;

View File

@@ -25,6 +25,14 @@ export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
justify?: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly';
}
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
}
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
}
const Card = forwardRef<HTMLDivElement, CardProps>(({
variant = 'elevated',
padding = 'md',
@@ -228,10 +236,87 @@ const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(({
);
});
const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(({
as: Component = 'h3',
className,
children,
...props
}, ref) => {
const classes = clsx(
'text-lg font-semibold leading-none tracking-tight',
className
);
return (
<Component
ref={ref}
className={classes}
{...props}
>
{children}
</Component>
);
});
const CardContent = forwardRef<HTMLDivElement, CardContentProps>(({
padding = 'md',
className,
children,
...props
}, ref) => {
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
xl: 'p-8',
};
const classes = clsx(
paddingClasses[padding],
'flex-1',
className
);
return (
<div
ref={ref}
className={classes}
{...props}
>
{children}
</div>
);
});
const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(({
className,
children,
...props
}, ref) => {
const classes = clsx(
'text-sm text-[var(--text-secondary)]',
className
);
return (
<p
ref={ref}
className={classes}
{...props}
>
{children}
</p>
);
});
Card.displayName = 'Card';
CardHeader.displayName = 'CardHeader';
CardBody.displayName = 'CardBody';
CardFooter.displayName = 'CardFooter';
CardTitle.displayName = 'CardTitle';
CardDescription.displayName = 'CardDescription';
CardContent.displayName = 'CardContent';
export default Card;
export { CardHeader, CardBody, CardFooter };
export { CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription };

View File

@@ -1,3 +1,3 @@
export { default } from './Card';
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
export { default as Card, CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription } from './Card';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps, CardContentProps, CardTitleProps } from './Card';

View File

@@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, Search } from 'lucide-react';
export interface FAQItem {
id: string;
question: string;
answer: string;
category?: string;
}
export interface FAQAccordionProps {
items: FAQItem[];
allowMultiple?: boolean;
showSearch?: boolean;
defaultOpen?: string[];
className?: string;
}
/**
* FAQAccordion - Collapsible FAQ component with search
*
* Features:
* - Smooth expand/collapse animations
* - Optional search functionality
* - Category filtering
* - Single or multiple open items
* - Fully accessible (keyboard navigation, ARIA)
*
* @example
* <FAQAccordion
* items={[
* { id: '1', question: '¿Cuántos datos necesito?', answer: '6-12 meses de datos de ventas.' },
* { id: '2', question: '¿Por qué necesito dar mi tarjeta?', answer: 'Para continuar automáticamente...' }
* ]}
* showSearch
* allowMultiple={false}
* />
*/
export const FAQAccordion: React.FC<FAQAccordionProps> = ({
items,
allowMultiple = false,
showSearch = false,
defaultOpen = [],
className = '',
}) => {
const [openItems, setOpenItems] = useState<string[]>(defaultOpen);
const [searchQuery, setSearchQuery] = useState('');
const toggleItem = (id: string) => {
if (allowMultiple) {
setOpenItems((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
} else {
setOpenItems((prev) => (prev.includes(id) ? [] : [id]));
}
};
const filteredItems = items.filter((item) => {
const query = searchQuery.toLowerCase();
return (
item.question.toLowerCase().includes(query) ||
item.answer.toLowerCase().includes(query)
);
});
return (
<div className={className}>
{/* Search */}
{showSearch && (
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar preguntas..."
className="w-full pl-10 pr-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent"
/>
</div>
</div>
)}
{/* FAQ Items */}
<div className="space-y-4">
{filteredItems.length > 0 ? (
filteredItems.map((item) => {
const isOpen = openItems.includes(item.id);
return (
<div
key={item.id}
className="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-primary)] overflow-hidden hover:border-[var(--color-primary)] transition-colors"
>
<button
onClick={() => toggleItem(item.id)}
className="w-full px-6 py-4 flex items-center justify-between gap-4 text-left hover:bg-[var(--bg-primary)] transition-colors"
aria-expanded={isOpen}
aria-controls={`faq-answer-${item.id}`}
>
<span className="text-lg font-bold text-[var(--text-primary)]">
{item.question}
</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="flex-shrink-0"
>
<ChevronDown className="w-5 h-5 text-[var(--text-secondary)]" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={`faq-answer-${item.id}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] },
opacity: { duration: 0.2 },
}}
className="overflow-hidden"
>
<div className="px-6 pb-4 text-[var(--text-secondary)] leading-relaxed">
{item.answer}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
No se encontraron preguntas que coincidan con tu búsqueda.
</div>
)}
</div>
{/* Results count */}
{showSearch && searchQuery && (
<div className="mt-4 text-sm text-[var(--text-tertiary)] text-center">
{filteredItems.length} {filteredItems.length === 1 ? 'resultado' : 'resultados'}
</div>
)}
</div>
);
};
export default FAQAccordion;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { Button } from './Button';
export interface FloatingCTAProps {
/** Text to display in the CTA button */
text: string;
/** Click handler for the CTA button */
onClick: () => void;
/** Icon to display (optional) */
icon?: React.ReactNode;
/** Position of the floating CTA */
position?: 'bottom-right' | 'bottom-left' | 'bottom-center';
/** Minimum scroll position (in pixels) to show the CTA */
showAfterScroll?: number;
/** Allow user to dismiss the CTA */
dismissible?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* FloatingCTA - Persistent call-to-action button that appears on scroll
*
* Features:
* - Appears after scrolling past a threshold
* - Smooth slide-in/slide-out animation
* - Dismissible with close button
* - Configurable position
* - Mobile-responsive
*
* @example
* <FloatingCTA
* text="Solicitar Demo"
* onClick={() => navigate('/demo')}
* position="bottom-right"
* showAfterScroll={500}
* dismissible
* />
*/
export const FloatingCTA: React.FC<FloatingCTAProps> = ({
text,
onClick,
icon,
position = 'bottom-right',
showAfterScroll = 400,
dismissible = true,
className = '',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
const handleScroll = () => {
const scrollPosition = window.scrollY;
setIsVisible(scrollPosition > showAfterScroll && !isDismissed);
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // Check initial position
return () => window.removeEventListener('scroll', handleScroll);
}, [showAfterScroll, isDismissed]);
const handleDismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setIsDismissed(true);
};
const positionClasses = {
'bottom-right': 'bottom-6 right-6',
'bottom-left': 'bottom-6 left-6',
'bottom-center': 'bottom-6 left-1/2 -translate-x-1/2',
};
const slideVariants = {
'bottom-right': {
hidden: { x: 100, opacity: 0 },
visible: { x: 0, opacity: 1 },
exit: { x: 100, opacity: 0 },
},
'bottom-left': {
hidden: { x: -100, opacity: 0 },
visible: { x: 0, opacity: 1 },
exit: { x: -100, opacity: 0 },
},
'bottom-center': {
hidden: { y: 100, opacity: 0 },
visible: { y: 0, opacity: 1 },
exit: { y: 100, opacity: 0 },
},
};
return (
<AnimatePresence>
{isVisible && (
<motion.div
className={`fixed ${positionClasses[position]} z-40 ${className}`}
initial="hidden"
animate="visible"
exit="exit"
variants={slideVariants[position]}
transition={{ type: 'spring', stiffness: 100, damping: 20 }}
>
<div className="relative">
<Button
onClick={onClick}
size="lg"
className="shadow-2xl hover:shadow-3xl transition-shadow duration-300 bg-gradient-to-r from-[var(--color-primary)] to-orange-600 hover:from-[var(--color-primary-dark)] hover:to-orange-700 text-white font-bold"
>
{icon && <span className="mr-2">{icon}</span>}
{text}
</Button>
{dismissible && (
<button
onClick={handleDismiss}
className="absolute -top-2 -right-2 w-6 h-6 bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-800 rounded-full flex items-center justify-center hover:bg-gray-700 dark:hover:bg-gray-300 transition-colors"
aria-label="Cerrar"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
export default FloatingCTA;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoaderProps {
size?: 'sm' | 'md' | 'lg' | 'default';
text?: string;
className?: string;
}
const Loader: React.FC<LoaderProps> = ({ size = 'default', text, className = '' }) => {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
default: 'h-10 w-10',
};
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<Loader2 className={`animate-spin text-primary ${sizeClasses[size]}`} />
{text && <span className="mt-2 text-sm text-muted-foreground">{text}</span>}
</div>
);
};
export { Loader };

View File

@@ -0,0 +1 @@
export { Loader } from './Loader';

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1 @@
export { Progress } from './Progress';

View File

@@ -0,0 +1,82 @@
import React, { useEffect, useState } from 'react';
import { motion, useScroll, useSpring } from 'framer-motion';
export interface ProgressBarProps {
/** Color of the progress bar */
color?: string;
/** Height of the progress bar in pixels */
height?: number;
/** Position of the progress bar */
position?: 'top' | 'bottom';
/** Show progress bar (default: true) */
show?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* ProgressBar - Shows page scroll progress
*
* Features:
* - Smooth animation with spring physics
* - Customizable color and height
* - Can be positioned at top or bottom
* - Automatically hides when at top of page
* - Zero-cost when not visible
*
* @example
* <ProgressBar color="var(--color-primary)" height={4} position="top" />
*/
export const ProgressBar: React.FC<ProgressBarProps> = ({
color = 'var(--color-primary)',
height = 4,
position = 'top',
show = true,
className = '',
}) => {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const unsubscribe = scrollYProgress.on('change', (latest) => {
// Show progress bar when scrolled past 100px
setIsVisible(latest > 0.05);
});
return () => unsubscribe();
}, [scrollYProgress]);
if (!show || !isVisible) {
return null;
}
return (
<motion.div
className={`fixed ${position === 'top' ? 'top-0' : 'bottom-0'} left-0 right-0 z-50 ${className}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
height: `${height}px`,
transformOrigin: '0%',
}}
>
<motion.div
style={{
scaleX,
height: '100%',
background: color,
transformOrigin: '0%',
}}
/>
</motion.div>
);
};
export default ProgressBar;

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { Calculator, TrendingUp } from 'lucide-react';
import { AnimatedCounter } from './AnimatedCounter';
import { Button } from './Button';
export interface SavingsCalculatorProps {
/** Default waste per day in units */
defaultWaste?: number;
/** Price per unit (e.g., €2 per loaf) */
pricePerUnit?: number;
/** Waste reduction percentage with AI (default: 80%) */
wasteReduction?: number;
/** Unit name (e.g., "barras", "loaves") */
unitName?: string;
/** Currency symbol */
currency?: string;
/** Additional CSS classes */
className?: string;
}
/**
* SavingsCalculator - Interactive calculator for waste reduction savings
*
* Features:
* - User inputs their current waste
* - Calculates potential savings with AI
* - Animated number counters
* - Daily, monthly, and yearly projections
* - Visual comparison (before/after)
*
* @example
* <SavingsCalculator
* defaultWaste={50}
* pricePerUnit={2}
* wasteReduction={80}
* unitName="barras"
* currency="€"
* />
*/
export const SavingsCalculator: React.FC<SavingsCalculatorProps> = ({
defaultWaste = 50,
pricePerUnit = 2,
wasteReduction = 80, // 80% reduction (from 50 to 10)
unitName = 'barras',
currency = '€',
className = '',
}) => {
const [wasteUnits, setWasteUnits] = useState<number>(defaultWaste);
const [showResults, setShowResults] = useState(false);
// Calculations
const currentDailyWaste = wasteUnits * pricePerUnit;
const currentMonthlyWaste = currentDailyWaste * 30;
const currentYearlyWaste = currentDailyWaste * 365;
const futureWasteUnits = Math.round(wasteUnits * (1 - wasteReduction / 100));
const futureDailyWaste = futureWasteUnits * pricePerUnit;
const futureMonthlyWaste = futureDailyWaste * 30;
const futureYearlyWaste = futureDailyWaste * 365;
const monthlySavings = currentMonthlyWaste - futureMonthlyWaste;
const yearlySavings = currentYearlyWaste - futureYearlyWaste;
const handleCalculate = () => {
setShowResults(true);
};
return (
<div className={`bg-gradient-to-br from-[var(--bg-primary)] to-[var(--bg-secondary)] rounded-2xl p-6 md:p-8 border-2 border-[var(--color-primary)] shadow-xl ${className}`}>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-[var(--color-primary)] rounded-xl flex items-center justify-center">
<Calculator className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-[var(--text-primary)]">
Calculadora de Ahorros
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Descubre cuánto podrías ahorrar
</p>
</div>
</div>
{/* Input */}
<div className="mb-6">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
¿Cuántas {unitName} tiras al día en promedio?
</label>
<div className="flex gap-3">
<input
type="number"
value={wasteUnits}
onChange={(e) => setWasteUnits(Number(e.target.value))}
min="0"
max="1000"
className="flex-1 px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-primary)] rounded-lg text-[var(--text-primary)] focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent"
placeholder="Ej: 50"
/>
<Button
onClick={handleCalculate}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white"
>
Calcular
</Button>
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-2">
Precio por unidad: {currency}{pricePerUnit}
</p>
</div>
{/* Results */}
{showResults && wasteUnits > 0 && (
<div className="space-y-6 animate-in fade-in duration-500">
{/* Before/After Comparison */}
<div className="grid md:grid-cols-2 gap-4">
{/* Before */}
<div className="bg-red-50 dark:bg-red-900/20 rounded-xl p-4 border-2 border-red-200 dark:border-red-800">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-2">
Ahora (Sin IA)
</p>
<div className="text-2xl font-bold text-red-900 dark:text-red-100">
<AnimatedCounter
value={currentDailyWaste}
prefix={currency}
suffix="/día"
decimals={0}
duration={1.5}
/>
</div>
<p className="text-xs text-red-700 dark:text-red-400 mt-1">
{wasteUnits} {unitName} desperdiciadas
</p>
</div>
{/* After */}
<div className="bg-green-50 dark:bg-green-900/20 rounded-xl p-4 border-2 border-green-200 dark:border-green-800">
<p className="text-sm font-medium text-green-700 dark:text-green-400 mb-2">
Con Bakery-IA
</p>
<div className="text-2xl font-bold text-green-900 dark:text-green-100">
<AnimatedCounter
value={futureDailyWaste}
prefix={currency}
suffix="/día"
decimals={0}
duration={1.5}
delay={0.3}
/>
</div>
<p className="text-xs text-green-700 dark:text-green-400 mt-1">
{futureWasteUnits} {unitName} desperdiciadas
</p>
</div>
</div>
{/* Savings Highlight */}
<div className="bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-3 mb-4">
<TrendingUp className="w-8 h-8" />
<h4 className="text-lg font-bold">Tu Ahorro Estimado</h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-white/80 text-sm mb-1">Al mes</p>
<p className="text-3xl font-bold">
<AnimatedCounter
value={monthlySavings}
prefix={currency}
decimals={0}
duration={2}
delay={0.5}
/>
</p>
</div>
<div>
<p className="text-white/80 text-sm mb-1">Al año</p>
<p className="text-3xl font-bold">
<AnimatedCounter
value={yearlySavings}
prefix={currency}
decimals={0}
duration={2}
delay={0.7}
/>
</p>
</div>
</div>
<p className="text-white/90 text-sm mt-4">
🎯 Reducción de desperdicios: {wasteReduction}% (de {wasteUnits} a {futureWasteUnits} {unitName}/día)
</p>
</div>
{/* ROI Message */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 border-l-4 border-[var(--color-primary)]">
<p className="text-sm font-medium text-[var(--text-primary)]">
💡 <strong>Recuperas la inversión en menos de 1 semana.</strong>
</p>
<p className="text-xs text-[var(--text-secondary)] mt-1">
Basado en predicciones 92% precisas y reducción de desperdicios de 20-40%.
</p>
</div>
</div>
)}
{showResults && wasteUnits === 0 && (
<div className="text-center py-8">
<p className="text-[var(--text-secondary)]">
Introduce una cantidad mayor que 0 para calcular tus ahorros
</p>
</div>
)}
</div>
);
};
export default SavingsCalculator;

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { motion, useInView } from 'framer-motion';
export interface ScrollRevealProps {
/** Children to animate */
children: React.ReactNode;
/** Animation variant */
variant?: 'fadeIn' | 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'scaleUp' | 'scaleDown';
/** Duration of animation in seconds */
duration?: number;
/** Delay before animation starts in seconds */
delay?: number;
/** Only animate once (default: true) */
once?: boolean;
/** Amount of element that must be visible to trigger (0-1) */
amount?: number;
/** Additional CSS classes */
className?: string;
/** Disable animation (renders children directly) */
disabled?: boolean;
}
const variants = {
fadeIn: {
hidden: { opacity: 0 },
visible: { opacity: 1 },
},
fadeUp: {
hidden: { opacity: 0, y: 40 },
visible: { opacity: 1, y: 0 },
},
fadeDown: {
hidden: { opacity: 0, y: -40 },
visible: { opacity: 1, y: 0 },
},
fadeLeft: {
hidden: { opacity: 0, x: 40 },
visible: { opacity: 1, x: 0 },
},
fadeRight: {
hidden: { opacity: 0, x: -40 },
visible: { opacity: 1, x: 0 },
},
scaleUp: {
hidden: { opacity: 0, scale: 0.8 },
visible: { opacity: 1, scale: 1 },
},
scaleDown: {
hidden: { opacity: 0, scale: 1.2 },
visible: { opacity: 1, scale: 1 },
},
};
/**
* ScrollReveal - Wrapper component that animates children when scrolling into view
*
* Features:
* - Multiple animation variants (fade, slide, scale)
* - Configurable duration and delay
* - Triggers only when element is in viewport
* - Respects prefers-reduced-motion
* - Optimized for performance
*
* @example
* <ScrollReveal variant="fadeUp" delay={0.2}>
* <h2>This will fade up when scrolled into view</h2>
* </ScrollReveal>
*/
export const ScrollReveal: React.FC<ScrollRevealProps> = ({
children,
variant = 'fadeUp',
duration = 0.6,
delay = 0,
once = true,
amount = 0.3,
className = '',
disabled = false,
}) => {
const ref = React.useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once, amount });
// Check for prefers-reduced-motion
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (disabled || prefersReducedMotion) {
return <div className={className}>{children}</div>;
}
const selectedVariants = variants[variant];
return (
<motion.div
ref={ref}
className={className}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
variants={selectedVariants}
transition={{
duration,
delay,
ease: [0.25, 0.1, 0.25, 1], // Custom easing for smoother animation
}}
>
{children}
</motion.div>
);
};
export default ScrollReveal;

View File

@@ -0,0 +1,189 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
export interface TimelineStep {
id: string;
number: number;
title: string;
description?: string;
items?: string[];
color: 'blue' | 'purple' | 'green' | 'amber' | 'red' | 'teal';
icon?: React.ReactNode;
}
export interface StepTimelineProps {
steps: TimelineStep[];
orientation?: 'vertical' | 'horizontal';
showConnector?: boolean;
animated?: boolean;
className?: string;
}
const colorClasses = {
blue: {
bg: 'from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20',
border: 'border-blue-200 dark:border-blue-800',
badge: 'bg-blue-600',
icon: 'text-blue-600',
line: 'bg-gradient-to-b from-blue-600 to-indigo-600',
},
purple: {
bg: 'from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20',
border: 'border-purple-200 dark:border-purple-800',
badge: 'bg-purple-600',
icon: 'text-purple-600',
line: 'bg-gradient-to-b from-purple-600 to-pink-600',
},
green: {
bg: 'from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20',
border: 'border-green-200 dark:border-green-800',
badge: 'bg-green-600',
icon: 'text-green-600',
line: 'bg-gradient-to-b from-green-600 to-emerald-600',
},
amber: {
bg: 'from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20',
border: 'border-amber-200 dark:border-amber-800',
badge: 'bg-amber-600',
icon: 'text-amber-600',
line: 'bg-gradient-to-b from-amber-600 to-orange-600',
},
red: {
bg: 'from-red-50 to-rose-50 dark:from-red-900/20 dark:to-rose-900/20',
border: 'border-red-200 dark:border-red-800',
badge: 'bg-red-600',
icon: 'text-red-600',
line: 'bg-gradient-to-b from-red-600 to-rose-600',
},
teal: {
bg: 'from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20',
border: 'border-teal-200 dark:border-teal-800',
badge: 'bg-teal-600',
icon: 'text-teal-600',
line: 'bg-gradient-to-b from-teal-600 to-cyan-600',
},
};
/**
* StepTimeline - Visual timeline for step-by-step processes
*
* Features:
* - Vertical or horizontal orientation
* - Connecting lines between steps
* - Color-coded steps
* - Optional animations
* - Support for icons and lists
*
* @example
* <StepTimeline
* steps={[
* { id: '1', number: 1, title: 'Step 1', color: 'blue', items: ['Item 1', 'Item 2'] },
* { id: '2', number: 2, title: 'Step 2', color: 'purple', items: ['Item 1', 'Item 2'] }
* ]}
* orientation="vertical"
* animated
* />
*/
export const StepTimeline: React.FC<StepTimelineProps> = ({
steps,
orientation = 'vertical',
showConnector = true,
animated = true,
className = '',
}) => {
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
},
},
};
const itemVariants = {
hidden: { opacity: 0, x: orientation === 'vertical' ? -20 : 0, y: orientation === 'horizontal' ? 20 : 0 },
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
return (
<motion.div
className={`${orientation === 'vertical' ? 'space-y-6' : 'flex gap-4 overflow-x-auto'} ${className}`}
variants={animated ? containerVariants : undefined}
initial={animated ? 'hidden' : undefined}
whileInView={animated ? 'visible' : undefined}
viewport={{ once: true, amount: 0.2 }}
>
{steps.map((step, index) => {
const colors = colorClasses[step.color];
const isLast = index === steps.length - 1;
return (
<motion.div
key={step.id}
className="relative"
variants={animated ? itemVariants : undefined}
>
{/* Connector Line */}
{showConnector && !isLast && orientation === 'vertical' && (
<div className="absolute left-8 top-20 bottom-0 w-1 -mb-6">
<div className={`h-full w-full ${colors.line} opacity-30`} />
</div>
)}
{/* Step Card */}
<div className={`bg-gradient-to-r ${colors.bg} rounded-2xl p-6 md:p-8 border-2 ${colors.border} relative z-10 hover:shadow-lg transition-shadow duration-300`}>
<div className="flex gap-4 md:gap-6 items-start">
{/* Number Badge */}
<div className={`w-16 h-16 ${colors.badge} rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0 shadow-lg`}>
{step.number}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h3 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-3">
{step.title}
</h3>
{step.description && (
<p className="text-[var(--text-secondary)] mb-4">
{step.description}
</p>
)}
{step.items && step.items.length > 0 && (
<ul className="space-y-2">
{step.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex items-start gap-2">
<Check className={`w-5 h-5 ${colors.icon} mt-0.5 flex-shrink-0`} />
<span className="text-[var(--text-secondary)]">{item}</span>
</li>
))}
</ul>
)}
{step.icon && (
<div className="mt-4">
{step.icon}
</div>
)}
</div>
</div>
</div>
</motion.div>
);
})}
</motion.div>
);
};
export default StepTimeline;

View File

@@ -0,0 +1,180 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, X } from 'lucide-react';
export interface TOCSection {
id: string;
label: string;
icon?: React.ReactNode;
}
export interface TableOfContentsProps {
/** Array of sections to display */
sections: TOCSection[];
/** Additional CSS classes */
className?: string;
/** Show on mobile (default: false) */
showOnMobile?: boolean;
/** Offset for scroll position (for fixed headers) */
scrollOffset?: number;
}
/**
* TableOfContents - Sticky navigation for page sections
*
* Features:
* - Highlights current section based on scroll position
* - Smooth scroll to sections
* - Collapsible on mobile
* - Responsive design
* - Keyboard accessible
*
* @example
* <TableOfContents
* sections={[
* { id: 'automatic-system', label: 'Sistema Automático' },
* { id: 'local-intelligence', label: 'Inteligencia Local' },
* ]}
* />
*/
export const TableOfContents: React.FC<TableOfContentsProps> = ({
sections,
className = '',
showOnMobile = false,
scrollOffset = 100,
}) => {
const [activeSection, setActiveSection] = useState<string>('');
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
const scrollPosition = window.scrollY + scrollOffset;
// Find the current section
for (let i = sections.length - 1; i >= 0; i--) {
const section = document.getElementById(sections[i].id);
if (section && section.offsetTop <= scrollPosition) {
setActiveSection(sections[i].id);
break;
}
}
};
window.addEventListener('scroll', handleScroll);
handleScroll(); // Check initial position
return () => window.removeEventListener('scroll', handleScroll);
}, [sections, scrollOffset]);
const scrollToSection = (sectionId: string) => {
const element = document.getElementById(sectionId);
if (element) {
const top = element.offsetTop - scrollOffset + 20;
window.scrollTo({ top, behavior: 'smooth' });
setIsOpen(false); // Close mobile menu after click
}
};
return (
<>
{/* Mobile Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`fixed top-20 right-4 z-50 lg:hidden ${showOnMobile ? '' : 'hidden'} bg-[var(--bg-primary)] border-2 border-[var(--border-primary)] rounded-lg p-2 shadow-lg`}
aria-label="Toggle table of contents"
>
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
{/* Desktop Sidebar */}
<nav
className={`hidden lg:block sticky top-24 h-fit max-h-[calc(100vh-120px)] overflow-y-auto ${className}`}
aria-label="Table of contents"
>
<div className="bg-[var(--bg-secondary)] rounded-2xl p-6 border border-[var(--border-primary)]">
<h2 className="text-sm font-bold text-[var(--text-secondary)] uppercase tracking-wider mb-4">
Contenido
</h2>
<ul className="space-y-2">
{sections.map((section) => (
<li key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full text-left px-3 py-2 rounded-lg transition-all duration-200 flex items-center gap-2 ${
activeSection === section.id
? 'bg-[var(--color-primary)] text-white font-medium'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-primary)] hover:text-[var(--text-primary)]'
}`}
>
{section.icon && <span className="flex-shrink-0">{section.icon}</span>}
<span className="text-sm">{section.label}</span>
</button>
</li>
))}
</ul>
</div>
</nav>
{/* Mobile Drawer */}
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsOpen(false)}
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
/>
{/* Drawer */}
<motion.nav
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed top-0 right-0 bottom-0 w-80 max-w-[80vw] bg-[var(--bg-primary)] shadow-2xl z-50 lg:hidden overflow-y-auto"
aria-label="Table of contents"
>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold text-[var(--text-primary)]">
Contenido
</h2>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
aria-label="Cerrar"
>
<X className="w-6 h-6" />
</button>
</div>
<ul className="space-y-2">
{sections.map((section) => (
<li key={section.id}>
<button
onClick={() => scrollToSection(section.id)}
className={`w-full text-left px-4 py-3 rounded-lg transition-all duration-200 flex items-center gap-3 ${
activeSection === section.id
? 'bg-[var(--color-primary)] text-white font-medium'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] hover:text-[var(--text-primary)]'
}`}
>
{section.icon && <span className="flex-shrink-0">{section.icon}</span>}
<span>{section.label}</span>
</button>
</li>
))}
</ul>
</div>
</motion.nav>
</>
)}
</AnimatePresence>
</>
);
};
export default TableOfContents;

View File

@@ -29,6 +29,17 @@ export { EmptyState } from './EmptyState';
export { ResponsiveText } from './ResponsiveText';
export { SearchAndFilter } from './SearchAndFilter';
export { BaseDeleteModal } from './BaseDeleteModal';
export { Alert, AlertTitle, AlertDescription } from './Alert';
export { Progress } from './Progress';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion';
export { Loader } from './Loader';
export { AnimatedCounter } from './AnimatedCounter';
export { ScrollReveal } from './ScrollReveal';
export { FloatingCTA } from './FloatingCTA';
export { TableOfContents } from './TableOfContents';
export { SavingsCalculator } from './SavingsCalculator';
export { StepTimeline } from './StepTimeline';
export { FAQAccordion } from './FAQAccordion';
// Export types
export type { ButtonProps } from './Button';
@@ -58,4 +69,12 @@ export type { LoadingSpinnerProps } from './LoadingSpinner';
export type { EmptyStateProps } from './EmptyState';
export type { ResponsiveTextProps } from './ResponsiveText';
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
export type { BaseDeleteModalProps, DeleteMode, EntityDisplayInfo, DeleteModeOption, DeleteWarning, DeletionSummaryData } from './BaseDeleteModal';
export type { BaseDeleteModalProps, DeleteMode, EntityDisplayInfo, DeleteModeOption, DeleteWarning, DeletionSummaryData } from './BaseDeleteModal';
export type { LoaderProps } from './Loader';
export type { AnimatedCounterProps } from './AnimatedCounter';
export type { ScrollRevealProps } from './ScrollReveal';
export type { FloatingCTAProps } from './FloatingCTA';
export type { TableOfContentsProps, TOCSection } from './TableOfContents';
export type { SavingsCalculatorProps } from './SavingsCalculator';
export type { StepTimelineProps, TimelineStep } from './StepTimeline';
export type { FAQAccordionProps, FAQItem } from './FAQAccordion';