Add POI feature and imporve the overall backend implementation
This commit is contained in:
@@ -87,6 +87,7 @@ class ApiClient {
|
||||
'/auth/me', // User profile endpoints
|
||||
'/auth/register', // Registration
|
||||
'/auth/login', // Login
|
||||
'/geocoding', // Geocoding/address search - utility service, no tenant context
|
||||
];
|
||||
|
||||
const isPublicEndpoint = publicEndpoints.some(endpoint =>
|
||||
|
||||
@@ -169,11 +169,11 @@ export const useCreateOrder = (
|
||||
queryKey: ordersKeys.orders(),
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey as string[];
|
||||
return queryKey.includes('list') &&
|
||||
return queryKey.includes('list') &&
|
||||
JSON.stringify(queryKey).includes(variables.tenant_id);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Invalidate dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.dashboard(variables.tenant_id),
|
||||
@@ -189,6 +189,39 @@ export const useCreateOrder = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateOrder = (
|
||||
options?: UseMutationOptions<OrderResponse, ApiError, { tenantId: string; orderId: string; data: OrderUpdate }>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<OrderResponse, ApiError, { tenantId: string; orderId: string; data: OrderUpdate }>({
|
||||
mutationFn: ({ tenantId, orderId, data }) => OrdersService.updateOrder(tenantId, orderId, data),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the specific order in cache
|
||||
queryClient.setQueryData(
|
||||
ordersKeys.order(variables.tenantId, variables.orderId),
|
||||
data
|
||||
);
|
||||
|
||||
// Invalidate orders list for this tenant
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.orders(),
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey as string[];
|
||||
return queryKey.includes('list') &&
|
||||
JSON.stringify(queryKey).includes(variables.tenantId);
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate dashboard
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ordersKeys.dashboard(variables.tenantId),
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateOrderStatus = (
|
||||
options?: UseMutationOptions<OrderResponse, ApiError, UpdateOrderStatusParams>
|
||||
) => {
|
||||
|
||||
@@ -10,7 +10,9 @@ import type {
|
||||
PurchaseOrderDetail,
|
||||
PurchaseOrderSearchParams,
|
||||
PurchaseOrderUpdateData,
|
||||
PurchaseOrderStatus
|
||||
PurchaseOrderStatus,
|
||||
CreateDeliveryInput,
|
||||
DeliveryResponse
|
||||
} from '../services/purchase_orders';
|
||||
import {
|
||||
listPurchaseOrders,
|
||||
@@ -21,7 +23,8 @@ import {
|
||||
approvePurchaseOrder,
|
||||
rejectPurchaseOrder,
|
||||
bulkApprovePurchaseOrders,
|
||||
deletePurchaseOrder
|
||||
deletePurchaseOrder,
|
||||
createDelivery
|
||||
} from '../services/purchase_orders';
|
||||
|
||||
// Query Keys
|
||||
@@ -257,3 +260,33 @@ export const useDeletePurchaseOrder = (
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a delivery for a purchase order
|
||||
*/
|
||||
export const useCreateDelivery = (
|
||||
options?: UseMutationOptions<
|
||||
DeliveryResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; deliveryData: CreateDeliveryInput }
|
||||
>
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
DeliveryResponse,
|
||||
ApiError,
|
||||
{ tenantId: string; poId: string; deliveryData: CreateDeliveryInput }
|
||||
>({
|
||||
mutationFn: ({ tenantId, poId, deliveryData }) => createDelivery(tenantId, poId, deliveryData),
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate all PO queries to refresh status
|
||||
queryClient.invalidateQueries({ queryKey: purchaseOrderKeys.all });
|
||||
// Invalidate detail for this specific PO
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: purchaseOrderKeys.detail(variables.tenantId, variables.poId)
|
||||
});
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -629,6 +629,7 @@ export {
|
||||
useBusinessModelDetection,
|
||||
useOrdersServiceStatus,
|
||||
useCreateOrder,
|
||||
useUpdateOrder,
|
||||
useUpdateOrderStatus,
|
||||
useCreateCustomer,
|
||||
useUpdateCustomer,
|
||||
|
||||
@@ -10,11 +10,12 @@ export const BACKEND_ONBOARDING_STEPS = [
|
||||
'user_registered', // Phase 0: User account created (auto-completed)
|
||||
'bakery-type-selection', // Phase 1: Choose bakery type
|
||||
'setup', // Phase 2: Basic bakery setup and tenant creation
|
||||
'upload-sales-data', // Phase 2a: File upload, validation, AI classification
|
||||
'inventory-review', // Phase 2a: Review AI-detected products with type selection
|
||||
'initial-stock-entry', // Phase 2a: Capture initial stock levels
|
||||
'product-categorization', // Phase 2b: Advanced categorization (optional)
|
||||
'suppliers-setup', // Phase 2c: Suppliers configuration
|
||||
'poi-detection', // Phase 2a: POI Detection (Location Context)
|
||||
'upload-sales-data', // Phase 2b: File upload, validation, AI classification
|
||||
'inventory-review', // Phase 2b: Review AI-detected products with type selection
|
||||
'initial-stock-entry', // Phase 2b: Capture initial stock levels
|
||||
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
||||
'suppliers-setup', // Phase 2d: Suppliers configuration
|
||||
'recipes-setup', // Phase 3: Production recipes (optional)
|
||||
'production-processes', // Phase 3: Finishing processes (optional)
|
||||
'quality-setup', // Phase 3: Quality standards (optional)
|
||||
@@ -28,11 +29,12 @@ export const BACKEND_ONBOARDING_STEPS = [
|
||||
export const FRONTEND_STEP_ORDER = [
|
||||
'bakery-type-selection', // Phase 1: Choose bakery type
|
||||
'setup', // Phase 2: Basic bakery setup and tenant creation
|
||||
'upload-sales-data', // Phase 2a: File upload and AI classification
|
||||
'inventory-review', // Phase 2a: Review AI-detected products
|
||||
'initial-stock-entry', // Phase 2a: Initial stock levels
|
||||
'product-categorization', // Phase 2b: Advanced categorization (optional)
|
||||
'suppliers-setup', // Phase 2c: Suppliers configuration
|
||||
'poi-detection', // Phase 2a: POI Detection (Location Context)
|
||||
'upload-sales-data', // Phase 2b: File upload and AI classification
|
||||
'inventory-review', // Phase 2b: Review AI-detected products
|
||||
'initial-stock-entry', // Phase 2b: Initial stock levels
|
||||
'product-categorization', // Phase 2c: Advanced categorization (optional)
|
||||
'suppliers-setup', // Phase 2d: Suppliers configuration
|
||||
'recipes-setup', // Phase 3: Production recipes (optional)
|
||||
'production-processes', // Phase 3: Finishing processes (optional)
|
||||
'quality-setup', // Phase 3: Quality standards (optional)
|
||||
|
||||
@@ -103,20 +103,28 @@ export class OrdersService {
|
||||
return apiClient.get<OrderResponse[]>(`/tenants/${tenant_id}/orders?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order details
|
||||
* PUT /tenants/{tenant_id}/orders/{order_id}
|
||||
*/
|
||||
static async updateOrder(tenantId: string, orderId: string, orderData: OrderUpdate): Promise<OrderResponse> {
|
||||
return apiClient.put<OrderResponse>(`/tenants/${tenantId}/orders/${orderId}`, orderData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order status
|
||||
* PUT /tenants/{tenant_id}/orders/{order_id}/status
|
||||
*/
|
||||
static async updateOrderStatus(params: UpdateOrderStatusParams): Promise<OrderResponse> {
|
||||
const { tenant_id, order_id, new_status, reason } = params;
|
||||
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (reason) {
|
||||
queryParams.append('reason', reason);
|
||||
}
|
||||
|
||||
const url = `/tenants/${tenant_id}/orders/${order_id}/status${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||
|
||||
|
||||
return apiClient.put<OrderResponse>(url, { status: new_status });
|
||||
}
|
||||
|
||||
|
||||
@@ -242,3 +242,68 @@ export async function deletePurchaseOrder(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}`
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DELIVERY TYPES AND METHODS
|
||||
// ================================================================
|
||||
|
||||
export interface DeliveryItemInput {
|
||||
purchase_order_item_id: string;
|
||||
inventory_product_id: string;
|
||||
ordered_quantity: number;
|
||||
delivered_quantity: number;
|
||||
accepted_quantity: number;
|
||||
rejected_quantity: number;
|
||||
batch_lot_number?: string;
|
||||
expiry_date?: string;
|
||||
quality_grade?: string;
|
||||
quality_issues?: string;
|
||||
rejection_reason?: string;
|
||||
item_notes?: string;
|
||||
}
|
||||
|
||||
export interface CreateDeliveryInput {
|
||||
purchase_order_id: string;
|
||||
supplier_id: string;
|
||||
supplier_delivery_note?: string;
|
||||
scheduled_date?: string;
|
||||
estimated_arrival?: string;
|
||||
carrier_name?: string;
|
||||
tracking_number?: string;
|
||||
inspection_passed?: boolean;
|
||||
inspection_notes?: string;
|
||||
notes?: string;
|
||||
items: DeliveryItemInput[];
|
||||
}
|
||||
|
||||
export interface DeliveryResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
purchase_order_id: string;
|
||||
supplier_id: string;
|
||||
delivery_number: string;
|
||||
status: string;
|
||||
scheduled_date?: string;
|
||||
estimated_arrival?: string;
|
||||
actual_arrival?: string;
|
||||
completed_at?: string;
|
||||
inspection_passed?: boolean;
|
||||
inspection_notes?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create delivery for purchase order
|
||||
*/
|
||||
export async function createDelivery(
|
||||
tenantId: string,
|
||||
poId: string,
|
||||
deliveryData: CreateDeliveryInput
|
||||
): Promise<DeliveryResponse> {
|
||||
return apiClient.post<DeliveryResponse>(
|
||||
`/tenants/${tenantId}/procurement/purchase-orders/${poId}/deliveries`,
|
||||
deliveryData
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +120,13 @@ export interface ProductionBatchResponse {
|
||||
actual_duration_minutes: number | null;
|
||||
status: ProductionStatus;
|
||||
priority: ProductionPriority;
|
||||
|
||||
// Process stage tracking (replaces frontend mock data)
|
||||
current_process_stage?: string | null;
|
||||
process_stage_history?: Array<Record<string, any>> | null;
|
||||
pending_quality_checks?: Array<Record<string, any>> | null;
|
||||
completed_quality_checks?: Array<Record<string, any>> | null;
|
||||
|
||||
estimated_cost: number | null;
|
||||
actual_cost: number | null;
|
||||
yield_percentage: number | null;
|
||||
|
||||
@@ -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';
|
||||
@@ -13,26 +13,26 @@ export const getDemoTourSteps = (): DriveStep[] => [
|
||||
{
|
||||
element: '[data-tour="dashboard-stats"]',
|
||||
popover: {
|
||||
title: 'Tu Panel de Control',
|
||||
description: 'Todo lo importante en un vistazo: ventas del día, pedidos pendientes, productos vendidos y alertas de stock crítico. Empieza tu día aquí en 30 segundos.',
|
||||
title: 'Estado de Salud de Tu Panadería',
|
||||
description: 'Aquí ves el estado general de tu negocio en tiempo real: órdenes pendientes, alertas de stock crítico, reducción de desperdicio y ahorro mensual. Todo lo importante en un vistazo para empezar el día en 30 segundos.',
|
||||
side: 'bottom',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="real-time-alerts"]',
|
||||
popover: {
|
||||
title: 'El Sistema Te Avisa de Todo',
|
||||
description: 'Olvídate de vigilar el stock constantemente. El sistema te alerta automáticamente de ingredientes bajos, pedidos urgentes, predicciones de demanda y oportunidades de producción. Tu asistente 24/7.',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="pending-po-approvals"]',
|
||||
popover: {
|
||||
title: 'Qué Comprar Hoy (Ya Calculado)',
|
||||
description: 'Cada mañana el sistema analiza automáticamente tus ventas, pronósticos y stock, y te dice exactamente qué ingredientes comprar. Solo tienes que revisar y aprobar con un clic. Adiós a Excel y cálculos manuales.',
|
||||
title: '¿Qué Requiere Tu Atención?',
|
||||
description: 'Aquí aparecen las acciones que necesitan tu aprobación: órdenes de compra generadas automáticamente, lotes de producción sugeridos, y otras decisiones importantes. Revisa, aprueba o modifica con un clic.',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="real-time-alerts"]',
|
||||
popover: {
|
||||
title: 'Lo Que el Sistema Hizo Por Ti',
|
||||
description: 'El sistema trabaja 24/7 analizando datos, generando pronósticos y creando planes. Aquí ves un resumen de las decisiones automáticas que tomó: órdenes generadas, producción planificada y optimizaciones realizadas.',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
},
|
||||
@@ -114,26 +114,26 @@ export const getMobileTourSteps = (): DriveStep[] => [
|
||||
{
|
||||
element: '[data-tour="dashboard-stats"]',
|
||||
popover: {
|
||||
title: 'Tu Panel de Control',
|
||||
description: 'Todo lo importante en un vistazo. Empieza tu día aquí en 30 segundos.',
|
||||
title: 'Estado de Tu Panadería',
|
||||
description: 'Estado de salud del negocio en tiempo real. Todo lo importante en un vistazo para empezar el día en 30 segundos.',
|
||||
side: 'bottom',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="real-time-alerts"]',
|
||||
popover: {
|
||||
title: 'El Sistema Te Avisa',
|
||||
description: 'Olvídate de vigilar el stock. Alertas automáticas de todo lo importante. Tu asistente 24/7.',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="pending-po-approvals"]',
|
||||
popover: {
|
||||
title: 'Qué Comprar (Ya Calculado)',
|
||||
description: 'Cada mañana el sistema calcula qué ingredientes comprar. Solo aprueba con un clic. Adiós Excel.',
|
||||
title: '¿Qué Requiere Atención?',
|
||||
description: 'Acciones que necesitan tu aprobación: compras, producción y decisiones. Revisa y aprueba con un clic.',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
},
|
||||
},
|
||||
{
|
||||
element: '[data-tour="real-time-alerts"]',
|
||||
popover: {
|
||||
title: 'Lo Que el Sistema Hizo',
|
||||
description: 'Resumen de decisiones automáticas: órdenes generadas, producción planificada, optimizaciones. Tu asistente 24/7.',
|
||||
side: 'top',
|
||||
align: 'start',
|
||||
},
|
||||
|
||||
@@ -71,8 +71,8 @@ export const useDemoTour = () => {
|
||||
}, 500);
|
||||
}, [navigate]);
|
||||
|
||||
const startTour = useCallback((fromStep: number = 0) => {
|
||||
console.log('[useDemoTour] startTour called with fromStep:', fromStep);
|
||||
const startTour = useCallback((fromStep: number = 0, retryCount: number = 0) => {
|
||||
console.log('[useDemoTour] startTour called with fromStep:', fromStep, 'retry:', retryCount);
|
||||
|
||||
// Check if we're already on the dashboard
|
||||
const currentPath = window.location.pathname;
|
||||
@@ -81,7 +81,7 @@ export const useDemoTour = () => {
|
||||
// Store tour intent in sessionStorage before navigation
|
||||
sessionStorage.setItem('demo_tour_should_start', 'true');
|
||||
sessionStorage.setItem('demo_tour_start_step', fromStep.toString());
|
||||
|
||||
|
||||
// Navigate to dashboard
|
||||
navigate(ROUTES.DASHBOARD);
|
||||
return;
|
||||
@@ -90,20 +90,41 @@ export const useDemoTour = () => {
|
||||
const steps = isMobile ? getMobileTourSteps() : getDemoTourSteps();
|
||||
console.log('[useDemoTour] Using', isMobile ? 'mobile' : 'desktop', 'steps, total:', steps.length);
|
||||
|
||||
// Check if first element exists (only if we're on the dashboard)
|
||||
// Check if critical tour elements exist (only if we're on the dashboard)
|
||||
if (currentPath === ROUTES.DASHBOARD) {
|
||||
const firstElement = steps[0]?.element;
|
||||
if (firstElement) {
|
||||
const selector = typeof firstElement === 'string' ? firstElement : String(firstElement);
|
||||
// Validate critical dashboard elements
|
||||
const criticalSelectors = [
|
||||
'[data-tour="demo-banner"]',
|
||||
'[data-tour="dashboard-stats"]'
|
||||
];
|
||||
|
||||
let missingElement = null;
|
||||
for (const selector of criticalSelectors) {
|
||||
const el = document.querySelector(selector);
|
||||
console.log('[useDemoTour] First element exists:', !!el, 'selector:', selector);
|
||||
if (!el) {
|
||||
console.warn('[useDemoTour] First tour element not found in DOM! Delaying tour start...');
|
||||
// Retry after DOM is ready
|
||||
setTimeout(() => startTour(fromStep), 500);
|
||||
missingElement = selector;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (missingElement) {
|
||||
// Retry up to 5 times with exponential backoff
|
||||
if (retryCount < 5) {
|
||||
const delay = Math.min(500 * Math.pow(1.5, retryCount), 3000);
|
||||
console.warn(`[useDemoTour] Critical tour element "${missingElement}" not found! Retrying in ${delay}ms (attempt ${retryCount + 1}/5)...`);
|
||||
setTimeout(() => startTour(fromStep, retryCount + 1), delay);
|
||||
return;
|
||||
} else {
|
||||
console.error(`[useDemoTour] Failed to find critical element "${missingElement}" after 5 retries. Tour cannot start.`);
|
||||
// Clear the tour start flag to prevent infinite retry loops
|
||||
sessionStorage.removeItem('demo_tour_should_start');
|
||||
sessionStorage.removeItem('demo_tour_start_step');
|
||||
clearTourStartPending();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[useDemoTour] All critical tour elements found, starting tour...');
|
||||
}
|
||||
|
||||
const config = getDriverConfig(handleStepComplete);
|
||||
|
||||
148
frontend/src/hooks/useAddressAutocomplete.ts
Normal file
148
frontend/src/hooks/useAddressAutocomplete.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Address Autocomplete Hook
|
||||
*
|
||||
* Provides address search and geocoding functionality with debouncing
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { geocodingApi, AddressResult } from '@/services/api/geocodingApi';
|
||||
|
||||
interface UseAddressAutocompleteOptions {
|
||||
countryCode?: string;
|
||||
limit?: number;
|
||||
debounceMs?: number;
|
||||
minQueryLength?: number;
|
||||
}
|
||||
|
||||
interface UseAddressAutocompleteReturn {
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
results: AddressResult[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedAddress: AddressResult | null;
|
||||
selectAddress: (address: AddressResult) => void;
|
||||
clearSelection: () => void;
|
||||
clearResults: () => void;
|
||||
}
|
||||
|
||||
export function useAddressAutocomplete(
|
||||
options: UseAddressAutocompleteOptions = {}
|
||||
): UseAddressAutocompleteReturn {
|
||||
const {
|
||||
countryCode = 'es',
|
||||
limit = 10,
|
||||
debounceMs = 500,
|
||||
minQueryLength = 3
|
||||
} = options;
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<AddressResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAddress, setSelectedAddress] = useState<AddressResult | null>(null);
|
||||
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const searchAddresses = useCallback(
|
||||
async (searchQuery: string) => {
|
||||
// Clear previous results if query is too short
|
||||
if (searchQuery.trim().length < minQueryLength) {
|
||||
setResults([]);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request if still pending
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const searchResults = await geocodingApi.searchAddresses(
|
||||
searchQuery,
|
||||
countryCode,
|
||||
limit
|
||||
);
|
||||
|
||||
setResults(searchResults);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
// Ignore abort errors
|
||||
if (err.name === 'AbortError' || err.name === 'CanceledError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Address search error:', err);
|
||||
setError(err.message || 'Failed to search addresses');
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[countryCode, limit, minQueryLength]
|
||||
);
|
||||
|
||||
// Debounced search effect
|
||||
useEffect(() => {
|
||||
// Clear previous timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Don't search if query is empty or selected address matches query
|
||||
if (!query || (selectedAddress && selectedAddress.display_name === query)) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
searchAddresses(query);
|
||||
}, debounceMs);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [query, debounceMs, searchAddresses, selectedAddress]);
|
||||
|
||||
const selectAddress = useCallback((address: AddressResult) => {
|
||||
setSelectedAddress(address);
|
||||
setQuery(address.display_name);
|
||||
setResults([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedAddress(null);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
setResults([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
results,
|
||||
isLoading,
|
||||
error,
|
||||
selectedAddress,
|
||||
selectAddress,
|
||||
clearSelection,
|
||||
clearResults
|
||||
};
|
||||
}
|
||||
209
frontend/src/hooks/usePOIContext.ts
Normal file
209
frontend/src/hooks/usePOIContext.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* POI Context React Hook
|
||||
*
|
||||
* Custom hook for managing POI context state and operations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { poiContextApi } from '@/services/api/poiContextApi';
|
||||
import type {
|
||||
POIContext,
|
||||
POIDetectionResponse,
|
||||
FeatureImportanceResponse,
|
||||
CompetitorAnalysis
|
||||
} from '@/types/poi';
|
||||
|
||||
export interface UsePOIContextOptions {
|
||||
tenantId: string;
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
export interface UsePOIContextResult {
|
||||
poiContext: POIContext | null;
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: string | null;
|
||||
isStale: boolean;
|
||||
needsRefresh: boolean;
|
||||
featureImportance: FeatureImportanceResponse | null;
|
||||
competitorAnalysis: CompetitorAnalysis | null;
|
||||
competitiveInsights: string[];
|
||||
|
||||
// Actions
|
||||
detectPOIs: (latitude: number, longitude: number, forceRefresh?: boolean) => Promise<void>;
|
||||
refreshPOIs: () => Promise<void>;
|
||||
fetchContext: () => Promise<void>;
|
||||
fetchFeatureImportance: () => Promise<void>;
|
||||
fetchCompetitorAnalysis: () => Promise<void>;
|
||||
deletePOIContext: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePOIContext({ tenantId, autoFetch = true }: UsePOIContextOptions): UsePOIContextResult {
|
||||
const [poiContext, setPOIContext] = useState<POIContext | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStale, setIsStale] = useState(false);
|
||||
const [needsRefresh, setNeedsRefresh] = useState(false);
|
||||
const [featureImportance, setFeatureImportance] = useState<FeatureImportanceResponse | null>(null);
|
||||
const [competitorAnalysis, setCompetitorAnalysis] = useState<CompetitorAnalysis | null>(null);
|
||||
const [competitiveInsights, setCompetitiveInsights] = useState<string[]>([]);
|
||||
|
||||
const fetchContext = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await poiContextApi.getPOIContext(tenantId);
|
||||
setPOIContext(response.poi_context);
|
||||
setIsStale(response.is_stale);
|
||||
setNeedsRefresh(response.needs_refresh);
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
// No POI context found - this is normal for new tenants
|
||||
setPOIContext(null);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(err.message || 'Failed to fetch POI context');
|
||||
console.error('Error fetching POI context:', err);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
const detectPOIs = useCallback(async (
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
forceRefresh: boolean = false
|
||||
) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await poiContextApi.detectPOIs(
|
||||
tenantId,
|
||||
latitude,
|
||||
longitude,
|
||||
forceRefresh
|
||||
);
|
||||
|
||||
setPOIContext(response.poi_context);
|
||||
setIsStale(false);
|
||||
setNeedsRefresh(false);
|
||||
|
||||
// Update competitor analysis if available
|
||||
if (response.competitor_analysis) {
|
||||
setCompetitorAnalysis(response.competitor_analysis);
|
||||
}
|
||||
|
||||
// Update competitive insights if available
|
||||
if (response.competitive_insights) {
|
||||
setCompetitiveInsights(response.competitive_insights);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to detect POIs');
|
||||
console.error('Error detecting POIs:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
const refreshPOIs = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const response = await poiContextApi.refreshPOIContext(tenantId);
|
||||
setPOIContext(response.poi_context);
|
||||
setIsStale(false);
|
||||
setNeedsRefresh(false);
|
||||
|
||||
// Update competitor analysis if available
|
||||
if (response.competitor_analysis) {
|
||||
setCompetitorAnalysis(response.competitor_analysis);
|
||||
}
|
||||
|
||||
// Update competitive insights if available
|
||||
if (response.competitive_insights) {
|
||||
setCompetitiveInsights(response.competitive_insights);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to refresh POI context');
|
||||
console.error('Error refreshing POI context:', err);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
const fetchFeatureImportance = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const response = await poiContextApi.getFeatureImportance(tenantId);
|
||||
setFeatureImportance(response);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching feature importance:', err);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
const fetchCompetitorAnalysis = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const response = await poiContextApi.getCompetitorAnalysis(tenantId);
|
||||
setCompetitorAnalysis(response.competitor_analysis);
|
||||
setCompetitiveInsights(response.insights);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching competitor analysis:', err);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
const deletePOIContext = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
await poiContextApi.deletePOIContext(tenantId);
|
||||
setPOIContext(null);
|
||||
setFeatureImportance(null);
|
||||
setCompetitorAnalysis(null);
|
||||
setCompetitiveInsights([]);
|
||||
setIsStale(false);
|
||||
setNeedsRefresh(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete POI context');
|
||||
console.error('Error deleting POI context:', err);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Auto-fetch on mount if enabled
|
||||
useEffect(() => {
|
||||
if (autoFetch && tenantId) {
|
||||
fetchContext();
|
||||
}
|
||||
}, [autoFetch, tenantId, fetchContext]);
|
||||
|
||||
return {
|
||||
poiContext,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
error,
|
||||
isStale,
|
||||
needsRefresh,
|
||||
featureImportance,
|
||||
competitorAnalysis,
|
||||
competitiveInsights,
|
||||
detectPOIs,
|
||||
refreshPOIs,
|
||||
fetchContext,
|
||||
fetchFeatureImportance,
|
||||
fetchCompetitorAnalysis,
|
||||
deletePOIContext
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"toc": {
|
||||
"automatic": "Automatic System",
|
||||
"local": "Local Intelligence",
|
||||
"forecasting": "Demand Forecasting",
|
||||
"waste": "Waste Reduction",
|
||||
"sustainability": "Sustainability",
|
||||
"business": "Business Models"
|
||||
},
|
||||
"hero": {
|
||||
"title": "How Bakery-AI Works For You Every Day",
|
||||
"subtitle": "All features explained in simple language for bakery owners"
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
{
|
||||
"hero": {
|
||||
"pre_headline": "For Bakeries Losing €500-2,000/Month on Waste",
|
||||
"scarcity": "Only 12 spots left out of 20 • 3 months FREE",
|
||||
"scarcity_badge": "🔥 Only 12 spots left out of 20 in pilot program",
|
||||
"badge": "Advanced AI for Modern Bakeries",
|
||||
"title_line1": "Stop Losing €2,000 Per Month",
|
||||
"title_line2": "on Bread Nobody Buys",
|
||||
"subtitle": "AI that predicts exactly what you'll sell tomorrow. Produce just enough. Reduce waste. Increase profits. <strong>3 months free for the first 20 bakeries</strong>.",
|
||||
"cta_primary": "Request Pilot Spot",
|
||||
"title_line1": "Increase Profits,",
|
||||
"title_line2": "Reduce Waste",
|
||||
"title_option_a_line1": "Save €500-2,000 Per Month",
|
||||
"title_option_a_line2": "By Producing Exactly What You'll Sell",
|
||||
"title_option_b": "Stop Guessing How Much to Bake Every Day",
|
||||
"subtitle": "AI that predicts demand using local data so you produce exactly what you'll sell. Reduce waste, improve margins, save time.",
|
||||
"subtitle_option_a": "The first AI that knows your neighborhood: nearby schools, local weather, your competition, events. Automatic system every morning. Ready at 6 AM.",
|
||||
"subtitle_option_b": "AI that knows your area predicts sales with 92% accuracy. Wake up with your plan ready: what to make, what to order, when it arrives. Save €500-2,000/month on waste.",
|
||||
"cta_primary": "Join Pilot Program",
|
||||
"cta_secondary": "See How It Works (2 min)",
|
||||
"social_proof": {
|
||||
"bakeries": "20 bakeries already saving €1,500/month on average",
|
||||
"accuracy": "92% accurate predictions (vs 60% generic systems)",
|
||||
"setup": "15-minute setup"
|
||||
},
|
||||
"trust": {
|
||||
"no_cc": "3 months free",
|
||||
"card": "Card required",
|
||||
"quick": "Ready in 10 minutes",
|
||||
"quick": "15-minute setup",
|
||||
"spanish": "Support in Spanish"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"title": "Register Bakery",
|
||||
"description": "Configure your bakery's basic information"
|
||||
},
|
||||
"poi_detection": {
|
||||
"title": "Location Analysis",
|
||||
"description": "Detect nearby points of interest"
|
||||
},
|
||||
"smart_inventory_setup": {
|
||||
"title": "Configure Inventory",
|
||||
"description": "Upload sales data and set up your initial inventory"
|
||||
@@ -190,5 +194,22 @@
|
||||
"invalid_url": "Invalid URL",
|
||||
"file_too_large": "File too large",
|
||||
"invalid_file_type": "Invalid file type"
|
||||
},
|
||||
"stock": {
|
||||
"title": "Initial Stock Levels",
|
||||
"subtitle": "Enter current quantities for each product. This allows the system to track inventory from today.",
|
||||
"info_title": "Why is this important?",
|
||||
"info_text": "Without initial stock levels, the system cannot alert you about low stock, plan production, or calculate costs correctly. Take a moment to enter your current quantities.",
|
||||
"progress": "Capture progress",
|
||||
"set_all_zero": "Set all to 0",
|
||||
"skip_for_now": "Skip for now (will be set to 0)",
|
||||
"ingredients": "Ingredients",
|
||||
"finished_products": "Finished Products",
|
||||
"incomplete_warning": "{{count}} products remaining",
|
||||
"incomplete_help": "You can continue, but we recommend entering all quantities for better inventory control.",
|
||||
"complete": "Complete Setup",
|
||||
"continue_anyway": "Continue anyway",
|
||||
"no_products_title": "Initial Stock",
|
||||
"no_products_message": "You can configure stock levels later in the inventory section."
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"toc": {
|
||||
"automatic": "Sistema Automático",
|
||||
"local": "Inteligencia Local",
|
||||
"forecasting": "Predicción de Demanda",
|
||||
"waste": "Reducción de Desperdicios",
|
||||
"sustainability": "Sostenibilidad",
|
||||
"business": "Modelos de Negocio"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Cómo Bakery-IA Trabaja Para Ti Cada Día",
|
||||
"subtitle": "Todas las funcionalidades explicadas en lenguaje sencillo para dueños de panaderías"
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
{
|
||||
"hero": {
|
||||
"pre_headline": "Para Panaderías que Pierden €500-2,000/Mes en Desperdicios",
|
||||
"scarcity": "Solo 12 plazas restantes de 20 • 3 meses GRATIS",
|
||||
"scarcity_badge": "🔥 Solo 12 plazas restantes de 20 en el programa piloto",
|
||||
"badge": "IA Avanzada para Panaderías Modernas",
|
||||
"title_line1": "Deja de Perder €2,000 al Mes",
|
||||
"title_line2": "en Pan Que Nadie Compra",
|
||||
"subtitle": "IA que predice exactamente cuánto venderás mañana. Produce lo justo. Reduce desperdicios. Aumenta ganancias. <strong>3 meses gratis para las primeras 20 panaderías</strong>.",
|
||||
"cta_primary": "Solicitar Plaza en el Piloto",
|
||||
"title_line1": "Aumenta Ganancias,",
|
||||
"title_line2": "Reduce Desperdicios",
|
||||
"title_option_a_line1": "Ahorra €500-2,000 al Mes",
|
||||
"title_option_a_line2": "Produciendo Exactamente Lo Que Venderás",
|
||||
"title_option_b": "Deja de Adivinar Cuánto Hornear Cada Día",
|
||||
"subtitle": "IA que predice demanda con datos de tu zona para que produzcas exactamente lo que vas a vender. Reduce desperdicios, mejora márgenes y ahorra tiempo.",
|
||||
"subtitle_option_a": "La primera IA que conoce tu barrio: colegios cerca, clima local, tu competencia, eventos. Sistema automático cada mañana. Listo a las 6 AM.",
|
||||
"subtitle_option_b": "IA que conoce tu zona predice ventas con 92% de precisión. Despierta con tu plan listo: qué hacer, qué pedir, cuándo llegará. Ahorra €500-2,000/mes en desperdicios.",
|
||||
"cta_primary": "Únete al Programa Piloto",
|
||||
"cta_secondary": "Ver Cómo Funciona (2 min)",
|
||||
"social_proof": {
|
||||
"bakeries": "20 panaderías ya ahorran €1,500/mes de promedio",
|
||||
"accuracy": "Predicciones 92% precisas (vs 60% sistemas genéricos)",
|
||||
"setup": "Configuración en 15 minutos"
|
||||
},
|
||||
"trust": {
|
||||
"no_cc": "3 meses gratis",
|
||||
"card": "Tarjeta requerida",
|
||||
"quick": "Lista en 10 minutos",
|
||||
"quick": "Configuración en 15 min",
|
||||
"spanish": "Soporte en español"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"title": "Registrar Panadería",
|
||||
"description": "Información básica"
|
||||
},
|
||||
"poi_detection": {
|
||||
"title": "Análisis de Ubicación",
|
||||
"description": "Detectar puntos de interés cercanos"
|
||||
},
|
||||
"smart_inventory": {
|
||||
"title": "Subir Datos de Ventas",
|
||||
"description": "Configuración con IA"
|
||||
@@ -394,6 +398,8 @@
|
||||
"incomplete_warning": "Faltan {{count}} productos por completar",
|
||||
"incomplete_help": "Puedes continuar, pero recomendamos ingresar todas las cantidades para un mejor control de inventario.",
|
||||
"complete": "Completar Configuración",
|
||||
"continue_anyway": "Continuar de todos modos"
|
||||
"continue_anyway": "Continuar de todos modos",
|
||||
"no_products_title": "Stock Inicial",
|
||||
"no_products_message": "Podrás configurar los niveles de stock más tarde en la sección de inventario."
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
* - Trust-building (explain system reasoning)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RefreshCw, ExternalLink, Plus, Sparkles } from 'lucide-react';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useStartProductionBatch,
|
||||
usePauseProductionBatch,
|
||||
} from '../../api/hooks/newDashboard';
|
||||
import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders';
|
||||
import { HealthStatusCard } from '../../components/dashboard/HealthStatusCard';
|
||||
import { ActionQueueCard } from '../../components/dashboard/ActionQueueCard';
|
||||
import { OrchestrationSummaryCard } from '../../components/dashboard/OrchestrationSummaryCard';
|
||||
@@ -36,11 +37,15 @@ import { ProductionTimelineCard } from '../../components/dashboard/ProductionTim
|
||||
import { InsightsGrid } from '../../components/dashboard/InsightsGrid';
|
||||
import { UnifiedAddWizard } from '../../components/domain/unified-wizard';
|
||||
import type { ItemType } from '../../components/domain/unified-wizard';
|
||||
import { useDemoTour, shouldStartTour, clearTourStartPending } from '../../features/demo-onboarding';
|
||||
import { DemoBanner } from '../../components/layout/DemoBanner/DemoBanner';
|
||||
|
||||
export function NewDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { currentTenant } = useTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const { startTour } = useDemoTour();
|
||||
const isDemoMode = localStorage.getItem('demo_mode') === 'true';
|
||||
|
||||
// Unified Add Wizard state
|
||||
const [isAddWizardOpen, setIsAddWizardOpen] = useState(false);
|
||||
@@ -79,6 +84,7 @@ export function NewDashboardPage() {
|
||||
|
||||
// Mutations
|
||||
const approvePO = useApprovePurchaseOrder();
|
||||
const rejectPO = useRejectPurchaseOrder();
|
||||
const startBatch = useStartProductionBatch();
|
||||
const pauseBatch = usePauseProductionBatch();
|
||||
|
||||
@@ -94,6 +100,17 @@ export function NewDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (actionId: string, reason: string) => {
|
||||
try {
|
||||
await rejectPO.mutateAsync({ tenantId, poId: actionId, reason });
|
||||
// Refetch to update UI
|
||||
refetchActionQueue();
|
||||
refetchHealth();
|
||||
} catch (error) {
|
||||
console.error('Error rejecting PO:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (actionId: string) => {
|
||||
// Navigate to appropriate detail page based on action type
|
||||
navigate(`/app/operations/procurement`);
|
||||
@@ -137,8 +154,43 @@ export function NewDashboardPage() {
|
||||
handleRefreshAll();
|
||||
};
|
||||
|
||||
// Demo tour auto-start logic
|
||||
useEffect(() => {
|
||||
console.log('[Dashboard] Demo mode:', isDemoMode);
|
||||
console.log('[Dashboard] Should start tour:', shouldStartTour());
|
||||
console.log('[Dashboard] SessionStorage demo_tour_should_start:', sessionStorage.getItem('demo_tour_should_start'));
|
||||
console.log('[Dashboard] SessionStorage demo_tour_start_step:', sessionStorage.getItem('demo_tour_start_step'));
|
||||
|
||||
// Check if there's a tour intent from redirection (higher priority)
|
||||
const shouldStartFromRedirect = sessionStorage.getItem('demo_tour_should_start') === 'true';
|
||||
const redirectStartStep = parseInt(sessionStorage.getItem('demo_tour_start_step') || '0', 10);
|
||||
|
||||
if (isDemoMode && (shouldStartTour() || shouldStartFromRedirect)) {
|
||||
console.log('[Dashboard] Starting tour in 1.5s...');
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[Dashboard] Executing startTour()');
|
||||
if (shouldStartFromRedirect) {
|
||||
// Start tour from the specific step that was intended
|
||||
startTour(redirectStartStep);
|
||||
// Clear the redirect intent
|
||||
sessionStorage.removeItem('demo_tour_should_start');
|
||||
sessionStorage.removeItem('demo_tour_start_step');
|
||||
} else {
|
||||
// Start tour normally (from beginning or resume)
|
||||
startTour();
|
||||
clearTourStartPending();
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isDemoMode, startTour]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20 md:pb-8" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||||
{/* Demo Banner */}
|
||||
{isDemoMode && <DemoBanner />}
|
||||
|
||||
{/* Mobile-optimized container */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Header */}
|
||||
@@ -183,30 +235,40 @@ export function NewDashboardPage() {
|
||||
{/* Main Dashboard Layout */}
|
||||
<div className="space-y-6">
|
||||
{/* SECTION 1: Bakery Health Status */}
|
||||
<HealthStatusCard healthStatus={healthStatus} loading={healthLoading} />
|
||||
<div data-tour="dashboard-stats">
|
||||
<HealthStatusCard healthStatus={healthStatus} loading={healthLoading} />
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: What Needs Your Attention (Action Queue) */}
|
||||
<ActionQueueCard
|
||||
actionQueue={actionQueue}
|
||||
loading={actionQueueLoading}
|
||||
onApprove={handleApprove}
|
||||
onViewDetails={handleViewDetails}
|
||||
onModify={handleModify}
|
||||
/>
|
||||
<div data-tour="pending-po-approvals">
|
||||
<ActionQueueCard
|
||||
actionQueue={actionQueue}
|
||||
loading={actionQueueLoading}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onViewDetails={handleViewDetails}
|
||||
onModify={handleModify}
|
||||
tenantId={tenantId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: What the System Did for You (Orchestration Summary) */}
|
||||
<OrchestrationSummaryCard
|
||||
summary={orchestrationSummary}
|
||||
loading={orchestrationLoading}
|
||||
/>
|
||||
<div data-tour="real-time-alerts">
|
||||
<OrchestrationSummaryCard
|
||||
summary={orchestrationSummary}
|
||||
loading={orchestrationLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SECTION 4: Today's Production Timeline */}
|
||||
<ProductionTimelineCard
|
||||
timeline={productionTimeline}
|
||||
loading={timelineLoading}
|
||||
onStart={handleStartBatch}
|
||||
onPause={handlePauseBatch}
|
||||
/>
|
||||
<div data-tour="today-production">
|
||||
<ProductionTimelineCard
|
||||
timeline={productionTimeline}
|
||||
loading={timelineLoading}
|
||||
onStart={handleStartBatch}
|
||||
onPause={handlePauseBatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SECTION 5: Quick Insights Grid */}
|
||||
<div>
|
||||
|
||||
@@ -28,6 +28,75 @@ const SustainabilityPage: React.FC = () => {
|
||||
// Date range state (default to last 30 days)
|
||||
const [dateRange, setDateRange] = useState<{ start?: string; end?: string }>({});
|
||||
|
||||
// CSV Export function
|
||||
const exportToCSV = () => {
|
||||
if (!metrics) return;
|
||||
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const filename = `sustainability_report_${currentTenant?.name || 'bakery'}_${timestamp}.csv`;
|
||||
|
||||
// Build CSV content
|
||||
const csvRows = [
|
||||
['Sustainability Report'],
|
||||
['Generated:', new Date().toLocaleString()],
|
||||
['Bakery:', currentTenant?.name || 'N/A'],
|
||||
['Period:', `${metrics.period.start_date} to ${metrics.period.end_date}`],
|
||||
[],
|
||||
['WASTE METRICS'],
|
||||
['Total Waste (kg)', metrics.waste_metrics.total_waste_kg.toFixed(2)],
|
||||
['Production Waste (kg)', metrics.waste_metrics.production_waste_kg.toFixed(2)],
|
||||
['Inventory Waste (kg)', metrics.waste_metrics.inventory_waste_kg.toFixed(2)],
|
||||
['Waste Percentage (%)', metrics.waste_metrics.waste_percentage.toFixed(2)],
|
||||
[],
|
||||
['SDG 12.3 COMPLIANCE'],
|
||||
['Status', metrics.sdg_compliance.sdg_12_3.status_label],
|
||||
['Reduction Achieved (%)', metrics.sdg_compliance.sdg_12_3.reduction_achieved.toFixed(2)],
|
||||
['Progress to Target (%)', metrics.sdg_compliance.sdg_12_3.progress_to_target.toFixed(2)],
|
||||
['Target (%)', metrics.sdg_compliance.sdg_12_3.target_percentage],
|
||||
[],
|
||||
['ENVIRONMENTAL IMPACT'],
|
||||
['CO2 Emissions (kg)', metrics.environmental_impact.co2_emissions.kg.toFixed(2)],
|
||||
['CO2 Emissions (tons)', metrics.environmental_impact.co2_emissions.tons.toFixed(4)],
|
||||
['Trees to Offset', metrics.environmental_impact.co2_emissions.trees_to_offset.toFixed(1)],
|
||||
['Equivalent Car KM', metrics.environmental_impact.co2_emissions.car_km_equivalent.toFixed(0)],
|
||||
['Water Footprint (liters)', metrics.environmental_impact.water_footprint.liters.toFixed(0)],
|
||||
['Water Footprint (m³)', metrics.environmental_impact.water_footprint.cubic_meters.toFixed(2)],
|
||||
['Equivalent Showers', metrics.environmental_impact.water_footprint.shower_equivalent.toFixed(0)],
|
||||
['Land Use (m²)', metrics.environmental_impact.land_use.square_meters.toFixed(2)],
|
||||
['Land Use (hectares)', metrics.environmental_impact.land_use.hectares.toFixed(4)],
|
||||
[],
|
||||
['FINANCIAL IMPACT'],
|
||||
['Waste Cost (EUR)', metrics.financial_impact.waste_cost_eur.toFixed(2)],
|
||||
['Potential Monthly Savings (EUR)', metrics.financial_impact.potential_monthly_savings.toFixed(2)],
|
||||
['ROI on Prevention (%)', metrics.financial_impact.roi_on_waste_prevention.toFixed(2)],
|
||||
[],
|
||||
['AVOIDED WASTE (AI PREDICTIONS)'],
|
||||
['Waste Avoided (kg)', metrics.avoided_waste.total_waste_avoided_kg.toFixed(2)],
|
||||
['Cost Savings (EUR)', metrics.avoided_waste.cost_savings_eur.toFixed(2)],
|
||||
['CO2 Avoided (kg)', metrics.avoided_waste.environmental_impact_avoided.co2_kg.toFixed(2)],
|
||||
['Water Saved (liters)', metrics.avoided_waste.environmental_impact_avoided.water_liters.toFixed(0)],
|
||||
[],
|
||||
['GRANT READINESS'],
|
||||
['Certification Ready', metrics.sdg_compliance.certification_ready ? 'Yes' : 'No'],
|
||||
['Eligible Programs', Object.values(metrics.grant_readiness.grant_programs).filter(p => p.eligible).length],
|
||||
['Recommended Applications', metrics.grant_readiness.recommended_applications.join(', ')]
|
||||
];
|
||||
|
||||
// Convert to CSV string
|
||||
const csvContent = csvRows.map(row => row.join(',')).join('\n');
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Fetch sustainability metrics
|
||||
const {
|
||||
data: metrics,
|
||||
@@ -226,12 +295,10 @@ const SustainabilityPage: React.FC = () => {
|
||||
id: "export-report",
|
||||
label: t('sustainability:actions.export_report', 'Exportar Informe'),
|
||||
icon: Download,
|
||||
onClick: () => {
|
||||
// TODO: Implement export
|
||||
console.log('Export sustainability report');
|
||||
},
|
||||
onClick: exportToCSV,
|
||||
variant: "outline",
|
||||
size: "sm"
|
||||
size: "sm",
|
||||
disabled: !metrics
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -17,9 +17,9 @@ import type { ItemType } from '../../../../components/domain/unified-wizard';
|
||||
|
||||
// Import AddStockModal separately since we need it for adding batches
|
||||
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
|
||||
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useUpdateStock, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
|
||||
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useUpdateStock, useCreateStockMovement, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
|
||||
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
|
||||
import { IngredientResponse, StockCreate, StockMovementCreate, StockMovementType, IngredientCreate } from '../../../../api/types/inventory';
|
||||
import { subscriptionService } from '../../../../api/services/subscription';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
@@ -55,6 +55,7 @@ const InventoryPage: React.FC = () => {
|
||||
const consumeStockMutation = useConsumeStock();
|
||||
const updateIngredientMutation = useUpdateIngredient();
|
||||
const updateStockMutation = useUpdateStock();
|
||||
const createStockMovementMutation = useCreateStockMovement();
|
||||
|
||||
// API Data
|
||||
const {
|
||||
@@ -795,8 +796,36 @@ const InventoryPage: React.FC = () => {
|
||||
});
|
||||
}}
|
||||
onMarkAsWaste={async (batchId) => {
|
||||
// TODO: Implement mark as waste functionality
|
||||
console.log('Mark as waste:', batchId);
|
||||
if (!tenantId || !batches) return;
|
||||
|
||||
try {
|
||||
// Find the batch to get its details
|
||||
const batch = batches.find(b => b.id === batchId);
|
||||
if (!batch) {
|
||||
console.error('Batch not found:', batchId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a waste movement for the entire current quantity
|
||||
const movementData: StockMovementCreate = {
|
||||
ingredient_id: selectedItem!.id,
|
||||
stock_id: batchId,
|
||||
movement_type: StockMovementType.WASTE,
|
||||
quantity: batch.current_quantity,
|
||||
unit_cost: batch.unit_cost || undefined,
|
||||
notes: `Batch marked as waste. Reason: ${batch.is_expired ? 'Expired' : 'Damaged/Spoiled'}`
|
||||
};
|
||||
|
||||
await createStockMovementMutation.mutateAsync({
|
||||
tenantId,
|
||||
movementData
|
||||
});
|
||||
|
||||
// Refresh the batches list
|
||||
queryClient.invalidateQueries({ queryKey: ['stock', 'byIngredient', tenantId, selectedItem!.id] });
|
||||
} catch (error) {
|
||||
console.error('Failed to mark batch as waste:', error);
|
||||
}
|
||||
}}
|
||||
waitForRefetch={true}
|
||||
isRefetching={isRefetchingBatches}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
CustomerType,
|
||||
CustomerSegment
|
||||
} from '../../../../api/types/orders';
|
||||
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer, useUpdateCustomer } from '../../../../api/hooks/orders';
|
||||
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useUpdateOrder, useCreateCustomer, useUpdateCustomer } from '../../../../api/hooks/orders';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { OrderFormModal } from '../../../../components/domain/orders';
|
||||
@@ -76,6 +76,7 @@ const OrdersPage: React.FC = () => {
|
||||
|
||||
// Mutations
|
||||
const createOrderMutation = useCreateOrder();
|
||||
const updateOrderMutation = useUpdateOrder();
|
||||
const createCustomerMutation = useCreateCustomer();
|
||||
const updateCustomerMutation = useUpdateCustomer();
|
||||
|
||||
@@ -634,11 +635,36 @@ const OrdersPage: React.FC = () => {
|
||||
sections={sections}
|
||||
showDefaultActions={true}
|
||||
onSave={async () => {
|
||||
// TODO: Implement order update functionality
|
||||
// Note: The backend only has updateOrderStatus, not a general update endpoint
|
||||
// For now, orders can be updated via status changes using useUpdateOrderStatus
|
||||
console.log('Saving order:', selectedOrder);
|
||||
console.warn('Order update not yet implemented - only status updates are supported via useUpdateOrderStatus');
|
||||
if (!selectedOrder || !tenantId) return;
|
||||
|
||||
try {
|
||||
// Build the update payload from the selectedOrder state
|
||||
const updateData = {
|
||||
status: selectedOrder.status,
|
||||
priority: selectedOrder.priority,
|
||||
requested_delivery_date: selectedOrder.requested_delivery_date,
|
||||
delivery_method: selectedOrder.delivery_method,
|
||||
delivery_address: selectedOrder.delivery_address,
|
||||
delivery_instructions: selectedOrder.delivery_instructions,
|
||||
delivery_window_start: selectedOrder.delivery_window_start,
|
||||
delivery_window_end: selectedOrder.delivery_window_end,
|
||||
payment_method: selectedOrder.payment_method,
|
||||
payment_status: selectedOrder.payment_status,
|
||||
special_instructions: selectedOrder.special_instructions,
|
||||
custom_requirements: selectedOrder.custom_requirements,
|
||||
allergen_warnings: selectedOrder.allergen_warnings,
|
||||
};
|
||||
|
||||
await updateOrderMutation.mutateAsync({
|
||||
tenantId: tenantId,
|
||||
orderId: selectedOrder.id,
|
||||
data: updateData
|
||||
});
|
||||
|
||||
setShowForm(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update order:', error);
|
||||
}
|
||||
}}
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
const newOrder = { ...selectedOrder };
|
||||
|
||||
@@ -11,6 +11,7 @@ import { showToast } from '../../../../utils/toast';
|
||||
import { usePOSConfigurationData, usePOSConfigurationManager, usePOSTransactions, usePOSTransactionsDashboard, usePOSTransaction } from '../../../../api/hooks/pos';
|
||||
import { POSConfiguration } from '../../../../api/types/pos';
|
||||
import { posService } from '../../../../api/services/pos';
|
||||
import { salesService } from '../../../../api/services/sales';
|
||||
import { bakeryColors } from '../../../../styles/colors';
|
||||
|
||||
// Import new POS components
|
||||
@@ -18,6 +19,7 @@ import { POSProductCard } from '../../../../components/domain/pos/POSProductCard
|
||||
import { POSCart } from '../../../../components/domain/pos/POSCart';
|
||||
import { POSPayment } from '../../../../components/domain/pos/POSPayment';
|
||||
import { CreatePOSConfigModal } from '../../../../components/domain/pos/CreatePOSConfigModal';
|
||||
import { POSSyncStatus } from '../../../../components/domain/pos/POSSyncStatus';
|
||||
|
||||
interface CartItem {
|
||||
id: string;
|
||||
@@ -752,17 +754,37 @@ const POSPage: React.FC = () => {
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
|
||||
const processPayment = (paymentData: any) => {
|
||||
const processPayment = async (paymentData: any) => {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
console.log('Processing payment:', {
|
||||
cart,
|
||||
...paymentData,
|
||||
total,
|
||||
});
|
||||
try {
|
||||
// Create sales records for each item in the cart
|
||||
const saleDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
setCart([]);
|
||||
showToast.success('Venta procesada exitosamente');
|
||||
for (const item of cart) {
|
||||
const salesData = {
|
||||
inventory_product_id: item.id, // Product ID for inventory tracking
|
||||
product_name: item.name,
|
||||
product_category: 'finished_product',
|
||||
quantity_sold: item.quantity,
|
||||
unit_price: item.price,
|
||||
total_amount: item.price * item.quantity,
|
||||
sale_date: saleDate,
|
||||
sales_channel: 'pos',
|
||||
source: 'manual_pos',
|
||||
payment_method: paymentData.method || 'cash',
|
||||
notes: paymentData.notes || 'Venta desde POS manual',
|
||||
};
|
||||
|
||||
await salesService.createSalesRecord(tenantId, salesData);
|
||||
}
|
||||
|
||||
setCart([]);
|
||||
showToast.success(`Venta procesada exitosamente: €${total.toFixed(2)}`);
|
||||
} catch (error: any) {
|
||||
console.error('Error processing payment:', error);
|
||||
showToast.error(error.response?.data?.detail || 'Error al procesar la venta');
|
||||
}
|
||||
};
|
||||
|
||||
// Loading and error states
|
||||
@@ -1030,6 +1052,11 @@ const POSPage: React.FC = () => {
|
||||
{posData.configurations.length > 0 && (
|
||||
<TransactionsSection tenantId={tenantId} />
|
||||
)}
|
||||
|
||||
{/* POS to Sales Sync Status - Only show if there are configurations */}
|
||||
{posData.configurations.length > 0 && (
|
||||
<POSSyncStatus tenantId={tenantId} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -135,78 +135,19 @@ const ProductionPage: React.FC = () => {
|
||||
// The QualityCheckModal should be enhanced to handle stage-specific checks
|
||||
};
|
||||
|
||||
// Helper function to generate mock process stage data for the selected batch
|
||||
const generateMockProcessStageData = (batch: ProductionBatchResponse) => {
|
||||
// Mock data based on batch status - this would come from the API in real implementation
|
||||
const mockProcessStage = {
|
||||
current: batch.status === ProductionStatusEnum.PENDING ? 'mixing' as const :
|
||||
batch.status === ProductionStatusEnum.IN_PROGRESS ? 'baking' as const :
|
||||
batch.status === ProductionStatusEnum.QUALITY_CHECK ? 'cooling' as const :
|
||||
'finishing' as const,
|
||||
history: batch.status !== ProductionStatusEnum.PENDING ? [
|
||||
{ stage: 'mixing' as const, timestamp: batch.actual_start_time || batch.planned_start_time, duration: 30 },
|
||||
...(batch.status === ProductionStatusEnum.IN_PROGRESS || batch.status === ProductionStatusEnum.QUALITY_CHECK || batch.status === ProductionStatusEnum.COMPLETED ? [
|
||||
{ stage: 'proofing' as const, timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), duration: 90 },
|
||||
{ stage: 'shaping' as const, timestamp: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(), duration: 15 }
|
||||
] : []),
|
||||
...(batch.status === ProductionStatusEnum.QUALITY_CHECK || batch.status === ProductionStatusEnum.COMPLETED ? [
|
||||
{ stage: 'baking' as const, timestamp: new Date(Date.now() - 30 * 60 * 1000).toISOString(), duration: 45 }
|
||||
] : [])
|
||||
] : [],
|
||||
pendingQualityChecks: batch.status === ProductionStatusEnum.IN_PROGRESS ? [
|
||||
{
|
||||
id: 'qc1',
|
||||
name: 'Control de temperatura interna',
|
||||
stage: 'baking' as const,
|
||||
isRequired: true,
|
||||
isCritical: true,
|
||||
status: 'pending' as const,
|
||||
checkType: 'temperature' as const
|
||||
}
|
||||
] : batch.status === ProductionStatusEnum.QUALITY_CHECK ? [
|
||||
{
|
||||
id: 'qc2',
|
||||
name: 'Inspección visual final',
|
||||
stage: 'cooling' as const,
|
||||
isRequired: true,
|
||||
isCritical: false,
|
||||
status: 'pending' as const,
|
||||
checkType: 'visual' as const
|
||||
}
|
||||
] : [],
|
||||
completedQualityChecks: batch.status === ProductionStatusEnum.COMPLETED ? [
|
||||
{
|
||||
id: 'qc1',
|
||||
name: 'Control de temperatura interna',
|
||||
stage: 'baking' as const,
|
||||
isRequired: true,
|
||||
isCritical: true,
|
||||
status: 'completed' as const,
|
||||
checkType: 'temperature' as const
|
||||
},
|
||||
{
|
||||
id: 'qc2',
|
||||
name: 'Inspección visual final',
|
||||
stage: 'cooling' as const,
|
||||
isRequired: true,
|
||||
isCritical: false,
|
||||
status: 'completed' as const,
|
||||
checkType: 'visual' as const
|
||||
}
|
||||
] : batch.status === ProductionStatusEnum.IN_PROGRESS ? [
|
||||
{
|
||||
id: 'qc3',
|
||||
name: 'Verificación de masa',
|
||||
stage: 'mixing' as const,
|
||||
isRequired: true,
|
||||
isCritical: false,
|
||||
status: 'completed' as const,
|
||||
checkType: 'visual' as const
|
||||
}
|
||||
] : []
|
||||
// Helper function to get process stage data from the batch (now from real backend data)
|
||||
const getProcessStageData = (batch: ProductionBatchResponse) => {
|
||||
// Backend now provides these fields in the API response:
|
||||
// - current_process_stage
|
||||
// - process_stage_history
|
||||
// - pending_quality_checks
|
||||
// - completed_quality_checks
|
||||
return {
|
||||
current: batch.current_process_stage || 'mixing',
|
||||
history: batch.process_stage_history || [],
|
||||
pendingQualityChecks: batch.pending_quality_checks || [],
|
||||
completedQualityChecks: batch.completed_quality_checks || []
|
||||
};
|
||||
|
||||
return mockProcessStage;
|
||||
};
|
||||
|
||||
|
||||
@@ -576,7 +517,7 @@ const ProductionPage: React.FC = () => {
|
||||
label: '',
|
||||
value: (
|
||||
<CompactProcessStageTracker
|
||||
processStage={generateMockProcessStageData(selectedBatch)}
|
||||
processStage={getProcessStageData(selectedBatch)}
|
||||
onAdvanceStage={(currentStage) => handleStageAdvance(selectedBatch.id, currentStage)}
|
||||
onQualityCheck={(checkId) => {
|
||||
setShowQualityModal(true);
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { Button } from '../../components/ui';
|
||||
import {
|
||||
Button,
|
||||
TableOfContents,
|
||||
ProgressBar,
|
||||
FloatingCTA,
|
||||
ScrollReveal,
|
||||
SavingsCalculator,
|
||||
StepTimeline,
|
||||
AnimatedCounter,
|
||||
TOCSection,
|
||||
TimelineStep
|
||||
} from '../../components/ui';
|
||||
import { getDemoUrl } from '../../utils/navigation';
|
||||
import {
|
||||
Clock,
|
||||
@@ -35,6 +46,110 @@ import {
|
||||
|
||||
const FeaturesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Automatic System Timeline Steps
|
||||
const automaticSystemSteps: TimelineStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
number: 1,
|
||||
title: t('features:automatic.step1.title', 'Revisa Todo Tu Inventario'),
|
||||
color: 'blue',
|
||||
items: [
|
||||
t('features:automatic.step1.item1', 'Cuenta cada kilo de harina, cada litro de leche'),
|
||||
t('features:automatic.step1.item2', 'Comprueba fechas de caducidad'),
|
||||
t('features:automatic.step1.item3', 'Ve qué llega hoy de proveedores'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
number: 2,
|
||||
title: t('features:automatic.step2.title', 'Predice Ventas de Hoy'),
|
||||
color: 'purple',
|
||||
items: [
|
||||
t('features:automatic.step2.item1', 'Analiza el día (lunes lluvioso, fiesta local, colegio cerrado)'),
|
||||
t('features:automatic.step2.item2', 'Compara con días similares del pasado'),
|
||||
t('features:automatic.step2.item3', 'Te dice: "Hoy venderás 80 croissants, 120 barras, 50 magdalenas"'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
number: 3,
|
||||
title: t('features:automatic.step3.title', 'Planifica Qué Hacer'),
|
||||
color: 'green',
|
||||
items: [
|
||||
t('features:automatic.step3.item1', 'Calcula exactamente cuánto hornear'),
|
||||
t('features:automatic.step3.item2', 'Te da una lista lista para ejecutar'),
|
||||
t('features:automatic.step3.item3', '"Haz 80 croissants (no 100), usa 5kg mantequilla, 3kg harina..."'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
number: 4,
|
||||
title: t('features:automatic.step4.title', 'Gestiona Inventario Inteligentemente'),
|
||||
color: 'amber',
|
||||
items: [
|
||||
t('features:automatic.step4.projection_title', 'Proyecta 7 días hacia adelante:'),
|
||||
t('features:automatic.step4.solution', '"Pide 50kg hoy, llega en 3 días, problema resuelto"'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step5',
|
||||
number: 5,
|
||||
title: t('features:automatic.step5.title', 'Crea Pedidos a Proveedores'),
|
||||
color: 'red',
|
||||
items: [
|
||||
t('features:automatic.step5.item1', 'Sabe que Proveedor A tarda 3 días, Proveedor B tarda 5'),
|
||||
t('features:automatic.step5.item2', 'Calcula cuándo pedir para que llegue justo a tiempo'),
|
||||
t('features:automatic.step5.item3', 'Prepara pedidos listos para aprobar con 1 clic'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step6',
|
||||
number: 6,
|
||||
title: t('features:automatic.step6.title', 'Previene Desperdicios'),
|
||||
color: 'teal',
|
||||
items: [
|
||||
t('features:automatic.step6.item1', '"Tienes leche que caduca en 5 días"'),
|
||||
t('features:automatic.step6.item2', '"Solo usarás 15L en 5 días"'),
|
||||
t('features:automatic.step6.item3', '"No pidas más de 15L, se desperdiciará"'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Table of Contents sections
|
||||
const tocSections: TOCSection[] = [
|
||||
{
|
||||
id: 'automatic-system',
|
||||
label: t('features:toc.automatic', 'Sistema Automático'),
|
||||
icon: <Clock className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'local-intelligence',
|
||||
label: t('features:toc.local', 'Inteligencia Local'),
|
||||
icon: <MapPin className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'demand-forecasting',
|
||||
label: t('features:toc.forecasting', 'Predicción de Demanda'),
|
||||
icon: <Target className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'waste-reduction',
|
||||
label: t('features:toc.waste', 'Reducción de Desperdicios'),
|
||||
icon: <Recycle className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'sustainability',
|
||||
label: t('features:toc.sustainability', 'Sostenibilidad'),
|
||||
icon: <Leaf className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
id: 'business-models',
|
||||
label: t('features:toc.business', 'Modelos de Negocio'),
|
||||
icon: <Store className="w-4 h-4" />
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
@@ -47,226 +162,71 @@ const FeaturesPage: React.FC = () => {
|
||||
variant: "default"
|
||||
}}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<ProgressBar position="top" height={3} />
|
||||
|
||||
{/* Floating CTA */}
|
||||
<FloatingCTA
|
||||
text={t('features:cta.button', 'Solicitar Demo')}
|
||||
onClick={() => navigate(getDemoUrl())}
|
||||
icon={<ArrowRight className="w-4 h-4" />}
|
||||
position="bottom-right"
|
||||
showAfterScroll={500}
|
||||
dismissible
|
||||
/>
|
||||
|
||||
{/* Main Content with Sidebar */}
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar - Table of Contents */}
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<TableOfContents sections={tocSections} />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl lg:text-6xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('features:hero.title', 'Cómo Bakery-IA Trabaja Para Ti Cada Día')}
|
||||
</h1>
|
||||
<p className="text-xl text-[var(--text-secondary)] leading-relaxed">
|
||||
{t('features:hero.subtitle', 'Todas las funcionalidades explicadas en lenguaje sencillo para dueños de panaderías')}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl lg:text-6xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('features:hero.title', 'Cómo Bakery-IA Trabaja Para Ti Cada Día')}
|
||||
</h1>
|
||||
<p className="text-xl text-[var(--text-secondary)] leading-relaxed">
|
||||
{t('features:hero.subtitle', 'Todas las funcionalidades explicadas en lenguaje sencillo para dueños de panaderías')}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 1: Automatic Daily System - THE KILLER FEATURE */}
|
||||
<section className="py-20 bg-[var(--bg-primary)]">
|
||||
<section id="automatic-system" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{t('features:automatic.badge', 'La Funcionalidad Estrella')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('features:automatic.title', 'Tu Asistente Personal Que Nunca Duerme')}
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
{t('features:automatic.intro', 'Imagina contratar un ayudante súper organizado que llega a las 5:30 AM (antes que tú) y hace todo esto AUTOMÁTICAMENTE:')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
{/* Step 1 */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-8 border-2 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
|
||||
{t('features:automatic.step1.title', 'Revisa Todo Tu Inventario')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[var(--text-secondary)]">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step1.item1', 'Cuenta cada kilo de harina, cada litro de leche')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step1.item2', 'Comprueba fechas de caducidad')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step1.item3', 'Ve qué llega hoy de proveedores')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{t('features:automatic.badge', 'La Funcionalidad Estrella')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('features:automatic.title', 'Tu Asistente Personal Que Nunca Duerme')}
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
{t('features:automatic.intro', 'Imagina contratar un ayudante súper organizado que llega a las 5:30 AM (antes que tú) y hace todo esto AUTOMÁTICAMENTE:')}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 rounded-2xl p-8 border-2 border-purple-200 dark:border-purple-800">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-16 h-16 bg-purple-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
|
||||
{t('features:automatic.step2.title', 'Predice Ventas de Hoy')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[var(--text-secondary)]">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step2.item1', 'Analiza el día (lunes lluvioso, fiesta local, colegio cerrado)')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step2.item2', 'Compara con días similares del pasado')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-purple-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step2.item3', 'Te dice: "Hoy venderás 80 croissants, 120 barras, 50 magdalenas"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-2xl p-8 border-2 border-green-200 dark:border-green-800">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-16 h-16 bg-green-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
|
||||
{t('features:automatic.step3.title', 'Planifica Qué Hacer')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[var(--text-secondary)]">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step3.item1', 'Calcula exactamente cuánto hornear')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step3.item2', 'Te da una lista lista para ejecutar')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step3.item3', '"Haz 80 croissants (no 100), usa 5kg mantequilla, 3kg harina..."')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4 - Inventory Management */}
|
||||
<div className="bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-2xl p-8 border-2 border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-16 h-16 bg-amber-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
|
||||
{t('features:automatic.step4.title', 'Gestiona Inventario Inteligentemente')}
|
||||
</h3>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 mb-4 border border-amber-300 dark:border-amber-700">
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)] mb-2">{t('features:automatic.step4.projection_title', 'Proyecta 7 días hacia adelante:')}</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day1', 'Hoy: 50kg harina')}</span>
|
||||
<span className="text-green-600">✅</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day2', 'Mañana: 42kg')}</span>
|
||||
<span className="text-green-600">✅</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day3', 'Pasado: 30kg')}</span>
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day4', 'Día 4: 15kg')}</span>
|
||||
<span className="text-red-600">🚨</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">{t('features:automatic.step4.day5', 'Día 5: Te quedarías sin harina')}</span>
|
||||
<span className="text-red-600">❌</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-green-100 dark:bg-green-900/30 rounded-lg p-4 border-l-4 border-green-600">
|
||||
<p className="font-bold text-green-900 dark:text-green-100 mb-2">
|
||||
{t('features:automatic.step4.solution_title', 'SOLUCIÓN AUTOMÁTICA:')}
|
||||
</p>
|
||||
<p className="text-green-800 dark:text-green-200">
|
||||
{t('features:automatic.step4.solution', '"Pide 50kg hoy, llega en 3 días, problema resuelto"')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 5 - Purchase Orders */}
|
||||
<div className="bg-gradient-to-r from-red-50 to-rose-50 dark:from-red-900/20 dark:to-rose-900/20 rounded-2xl p-8 border-2 border-red-200 dark:border-red-800">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-16 h-16 bg-red-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
|
||||
5
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
|
||||
{t('features:automatic.step5.title', 'Crea Pedidos a Proveedores')}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-[var(--text-secondary)]">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step5.item1', 'Sabe que Proveedor A tarda 3 días, Proveedor B tarda 5')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step5.item2', 'Calcula cuándo pedir para que llegue justo a tiempo')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step5.item3', 'Prepara pedidos listos para aprobar con 1 clic')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 6 - Waste Prevention */}
|
||||
<div className="bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20 rounded-2xl p-8 border-2 border-teal-200 dark:border-teal-800">
|
||||
<div className="flex gap-6 items-start">
|
||||
<div className="w-16 h-16 bg-teal-600 rounded-full flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
|
||||
6
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--text-primary)] mb-3">
|
||||
{t('features:automatic.step6.title', 'Previene Desperdicios')}
|
||||
</h3>
|
||||
<div className="space-y-3 text-[var(--text-secondary)]">
|
||||
<p className="font-medium">{t('features:automatic.step6.perishables', 'Ingredientes perecederos (leche, nata, huevos):')}</p>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-teal-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step6.item1', '"Tienes leche que caduca en 5 días"')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-teal-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step6.item2', '"Solo usarás 15L en 5 días"')}</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-5 h-5 text-teal-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{t('features:automatic.step6.item3', '"No pidas más de 15L, se desperdiciará"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollReveal variant="fadeUp" delay={0.2}>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Automatic System Timeline */}
|
||||
<StepTimeline
|
||||
steps={automaticSystemSteps}
|
||||
orientation="vertical"
|
||||
showConnector
|
||||
animated
|
||||
/>
|
||||
|
||||
{/* Morning Result */}
|
||||
<div className="bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
|
||||
@@ -328,29 +288,33 @@ const FeaturesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 2: Local Intelligence */}
|
||||
<section className="py-20 bg-[var(--bg-secondary)]">
|
||||
<section id="local-intelligence" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{t('features:local.badge', 'Tu Ventaja Competitiva')}</span>
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--color-primary)]/10 text-[var(--color-primary)] px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{t('features:local.badge', 'Tu Ventaja Competitiva')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('features:local.title', 'Tu Panadería Es Única. La IA También.')}
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
{t('features:local.intro', 'Las IA genéricas saben que es lunes. La TUYA sabe que:')}
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-5xl font-extrabold text-[var(--text-primary)] mb-6">
|
||||
{t('features:local.title', 'Tu Panadería Es Única. La IA También.')}
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
{t('features:local.intro', 'Las IA genéricas saben que es lunes. La TUYA sabe que:')}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 max-w-6xl mx-auto">
|
||||
{/* Schools */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-blue-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<School className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
@@ -371,10 +335,12 @@ const FeaturesPage: React.FC = () => {
|
||||
<span>{t('features:local.schools.item3', '"Los lunes a las 8:30 hay pico (padres tras dejar niños)"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Offices */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<ScrollReveal variant="fadeUp" delay={0.15}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-purple-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Building2 className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
@@ -395,10 +361,12 @@ const FeaturesPage: React.FC = () => {
|
||||
<span>{t('features:local.offices.item3', '"Hora punta: 13:00-14:00 (bocadillos)"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Gyms */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<ScrollReveal variant="fadeUp" delay={0.2}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-green-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Dumbbell className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
@@ -419,10 +387,12 @@ const FeaturesPage: React.FC = () => {
|
||||
<span>{t('features:local.gyms.item3', '"Pico a las 7:00 AM y 19:00 PM"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Competition */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<ScrollReveal variant="fadeUp" delay={0.25}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-amber-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<ShoppingBag className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
@@ -443,10 +413,12 @@ const FeaturesPage: React.FC = () => {
|
||||
<span>{t('features:local.competition.item3', '"Oportunidad: Diferénciate con especialidades"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Weather */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<ScrollReveal variant="fadeUp" delay={0.3}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-sky-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<Cloud className="w-6 h-6 text-sky-600" />
|
||||
</div>
|
||||
@@ -467,10 +439,12 @@ const FeaturesPage: React.FC = () => {
|
||||
<span>{t('features:local.weather.item3', '"Calor → +30% productos frescos"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Events */}
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<ScrollReveal variant="fadeUp" delay={0.35}>
|
||||
<div className="bg-[var(--bg-primary)] rounded-2xl p-6 border border-[var(--border-primary)] hover:shadow-xl transition-all">
|
||||
<div className="w-12 h-12 bg-pink-500/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<PartyPopper className="w-6 h-6 text-pink-600" />
|
||||
</div>
|
||||
@@ -491,11 +465,13 @@ const FeaturesPage: React.FC = () => {
|
||||
<span>{t('features:local.events.item3', '"Partido importante → pico de ventas pre-evento"')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* Why it matters */}
|
||||
<div className="mt-12 max-w-4xl mx-auto bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
|
||||
<ScrollReveal variant="fadeUp" delay={0.4}>
|
||||
<div className="mt-12 max-w-4xl mx-auto bg-gradient-to-r from-[var(--color-primary)] to-orange-600 rounded-2xl p-8 text-white">
|
||||
<h3 className="text-2xl font-bold mb-4 text-center">
|
||||
{t('features:local.why_matters.title', 'Por qué importa:')}
|
||||
</h3>
|
||||
@@ -510,23 +486,26 @@ const FeaturesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center mt-6 text-xl font-bold">
|
||||
{t('features:local.accuracy', 'Precisión: 92% (vs 60-70% de sistemas genéricos)')}
|
||||
Precisión: <AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> (vs 60-70% de sistemas genéricos)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 3: Demand Forecasting */}
|
||||
<section className="py-20 bg-[var(--bg-primary)]">
|
||||
<section id="demand-forecasting" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('features:forecasting.title', 'Sabe Cuánto Venderás Mañana (92% de Precisión)')}
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
{t('features:forecasting.subtitle', 'No es magia. Es matemáticas con tus datos.')}
|
||||
</p>
|
||||
</div>
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
Sabe Cuánto Venderás Mañana (<AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> de Precisión)
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-secondary)] max-w-3xl mx-auto">
|
||||
{t('features:forecasting.subtitle', 'No es magia. Es matemáticas con tus datos.')}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-8 border-2 border-blue-200 dark:border-blue-800 mb-8">
|
||||
@@ -591,17 +570,20 @@ const FeaturesPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature 4: Reduce Waste = Save Money */}
|
||||
<section className="py-20 bg-[var(--bg-secondary)]">
|
||||
<section id="waste-reduction" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('features:waste.title', 'Menos Pan en la Basura, Más Dinero en Tu Bolsillo')}
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('features:waste.title', 'Menos Pan en la Basura, Más Dinero en Tu Bolsillo')}
|
||||
</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="max-w-5xl mx-auto grid md:grid-cols-2 gap-8">
|
||||
{/* Before */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-2xl p-8 border-2 border-red-200 dark:border-red-800">
|
||||
<ScrollReveal variant="fadeLeft" delay={0.2}>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-2xl p-8 border-2 border-red-200 dark:border-red-800">
|
||||
<div className="w-12 h-12 bg-red-500/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
@@ -615,10 +597,12 @@ const FeaturesPage: React.FC = () => {
|
||||
<li className="font-bold text-red-700 dark:text-red-400">{t('features:waste.before.monthly', 'Al mes: €100 × 30 = €3,000 perdidos')}</li>
|
||||
<li className="font-bold text-red-900 dark:text-red-300 text-lg">{t('features:waste.before.yearly', 'Al año: €36,000 tirados a la basura')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* After */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-2xl p-8 border-2 border-green-200 dark:border-green-800">
|
||||
<ScrollReveal variant="fadeRight" delay={0.2}>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-2xl p-8 border-2 border-green-200 dark:border-green-800">
|
||||
<div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center mb-4">
|
||||
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
@@ -630,29 +614,45 @@ const FeaturesPage: React.FC = () => {
|
||||
<li>{t('features:waste.after.item2', 'Desperdicio: 5 × €2 = €10/día')}</li>
|
||||
<li className="font-bold text-green-700 dark:text-green-400">{t('features:waste.after.monthly', 'Al mes: €300')}</li>
|
||||
<li className="font-bold text-green-900 dark:text-green-300 text-xl bg-green-100 dark:bg-green-900/40 p-3 rounded-lg">
|
||||
{t('features:waste.after.savings', 'AHORRO: €2,700/mes (€32,400/año)')}
|
||||
AHORRO: <AnimatedCounter value={2700} prefix="€" className="inline" decimals={0} />/mes (<AnimatedCounter value={32400} prefix="€" className="inline" decimals={0} />/año)
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-4 text-sm font-medium text-green-700 dark:text-green-400">
|
||||
{t('features:waste.after.roi', 'Recuperas la inversión en semana 1.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* Interactive Savings Calculator */}
|
||||
<div className="mt-12 max-w-4xl mx-auto">
|
||||
<ScrollReveal variant="scaleUp" delay={0.2}>
|
||||
<SavingsCalculator
|
||||
defaultWaste={50}
|
||||
pricePerUnit={2}
|
||||
wasteReduction={80}
|
||||
unitName="barras"
|
||||
currency="€"
|
||||
/>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 5: Sustainability + Grants */}
|
||||
<section className="py-20 bg-[var(--bg-primary)]">
|
||||
<section id="sustainability" className="py-20 bg-[var(--bg-primary)] scroll-mt-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Leaf className="w-4 h-4" />
|
||||
<span>{t('features:sustainability.badge', 'Funcionalidad del Sistema')}</span>
|
||||
<ScrollReveal variant="fadeUp" delay={0.1}>
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Leaf className="w-4 h-4" />
|
||||
<span>{t('features:sustainability.badge', 'Funcionalidad del Sistema')}</span>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('features:sustainability.title', 'Impacto Ambiental y Sostenibilidad')}
|
||||
</h2>
|
||||
</div>
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
{t('features:sustainability.title', 'Impacto Ambiental y Sostenibilidad')}
|
||||
</h2>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* UN SDG Compliance */}
|
||||
<div className="max-w-5xl mx-auto mb-12">
|
||||
@@ -813,7 +813,7 @@ const FeaturesPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature 6: Business Models */}
|
||||
<section className="py-20 bg-[var(--bg-secondary)]">
|
||||
<section id="business-models" className="py-20 bg-[var(--bg-secondary)] scroll-mt-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)] mb-4">
|
||||
@@ -896,6 +896,8 @@ const FeaturesPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/ui';
|
||||
import { Button, ScrollReveal, FloatingCTA, AnimatedCounter } from '../../components/ui';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
import { PricingSection } from '../../components/subscription';
|
||||
import { getRegisterUrl, getDemoUrl } from '../../utils/navigation';
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<PublicLayout
|
||||
@@ -38,26 +39,42 @@ const LandingPage: React.FC = () => {
|
||||
variant: "default"
|
||||
}}
|
||||
>
|
||||
{/* Floating CTA - appears after scrolling */}
|
||||
<FloatingCTA
|
||||
text={t('landing:hero.cta_primary', 'Únete al Programa Piloto')}
|
||||
onClick={() => navigate(getRegisterUrl())}
|
||||
icon={<ArrowRight className="w-4 h-4" />}
|
||||
position="bottom-right"
|
||||
showAfterScroll={600}
|
||||
dismissible
|
||||
/>
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5">
|
||||
<section className="relative py-20 lg:py-32 bg-gradient-to-br from-[var(--bg-primary)] via-[var(--bg-secondary)] to-[var(--color-primary)]/5 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
{/* Pre-headline */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm md:text-base font-medium text-[var(--text-tertiary)]">
|
||||
{t('landing:hero.pre_headline', 'Para Panaderías que Pierden €500-2,000/Mes en Desperdicios')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scarcity Badge */}
|
||||
<div className="mb-6 inline-block">
|
||||
<div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-6 py-3 shadow-lg">
|
||||
<div className="mb-8 inline-block">
|
||||
<div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-6 py-3 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<p className="text-sm font-bold text-amber-700 dark:text-amber-300">
|
||||
🔥 {t('landing:hero.scarcity', 'Solo 12 plazas restantes de 20 • 3 meses GRATIS')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl">
|
||||
<span className="block">{t('landing:hero.title_line1', 'Aumenta Ganancias,')}</span>
|
||||
<span className="block text-[var(--color-primary)]">{t('landing:hero.title_line2', 'Reduce Desperdicios')}</span>
|
||||
<h1 className="text-4xl tracking-tight font-extrabold text-[var(--text-primary)] sm:text-5xl lg:text-7xl leading-tight">
|
||||
<span className="block">{t('landing:hero.title_option_a_line1', 'Ahorra €500-2,000 al Mes')}</span>
|
||||
<span className="block text-[var(--color-primary)] mt-2">{t('landing:hero.title_option_a_line2', 'Produciendo Exactamente Lo Que Venderás')}</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl">
|
||||
{t('landing:hero.subtitle', 'IA que predice demanda con datos de tu zona para que produzcas exactamente lo que vas a vender. Reduce desperdicios, mejora márgenes y ahorra tiempo.')}
|
||||
<p className="mt-8 max-w-3xl mx-auto text-lg text-[var(--text-secondary)] sm:text-xl leading-relaxed">
|
||||
{t('landing:hero.subtitle_option_a', 'La primera IA que conoce tu barrio: colegios cerca, clima local, tu competencia, eventos. Sistema automático cada mañana. Listo a las 6 AM.')}
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
@@ -75,24 +92,48 @@ const LandingPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Social Proof - New */}
|
||||
<div className="mt-12 max-w-3xl mx-auto">
|
||||
<div className="grid md:grid-cols-3 gap-6 text-left">
|
||||
<div className="flex items-start gap-3 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm p-4 rounded-xl shadow-sm border border-[var(--border-primary)]">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
<AnimatedCounter value={20} className="inline font-bold" /> panaderías ya ahorran <AnimatedCounter value={1500} prefix="€" className="inline font-bold" />/mes de promedio
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm p-4 rounded-xl shadow-sm border border-[var(--border-primary)]">
|
||||
<Target className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
Predicciones <AnimatedCounter value={92} suffix="%" className="inline font-bold" /> precisas (vs 60% sistemas genéricos)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm p-4 rounded-xl shadow-sm border border-[var(--border-primary)]">
|
||||
<Clock className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{t('landing:hero.social_proof.setup', 'Configuración en 15 minutos')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust Badges */}
|
||||
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-8 gap-y-4 text-sm">
|
||||
<div className="flex items-center bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm border border-[var(--border-primary)]">
|
||||
<CheckCircle2 className="w-4 h-4 text-amber-600 dark:text-amber-400 mr-2" />
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{t('landing:hero.trust.card', 'Tarjeta requerida')}
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-8 gap-y-4 text-sm">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 mr-2" />
|
||||
<span className="font-medium text-[var(--text-tertiary)]">
|
||||
{t('landing:hero.trust.no_cc', '3 meses gratis')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm border border-[var(--border-primary)]">
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400 mr-2" />
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{t('landing:hero.trust.quick', '3 meses gratis')}
|
||||
<span className="font-medium text-[var(--text-tertiary)]">
|
||||
{t('landing:hero.trust.quick', 'Configuración en 15 min')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm px-4 py-2 rounded-full shadow-sm border border-[var(--border-primary)]">
|
||||
<Zap className="w-4 h-4 text-green-600 dark:text-green-400 mr-2" />
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{t('landing:hero.trust.setup', 'Configuración en 15 min')}
|
||||
<div className="flex items-center">
|
||||
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400 mr-2" />
|
||||
<span className="font-medium text-[var(--text-tertiary)]">
|
||||
{t('landing:hero.trust.card', 'Tarjeta requerida')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,10 +146,11 @@ const LandingPage: React.FC = () => {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{/* Problems */}
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
|
||||
{t('landing:problems.title', '❌ Los Problemas Que Enfrentas')}
|
||||
</h2>
|
||||
<ScrollReveal variant="fadeRight" delay={0.1}>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
|
||||
{t('landing:problems.title', '❌ Los Problemas Que Enfrentas')}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-red-100 dark:bg-red-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
@@ -150,13 +192,15 @@ const LandingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* Solutions */}
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
|
||||
{t('landing:solutions.title', '✅ La Solución Con IA')}
|
||||
</h2>
|
||||
<ScrollReveal variant="fadeLeft" delay={0.2}>
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-8">
|
||||
{t('landing:solutions.title', '✅ La Solución Con IA')}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
@@ -198,7 +242,8 @@ const LandingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -279,7 +324,7 @@ const LandingPage: React.FC = () => {
|
||||
|
||||
<div className="mt-6 bg-gradient-to-r from-[var(--color-primary)]/10 to-orange-500/10 rounded-lg p-4 border-l-4 border-[var(--color-primary)]">
|
||||
<p className="font-bold text-[var(--text-primary)]">
|
||||
{t('landing:pillar1.accuracy', '🎯 Precisión: 92% (vs 60-70% de sistemas genéricos)')}
|
||||
🎯 Precisión: <AnimatedCounter value={92} suffix="%" className="inline text-[var(--color-primary)]" /> (vs 60-70% de sistemas genéricos)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
163
frontend/src/services/api/geocodingApi.ts
Normal file
163
frontend/src/services/api/geocodingApi.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Geocoding API Client
|
||||
*
|
||||
* Provides address search, autocomplete, and geocoding functionality
|
||||
* using the backend Nominatim service.
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../api/client/apiClient';
|
||||
|
||||
const GEOCODING_BASE_URL = '/geocoding';
|
||||
|
||||
export interface AddressResult {
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
osm_type: string;
|
||||
osm_id: number;
|
||||
place_id: number;
|
||||
type: string;
|
||||
class: string;
|
||||
address: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
municipality?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
};
|
||||
boundingbox: string[];
|
||||
}
|
||||
|
||||
export interface GeocodeResult {
|
||||
display_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
address: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
suburb?: string;
|
||||
city?: string;
|
||||
municipality?: string;
|
||||
state?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
country_code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoordinateValidation {
|
||||
valid: boolean;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export const geocodingApi = {
|
||||
/**
|
||||
* Search for addresses matching query (autocomplete)
|
||||
*
|
||||
* @param query - Search query (minimum 3 characters)
|
||||
* @param countryCode - ISO country code (default: 'es')
|
||||
* @param limit - Maximum number of results (default: 10)
|
||||
*/
|
||||
async searchAddresses(
|
||||
query: string,
|
||||
countryCode: string = 'es',
|
||||
limit: number = 10
|
||||
): Promise<AddressResult[]> {
|
||||
if (!query || query.trim().length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await apiClient.get<AddressResult[]>(
|
||||
`${GEOCODING_BASE_URL}/search`,
|
||||
{
|
||||
params: {
|
||||
q: query,
|
||||
country_code: countryCode,
|
||||
limit
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Geocode an address to get coordinates
|
||||
*
|
||||
* @param address - Full address string
|
||||
* @param countryCode - ISO country code (default: 'es')
|
||||
*/
|
||||
async geocodeAddress(
|
||||
address: string,
|
||||
countryCode: string = 'es'
|
||||
): Promise<GeocodeResult> {
|
||||
const response = await apiClient.get<GeocodeResult>(
|
||||
`${GEOCODING_BASE_URL}/geocode`,
|
||||
{
|
||||
params: {
|
||||
address,
|
||||
country_code: countryCode
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reverse geocode coordinates to get address
|
||||
*
|
||||
* @param lat - Latitude
|
||||
* @param lon - Longitude
|
||||
*/
|
||||
async reverseGeocode(
|
||||
lat: number,
|
||||
lon: number
|
||||
): Promise<GeocodeResult> {
|
||||
const response = await apiClient.get<GeocodeResult>(
|
||||
`${GEOCODING_BASE_URL}/reverse`,
|
||||
{
|
||||
params: { lat, lon }
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate coordinates
|
||||
*
|
||||
* @param lat - Latitude
|
||||
* @param lon - Longitude
|
||||
*/
|
||||
async validateCoordinates(
|
||||
lat: number,
|
||||
lon: number
|
||||
): Promise<CoordinateValidation> {
|
||||
const response = await apiClient.get<CoordinateValidation>(
|
||||
`${GEOCODING_BASE_URL}/validate`,
|
||||
{
|
||||
params: { lat, lon }
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check geocoding service health
|
||||
*/
|
||||
async checkHealth(): Promise<{
|
||||
status: string;
|
||||
service: string;
|
||||
base_url: string;
|
||||
is_public_api: boolean;
|
||||
}> {
|
||||
const response = await apiClient.get(`${GEOCODING_BASE_URL}/health`);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
109
frontend/src/services/api/poiContextApi.ts
Normal file
109
frontend/src/services/api/poiContextApi.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* POI Context API Client
|
||||
*
|
||||
* API client for POI detection and context management
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../api/client/apiClient';
|
||||
import type {
|
||||
POIDetectionResponse,
|
||||
POIContextResponse,
|
||||
FeatureImportanceResponse,
|
||||
CompetitorAnalysis,
|
||||
POICacheStats
|
||||
} from '@/types/poi';
|
||||
|
||||
const POI_BASE_URL = '/poi-context';
|
||||
|
||||
export const poiContextApi = {
|
||||
/**
|
||||
* Detect POIs for a tenant's bakery location
|
||||
*/
|
||||
async detectPOIs(
|
||||
tenantId: string,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
forceRefresh: boolean = false
|
||||
): Promise<POIDetectionResponse> {
|
||||
const response = await apiClient.post<POIDetectionResponse>(
|
||||
`${POI_BASE_URL}/${tenantId}/detect`,
|
||||
null,
|
||||
{
|
||||
params: {
|
||||
latitude,
|
||||
longitude,
|
||||
force_refresh: forceRefresh
|
||||
}
|
||||
}
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get POI context for a tenant
|
||||
*/
|
||||
async getPOIContext(tenantId: string): Promise<POIContextResponse> {
|
||||
const response = await apiClient.get<POIContextResponse>(
|
||||
`${POI_BASE_URL}/${tenantId}`
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh POI context for a tenant
|
||||
*/
|
||||
async refreshPOIContext(tenantId: string): Promise<POIDetectionResponse> {
|
||||
const response = await apiClient.post<POIDetectionResponse>(
|
||||
`${POI_BASE_URL}/${tenantId}/refresh`
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete POI context for a tenant
|
||||
*/
|
||||
async deletePOIContext(tenantId: string): Promise<void> {
|
||||
await apiClient.delete(`${POI_BASE_URL}/${tenantId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get feature importance summary
|
||||
*/
|
||||
async getFeatureImportance(tenantId: string): Promise<FeatureImportanceResponse> {
|
||||
const response = await apiClient.get<FeatureImportanceResponse>(
|
||||
`${POI_BASE_URL}/${tenantId}/feature-importance`
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get competitor analysis
|
||||
*/
|
||||
async getCompetitorAnalysis(tenantId: string): Promise<{
|
||||
tenant_id: string;
|
||||
location: { latitude: number; longitude: number };
|
||||
competitor_analysis: CompetitorAnalysis;
|
||||
insights: string[];
|
||||
}> {
|
||||
const response = await apiClient.get(
|
||||
`${POI_BASE_URL}/${tenantId}/competitor-analysis`
|
||||
);
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check POI service health
|
||||
*/
|
||||
async checkHealth(): Promise<{ status: string; overpass_api: any }> {
|
||||
const response = await apiClient.get(`${POI_BASE_URL}/health`);
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{ status: string; cache_stats: POICacheStats }> {
|
||||
const response = await apiClient.get(`${POI_BASE_URL}/cache/stats`);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
@@ -68,6 +68,8 @@
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
--shadow-3xl: 0 35px 60px -15px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
@@ -120,8 +122,13 @@
|
||||
|
||||
/* Transition */
|
||||
--transition-fast: 150ms ease-in-out;
|
||||
--transition-base: 200ms ease-in-out;
|
||||
--transition-normal: 300ms ease-in-out;
|
||||
--transition-slow: 500ms ease-in-out;
|
||||
|
||||
/* Animation Timing Functions */
|
||||
--ease-in-out-smooth: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
--ease-spring: cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
/* Layout */
|
||||
--container-max-width: 1280px;
|
||||
|
||||
247
frontend/src/types/poi.ts
Normal file
247
frontend/src/types/poi.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* POI (Point of Interest) Type Definitions
|
||||
*
|
||||
* Types for POI detection, context, and visualization
|
||||
*/
|
||||
|
||||
export interface POILocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface POI {
|
||||
osm_id: string;
|
||||
type: 'node' | 'way';
|
||||
lat: number;
|
||||
lon: number;
|
||||
tags: Record<string, string>;
|
||||
name: string;
|
||||
distance_m?: number;
|
||||
zone?: string;
|
||||
}
|
||||
|
||||
export interface POIFeatures {
|
||||
proximity_score: number;
|
||||
weighted_proximity_score: number;
|
||||
count_0_100m: number;
|
||||
count_100_300m: number;
|
||||
count_300_500m: number;
|
||||
count_500_1000m: number;
|
||||
total_count: number;
|
||||
distance_to_nearest_m: number;
|
||||
has_within_100m: boolean;
|
||||
has_within_300m: boolean;
|
||||
has_within_500m: boolean;
|
||||
}
|
||||
|
||||
export interface POICategoryData {
|
||||
pois: POI[];
|
||||
features: POIFeatures;
|
||||
count: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface POICategories {
|
||||
schools: POICategoryData;
|
||||
offices: POICategoryData;
|
||||
gyms_sports: POICategoryData;
|
||||
residential: POICategoryData;
|
||||
tourism: POICategoryData;
|
||||
competitors: POICategoryData;
|
||||
transport_hubs: POICategoryData;
|
||||
coworking: POICategoryData;
|
||||
retail: POICategoryData;
|
||||
}
|
||||
|
||||
export interface POISummary {
|
||||
total_pois_detected: number;
|
||||
categories_with_pois: string[];
|
||||
high_impact_categories: string[];
|
||||
categories_count: number;
|
||||
}
|
||||
|
||||
export interface CompetitorAnalysis {
|
||||
competitive_pressure_score: number;
|
||||
direct_competitors_count: number;
|
||||
nearby_competitors_count: number;
|
||||
market_competitors_count: number;
|
||||
total_competitors_count: number;
|
||||
competitive_zone: 'low_competition' | 'moderate_competition' | 'high_competition';
|
||||
market_type: 'underserved' | 'normal_market' | 'competitive_market' | 'bakery_district';
|
||||
competitive_advantage: 'first_mover' | 'local_leader' | 'quality_focused' | 'differentiation_required';
|
||||
competitor_details: POI[];
|
||||
nearest_competitor: POI | null;
|
||||
}
|
||||
|
||||
export interface POIContext {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
location: POILocation;
|
||||
poi_detection_results: POICategories;
|
||||
ml_features: Record<string, number>;
|
||||
total_pois_detected: number;
|
||||
high_impact_categories: string[];
|
||||
relevant_categories: string[];
|
||||
detection_timestamp: string;
|
||||
detection_source: string;
|
||||
detection_status: 'completed' | 'partial' | 'failed';
|
||||
detection_error?: string;
|
||||
next_refresh_date?: string;
|
||||
last_refreshed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RelevanceReportItem {
|
||||
category: string;
|
||||
relevant: boolean;
|
||||
reason: string;
|
||||
proximity_score: number;
|
||||
count: number;
|
||||
distance_to_nearest_m: number;
|
||||
}
|
||||
|
||||
export interface POIDetectionResponse {
|
||||
status: 'success' | 'error';
|
||||
source: 'detection' | 'cache';
|
||||
poi_context: POIContext;
|
||||
feature_selection?: {
|
||||
features: Record<string, number>;
|
||||
relevant_categories: string[];
|
||||
relevance_report: RelevanceReportItem[];
|
||||
total_features: number;
|
||||
total_relevant_categories: number;
|
||||
};
|
||||
competitor_analysis?: CompetitorAnalysis;
|
||||
competitive_insights?: string[];
|
||||
}
|
||||
|
||||
export interface POIContextResponse {
|
||||
poi_context: POIContext;
|
||||
is_stale: boolean;
|
||||
needs_refresh: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureImportanceItem {
|
||||
category: string;
|
||||
is_relevant: boolean;
|
||||
proximity_score: number;
|
||||
weighted_score: number;
|
||||
total_count: number;
|
||||
distance_to_nearest_m: number;
|
||||
has_within_100m: boolean;
|
||||
rejection_reason?: string;
|
||||
}
|
||||
|
||||
export interface FeatureImportanceResponse {
|
||||
tenant_id: string;
|
||||
feature_importance: FeatureImportanceItem[];
|
||||
total_categories: number;
|
||||
relevant_categories: number;
|
||||
}
|
||||
|
||||
export interface POICacheStats {
|
||||
total_cached_locations: number;
|
||||
cache_ttl_days: number;
|
||||
coordinate_precision: number;
|
||||
}
|
||||
|
||||
// Category metadata for UI display
|
||||
export interface POICategoryMetadata {
|
||||
name: string;
|
||||
displayName: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const POI_CATEGORY_METADATA: Record<string, POICategoryMetadata> = {
|
||||
schools: {
|
||||
name: 'schools',
|
||||
displayName: 'Schools',
|
||||
icon: '🏫',
|
||||
color: '#22c55e',
|
||||
description: 'Educational institutions causing morning/afternoon rush patterns'
|
||||
},
|
||||
offices: {
|
||||
name: 'offices',
|
||||
displayName: 'Offices',
|
||||
icon: '🏢',
|
||||
color: '#3b82f6',
|
||||
description: 'Office buildings and business centers'
|
||||
},
|
||||
gyms_sports: {
|
||||
name: 'gyms_sports',
|
||||
displayName: 'Gyms & Sports',
|
||||
icon: '🏋️',
|
||||
color: '#8b5cf6',
|
||||
description: 'Fitness centers and sports facilities'
|
||||
},
|
||||
residential: {
|
||||
name: 'residential',
|
||||
displayName: 'Residential',
|
||||
icon: '🏘️',
|
||||
color: '#64748b',
|
||||
description: 'Residential buildings and housing'
|
||||
},
|
||||
tourism: {
|
||||
name: 'tourism',
|
||||
displayName: 'Tourism',
|
||||
icon: '🗼',
|
||||
color: '#f59e0b',
|
||||
description: 'Tourist attractions, hotels, and points of interest'
|
||||
},
|
||||
competitors: {
|
||||
name: 'competitors',
|
||||
displayName: 'Competitors',
|
||||
icon: '🥖',
|
||||
color: '#ef4444',
|
||||
description: 'Competing bakeries and pastry shops'
|
||||
},
|
||||
transport_hubs: {
|
||||
name: 'transport_hubs',
|
||||
displayName: 'Transport Hubs',
|
||||
icon: '🚇',
|
||||
color: '#06b6d4',
|
||||
description: 'Public transport stations and hubs'
|
||||
},
|
||||
coworking: {
|
||||
name: 'coworking',
|
||||
displayName: 'Coworking',
|
||||
icon: '💼',
|
||||
color: '#84cc16',
|
||||
description: 'Coworking spaces and shared offices'
|
||||
},
|
||||
retail: {
|
||||
name: 'retail',
|
||||
displayName: 'Retail',
|
||||
icon: '🛍️',
|
||||
color: '#ec4899',
|
||||
description: 'Retail shops and commercial areas'
|
||||
}
|
||||
};
|
||||
|
||||
export const IMPACT_LEVELS = {
|
||||
HIGH: { label: 'High Impact', color: '#22c55e', threshold: 2.0 },
|
||||
MODERATE: { label: 'Moderate Impact', color: '#f59e0b', threshold: 1.0 },
|
||||
LOW: { label: 'Low Impact', color: '#64748b', threshold: 0 }
|
||||
} as const;
|
||||
|
||||
export type ImpactLevel = keyof typeof IMPACT_LEVELS;
|
||||
|
||||
export function getImpactLevel(proximityScore: number): ImpactLevel {
|
||||
if (proximityScore >= IMPACT_LEVELS.HIGH.threshold) return 'HIGH';
|
||||
if (proximityScore >= IMPACT_LEVELS.MODERATE.threshold) return 'MODERATE';
|
||||
return 'LOW';
|
||||
}
|
||||
|
||||
export function formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)}m`;
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)}km`;
|
||||
}
|
||||
|
||||
export function formatCategoryName(category: string): string {
|
||||
return POI_CATEGORY_METADATA[category]?.displayName || category.replace(/_/g, ' ');
|
||||
}
|
||||
Reference in New Issue
Block a user