Add POI feature and imporve the overall backend implementation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
|
||||
import {
|
||||
BakeryTypeSelectionStep,
|
||||
RegisterTenantStep,
|
||||
POIDetectionStep,
|
||||
FileUploadStep,
|
||||
InventoryReviewStep,
|
||||
ProductCategorizationStep,
|
||||
@@ -74,6 +75,15 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
isConditional: true,
|
||||
condition: (ctx) => ctx.state.bakeryType !== null,
|
||||
},
|
||||
// Phase 2b: POI Detection
|
||||
{
|
||||
id: 'poi-detection',
|
||||
title: t('onboarding:steps.poi_detection.title', 'Detección de Ubicación'),
|
||||
description: t('onboarding:steps.poi_detection.description', 'Analizar puntos de interés cercanos'),
|
||||
component: POIDetectionStep,
|
||||
isConditional: true,
|
||||
condition: (ctx) => ctx.state.bakeryType !== null && ctx.state.bakeryLocation !== undefined,
|
||||
},
|
||||
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
|
||||
{
|
||||
id: 'upload-sales-data',
|
||||
@@ -325,6 +335,10 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
}
|
||||
if (currentStep.id === 'inventory-review') {
|
||||
wizardContext.markStepComplete('inventoryReviewCompleted');
|
||||
// Store inventory items in context for the next step
|
||||
if (data?.inventoryItems) {
|
||||
wizardContext.updateInventoryItems(data.inventoryItems);
|
||||
}
|
||||
}
|
||||
if (currentStep.id === 'product-categorization' && data?.categorizedProducts) {
|
||||
wizardContext.updateCategorizedProducts(data.categorizedProducts);
|
||||
@@ -339,6 +353,11 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
}
|
||||
if (currentStep.id === 'setup' && data?.tenant) {
|
||||
setCurrentTenant(data.tenant);
|
||||
|
||||
// If tenant info and location are available in data, update the wizard context
|
||||
if (data.tenantId && data.bakeryLocation) {
|
||||
wizardContext.updateTenantInfo(data.tenantId, data.bakeryLocation);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark step as completed in backend
|
||||
@@ -531,6 +550,24 @@ const OnboardingWizardContent: React.FC = () => {
|
||||
uploadedFileName: wizardContext.state.uploadedFileName || '',
|
||||
uploadedFileSize: wizardContext.state.uploadedFileSize || 0,
|
||||
}
|
||||
: // Pass inventory items to InitialStockEntryStep
|
||||
currentStep.id === 'initial-stock-entry' && wizardContext.state.inventoryItems
|
||||
? {
|
||||
productsWithStock: wizardContext.state.inventoryItems.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.product_type === 'ingredient' ? 'ingredient' : 'finished_product',
|
||||
category: item.category,
|
||||
unit: item.unit_of_measure,
|
||||
initialStock: undefined,
|
||||
}))
|
||||
}
|
||||
: // Pass tenant info to POI detection step
|
||||
currentStep.id === 'poi-detection'
|
||||
? {
|
||||
tenantId: wizardContext.state.tenantId,
|
||||
bakeryLocation: wizardContext.state.bakeryLocation,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -16,11 +16,34 @@ export interface AISuggestion {
|
||||
isAccepted?: boolean;
|
||||
}
|
||||
|
||||
// Inventory item structure from InventoryReviewStep
|
||||
export interface InventoryItemForm {
|
||||
id: string;
|
||||
name: string;
|
||||
product_type: string;
|
||||
category: string;
|
||||
unit_of_measure: string;
|
||||
isSuggested?: boolean;
|
||||
confidence_score?: number;
|
||||
sales_data?: {
|
||||
total_quantity: number;
|
||||
total_revenue: number;
|
||||
average_price: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
// Discovery Phase
|
||||
bakeryType: BakeryType;
|
||||
dataSource: DataSource;
|
||||
|
||||
// Core Setup Data
|
||||
tenantId?: string;
|
||||
bakeryLocation?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
// AI-Assisted Path Data
|
||||
uploadedFile?: File; // NEW: The actual file object needed for sales import API
|
||||
uploadedFileName?: string;
|
||||
@@ -30,6 +53,7 @@ export interface WizardState {
|
||||
aiAnalysisComplete: boolean;
|
||||
categorizedProducts?: any[]; // Products with type classification
|
||||
productsWithStock?: any[]; // Products with initial stock levels
|
||||
inventoryItems?: InventoryItemForm[]; // NEW: Inventory items created in InventoryReviewStep
|
||||
|
||||
// Setup Progress
|
||||
categorizationCompleted: boolean;
|
||||
@@ -55,11 +79,15 @@ export interface WizardContextValue {
|
||||
state: WizardState;
|
||||
updateBakeryType: (type: BakeryType) => void;
|
||||
updateDataSource: (source: DataSource) => void;
|
||||
updateTenantInfo: (tenantId: string, location: { latitude: number; longitude: number }) => void;
|
||||
updateLocation: (location: { latitude: number; longitude: number }) => void;
|
||||
updateTenantId: (tenantId: string) => void;
|
||||
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
|
||||
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
|
||||
setAIAnalysisComplete: (complete: boolean) => void;
|
||||
updateCategorizedProducts: (products: any[]) => void;
|
||||
updateProductsWithStock: (products: any[]) => void;
|
||||
updateInventoryItems: (items: InventoryItemForm[]) => void; // NEW: Store inventory items
|
||||
markStepComplete: (step: keyof WizardState) => void;
|
||||
getVisibleSteps: () => string[];
|
||||
shouldShowStep: (stepId: string) => boolean;
|
||||
@@ -126,6 +154,28 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
||||
setState(prev => ({ ...prev, dataSource: source }));
|
||||
};
|
||||
|
||||
const updateTenantInfo = (tenantId: string, location: { latitude: number; longitude: number }) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tenantId,
|
||||
bakeryLocation: location
|
||||
}));
|
||||
};
|
||||
|
||||
const updateLocation = (location: { latitude: number; longitude: number }) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
bakeryLocation: location
|
||||
}));
|
||||
};
|
||||
|
||||
const updateTenantId = (tenantId: string) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
tenantId
|
||||
}));
|
||||
};
|
||||
|
||||
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
|
||||
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
|
||||
};
|
||||
@@ -152,6 +202,10 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
||||
setState(prev => ({ ...prev, productsWithStock: products }));
|
||||
};
|
||||
|
||||
const updateInventoryItems = (items: InventoryItemForm[]) => {
|
||||
setState(prev => ({ ...prev, inventoryItems: items }));
|
||||
};
|
||||
|
||||
const markStepComplete = (step: keyof WizardState) => {
|
||||
setState(prev => ({ ...prev, [step]: true }));
|
||||
};
|
||||
@@ -244,11 +298,15 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
|
||||
state,
|
||||
updateBakeryType,
|
||||
updateDataSource,
|
||||
updateTenantInfo,
|
||||
updateLocation,
|
||||
updateTenantId,
|
||||
updateAISuggestions,
|
||||
updateUploadedFile,
|
||||
setAIAnalysisComplete,
|
||||
updateCategorizedProducts,
|
||||
updateProductsWithStock,
|
||||
updateInventoryItems,
|
||||
markStepComplete,
|
||||
getVisibleSteps,
|
||||
shouldShowStep,
|
||||
|
||||
@@ -330,10 +330,11 @@ export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the step with metadata
|
||||
// Complete the step with metadata and inventory items
|
||||
onComplete({
|
||||
inventoryItemsCreated: inventoryItems.length,
|
||||
salesDataImported: salesImported,
|
||||
inventoryItems: inventoryItems, // Pass the created items to the next step
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating inventory items:', error);
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* POI Detection Onboarding Step
|
||||
*
|
||||
* Onboarding wizard step for automatic POI detection during bakery registration
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/Card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { CheckCircle, MapPin, AlertCircle, Loader2, ArrowRight } from 'lucide-react';
|
||||
import { poiContextApi } from '@/services/api/poiContextApi';
|
||||
import { POI_CATEGORY_METADATA } from '@/types/poi';
|
||||
import type { POIDetectionResponse } from '@/types/poi';
|
||||
import { useWizardContext } from '../context';
|
||||
|
||||
interface POIDetectionStepProps {
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onComplete?: (data?: any) => void;
|
||||
onUpdate?: (data: any) => void;
|
||||
isFirstStep?: boolean;
|
||||
isLastStep?: boolean;
|
||||
initialData?: any;
|
||||
}
|
||||
|
||||
export const POIDetectionStep: React.FC<POIDetectionStepProps> = ({
|
||||
onComplete,
|
||||
onUpdate,
|
||||
initialData,
|
||||
}) => {
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [detectionResult, setDetectionResult] = useState<POIDetectionResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const wizardContext = useWizardContext();
|
||||
|
||||
// Extract tenantId and location from context or initialData
|
||||
// Prioritize initialData (from previous step completion), fall back to context
|
||||
const tenantId = initialData?.tenantId || wizardContext.state.tenantId;
|
||||
const bakeryLocation = initialData?.bakeryLocation || wizardContext.state.bakeryLocation;
|
||||
|
||||
// Auto-detect POIs when both tenantId and location are available
|
||||
useEffect(() => {
|
||||
if (tenantId && bakeryLocation?.latitude && bakeryLocation?.longitude) {
|
||||
handleDetectPOIs();
|
||||
} else {
|
||||
// If we don't have the required data, show a message
|
||||
setError('Location data not available. Please complete the previous step first.');
|
||||
}
|
||||
}, [tenantId, bakeryLocation]);
|
||||
|
||||
const handleDetectPOIs = async () => {
|
||||
if (!tenantId || !bakeryLocation?.latitude || !bakeryLocation?.longitude) {
|
||||
setError('Tenant ID and location are required for POI detection.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDetecting(true);
|
||||
setError(null);
|
||||
setProgress(10);
|
||||
|
||||
try {
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(progressInterval);
|
||||
return 90;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
const result = await poiContextApi.detectPOIs(
|
||||
tenantId,
|
||||
bakeryLocation.latitude,
|
||||
bakeryLocation.longitude,
|
||||
false
|
||||
);
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
setDetectionResult(result);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to detect POIs');
|
||||
console.error('POI detection error:', err);
|
||||
} finally {
|
||||
setIsDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isDetecting) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Detecting Nearby Points of Interest
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Analyzing your bakery's location to identify nearby schools, offices, transport hubs, and more...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mb-4" />
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-medium mb-2">
|
||||
Scanning OpenStreetMap data...
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
This may take a few moments
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Detection Progress</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Object.values(POI_CATEGORY_METADATA).slice(0, 9).map(category => (
|
||||
<div key={category.name} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
|
||||
<span style={{ fontSize: '20px' }}>{category.icon}</span>
|
||||
<span className="text-xs text-gray-700">{category.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !detectionResult) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
POI Detection Failed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleDetectPOIs}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={isDetecting}
|
||||
>
|
||||
{isDetecting ? 'Detecting...' : 'Try Again'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onComplete?.({ poi_detection_skipped: true })}
|
||||
variant="ghost"
|
||||
className="flex-1"
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (detectionResult) {
|
||||
const { poi_context, competitive_insights } = detectionResult;
|
||||
const categoriesWithPOIs = Object.entries(poi_context.poi_detection_results)
|
||||
.filter(([_, data]) => data.count > 0)
|
||||
.sort((a, b) => (b[1].features?.proximity_score || 0) - (a[1].features?.proximity_score || 0));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
POI Detection Complete
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Successfully detected {poi_context.total_pois_detected} points of interest around your bakery
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{poi_context.total_pois_detected}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 mt-1">Total POIs</div>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg text-center">
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{poi_context.relevant_categories?.length || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 mt-1">Relevant Categories</div>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 rounded-lg text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{Object.keys(poi_context.ml_features || {}).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 mt-1">ML Features</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Competitive Insights */}
|
||||
{competitive_insights && competitive_insights.length > 0 && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold mb-2">Location Insights</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{competitive_insights.map((insight, index) => (
|
||||
<li key={index}>{insight}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* High Impact Categories */}
|
||||
{poi_context.high_impact_categories && poi_context.high_impact_categories.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 mb-3">
|
||||
High Impact Factors for Your Location
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{poi_context.high_impact_categories.map(category => {
|
||||
const metadata = (POI_CATEGORY_METADATA as Record<string, any>)[category];
|
||||
if (!metadata) return null;
|
||||
|
||||
const categoryData = poi_context.poi_detection_results[category];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="p-3 border border-green-200 bg-green-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
|
||||
<span className="font-medium text-sm">{metadata.displayName}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700">
|
||||
{categoryData.count} {categoryData.count === 1 ? 'location' : 'locations'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Categories */}
|
||||
{categoriesWithPOIs.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 mb-3">
|
||||
All Detected Categories
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{categoriesWithPOIs.map(([category, data]) => {
|
||||
const metadata = (POI_CATEGORY_METADATA as Record<string, any>)[category];
|
||||
if (!metadata) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center gap-2 p-2 bg-gray-50 rounded"
|
||||
>
|
||||
<span style={{ fontSize: '20px' }}>{metadata.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">
|
||||
{metadata.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{data.count} nearby
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
onClick={() => onComplete?.({
|
||||
poi_detection_completed: true,
|
||||
total_pois_detected: poi_context.total_pois_detected,
|
||||
relevant_categories: poi_context.relevant_categories,
|
||||
high_impact_categories: poi_context.high_impact_categories,
|
||||
})}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
Continue to Next Step
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<p className="text-xs text-center text-gray-600 mt-3">
|
||||
These location-based features will enhance your demand forecasting accuracy
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Default state - show loading or instruction if needed
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Preparing POI Detection
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Preparing to analyze nearby points of interest around your bakery
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center py-8">
|
||||
<div className="text-gray-600 mb-4">
|
||||
{error || 'Waiting for location data...'}
|
||||
</div>
|
||||
{error && !tenantId && !bakeryLocation && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Please complete the previous step to provide location information.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '../../../ui/Button';
|
||||
import { Input } from '../../../ui/Input';
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Input } from '../../../ui';
|
||||
import { AddressAutocomplete } from '../../../ui/AddressAutocomplete';
|
||||
import { useRegisterBakery } from '../../../../api/hooks/tenant';
|
||||
import { BakeryRegistration } from '../../../../api/types/tenant';
|
||||
import { nominatimService, NominatimResult } from '../../../../api/services/nominatim';
|
||||
import { debounce } from 'lodash';
|
||||
import { AddressResult } from '../../../../services/api/geocodingApi';
|
||||
import { useWizardContext } from '../context';
|
||||
|
||||
interface RegisterTenantStepProps {
|
||||
onNext: () => void;
|
||||
@@ -18,6 +18,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
onComplete,
|
||||
isFirstStep
|
||||
}) => {
|
||||
const wizardContext = useWizardContext();
|
||||
const [formData, setFormData] = useState<BakeryRegistration>({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -29,51 +30,14 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [addressSuggestions, setAddressSuggestions] = useState<NominatimResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const registerBakery = useRegisterBakery();
|
||||
|
||||
// Debounced address search
|
||||
const searchAddress = useCallback(
|
||||
debounce(async (query: string) => {
|
||||
if (query.length < 3) {
|
||||
setAddressSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await nominatimService.searchAddress(query);
|
||||
setAddressSuggestions(results);
|
||||
setShowSuggestions(true);
|
||||
} catch (error) {
|
||||
console.error('Address search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
searchAddress.cancel();
|
||||
};
|
||||
}, [searchAddress]);
|
||||
|
||||
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
|
||||
// Trigger address search when address field changes
|
||||
if (field === 'address') {
|
||||
searchAddress(value);
|
||||
}
|
||||
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
@@ -82,18 +46,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressSelect = (result: NominatimResult) => {
|
||||
const parsed = nominatimService.parseAddress(result);
|
||||
|
||||
const handleAddressSelect = (address: AddressResult) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
address: parsed.street,
|
||||
city: parsed.city,
|
||||
postal_code: parsed.postalCode,
|
||||
address: address.display_name,
|
||||
city: address.address.city || address.address.municipality || address.address.suburb || prev.city,
|
||||
postal_code: address.address.postcode || prev.postal_code,
|
||||
}));
|
||||
};
|
||||
|
||||
setShowSuggestions(false);
|
||||
setAddressSuggestions([]);
|
||||
const handleCoordinatesChange = (lat: number, lon: number) => {
|
||||
// Store coordinates in the wizard context immediately
|
||||
// This allows the POI detection step to access location information when it's available
|
||||
wizardContext.updateLocation({ latitude: lat, longitude: lon });
|
||||
console.log('Coordinates captured and stored:', { latitude: lat, longitude: lon });
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
@@ -145,7 +111,14 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
|
||||
try {
|
||||
const tenant = await registerBakery.mutateAsync(formData);
|
||||
onComplete({ tenant });
|
||||
|
||||
// Update the wizard context with tenant info and pass the bakeryLocation coordinates
|
||||
// that were captured during address selection to the next step (POI Detection)
|
||||
onComplete({
|
||||
tenant,
|
||||
tenantId: tenant.id,
|
||||
bakeryLocation: wizardContext.state.bakeryLocation
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error registering bakery:', error);
|
||||
setErrors({ submit: 'Error al registrar la panadería. Por favor, inténtalo de nuevo.' });
|
||||
@@ -174,41 +147,24 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2 relative">
|
||||
<Input
|
||||
label="Dirección"
|
||||
placeholder="Calle Principal 123, Madrid"
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dirección <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<AddressAutocomplete
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
onFocus={() => {
|
||||
if (addressSuggestions.length > 0) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
placeholder="Enter bakery address..."
|
||||
onAddressSelect={(address) => {
|
||||
console.log('Selected:', address.display_name);
|
||||
handleAddressSelect(address);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}}
|
||||
error={errors.address}
|
||||
isRequired
|
||||
onCoordinatesChange={handleCoordinatesChange}
|
||||
countryCode="es"
|
||||
required
|
||||
/>
|
||||
{isSearching && (
|
||||
<div className="absolute right-3 top-10 text-gray-400">
|
||||
Buscando...
|
||||
</div>
|
||||
)}
|
||||
{showSuggestions && addressSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{addressSuggestions.map((result) => (
|
||||
<div
|
||||
key={result.place_id}
|
||||
className="px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0"
|
||||
onClick={() => handleAddressSelect(result)}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{nominatimService.formatAddress(result)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{errors.address && (
|
||||
<div className="mt-1 text-sm text-red-600">
|
||||
{errors.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
|
||||
|
||||
// Core Onboarding Steps
|
||||
export { RegisterTenantStep } from './RegisterTenantStep';
|
||||
export { POIDetectionStep } from './POIDetectionStep';
|
||||
|
||||
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
|
||||
export { FileUploadStep } from './FileUploadStep';
|
||||
|
||||
256
frontend/src/components/domain/pos/POSSyncStatus.tsx
Normal file
256
frontend/src/components/domain/pos/POSSyncStatus.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from 'react';
|
||||
import { RefreshCw, CheckCircle, AlertCircle, Clock, TrendingUp, Loader2 } from 'lucide-react';
|
||||
import { Card } from '../../ui';
|
||||
import { showToast } from '../../../utils/toast';
|
||||
import { posService } from '../../../api/services/pos';
|
||||
|
||||
interface POSSyncStatusProps {
|
||||
tenantId: string;
|
||||
onSyncComplete?: () => void;
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
total_completed_transactions: number;
|
||||
synced_to_sales: number;
|
||||
pending_sync: number;
|
||||
sync_rate: number;
|
||||
}
|
||||
|
||||
export const POSSyncStatus: React.FC<POSSyncStatusProps> = ({ tenantId, onSyncComplete }) => {
|
||||
const [status, setStatus] = useState<SyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/pos/tenants/${tenantId}/pos/transactions/sync-status`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add auth headers as needed
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sync status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching sync status:', error);
|
||||
showToast.error('Error al obtener estado de sincronización');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/pos/tenants/${tenantId}/pos/transactions/sync-all-to-sales`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add auth headers as needed
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Sync failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
showToast.success(
|
||||
`Sincronización completada: ${result.synced} de ${result.total_transactions} transacciones`
|
||||
);
|
||||
|
||||
// Refresh status after sync
|
||||
await fetchSyncStatus();
|
||||
|
||||
if (onSyncComplete) {
|
||||
onSyncComplete();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error during sync:', error);
|
||||
showToast.error('Error al sincronizar transacciones');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tenantId) {
|
||||
fetchSyncStatus();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(fetchSyncStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[var(--color-primary)]" />
|
||||
<span className="ml-3 text-[var(--text-secondary)]">Cargando estado...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasPendingSync = status.pending_sync > 0;
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
|
||||
<RefreshCw className="w-5 h-5 mr-2 text-blue-500" />
|
||||
Estado de Sincronización POS → Ventas
|
||||
</h3>
|
||||
<button
|
||||
onClick={fetchSyncStatus}
|
||||
disabled={loading}
|
||||
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Actualizar estado"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Total Transactions */}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Total Transacciones</span>
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{status.total_completed_transactions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Synced */}
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-green-700 dark:text-green-400">Sincronizadas</span>
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-800 dark:text-green-300">
|
||||
{status.synced_to_sales}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
hasPendingSync
|
||||
? 'bg-orange-50 dark:bg-orange-900/20'
|
||||
: 'bg-gray-50 dark:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
hasPendingSync
|
||||
? 'text-orange-700 dark:text-orange-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Pendientes
|
||||
</span>
|
||||
<AlertCircle
|
||||
className={`w-4 h-4 ${
|
||||
hasPendingSync
|
||||
? 'text-orange-600 dark:text-orange-400'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
hasPendingSync
|
||||
? 'text-orange-800 dark:text-orange-300'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{status.pending_sync}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Rate */}
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Tasa de Sincronización</span>
|
||||
<TrendingUp className="w-4 h-4 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${status.sync_rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-lg font-semibold text-[var(--text-primary)]">
|
||||
{status.sync_rate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{hasPendingSync && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{status.pending_sync} transacción{status.pending_sync !== 1 ? 'es' : ''} esperando
|
||||
sincronización
|
||||
</div>
|
||||
<button
|
||||
onClick={triggerSync}
|
||||
disabled={syncing}
|
||||
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sincronizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Sincronizar Ahora
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Updated */}
|
||||
{lastUpdated && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] text-center">
|
||||
Última actualización: {lastUpdated.toLocaleTimeString('es-ES')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,499 @@
|
||||
// ================================================================
|
||||
// frontend/src/components/domain/procurement/DeliveryReceiptModal.tsx
|
||||
// ================================================================
|
||||
/**
|
||||
* Delivery Receipt Modal
|
||||
*
|
||||
* Modal for recording delivery receipt with:
|
||||
* - Item-by-item quantity verification
|
||||
* - Batch/lot number entry
|
||||
* - Expiration date entry
|
||||
* - Quality inspection toggle
|
||||
* - Rejection reasons for damaged/incorrect items
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Package,
|
||||
Calendar,
|
||||
Hash,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Truck,
|
||||
ClipboardCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Define delivery item type
|
||||
interface DeliveryItemInput {
|
||||
purchase_order_item_id: string;
|
||||
inventory_product_id: string;
|
||||
product_name: string;
|
||||
ordered_quantity: number;
|
||||
unit_of_measure: string;
|
||||
delivered_quantity: number;
|
||||
accepted_quantity: number;
|
||||
rejected_quantity: number;
|
||||
batch_lot_number?: string;
|
||||
expiry_date?: string;
|
||||
quality_issues?: string;
|
||||
rejection_reason?: string;
|
||||
}
|
||||
|
||||
interface DeliveryReceiptModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
purchaseOrder: {
|
||||
id: string;
|
||||
po_number: string;
|
||||
supplier_id: string;
|
||||
supplier_name?: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
inventory_product_id: string;
|
||||
product_name: string;
|
||||
ordered_quantity: number;
|
||||
unit_of_measure: string;
|
||||
received_quantity: number;
|
||||
}>;
|
||||
};
|
||||
onSubmit: (deliveryData: {
|
||||
purchase_order_id: string;
|
||||
supplier_id: string;
|
||||
items: DeliveryItemInput[];
|
||||
inspection_passed: boolean;
|
||||
inspection_notes?: string;
|
||||
notes?: string;
|
||||
}) => Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function DeliveryReceiptModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
purchaseOrder,
|
||||
onSubmit,
|
||||
loading = false,
|
||||
}: DeliveryReceiptModalProps) {
|
||||
const [items, setItems] = useState<DeliveryItemInput[]>(() =>
|
||||
purchaseOrder.items.map(item => ({
|
||||
purchase_order_item_id: item.id,
|
||||
inventory_product_id: item.inventory_product_id,
|
||||
product_name: item.product_name,
|
||||
ordered_quantity: item.ordered_quantity,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
delivered_quantity: item.ordered_quantity - item.received_quantity, // Remaining qty
|
||||
accepted_quantity: item.ordered_quantity - item.received_quantity,
|
||||
rejected_quantity: 0,
|
||||
batch_lot_number: '',
|
||||
expiry_date: '',
|
||||
quality_issues: '',
|
||||
rejection_reason: '',
|
||||
}))
|
||||
);
|
||||
|
||||
const [inspectionPassed, setInspectionPassed] = useState(true);
|
||||
const [inspectionNotes, setInspectionNotes] = useState('');
|
||||
const [generalNotes, setGeneralNotes] = useState('');
|
||||
|
||||
// Calculate summary statistics
|
||||
const summary = useMemo(() => {
|
||||
const totalOrdered = items.reduce((sum, item) => sum + item.ordered_quantity, 0);
|
||||
const totalDelivered = items.reduce((sum, item) => sum + item.delivered_quantity, 0);
|
||||
const totalAccepted = items.reduce((sum, item) => sum + item.accepted_quantity, 0);
|
||||
const totalRejected = items.reduce((sum, item) => sum + item.rejected_quantity, 0);
|
||||
const hasIssues = items.some(item => item.rejected_quantity > 0 || item.quality_issues);
|
||||
|
||||
return {
|
||||
totalOrdered,
|
||||
totalDelivered,
|
||||
totalAccepted,
|
||||
totalRejected,
|
||||
hasIssues,
|
||||
completionRate: totalOrdered > 0 ? (totalAccepted / totalOrdered) * 100 : 0,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
const updateItem = (index: number, field: keyof DeliveryItemInput, value: any) => {
|
||||
setItems(prevItems => {
|
||||
const newItems = [...prevItems];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
|
||||
// Auto-calculate accepted quantity when delivered or rejected changes
|
||||
if (field === 'delivered_quantity' || field === 'rejected_quantity') {
|
||||
const delivered = field === 'delivered_quantity' ? value : newItems[index].delivered_quantity;
|
||||
const rejected = field === 'rejected_quantity' ? value : newItems[index].rejected_quantity;
|
||||
newItems[index].accepted_quantity = Math.max(0, delivered - rejected);
|
||||
}
|
||||
|
||||
return newItems;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate that all items have required fields
|
||||
const hasErrors = items.some(item =>
|
||||
item.delivered_quantity < 0 ||
|
||||
item.accepted_quantity < 0 ||
|
||||
item.rejected_quantity < 0 ||
|
||||
item.delivered_quantity < item.rejected_quantity
|
||||
);
|
||||
|
||||
if (hasErrors) {
|
||||
alert('Please fix validation errors before submitting');
|
||||
return;
|
||||
}
|
||||
|
||||
const deliveryData = {
|
||||
purchase_order_id: purchaseOrder.id,
|
||||
supplier_id: purchaseOrder.supplier_id,
|
||||
items: items.filter(item => item.delivered_quantity > 0), // Only include delivered items
|
||||
inspection_passed: inspectionPassed,
|
||||
inspection_notes: inspectionNotes || undefined,
|
||||
notes: generalNotes || undefined,
|
||||
};
|
||||
|
||||
await onSubmit(deliveryData);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div
|
||||
className="rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"
|
||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-6 border-b flex items-center justify-between"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-6 h-6" style={{ color: 'var(--color-info-600)' }} />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
Record Delivery Receipt
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
PO #{purchaseOrder.po_number} • {purchaseOrder.supplier_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-opacity-80 transition-colors"
|
||||
style={{ backgroundColor: 'var(--bg-secondary)' }}
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div
|
||||
className="p-4 border-b grid grid-cols-4 gap-4"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Ordered
|
||||
</p>
|
||||
<p className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
{summary.totalOrdered.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Delivered
|
||||
</p>
|
||||
<p className="text-lg font-bold" style={{ color: 'var(--color-info-600)' }}>
|
||||
{summary.totalDelivered.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Accepted
|
||||
</p>
|
||||
<p className="text-lg font-bold" style={{ color: 'var(--color-success-600)' }}>
|
||||
{summary.totalAccepted.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Rejected
|
||||
</p>
|
||||
<p className="text-lg font-bold" style={{ color: 'var(--color-error-600)' }}>
|
||||
{summary.totalRejected.toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.purchase_order_item_id}
|
||||
className="border rounded-lg p-4 space-y-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: item.rejected_quantity > 0
|
||||
? 'var(--color-error-300)'
|
||||
: 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
{/* Item Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2 flex-1">
|
||||
<Package className="w-5 h-5 mt-0.5" style={{ color: 'var(--color-info-600)' }} />
|
||||
<div>
|
||||
<p className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{item.product_name}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Ordered: {item.ordered_quantity} {item.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Inputs */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Delivered Qty *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.delivered_quantity}
|
||||
onChange={(e) => updateItem(index, 'delivered_quantity', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Rejected Qty
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.rejected_quantity}
|
||||
onChange={(e) => updateItem(index, 'rejected_quantity', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
step="0.01"
|
||||
max={item.delivered_quantity}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: item.rejected_quantity > 0 ? 'var(--color-error-300)' : 'var(--border-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Accepted Qty
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.accepted_quantity}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 rounded border text-sm font-semibold"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
color: 'var(--color-success-600)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch & Expiry */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Hash className="w-3 h-3" />
|
||||
Batch/Lot Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.batch_lot_number}
|
||||
onChange={(e) => updateItem(index, 'batch_lot_number', e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
<Calendar className="w-3 h-3" />
|
||||
Expiration Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={item.expiry_date}
|
||||
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Issues / Rejection Reason */}
|
||||
{item.rejected_quantity > 0 && (
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium mb-1" style={{ color: 'var(--color-error-600)' }}>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Rejection Reason *
|
||||
</label>
|
||||
<textarea
|
||||
value={item.rejection_reason}
|
||||
onChange={(e) => updateItem(index, 'rejection_reason', e.target.value)}
|
||||
placeholder="Why was this item rejected? (damaged, wrong product, quality issues, etc.)"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--color-error-300)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quality Inspection */}
|
||||
<div
|
||||
className="p-4 border-t space-y-3"
|
||||
style={{ borderColor: 'var(--border-primary)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="w-5 h-5" style={{ color: 'var(--color-info-600)' }} />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inspectionPassed}
|
||||
onChange={(e) => setInspectionPassed(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
Quality inspection passed
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!inspectionPassed && (
|
||||
<textarea
|
||||
value={inspectionNotes}
|
||||
onChange={(e) => setInspectionNotes(e.target.value)}
|
||||
placeholder="Describe quality inspection issues..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--color-warning-300)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={generalNotes}
|
||||
onChange={(e) => setGeneralNotes(e.target.value)}
|
||||
placeholder="General delivery notes (optional)"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
borderColor: 'var(--border-primary)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div
|
||||
className="p-6 border-t flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
borderColor: 'var(--border-primary)'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{summary.hasIssues && (
|
||||
<p className="text-sm flex items-center gap-2" style={{ color: 'var(--color-warning-700)' }}>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
This delivery has quality issues or rejections
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || summary.totalDelivered === 0}
|
||||
className="px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2"
|
||||
style={{
|
||||
backgroundColor: loading ? 'var(--color-info-300)' : 'var(--color-info-600)',
|
||||
color: 'white',
|
||||
opacity: loading || summary.totalDelivered === 0 ? 0.6 : 1,
|
||||
cursor: loading || summary.totalDelivered === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>Processing...</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Record Delivery
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Procurement Components - Components for procurement and purchase order management
|
||||
|
||||
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
|
||||
export { default as CreatePurchaseOrderModal } from './CreatePurchaseOrderModal';
|
||||
export { DeliveryReceiptModal } from './DeliveryReceiptModal';
|
||||
231
frontend/src/components/domain/settings/POICategoryAccordion.tsx
Normal file
231
frontend/src/components/domain/settings/POICategoryAccordion.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* POI Category Accordion Component
|
||||
*
|
||||
* Expandable accordion showing detailed POI information by category
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/Accordion';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import type { POIContext, POICategoryData } from '@/types/poi';
|
||||
import { POI_CATEGORY_METADATA, formatDistance, getImpactLevel, IMPACT_LEVELS } from '@/types/poi';
|
||||
|
||||
interface POICategoryAccordionProps {
|
||||
poiContext: POIContext;
|
||||
selectedCategory?: string | null;
|
||||
onCategorySelect?: (category: string | null) => void;
|
||||
}
|
||||
|
||||
export const POICategoryAccordion: React.FC<POICategoryAccordionProps> = ({
|
||||
poiContext,
|
||||
selectedCategory,
|
||||
onCategorySelect
|
||||
}) => {
|
||||
// Sort categories by proximity score (descending)
|
||||
const sortedCategories = Object.entries(poiContext.poi_detection_results)
|
||||
.filter(([_, data]) => data.count > 0)
|
||||
.sort((a, b) => b[1].features.proximity_score - a[1].features.proximity_score);
|
||||
|
||||
const renderCategoryDetails = (category: string, data: POICategoryData) => {
|
||||
const { features } = data;
|
||||
const impactLevel = getImpactLevel(features.proximity_score);
|
||||
const impactConfig = IMPACT_LEVELS[impactLevel];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Total Count</div>
|
||||
<div className="text-2xl font-bold">{features.total_count}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Proximity Score</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{features.proximity_score.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Nearest</div>
|
||||
<div className="text-lg font-semibold">
|
||||
{formatDistance(features.distance_to_nearest_m)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Impact Level</div>
|
||||
<Badge
|
||||
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
|
||||
>
|
||||
{impactConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distance Distribution */}
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 mb-3">
|
||||
Distance Distribution
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>0-100m (Immediate)</span>
|
||||
<span className="font-medium">{features.count_0_100m}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(features.count_0_100m / features.total_count) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>100-300m (Primary)</span>
|
||||
<span className="font-medium">{features.count_100_300m}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(features.count_100_300m / features.total_count) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>300-500m (Secondary)</span>
|
||||
<span className="font-medium">{features.count_300_500m}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(features.count_300_500m / features.total_count) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>500-1000m (Tertiary)</span>
|
||||
<span className="font-medium">{features.count_500_1000m}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(features.count_500_1000m / features.total_count) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* POI List */}
|
||||
{data.pois.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 mb-2">
|
||||
Locations ({data.pois.length})
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{data.pois.map((poi, index) => (
|
||||
<div
|
||||
key={`${poi.osm_id}-${index}`}
|
||||
className="p-2 border border-gray-200 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{poi.name}</div>
|
||||
{poi.tags.addr_street && (
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{poi.tags.addr_street}
|
||||
{poi.tags.addr_housenumber && ` ${poi.tags.addr_housenumber}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{poi.distance_m !== undefined && (
|
||||
<Badge variant="outline" className="text-xs ml-2">
|
||||
{formatDistance(poi.distance_m)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{poi.zone && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Zone: {poi.zone.replace('_', ' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{data.error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
Error: {data.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (sortedCategories.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No POIs detected in any category
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{sortedCategories.map(([category, data]) => {
|
||||
const metadata = POI_CATEGORY_METADATA[category];
|
||||
if (!metadata) return null;
|
||||
|
||||
const impactLevel = getImpactLevel(data.features.proximity_score);
|
||||
const impactConfig = IMPACT_LEVELS[impactLevel];
|
||||
const isSelected = selectedCategory === category;
|
||||
|
||||
return (
|
||||
<AccordionItem key={category} value={category}>
|
||||
<AccordionTrigger
|
||||
className={`hover:no-underline ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => onCategorySelect?.(isSelected ? null : category)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">{metadata.displayName}</div>
|
||||
<div className="text-xs text-gray-600 font-normal">
|
||||
{data.count} {data.count === 1 ? 'location' : 'locations'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
|
||||
>
|
||||
{impactConfig.label}
|
||||
</Badge>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium">
|
||||
Score: {data.features.proximity_score.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
Nearest: {formatDistance(data.features.distance_to_nearest_m)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="pt-4 pb-2 px-2">
|
||||
{renderCategoryDetails(category, data)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
314
frontend/src/components/domain/settings/POIContextView.tsx
Normal file
314
frontend/src/components/domain/settings/POIContextView.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* POI Context View Component
|
||||
*
|
||||
* Main view for POI detection results and management
|
||||
* Displays map, summary, and detailed category information
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs';
|
||||
import { RefreshCw, AlertCircle, CheckCircle, MapPin } from 'lucide-react';
|
||||
import { POIMap } from './POIMap';
|
||||
import { POISummaryCard } from './POISummaryCard';
|
||||
import { POICategoryAccordion } from './POICategoryAccordion';
|
||||
import { usePOIContext } from '@/hooks/usePOIContext';
|
||||
import { Loader } from '@/components/ui/Loader';
|
||||
|
||||
interface POIContextViewProps {
|
||||
tenantId: string;
|
||||
bakeryLocation?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const POIContextView: React.FC<POIContextViewProps> = ({
|
||||
tenantId,
|
||||
bakeryLocation
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const {
|
||||
poiContext,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
error,
|
||||
isStale,
|
||||
needsRefresh,
|
||||
competitorAnalysis,
|
||||
competitiveInsights,
|
||||
detectPOIs,
|
||||
refreshPOIs,
|
||||
fetchContext,
|
||||
fetchCompetitorAnalysis
|
||||
} = usePOIContext({ tenantId, autoFetch: true });
|
||||
|
||||
// Fetch competitor analysis when POI context is available
|
||||
useEffect(() => {
|
||||
if (poiContext && !competitorAnalysis) {
|
||||
fetchCompetitorAnalysis();
|
||||
}
|
||||
}, [poiContext, competitorAnalysis, fetchCompetitorAnalysis]);
|
||||
|
||||
// Handle initial POI detection if no context exists
|
||||
const handleInitialDetection = async () => {
|
||||
if (!bakeryLocation) {
|
||||
return;
|
||||
}
|
||||
await detectPOIs(bakeryLocation.latitude, bakeryLocation.longitude);
|
||||
};
|
||||
|
||||
// Handle POI refresh
|
||||
const handleRefresh = async () => {
|
||||
await refreshPOIs();
|
||||
};
|
||||
|
||||
if (isLoading && !poiContext) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<Loader size="large" text="Loading POI context..." />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// No POI context - show detection prompt
|
||||
if (!poiContext && !error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Location Context
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Detect nearby points of interest to enhance demand forecasting accuracy
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
POI detection has not been run for this location. Click the button below to
|
||||
automatically detect nearby schools, offices, transport hubs, and other
|
||||
points of interest that may affect bakery demand.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{bakeryLocation && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||
Bakery Location
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Latitude: {bakeryLocation.latitude.toFixed(6)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Longitude: {bakeryLocation.longitude.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleInitialDetection}
|
||||
disabled={!bakeryLocation || isLoading}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Detecting POIs...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPin className="mr-2 h-4 w-4" />
|
||||
Detect Points of Interest
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!bakeryLocation && (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Bakery location is required for POI detection. Please ensure your
|
||||
bakery address has been geocoded.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !poiContext) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Error Loading POI Context
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<Button onClick={fetchContext} className="mt-4">
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!poiContext) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Location Context & POI Analysis
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Detected {poiContext.total_pois_detected} points of interest around your bakery
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(isStale || needsRefresh) && (
|
||||
<Alert variant="warning" className="mb-0">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
POI data may be outdated
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh POI Data
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Competitive Insights */}
|
||||
{competitiveInsights && competitiveInsights.length > 0 && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold mb-2">Competitive Analysis</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{competitiveInsights.map((insight, index) => (
|
||||
<li key={index}>{insight}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="overview">Overview & Map</TabsTrigger>
|
||||
<TabsTrigger value="categories">Detailed Categories</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Map */}
|
||||
<div className="lg:col-span-2 h-[600px]">
|
||||
<POIMap
|
||||
poiContext={poiContext}
|
||||
selectedCategory={selectedCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<POISummaryCard
|
||||
poiContext={poiContext}
|
||||
onCategorySelect={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>POI Categories</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed breakdown of detected points of interest by category
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<POICategoryAccordion
|
||||
poiContext={poiContext}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategorySelect={setSelectedCategory}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Detection Metadata */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Detection Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-600">Detection Date</div>
|
||||
<div className="font-medium">
|
||||
{new Date(poiContext.detection_timestamp).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-600">Source</div>
|
||||
<div className="font-medium capitalize">{poiContext.detection_source}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-600">Relevant Categories</div>
|
||||
<div className="font-medium">{poiContext.relevant_categories?.length || 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-600">ML Features</div>
|
||||
<div className="font-medium">
|
||||
{Object.keys(poiContext.ml_features || {}).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
201
frontend/src/components/domain/settings/POIMap.tsx
Normal file
201
frontend/src/components/domain/settings/POIMap.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* POI Map Component
|
||||
*
|
||||
* Interactive map visualization of POIs around bakery location
|
||||
* Uses Leaflet for mapping and displays POIs with color-coded markers
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Circle, Popup, useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { POIContext, POI } from '@/types/poi';
|
||||
import { POI_CATEGORY_METADATA, formatDistance } from '@/types/poi';
|
||||
|
||||
// Fix for default marker icons in Leaflet
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
interface POIMapProps {
|
||||
poiContext: POIContext;
|
||||
selectedCategory?: string | null;
|
||||
}
|
||||
|
||||
// Helper component to create custom colored icons
|
||||
function createColoredIcon(color: string, emoji: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: 'custom-poi-marker',
|
||||
html: `<div style="background-color: ${color}; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3); font-size: 18px;">${emoji}</div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
}
|
||||
|
||||
function createBakeryIcon(): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: 'bakery-marker',
|
||||
html: `<div style="background-color: #dc2626; width: 40px; height: 40px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 3px solid white; box-shadow: 0 3px 6px rgba(0,0,0,0.4); font-size: 24px;">🏪</div>`,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20]
|
||||
});
|
||||
}
|
||||
|
||||
// Component to recenter map when location changes
|
||||
function MapRecenter({ center }: { center: [number, number] }) {
|
||||
const map = useMap();
|
||||
React.useEffect(() => {
|
||||
map.setView(center, map.getZoom());
|
||||
}, [center, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const POIMap: React.FC<POIMapProps> = ({ poiContext, selectedCategory }) => {
|
||||
const center: [number, number] = [
|
||||
poiContext.location.latitude,
|
||||
poiContext.location.longitude
|
||||
];
|
||||
|
||||
// Filter POIs by selected category
|
||||
const poisToDisplay = useMemo(() => {
|
||||
const pois: Array<{ category: string; poi: POI }> = [];
|
||||
|
||||
Object.entries(poiContext.poi_detection_results).forEach(([category, data]) => {
|
||||
if (selectedCategory && selectedCategory !== category) {
|
||||
return; // Skip if category filter is active and doesn't match
|
||||
}
|
||||
|
||||
data.pois.forEach(poi => {
|
||||
pois.push({ category, poi });
|
||||
});
|
||||
});
|
||||
|
||||
return pois;
|
||||
}, [poiContext, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full rounded-lg overflow-hidden border border-gray-200">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={15}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<MapRecenter center={center} />
|
||||
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Bakery marker */}
|
||||
<Marker position={center} icon={createBakeryIcon()}>
|
||||
<Popup>
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-base">Your Bakery</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{center[0].toFixed(6)}, {center[1].toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
||||
{/* Distance rings */}
|
||||
<Circle
|
||||
center={center}
|
||||
radius={100}
|
||||
pathOptions={{
|
||||
color: '#22c55e',
|
||||
fillColor: '#22c55e',
|
||||
fillOpacity: 0.05,
|
||||
weight: 2,
|
||||
dashArray: '5, 5'
|
||||
}}
|
||||
/>
|
||||
<Circle
|
||||
center={center}
|
||||
radius={300}
|
||||
pathOptions={{
|
||||
color: '#f59e0b',
|
||||
fillColor: '#f59e0b',
|
||||
fillOpacity: 0.03,
|
||||
weight: 2,
|
||||
dashArray: '5, 5'
|
||||
}}
|
||||
/>
|
||||
<Circle
|
||||
center={center}
|
||||
radius={500}
|
||||
pathOptions={{
|
||||
color: '#ef4444',
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.02,
|
||||
weight: 2,
|
||||
dashArray: '5, 5'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* POI markers */}
|
||||
{poisToDisplay.map(({ category, poi }, index) => {
|
||||
const metadata = POI_CATEGORY_METADATA[category];
|
||||
if (!metadata) return null;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={`${category}-${poi.osm_id}-${index}`}
|
||||
position={[poi.lat, poi.lon]}
|
||||
icon={createColoredIcon(metadata.color, metadata.icon)}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span style={{ fontSize: '24px' }}>{metadata.icon}</span>
|
||||
<div>
|
||||
<div className="font-semibold">{poi.name}</div>
|
||||
<div className="text-xs text-gray-600">{metadata.displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
{poi.distance_m && (
|
||||
<div className="text-sm text-gray-700 mt-1">
|
||||
Distance: <span className="font-medium">{formatDistance(poi.distance_m)}</span>
|
||||
</div>
|
||||
)}
|
||||
{poi.zone && (
|
||||
<div className="text-sm text-gray-700">
|
||||
Zone: <span className="font-medium capitalize">{poi.zone.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
OSM ID: {poi.osm_id}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-4 right-4 bg-white rounded-lg shadow-lg p-3 max-w-xs z-[1000]">
|
||||
<div className="font-semibold text-sm mb-2">Distance Rings</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 border-t-2 border-green-500 border-dashed"></div>
|
||||
<span>100m - Immediate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 border-t-2 border-orange-500 border-dashed"></div>
|
||||
<span>300m - Primary</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 border-t-2 border-red-500 border-dashed"></div>
|
||||
<span>500m - Secondary</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
185
frontend/src/components/domain/settings/POISummaryCard.tsx
Normal file
185
frontend/src/components/domain/settings/POISummaryCard.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* POI Summary Card Component
|
||||
*
|
||||
* Displays summary statistics and high-impact categories
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { POIContext } from '@/types/poi';
|
||||
import { POI_CATEGORY_METADATA, getImpactLevel, IMPACT_LEVELS } from '@/types/poi';
|
||||
|
||||
interface POISummaryCardProps {
|
||||
poiContext: POIContext;
|
||||
onCategorySelect?: (category: string) => void;
|
||||
}
|
||||
|
||||
export const POISummaryCard: React.FC<POISummaryCardProps> = ({
|
||||
poiContext,
|
||||
onCategorySelect
|
||||
}) => {
|
||||
const highImpactCategories = poiContext.high_impact_categories || [];
|
||||
const relevantCategories = poiContext.relevant_categories || [];
|
||||
|
||||
// Calculate category impact levels
|
||||
const categoryImpacts = Object.entries(poiContext.poi_detection_results)
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
proximityScore: data.features.proximity_score,
|
||||
count: data.count,
|
||||
impactLevel: getImpactLevel(data.features.proximity_score)
|
||||
}))
|
||||
.filter(item => item.count > 0)
|
||||
.sort((a, b) => b.proximityScore - a.proximityScore);
|
||||
|
||||
const detectionDate = poiContext.detection_timestamp
|
||||
? new Date(poiContext.detection_timestamp).toLocaleDateString()
|
||||
: 'Unknown';
|
||||
|
||||
const needsRefresh = poiContext.next_refresh_date
|
||||
? new Date(poiContext.next_refresh_date) < new Date()
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>POI Summary</span>
|
||||
{needsRefresh && (
|
||||
<Badge variant="warning" className="text-xs">
|
||||
Refresh Recommended
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Total POIs */}
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Total POIs Detected</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{poiContext.total_pois_detected}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detection Info */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-600">Detection Date</div>
|
||||
<div className="text-sm font-medium">{detectionDate}</div>
|
||||
</div>
|
||||
|
||||
{/* Detection Status */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-600">Status</div>
|
||||
<Badge
|
||||
variant={
|
||||
poiContext.detection_status === 'completed'
|
||||
? 'success'
|
||||
: poiContext.detection_status === 'partial'
|
||||
? 'warning'
|
||||
: 'destructive'
|
||||
}
|
||||
>
|
||||
{poiContext.detection_status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Impact Categories */}
|
||||
{categoryImpacts.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 mb-2">
|
||||
Impact by Category
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{categoryImpacts.map(({ category, count, proximityScore, impactLevel }) => {
|
||||
const metadata = POI_CATEGORY_METADATA[category];
|
||||
if (!metadata) return null;
|
||||
|
||||
const impactConfig = IMPACT_LEVELS[impactLevel];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center justify-between p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => onCategorySelect?.(category)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ fontSize: '20px' }}>{metadata.icon}</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{metadata.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{count} {count === 1 ? 'location' : 'locations'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge
|
||||
variant={impactLevel === 'HIGH' ? 'success' : impactLevel === 'MODERATE' ? 'warning' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{impactConfig.label}
|
||||
</Badge>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
Score: {proximityScore.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* High Impact Highlights */}
|
||||
{highImpactCategories.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-700 mb-2">
|
||||
High Impact Factors
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{highImpactCategories.map(category => {
|
||||
const metadata = POI_CATEGORY_METADATA[category];
|
||||
if (!metadata) return null;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={category}
|
||||
variant="success"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onCategorySelect?.(category)}
|
||||
>
|
||||
{metadata.icon} {metadata.displayName}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ML Features Count */}
|
||||
<div className="pt-3 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-600">ML Features Generated</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{Object.keys(poiContext.ml_features || {}).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Used for demand forecasting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location Coordinates */}
|
||||
<div className="pt-3 border-t border-gray-200 text-xs text-gray-500">
|
||||
<div className="font-semibold mb-1">Location</div>
|
||||
<div>
|
||||
Lat: {poiContext.location.latitude.toFixed(6)}
|
||||
</div>
|
||||
<div>
|
||||
Lon: {poiContext.location.longitude.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -720,6 +720,7 @@ const ReviewStep: React.FC<EntryMethodStepProps> = ({ data, onComplete }) => {
|
||||
// Create individual sales records for each item
|
||||
for (const item of data.salesItems) {
|
||||
const salesData = {
|
||||
inventory_product_id: item.productId || null, // Include inventory product ID for stock tracking
|
||||
product_name: item.product,
|
||||
product_category: 'general', // Could be enhanced with category selection
|
||||
quantity_sold: item.quantity,
|
||||
|
||||
@@ -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);
|
||||
|
||||
56
frontend/src/components/ui/Accordion.tsx
Normal file
56
frontend/src/components/ui/Accordion.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Accordion/index.ts
Normal file
1
frontend/src/components/ui/Accordion/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './Accordion';
|
||||
191
frontend/src/components/ui/AddressAutocomplete.tsx
Normal file
191
frontend/src/components/ui/AddressAutocomplete.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
frontend/src/components/ui/Alert.tsx
Normal file
60
frontend/src/components/ui/Alert.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Alert/index.ts
Normal file
1
frontend/src/components/ui/Alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Alert, AlertTitle, AlertDescription } from './Alert';
|
||||
101
frontend/src/components/ui/AnimatedCounter.tsx
Normal file
101
frontend/src/components/ui/AnimatedCounter.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
154
frontend/src/components/ui/FAQAccordion.tsx
Normal file
154
frontend/src/components/ui/FAQAccordion.tsx
Normal 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;
|
||||
132
frontend/src/components/ui/FloatingCTA.tsx
Normal file
132
frontend/src/components/ui/FloatingCTA.tsx
Normal 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;
|
||||
26
frontend/src/components/ui/Loader.tsx
Normal file
26
frontend/src/components/ui/Loader.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Loader/index.ts
Normal file
1
frontend/src/components/ui/Loader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Loader } from './Loader';
|
||||
26
frontend/src/components/ui/Progress.tsx
Normal file
26
frontend/src/components/ui/Progress.tsx
Normal 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 };
|
||||
1
frontend/src/components/ui/Progress/index.ts
Normal file
1
frontend/src/components/ui/Progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Progress } from './Progress';
|
||||
82
frontend/src/components/ui/ProgressBar.tsx
Normal file
82
frontend/src/components/ui/ProgressBar.tsx
Normal 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;
|
||||
219
frontend/src/components/ui/SavingsCalculator.tsx
Normal file
219
frontend/src/components/ui/SavingsCalculator.tsx
Normal 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;
|
||||
111
frontend/src/components/ui/ScrollReveal.tsx
Normal file
111
frontend/src/components/ui/ScrollReveal.tsx
Normal 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;
|
||||
189
frontend/src/components/ui/StepTimeline.tsx
Normal file
189
frontend/src/components/ui/StepTimeline.tsx
Normal 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;
|
||||
180
frontend/src/components/ui/TableOfContents.tsx
Normal file
180
frontend/src/components/ui/TableOfContents.tsx
Normal 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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user