Schema: - Site model (name, code, address, lat/lng, isActive) - ItemInventory model (quantity per product per site, unique[productId,siteId]) - ItemConsumption model (daily usage per product per site) - Vendor: add latitude, longitude - Vessel: add siteId (home port) - PurchaseOrder: add siteId (delivery site) Permissions: add manage_sites to MANAGER and ADMIN Sidebar: Inventory section (Vendors, Items, Vessels, Sites, Cart) for MANAGER and ADMIN; old admin items reorganised Lib: - lib/geo.ts: Haversine distance + Nominatim pincode geocoding - lib/gst-lookup.ts: AbhiAPI GSTIN lookup (ABHIAPI_KEY env var) - lib/cart.ts: localStorage cart (add/remove/clear + cart-updated event) API: GET /api/gst?gstin= — validates GSTIN, fetches via AbhiAPI, geocodes pincode via Nominatim, returns name/address/lat/lng Vendor form: GSTIN "Look up" button auto-fills name, address, and lat/lng; lat/lng fields editable as override Sites: full CRUD at /admin/sites; detail page with inventory table, consumption recording form, BarChart (stock) + LineChart (30-day consumption), linked vessels, recent POs Vessels: detail page at /admin/vessels/[id] with "Create PO" button, PO history and spend summary; accessible to MANAGER Items detail: price comparison BarChart; site distance filter (dropdown → re-render sorted by Haversine distance); "Add to Cart" per vendor row; stock-by-site section Cart: /inventory/cart — localStorage CartView; qty edit, remove, clear; "Create PO →" encodes cart into /po/new?cart=... Receipt/delivery: confirmReceipt now upserts ItemInventory (increment qty) for each linked line item, using PO siteId or vessel home site as the delivery location Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
30 lines
1.2 KiB
TypeScript
30 lines
1.2 KiB
TypeScript
/** Haversine distance between two lat/lng points, in kilometres. */
|
|
export function distanceKm(
|
|
lat1: number, lon1: number,
|
|
lat2: number, lon2: number
|
|
): number {
|
|
const R = 6371;
|
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos((lat1 * Math.PI) / 180) *
|
|
Math.cos((lat2 * Math.PI) / 180) *
|
|
Math.sin(dLon / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
export function formatDistance(km: number): string {
|
|
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(0)} km`;
|
|
}
|
|
|
|
/** Geocode an Indian pincode to lat/lng using Nominatim (free, no key). */
|
|
export async function geocodePincode(pincode: string): Promise<{ lat: number; lng: number } | null> {
|
|
try {
|
|
const url = `https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(pincode)}&countrycodes=in&format=json&limit=1`;
|
|
const res = await fetch(url, { headers: { "User-Agent": "PelagiaPortal/1.0" } });
|
|
const data = await res.json();
|
|
if (data[0]) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
|
} catch { /* ignore */ }
|
|
return null;
|
|
}
|