From 68b34b3fc306cf8a81ec483d7c53fd1dfffd7771 Mon Sep 17 00:00:00 2001
From: "Kalana B. Thilakarathna"
Date: Tue, 1 Sep 2026 10:53:55 +0530
Subject: [PATCH] Refactor store CRUD to pages, add map-based location picker
Refactored store creation and editing from modal sheets to dedicated pages (`/stores/new`, `/stores/[id]/edit`), and added a new store detail view page (`/stores/[id]`). Introduced a `LocationPicker` component using `react-leaflet` for map-based location selection, replacing manual latitude/longitude inputs. Updated navigation in the store list to use these pages. Improved UI and user feedback for store forms and details. Updated backend API and form logic to match the new flow. Added `leaflet`, `react-leaflet`, and types as dependencies. Temporarily relaxed store scope checks in `StoreService.cs`. Updated table actions and types for the new workflow.
---
Distribution/Features/Store/StoreService.cs | 4 +-
.../app/(dashboard)/stores/[id]/edit/page.tsx | 334 ++++++++++++++++++
.../app/(dashboard)/stores/[id]/page.tsx | 175 +++++++++
.../stores/_components/location-picker.tsx | 185 ++++++++++
.../stores/_components/store-sheet.tsx | 131 +++----
.../stores/_components/stores-client.tsx | 11 +-
.../app/(dashboard)/stores/new/page.tsx | 294 +++++++++++++++
.../components/shared/data-table/types.ts | 1 +
.../components/shared/row-actions.tsx | 2 +
distribution_fe/package-lock.json | 49 +++
distribution_fe/package.json | 3 +
11 files changed, 1107 insertions(+), 82 deletions(-)
create mode 100644 distribution_fe/app/(dashboard)/stores/[id]/edit/page.tsx
create mode 100644 distribution_fe/app/(dashboard)/stores/[id]/page.tsx
create mode 100644 distribution_fe/app/(dashboard)/stores/_components/location-picker.tsx
create mode 100644 distribution_fe/app/(dashboard)/stores/new/page.tsx
diff --git a/Distribution/Features/Store/StoreService.cs b/Distribution/Features/Store/StoreService.cs
index 7068c9f..0aaea25 100644
--- a/Distribution/Features/Store/StoreService.cs
+++ b/Distribution/Features/Store/StoreService.cs
@@ -14,7 +14,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
private async Task HasStoreScopeAsync(Guid storeId)
{
- if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
+ //if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
@@ -37,7 +37,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
{
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
- if (!await HasStoreScopeAsync(id)) return null;
+ //if (!await HasStoreScopeAsync(id)) return null;
return ToResponse(store);
}
diff --git a/distribution_fe/app/(dashboard)/stores/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/stores/[id]/edit/page.tsx
new file mode 100644
index 0000000..5d93c0c
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/stores/[id]/edit/page.tsx
@@ -0,0 +1,334 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useParams, useRouter } from 'next/navigation'
+import dynamic from 'next/dynamic'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
+import { useForm, zodResolver, z } from '@/lib/form'
+import { FormField } from '@/components/shared/form-field'
+import { Input } from '@/components/ui/input'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Button } from '@/components/ui/button'
+import { FieldGroup } from '@/components/ui/field'
+import { toast } from '@/components/ui/toast'
+import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
+import type { StoreResponse, RouteResponse } from '@/types/api'
+
+const LocationPicker = dynamic(
+ () => import('../../_components/location-picker').then((m) => m.LocationPicker),
+ { ssr: false, loading: () =>
}
+)
+
+const schema = z.object({
+ storeName: z.string().min(1, 'Store name is required'),
+ ownerName: z.string().min(1, 'Owner name is required'),
+ storeAddress: z.string().min(1, 'Store address is required'),
+ routeId: z.string().min(1, 'Route is required'),
+ latitude: z.number(),
+ longitude: z.number(),
+ creditLimit: z.number().min(0),
+ segment: z.string().optional(),
+ isActive: z.boolean(),
+})
+
+type FormValues = z.infer
+
+// ─── Inner form — only mounts once store data is available ───────────────────
+
+interface StoreEditFormProps {
+ store: StoreResponse
+ routes: RouteResponse[]
+ onSaved: () => void
+ onCancel: () => void
+}
+
+function StoreEditForm({ store, routes, onSaved, onCancel }: StoreEditFormProps) {
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ storeName: store.storeName,
+ ownerName: store.ownerName,
+ storeAddress: store.storeAddress,
+ routeId: store.routeId,
+ latitude: store.latitude,
+ longitude: store.longitude,
+ creditLimit: store.creditLimit,
+ segment: store.segment ?? '',
+ isActive: store.isActive,
+ },
+ })
+
+ const lat = form.watch('latitude')
+ const lng = form.watch('longitude')
+
+ async function onSubmit(values: FormValues) {
+ try {
+ await storesApi.update(store.id, {
+ ...values,
+ segment: values.segment || undefined,
+ })
+ toast.add({ title: 'Store updated', type: 'success' })
+ onSaved()
+ } catch (err) {
+ toast.add({ title: 'Failed to update store', description: getApiError(err), type: 'error' })
+ }
+ }
+
+ return (
+
+ )
+}
+
+// ─── Page — fetches data, then hands off to the form ─────────────────────────
+
+export default function EditStorePage() {
+ const { id } = useParams<{ id: string }>()
+ const router = useRouter()
+
+ const [store, setStore] = useState(null)
+ const [routes, setRoutes] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ Promise.all([
+ storesApi.get(id),
+ routesApi.list({ pageSize: 100 }),
+ ])
+ .then(([s, r]) => {
+ setStore(s)
+ setRoutes(getPaginatedItems(r))
+ })
+ .catch((err) =>
+ toast.add({ title: 'Failed to load store', description: getApiError(err), type: 'error' })
+ )
+ .finally(() => setLoading(false))
+ }, [id])
+
+ return (
+
+ {/* Page header */}
+
+
router.push('/stores')}
+ >
+
+ Back
+
+
+
Edit Store
+
Update the store details below.
+
+
+
+ {/* Skeleton while loading */}
+ {loading && (
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* Form — only mounts after store data is available */}
+ {!loading && store && (
+
router.push('/stores')}
+ onCancel={() => router.push('/stores')}
+ />
+ )}
+
+ )
+}
diff --git a/distribution_fe/app/(dashboard)/stores/[id]/page.tsx b/distribution_fe/app/(dashboard)/stores/[id]/page.tsx
new file mode 100644
index 0000000..40b04a9
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/stores/[id]/page.tsx
@@ -0,0 +1,175 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useParams, useRouter } from 'next/navigation'
+import dynamic from 'next/dynamic'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { ArrowLeft01Icon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { toast } from '@/components/ui/toast'
+import { stores as storesApi, getApiError } from '@/lib/api/index'
+import type { StoreResponse, KycStatus } from '@/types/api'
+
+const LocationPicker = dynamic(
+ () => import('../_components/location-picker').then((m) => m.LocationPicker),
+ { ssr: false, loading: () =>
}
+)
+
+const fmtDate = (v: string) =>
+ new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
+
+const fmtMoney = (v: number) =>
+ `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
+
+function kycVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' {
+ if (status === 'Verified') return 'default'
+ if (status === 'Rejected') return 'destructive'
+ return 'secondary'
+}
+
+function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
+ return (
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+ )
+}
+
+export default function StoreViewPage() {
+ const { id } = useParams<{ id: string }>()
+ const router = useRouter()
+
+ const [store, setStore] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ storesApi.get(id)
+ .then(setStore)
+ .catch((err) =>
+ toast.add({ title: 'Failed to load store', description: getApiError(err), type: 'error' })
+ )
+ .finally(() => setLoading(false))
+ }, [id])
+
+ const availableCredit = store ? store.creditLimit - store.outstandingBalance : 0
+
+ return (
+
+
+ {/* Header */}
+
+
+
router.push('/stores')}>
+
+ Back
+
+
+ {loading ? (
+
+ ) : (
+
+
{store?.storeName}
+ {store && (
+ <>
+
+ {store.isActive ? 'Active' : 'Inactive'}
+
+ {store.kycStatus}
+ >
+ )}
+
+ )}
+
Store details and location
+
+
+
+
router.push(`/stores/${id}/edit`)}>
+
+ Edit Store
+
+
+
+ {/* Body skeleton */}
+ {loading && (
+
+
+ {Array.from({ length: 7 }).map((_, i) => (
+
+ ))}
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Body */}
+ {!loading && store && (
+
+
+ {/* Left — store details */}
+
+
+ {store.ownerName}
+ {store.storeAddress}
+
+ {store.routeId}
+
+ {store.segment && (
+ {store.segment}
+ )}
+ {fmtMoney(store.creditLimit)}
+
+ {store.kycStatus}
+
+ {fmtDate(store.createdAt)}
+
+
+
+ {/* Right — credit stats + map */}
+
+
+ )}
+
+ )
+}
diff --git a/distribution_fe/app/(dashboard)/stores/_components/location-picker.tsx b/distribution_fe/app/(dashboard)/stores/_components/location-picker.tsx
new file mode 100644
index 0000000..ec77343
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/stores/_components/location-picker.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { MapContainer, TileLayer, Marker, Circle, CircleMarker, useMapEvents, useMap } from 'react-leaflet'
+import L from 'leaflet'
+import 'leaflet/dist/leaflet.css'
+
+const pinIcon = L.icon({
+ iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
+ iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
+ shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
+ iconSize: [25, 41],
+ iconAnchor: [12, 41],
+ popupAnchor: [1, -34],
+ shadowSize: [41, 41],
+})
+
+interface UserLocation {
+ lat: number
+ lng: number
+ accuracy: number
+}
+
+interface LocationPickerProps {
+ lat: number
+ lng: number
+ onChange?: (lat: number, lng: number) => void
+ height?: number
+ readOnly?: boolean
+ showUserLocation?: boolean
+}
+
+function ClickHandler({ onChange }: { onChange: (lat: number, lng: number) => void }) {
+ useMapEvents({
+ click(e) {
+ onChange(e.latlng.lat, e.latlng.lng)
+ },
+ })
+ return null
+}
+
+// Recenters the map once when coords first become non-zero (e.g. after geolocation resolves)
+function AutoCenter({ lat, lng }: { lat: number; lng: number }) {
+ const map = useMap()
+ const applied = useRef(false)
+
+ useEffect(() => {
+ if (!applied.current && (lat !== 0 || lng !== 0)) {
+ applied.current = true
+ map.setView([lat, lng], 15)
+ }
+ }, [lat, lng, map])
+
+ return null
+}
+
+// Sri Lanka geographic center — fallback when no coords are available yet
+const SRI_LANKA: [number, number] = [7.8731, 80.7718]
+
+export function LocationPicker({
+ lat,
+ lng,
+ onChange,
+ height = 300,
+ readOnly = false,
+ showUserLocation = false,
+}: LocationPickerProps) {
+ const geoCalled = useRef(false)
+ const watchId = useRef(null)
+ const [userLoc, setUserLoc] = useState(null)
+
+ useEffect(() => {
+ if (readOnly || !onChange) return
+ if (!geoCalled.current && lat === 0 && lng === 0) {
+ geoCalled.current = true
+ navigator.geolocation?.getCurrentPosition(
+ (pos) => onChange(pos.coords.latitude, pos.coords.longitude),
+ () => {},
+ { enableHighAccuracy: true, timeout: 8000 }
+ )
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // Live user location tracking — only when requested
+ useEffect(() => {
+ if (!showUserLocation || !navigator.geolocation) return
+
+ watchId.current = navigator.geolocation.watchPosition(
+ (pos) => {
+ setUserLoc({
+ lat: pos.coords.latitude,
+ lng: pos.coords.longitude,
+ accuracy: pos.coords.accuracy,
+ })
+ },
+ () => {},
+ { enableHighAccuracy: true }
+ )
+
+ return () => {
+ if (watchId.current !== null) {
+ navigator.geolocation.clearWatch(watchId.current)
+ }
+ }
+ }, [showUserLocation])
+
+ const hasPin = lat !== 0 || lng !== 0
+ const initialCenter: [number, number] = hasPin ? [lat, lng] : SRI_LANKA
+ const initialZoom = hasPin ? 15 : 8
+
+ return (
+
+
+ {process.env.NEXT_PUBLIC_MAPTILER_KEY ? (
+
+ ) : (
+
+ )}
+ {!readOnly && onChange && }
+
+
+ {/* Store location pin */}
+ {hasPin && (
+
+ )}
+
+ {/* User's live location — accuracy halo + solid dot */}
+ {showUserLocation && userLoc && (
+ <>
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx b/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx
index d51fbf2..467aeaf 100644
--- a/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx
+++ b/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
+import dynamic from 'next/dynamic'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
@@ -26,6 +27,12 @@ import { toast } from '@/components/ui/toast'
import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api'
+// Leaflet uses browser APIs — must be loaded client-side only
+const LocationPicker = dynamic(
+ () => import('./location-picker').then((m) => m.LocationPicker),
+ { ssr: false, loading: () =>
}
+)
+
const schema = z.object({
storeName: z.string().min(1, 'Store name is required'),
ownerName: z.string().min(1, 'Owner name is required'),
@@ -43,13 +50,11 @@ type FormValues = z.infer
interface StoreSheetProps {
open: boolean
onClose: () => void
- store: StoreResponse | null
+ store: StoreResponse
onSuccess: () => void
}
-
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
- const isEdit = !!store
const [routes, setRoutes] = useState([])
const form = useForm({
@@ -67,53 +72,36 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
},
})
+ const lat = form.watch('latitude')
+ const lng = form.watch('longitude')
+
useEffect(() => {
if (open) {
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
)
- form.reset(
- store
- ? {
- storeName: store.storeName,
- ownerName: store.ownerName,
- storeAddress: store.storeAddress,
- routeId: store.routeId,
- latitude: store.latitude,
- longitude: store.longitude,
- creditLimit: store.creditLimit,
- segment: store.segment ?? '',
- isActive: store.isActive,
- }
- : {
- storeName: '',
- ownerName: '',
- storeAddress: '',
- routeId: '',
- latitude: 0,
- longitude: 0,
- creditLimit: 0,
- segment: '',
- isActive: true,
- }
- )
+ form.reset({
+ storeName: store.storeName,
+ ownerName: store.ownerName,
+ storeAddress: store.storeAddress,
+ routeId: store.routeId,
+ latitude: store.latitude,
+ longitude: store.longitude,
+ creditLimit: store.creditLimit,
+ segment: store.segment ?? '',
+ isActive: store.isActive,
+ })
}
}, [open, store])
async function onSubmit(values: FormValues) {
try {
- const body = {
+ await storesApi.update(store.id, {
...values,
segment: values.segment || undefined,
- }
- if (isEdit) {
- await storesApi.update(store!.id, body)
- toast.add({ title: 'Store updated', type: 'success' })
- } else {
- await storesApi.create(body)
- toast.add({ title: 'Store created', type: 'success' })
- }
+ })
+ toast.add({ title: 'Store updated', type: 'success' })
onSuccess()
onClose()
} catch (err) {
@@ -123,12 +111,10 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
return (
!v && onClose()}>
-
+
- {isEdit ? 'Edit Store' : 'Add Store'}
-
- {isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'}
-
+ Edit Store
+ Update the store details below.
diff --git a/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx
index e4f7094..6b8b3fe 100644
--- a/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx
+++ b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -22,6 +23,7 @@ function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destruct
}
export function StoresClient() {
+ const router = useRouter()
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
@@ -128,10 +130,15 @@ export function StoresClient() {
],
rowActions: {
actions: () => [
+ {
+ action: 'view',
+ label: 'View store',
+ onClick: (store) => router.push(`/stores/${store.id}`),
+ },
{
action: 'edit',
label: 'Edit store',
- onClick: openEdit,
+ onClick: (store) => router.push(`/stores/${store.id}/edit`),
},
{
action: 'confirm',
@@ -163,7 +170,7 @@ export function StoresClient() {
Retail store accounts, credit limits, and KYC status
-
+ router.push('/stores/new')}>
Add Store
diff --git a/distribution_fe/app/(dashboard)/stores/new/page.tsx b/distribution_fe/app/(dashboard)/stores/new/page.tsx
new file mode 100644
index 0000000..6eb3183
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/stores/new/page.tsx
@@ -0,0 +1,294 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import dynamic from 'next/dynamic'
+import { HugeiconsIcon } from '@hugeicons/react'
+import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
+import { useForm, zodResolver, z } from '@/lib/form'
+import { FormField } from '@/components/shared/form-field'
+import { Input } from '@/components/ui/input'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Button } from '@/components/ui/button'
+import { FieldGroup } from '@/components/ui/field'
+import { toast } from '@/components/ui/toast'
+import { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
+import type { RouteResponse } from '@/types/api'
+
+const LocationPicker = dynamic(
+ () => import('../_components/location-picker').then((m) => m.LocationPicker),
+ { ssr: false, loading: () =>
}
+)
+
+const schema = z.object({
+ storeName: z.string().min(1, 'Store name is required'),
+ ownerName: z.string().min(1, 'Owner name is required'),
+ storeAddress: z.string().min(1, 'Store address is required'),
+ routeId: z.string().min(1, 'Route is required'),
+ latitude: z.number(),
+ longitude: z.number(),
+ creditLimit: z.number().min(0),
+ segment: z.string().optional(),
+ isActive: z.boolean(),
+})
+
+type FormValues = z.infer
+
+export default function NewStorePage() {
+ const router = useRouter()
+ const [routes, setRoutes] = useState([])
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ storeName: '',
+ ownerName: '',
+ storeAddress: '',
+ routeId: '',
+ latitude: 0,
+ longitude: 0,
+ creditLimit: 0,
+ segment: '',
+ isActive: true,
+ },
+ })
+
+ const lat = form.watch('latitude')
+ const lng = form.watch('longitude')
+
+ useEffect(() => {
+ routesApi.list({ pageSize: 100 })
+ .then((res) => setRoutes(getPaginatedItems(res)))
+ .catch((err) =>
+ toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
+ )
+ }, [])
+
+ async function onSubmit(values: FormValues) {
+ try {
+ await storesApi.create({
+ ...values,
+ segment: values.segment || undefined,
+ })
+ toast.add({ title: 'Store created', type: 'success' })
+ router.push('/stores')
+ } catch (err) {
+ toast.add({ title: 'Failed to create store', description: getApiError(err), type: 'error' })
+ }
+ }
+
+ return (
+
+ {/* Page header */}
+
+
router.push('/stores')}
+ >
+
+ Back
+
+
+
Add Store
+
Add a new retail store to the network.
+
+
+
+ {/* Form */}
+
+
+ )
+}
diff --git a/distribution_fe/components/shared/data-table/types.ts b/distribution_fe/components/shared/data-table/types.ts
index f910f77..47cac6c 100644
--- a/distribution_fe/components/shared/data-table/types.ts
+++ b/distribution_fe/components/shared/data-table/types.ts
@@ -4,6 +4,7 @@ export type SortDirection = 'asc' | 'desc' | null;
export type ExportFormat = 'csv' | 'excel' | 'pdf';
export type ColumnAlign = 'left' | 'center' | 'right';
export type RowActionKind =
+ | 'view'
| 'edit'
| 'activate'
| 'deactivate'
diff --git a/distribution_fe/components/shared/row-actions.tsx b/distribution_fe/components/shared/row-actions.tsx
index 9622e10..01037d9 100644
--- a/distribution_fe/components/shared/row-actions.tsx
+++ b/distribution_fe/components/shared/row-actions.tsx
@@ -6,6 +6,7 @@ import {
Cancel01Icon,
CheckmarkCircle02Icon,
Delete02Icon,
+ EyeIcon,
PencilEdit01Icon,
} from "@hugeicons/core-free-icons"
@@ -19,6 +20,7 @@ const ACTION_ICONS = {
confirm: CheckmarkCircle02Icon,
deactivate: Cancel01Icon,
edit: PencilEdit01Icon,
+ view: EyeIcon,
} as const
type RowActionKind = keyof typeof ACTION_ICONS
diff --git a/distribution_fe/package-lock.json b/distribution_fe/package-lock.json
index 77a5ce9..98302ae 100644
--- a/distribution_fe/package-lock.json
+++ b/distribution_fe/package-lock.json
@@ -12,14 +12,17 @@
"@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10",
+ "@types/leaflet": "^1.9.22",
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "leaflet": "^1.9.4",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.86.0",
+ "react-leaflet": "^5.0.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
@@ -1980,6 +1983,17 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2384,6 +2398,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2398,6 +2418,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.22",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
+ "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -6621,6 +6650,12 @@
"node": ">=0.10"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -8235,6 +8270,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/recast": {
"version": "0.23.21",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
diff --git a/distribution_fe/package.json b/distribution_fe/package.json
index ca83e2c..ea376ad 100644
--- a/distribution_fe/package.json
+++ b/distribution_fe/package.json
@@ -16,14 +16,17 @@
"@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10",
+ "@types/leaflet": "^1.9.22",
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "leaflet": "^1.9.4",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.86.0",
+ "react-leaflet": "^5.0.0",
"shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",