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 ( +
+
+ + {/* Left — text fields */} +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="0" + aria-invalid={invalid} + /> + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> + +
+ + +
+
+
+ + {/* Right — map */} +
+
+

+ Store Location * +

+

+ Click the map or drag the pin to update the location. +

+
+ + { + form.setValue('latitude', newLat, { shouldValidate: true }) + form.setValue('longitude', newLng, { shouldValidate: true }) + }} + /> + + {(lat !== 0 || lng !== 0) && ( +

+ {lat.toFixed(6)}, {lng.toFixed(6)} +

+ )} +
+
+
+ ) +} + +// ─── 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 */} +
+ +
+

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 */} +
+
+ +
+ {loading ? ( +
+ ) : ( +
+

{store?.storeName}

+ {store && ( + <> + + {store.isActive ? 'Active' : 'Inactive'} + + {store.kycStatus} + + )} +
+ )} +

Store details and location

+
+
+ + +
+ + {/* 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.
@@ -211,43 +197,32 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) )} /> - ( - field.onChange(parseFloat(e.target.value) || 0)} - onBlur={field.onBlur} - placeholder="e.g. 6.9271" - aria-invalid={invalid} - /> - )} - /> + {/* Location picker — replaces manual lat/lng inputs */} +
+
+

+ Store Location * +

+

+ Click the map or drag the pin to update the location. +

+
- ( - field.onChange(parseFloat(e.target.value) || 0)} - onBlur={field.onBlur} - placeholder="e.g. 79.8612" - aria-invalid={invalid} - /> + { + form.setValue('latitude', newLat, { shouldValidate: true }) + form.setValue('longitude', newLng, { shouldValidate: true }) + }} + /> + + {(lat !== 0 || lng !== 0) && ( +

+ {lat.toFixed(6)}, {lng.toFixed(6)} +

)} - /> +
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

- 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 */} +
+ +
+

Add Store

+

Add a new retail store to the network.

+
+
+ + {/* Form */} +
+
+ + {/* Left — text fields */} +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="0" + aria-invalid={invalid} + /> + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> + +
+ + +
+
+
+ + {/* Right — map */} +
+
+

+ Store Location * +

+

+ Your current location is used as the starting point. Click the map or drag the pin to adjust. +

+
+ +
+ { + form.setValue('latitude', newLat, { shouldValidate: true }) + form.setValue('longitude', newLng, { shouldValidate: true }) + }} + /> +
+ + {(lat !== 0 || lng !== 0) && ( +

+ {lat.toFixed(6)}, {lng.toFixed(6)} +

+ )} +
+
+
+
+ ) +} 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",