2026-05-24 19:51:29 +00:00
|
|
|
import { type ClassValue, clsx } from "clsx";
|
|
|
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
|
|
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
|
|
|
return twMerge(clsx(inputs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatDate(dateString: string): string {
|
|
|
|
|
if (!dateString) return 'N/A';
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
return date.toLocaleDateString('en-ZA', {
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
month: 'short',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatDateTime(dateString: string): string {
|
|
|
|
|
if (!dateString) return 'N/A';
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
return date.toLocaleString('en-ZA', {
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
month: 'short',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatCurrency(amount: number): string {
|
|
|
|
|
return new Intl.NumberFormat('en-ZA', {
|
|
|
|
|
style: 'currency',
|
|
|
|
|
currency: 'ZAR',
|
|
|
|
|
}).format(amount);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getRelativeTime(dateString: string): string {
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
|
|
|
|
|
|
|
|
if (diffInSeconds < 60) return 'Just now';
|
|
|
|
|
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
|
|
|
|
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
|
|
|
|
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
|
|
|
|
return formatDate(dateString);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
|
|
|
func: T,
|
|
|
|
|
wait: number
|
|
|
|
|
): (...args: Parameters<T>) => void {
|
|
|
|
|
let timeout: ReturnType<typeof setTimeout>;
|
|
|
|
|
return (...args: Parameters<T>) => {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
timeout = setTimeout(() => func(...args), wait);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
|
|
|
|
|
return array.reduce((result, item) => {
|
|
|
|
|
const groupKey = String(item[key]);
|
|
|
|
|
if (!result[groupKey]) {
|
|
|
|
|
result[groupKey] = [];
|
|
|
|
|
}
|
|
|
|
|
result[groupKey].push(item);
|
|
|
|
|
return result;
|
|
|
|
|
}, {} as Record<string, T[]>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function calculateDistance(
|
|
|
|
|
lat1: number,
|
|
|
|
|
lon1: number,
|
|
|
|
|
lat2: number,
|
|
|
|
|
lon2: number
|
|
|
|
|
): number {
|
|
|
|
|
const R = 6371; // Earth's radius in km
|
|
|
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
|
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
|
|
|
const a =
|
|
|
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
|
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
|
|
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
|
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
|
return R * c;
|
|
|
|
|
}
|
2026-05-24 20:31:35 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse coordinates from a text field.
|
|
|
|
|
* Supports formats like:
|
|
|
|
|
* "-33.9249, 18.4241"
|
|
|
|
|
* "-33.9249 18.4241"
|
|
|
|
|
* "lat: -33.9249, lng: 18.4241"
|
|
|
|
|
* Returns null if parsing fails.
|
|
|
|
|
*/
|
|
|
|
|
export function parseCoordinates(coordString: string | undefined): { latitude: number; longitude: number } | null {
|
|
|
|
|
if (!coordString) return null;
|
|
|
|
|
|
|
|
|
|
const matches = coordString.match(/-?\d+\.?\d*/g);
|
|
|
|
|
if (matches && matches.length >= 2) {
|
|
|
|
|
const lat = parseFloat(matches[0]);
|
|
|
|
|
const lng = parseFloat(matches[1]);
|
|
|
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
|
|
|
return { latitude: lat, longitude: lng };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-07-07 15:15:42 +00:00
|
|
|
|
|
|
|
|
export interface ProximityCluster<T> {
|
|
|
|
|
id: string;
|
|
|
|
|
items: T[];
|
|
|
|
|
center: { latitude: number; longitude: number };
|
|
|
|
|
label: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clusterByProximity<T extends { id: string; expand?: { supplier?: { coordinates?: string; suppliername?: string } } }>(
|
|
|
|
|
items: T[],
|
|
|
|
|
radiusKm: number = 2
|
|
|
|
|
): ProximityCluster<T>[] {
|
|
|
|
|
const withCoords: Array<{ item: T; coords: { latitude: number; longitude: number } }> = [];
|
|
|
|
|
const withoutCoords: T[] = [];
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const coords = parseCoordinates(item.expand?.supplier?.coordinates);
|
|
|
|
|
if (coords) {
|
|
|
|
|
withCoords.push({ item, coords });
|
|
|
|
|
} else {
|
|
|
|
|
withoutCoords.push(item);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const assigned = new Set<string>();
|
|
|
|
|
const clusters: ProximityCluster<T>[] = [];
|
|
|
|
|
|
|
|
|
|
for (const seed of withCoords) {
|
|
|
|
|
if (assigned.has(seed.item.id)) continue;
|
|
|
|
|
|
|
|
|
|
const clusterItems: Array<{ item: T; coords: { latitude: number; longitude: number } }> = [seed];
|
|
|
|
|
assigned.add(seed.item.id);
|
|
|
|
|
|
|
|
|
|
for (const candidate of withCoords) {
|
|
|
|
|
if (assigned.has(candidate.item.id)) continue;
|
|
|
|
|
const dist = calculateDistance(
|
|
|
|
|
seed.coords.latitude, seed.coords.longitude,
|
|
|
|
|
candidate.coords.latitude, candidate.coords.longitude
|
|
|
|
|
);
|
|
|
|
|
if (dist <= radiusKm) {
|
|
|
|
|
clusterItems.push(candidate);
|
|
|
|
|
assigned.add(candidate.item.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const centerLat = clusterItems.reduce((s, p) => s + p.coords.latitude, 0) / clusterItems.length;
|
|
|
|
|
const centerLng = clusterItems.reduce((s, p) => s + p.coords.longitude, 0) / clusterItems.length;
|
|
|
|
|
|
|
|
|
|
const supplierNames = Array.from(
|
|
|
|
|
new Set(
|
|
|
|
|
clusterItems
|
|
|
|
|
.map(c => c.item.expand?.supplier?.suppliername)
|
|
|
|
|
.filter((n): n is string => !!n)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-10 00:01:57 +00:00
|
|
|
let label: string;
|
|
|
|
|
if (supplierNames.length === 0) {
|
|
|
|
|
label = `Stop ${clusters.length + 1}`;
|
|
|
|
|
} else if (supplierNames.length === 1) {
|
|
|
|
|
label = supplierNames[0];
|
|
|
|
|
} else if (supplierNames.length === 2) {
|
|
|
|
|
label = `${supplierNames[0]} · ${supplierNames[1]}`;
|
|
|
|
|
} else if (supplierNames.length === 3) {
|
|
|
|
|
label = `${supplierNames[0]} · ${supplierNames[1]} · ${supplierNames[2]}`;
|
|
|
|
|
} else {
|
|
|
|
|
label = `${supplierNames[0]} · ${supplierNames[1]} +${supplierNames.length - 2} more`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
clusters.push({
|
|
|
|
|
id: `cluster-${clusters.length}`,
|
|
|
|
|
items: clusterItems.map(c => c.item),
|
|
|
|
|
center: { latitude: centerLat, longitude: centerLng },
|
2026-07-10 00:01:57 +00:00
|
|
|
label,
|
2026-07-07 15:15:42 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (withoutCoords.length > 0) {
|
|
|
|
|
clusters.push({
|
|
|
|
|
id: 'cluster-no-coords',
|
|
|
|
|
items: withoutCoords,
|
|
|
|
|
center: { latitude: 0, longitude: 0 },
|
|
|
|
|
label: 'No location',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return clusters;
|
|
|
|
|
}
|