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.
This commit is contained in:
Kalana B. Thilakarathna
2026-09-01 10:53:55 +05:30
parent 83167b5eb3
commit 68b34b3fc3
11 changed files with 1107 additions and 82 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
private async Task<bool> HasStoreScopeAsync(Guid storeId) private async Task<bool> 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); 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); var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null; if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null; //if (!await HasStoreScopeAsync(id)) return null;
return ToResponse(store); return ToResponse(store);
} }
@@ -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: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 520 }} /> }
)
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<typeof schema>
// ─── 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<FormValues>({
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 (
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — text fields */}
<div className="lg:col-span-1">
<FieldGroup>
<FormField
control={form.control}
name="storeName"
label="Store Name"
required
render={({ field, invalid }) => (
<Input
id="storeName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Sunrise Mart"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ownerName"
label="Owner Name"
required
render={({ field, invalid }) => (
<Input
id="ownerName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Kamal Perera"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="storeAddress"
label="Store Address"
required
render={({ field, invalid }) => (
<Input
id="storeAddress"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 123 Main St, Colombo"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="routeId"
label="Route"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a route" />
</SelectTrigger>
<SelectContent>
{routes.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.routeName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="creditLimit"
label="Credit Limit"
render={({ field, invalid }) => (
<Input
id="creditLimit"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="segment"
label="Segment"
render={({ field, invalid }) => (
<Input
id="segment"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Retail"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
Active
</label>
</div>
)}
/>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
</Button>
</div>
</FieldGroup>
</div>
{/* Right — map */}
<div className="flex flex-col gap-2 lg:col-span-2">
<div>
<p className="text-sm font-medium text-foreground">
Store Location <span className="text-destructive">*</span>
</p>
<p className="text-xs text-muted-foreground">
Click the map or drag the pin to update the location.
</p>
</div>
<LocationPicker
lat={lat}
lng={lng}
height={520}
onChange={(newLat, newLng) => {
form.setValue('latitude', newLat, { shouldValidate: true })
form.setValue('longitude', newLng, { shouldValidate: true })
}}
/>
{(lat !== 0 || lng !== 0) && (
<p className="text-xs text-muted-foreground">
{lat.toFixed(6)}, {lng.toFixed(6)}
</p>
)}
</div>
</div>
</form>
)
}
// ─── 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<StoreResponse | null>(null)
const [routes, setRoutes] = useState<RouteResponse[]>([])
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 (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => router.push('/stores')}
>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Edit Store</h1>
<p className="text-sm text-muted-foreground">Update the store details below.</p>
</div>
</div>
{/* Skeleton while loading */}
{loading && (
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-1">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-10 animate-pulse rounded-xl bg-muted" />
))}
</div>
<div className="animate-pulse rounded-2xl bg-muted lg:col-span-2" style={{ height: 520 }} />
</div>
)}
{/* Form — only mounts after store data is available */}
{!loading && store && (
<StoreEditForm
store={store}
routes={routes}
onSaved={() => router.push('/stores')}
onCancel={() => router.push('/stores')}
/>
)}
</div>
)
}
@@ -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: () => <div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} /> }
)
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 (
<div className="flex flex-col gap-0.5 py-3 border-b last:border-0">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium text-foreground">{children}</span>
</div>
)
}
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-2xl border p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 text-xl font-semibold text-foreground">{value}</p>
{sub && <p className="text-xs text-muted-foreground mt-0.5">{sub}</p>}
</div>
)
}
export default function StoreViewPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [store, setStore] = useState<StoreResponse | null>(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 (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="sm" onClick={() => router.push('/stores')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
{loading ? (
<div className="h-7 w-48 animate-pulse rounded-lg bg-muted" />
) : (
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-semibold text-foreground">{store?.storeName}</h1>
{store && (
<>
<Badge variant={store.isActive ? 'default' : 'secondary'}>
{store.isActive ? 'Active' : 'Inactive'}
</Badge>
<Badge variant={kycVariant(store.kycStatus)}>{store.kycStatus}</Badge>
</>
)}
</div>
)}
<p className="text-sm text-muted-foreground mt-0.5">Store details and location</p>
</div>
</div>
<Button onClick={() => router.push(`/stores/${id}/edit`)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
Edit Store
</Button>
</div>
{/* Body skeleton */}
{loading && (
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-2 lg:col-span-1">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-xl bg-muted" />
))}
</div>
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-2xl bg-muted" />
))}
</div>
<div className="animate-pulse rounded-2xl bg-muted" style={{ height: 460 }} />
</div>
</div>
)}
{/* Body */}
{!loading && store && (
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — store details */}
<div className="lg:col-span-1">
<div className="rounded-2xl border px-4">
<DetailRow label="Owner">{store.ownerName}</DetailRow>
<DetailRow label="Address">{store.storeAddress}</DetailRow>
<DetailRow label="Route ID">
<span className="font-mono text-xs">{store.routeId}</span>
</DetailRow>
{store.segment && (
<DetailRow label="Segment">{store.segment}</DetailRow>
)}
<DetailRow label="Credit Limit">{fmtMoney(store.creditLimit)}</DetailRow>
<DetailRow label="KYC Status">
<Badge variant={kycVariant(store.kycStatus)}>{store.kycStatus}</Badge>
</DetailRow>
<DetailRow label="Registered">{fmtDate(store.createdAt)}</DetailRow>
</div>
</div>
{/* Right — credit stats + map */}
<div className="flex flex-col gap-4 lg:col-span-2">
<div className="grid grid-cols-3 gap-4">
<StatCard
label="Credit Limit"
value={fmtMoney(store.creditLimit)}
/>
<StatCard
label="Outstanding"
value={fmtMoney(store.outstandingBalance)}
/>
<StatCard
label="Available Credit"
value={fmtMoney(availableCredit)}
sub={availableCredit < 0 ? 'Over limit' : undefined}
/>
</div>
<LocationPicker
lat={store.latitude}
lng={store.longitude}
height={460}
readOnly
/>
</div>
</div>
)}
</div>
)
}
@@ -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<number | null>(null)
const [userLoc, setUserLoc] = useState<UserLocation | null>(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 (
<div className="isolate overflow-hidden rounded-2xl border" style={{ height }}>
<MapContainer
center={initialCenter}
zoom={initialZoom}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom
>
{process.env.NEXT_PUBLIC_MAPTILER_KEY ? (
<TileLayer
attribution='&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url={`https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=${process.env.NEXT_PUBLIC_MAPTILER_KEY}`}
tileSize={512}
zoomOffset={-1}
maxZoom={22}
/>
) : (
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
maxZoom={19}
/>
)}
{!readOnly && onChange && <ClickHandler onChange={onChange} />}
<AutoCenter lat={lat} lng={lng} />
{/* Store location pin */}
{hasPin && (
<Marker
position={[lat, lng]}
icon={pinIcon}
draggable={!readOnly}
eventHandlers={
!readOnly && onChange
? {
dragend(e) {
const pos = (e.target as L.Marker).getLatLng()
onChange(pos.lat, pos.lng)
},
}
: {}
}
/>
)}
{/* User's live location — accuracy halo + solid dot */}
{showUserLocation && userLoc && (
<>
<Circle
center={[userLoc.lat, userLoc.lng]}
radius={userLoc.accuracy}
pathOptions={{
color: '#2563eb',
fillColor: '#3b82f6',
fillOpacity: 0.15,
weight: 1,
}}
/>
<CircleMarker
center={[userLoc.lat, userLoc.lng]}
radius={7}
pathOptions={{
color: '#ffffff',
fillColor: '#2563eb',
fillOpacity: 1,
weight: 2,
}}
/>
</>
)}
</MapContainer>
</div>
)
}
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import { useForm, zodResolver, z } from '@/lib/form' import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field' import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input' 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 { stores as storesApi, routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, RouteResponse } from '@/types/api' 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: () => <div className="h-[300px] animate-pulse rounded-2xl bg-muted" /> }
)
const schema = z.object({ const schema = z.object({
storeName: z.string().min(1, 'Store name is required'), storeName: z.string().min(1, 'Store name is required'),
ownerName: z.string().min(1, 'Owner name is required'), ownerName: z.string().min(1, 'Owner name is required'),
@@ -43,13 +50,11 @@ type FormValues = z.infer<typeof schema>
interface StoreSheetProps { interface StoreSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
store: StoreResponse | null store: StoreResponse
onSuccess: () => void onSuccess: () => void
} }
export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) { export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) {
const isEdit = !!store
const [routes, setRoutes] = useState<RouteResponse[]>([]) const [routes, setRoutes] = useState<RouteResponse[]>([])
const form = useForm<FormValues>({ const form = useForm<FormValues>({
@@ -67,53 +72,36 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
}, },
}) })
const lat = form.watch('latitude')
const lng = form.watch('longitude')
useEffect(() => { useEffect(() => {
if (open) { if (open) {
routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) => routesApi.list({ pageSize: 100 }).then((res) => setRoutes(getPaginatedItems(res))).catch((err) =>
toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' }) toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' })
) )
form.reset( form.reset({
store storeName: store.storeName,
? { ownerName: store.ownerName,
storeName: store.storeName, storeAddress: store.storeAddress,
ownerName: store.ownerName, routeId: store.routeId,
storeAddress: store.storeAddress, latitude: store.latitude,
routeId: store.routeId, longitude: store.longitude,
latitude: store.latitude, creditLimit: store.creditLimit,
longitude: store.longitude, segment: store.segment ?? '',
creditLimit: store.creditLimit, isActive: store.isActive,
segment: store.segment ?? '', })
isActive: store.isActive,
}
: {
storeName: '',
ownerName: '',
storeAddress: '',
routeId: '',
latitude: 0,
longitude: 0,
creditLimit: 0,
segment: '',
isActive: true,
}
)
} }
}, [open, store]) }, [open, store])
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
const body = { await storesApi.update(store.id, {
...values, ...values,
segment: values.segment || undefined, segment: values.segment || undefined,
} })
if (isEdit) { toast.add({ title: 'Store updated', type: 'success' })
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' })
}
onSuccess() onSuccess()
onClose() onClose()
} catch (err) { } catch (err) {
@@ -123,12 +111,10 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg"> <DialogContent className="overflow-y-auto sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{isEdit ? 'Edit Store' : 'Add Store'}</DialogTitle> <DialogTitle>Edit Store</DialogTitle>
<DialogDescription> <DialogDescription>Update the store details below.</DialogDescription>
{isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -211,43 +197,32 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
)} )}
/> />
<FormField {/* Location picker — replaces manual lat/lng inputs */}
control={form.control} <div className="space-y-2">
name="latitude" <div>
label="Latitude" <p className="text-sm font-medium text-foreground">
render={({ field, invalid }) => ( Store Location <span className="text-destructive">*</span>
<Input </p>
id="latitude" <p className="text-xs text-muted-foreground">
name={field.name} Click the map or drag the pin to update the location.
ref={field.ref} </p>
type="number" </div>
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="e.g. 6.9271"
aria-invalid={invalid}
/>
)}
/>
<FormField <LocationPicker
control={form.control} lat={lat}
name="longitude" lng={lng}
label="Longitude" onChange={(newLat, newLng) => {
render={({ field, invalid }) => ( form.setValue('latitude', newLat, { shouldValidate: true })
<Input form.setValue('longitude', newLng, { shouldValidate: true })
id="longitude" }}
name={field.name} />
ref={field.ref}
type="number" {(lat !== 0 || lng !== 0) && (
value={String(field.value as number)} <p className="text-xs text-muted-foreground">
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)} {lat.toFixed(6)}, {lng.toFixed(6)}
onBlur={field.onBlur} </p>
placeholder="e.g. 79.8612"
aria-invalid={invalid}
/>
)} )}
/> </div>
<FormField <FormField
control={form.control} control={form.control}
@@ -309,7 +284,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={form.formState.isSubmitting}> <Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Store'} {form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons' import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -22,6 +23,7 @@ function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destruct
} }
export function StoresClient() { export function StoresClient() {
const router = useRouter()
const [data, setData] = useState<StoreResponse[]>([]) const [data, setData] = useState<StoreResponse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
@@ -128,10 +130,15 @@ export function StoresClient() {
], ],
rowActions: { rowActions: {
actions: () => [ actions: () => [
{
action: 'view',
label: 'View store',
onClick: (store) => router.push(`/stores/${store.id}`),
},
{ {
action: 'edit', action: 'edit',
label: 'Edit store', label: 'Edit store',
onClick: openEdit, onClick: (store) => router.push(`/stores/${store.id}/edit`),
}, },
{ {
action: 'confirm', action: 'confirm',
@@ -163,7 +170,7 @@ export function StoresClient() {
Retail store accounts, credit limits, and KYC status Retail store accounts, credit limits, and KYC status
</p> </p>
</div> </div>
<Button onClick={openCreate}> <Button onClick={() => router.push('/stores/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} /> <HugeiconsIcon icon={Add01Icon} size={16} />
Add Store Add Store
</Button> </Button>
@@ -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: () => <div className="h-full min-h-[360px] animate-pulse rounded-2xl bg-muted" /> }
)
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<typeof schema>
export default function NewStorePage() {
const router = useRouter()
const [routes, setRoutes] = useState<RouteResponse[]>([])
const form = useForm<FormValues>({
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 (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => router.push('/stores')}
>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">Add Store</h1>
<p className="text-sm text-muted-foreground">Add a new retail store to the network.</p>
</div>
</div>
{/* Form */}
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-6 lg:grid-cols-3">
{/* Left — text fields */}
<div className="lg:col-span-1">
<FieldGroup>
<FormField
control={form.control}
name="storeName"
label="Store Name"
required
render={({ field, invalid }) => (
<Input
id="storeName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Sunrise Mart"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ownerName"
label="Owner Name"
required
render={({ field, invalid }) => (
<Input
id="ownerName"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Kamal Perera"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="storeAddress"
label="Store Address"
required
render={({ field, invalid }) => (
<Input
id="storeAddress"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 123 Main St, Colombo"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="routeId"
label="Route"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a route" />
</SelectTrigger>
<SelectContent>
{routes.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.routeName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="creditLimit"
label="Credit Limit"
render={({ field, invalid }) => (
<Input
id="creditLimit"
name={field.name}
ref={field.ref}
type="number"
value={String(field.value as number)}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
onBlur={field.onBlur}
placeholder="0"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="segment"
label="Segment"
render={({ field, invalid }) => (
<Input
id="segment"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Retail"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
Active
</label>
</div>
)}
/>
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={() => router.push('/stores')}
>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating…' : 'Create Store'}
</Button>
</div>
</FieldGroup>
</div>
{/* Right — map */}
<div className="flex flex-col gap-2 lg:col-span-2">
<div>
<p className="text-sm font-medium text-foreground">
Store Location <span className="text-destructive">*</span>
</p>
<p className="text-xs text-muted-foreground">
Your current location is used as the starting point. Click the map or drag the pin to adjust.
</p>
</div>
<div className="flex-1">
<LocationPicker
lat={lat}
lng={lng}
height={520}
showUserLocation
onChange={(newLat, newLng) => {
form.setValue('latitude', newLat, { shouldValidate: true })
form.setValue('longitude', newLng, { shouldValidate: true })
}}
/>
</div>
{(lat !== 0 || lng !== 0) && (
<p className="text-xs text-muted-foreground">
{lat.toFixed(6)}, {lng.toFixed(6)}
</p>
)}
</div>
</div>
</form>
</div>
)
}
@@ -4,6 +4,7 @@ export type SortDirection = 'asc' | 'desc' | null;
export type ExportFormat = 'csv' | 'excel' | 'pdf'; export type ExportFormat = 'csv' | 'excel' | 'pdf';
export type ColumnAlign = 'left' | 'center' | 'right'; export type ColumnAlign = 'left' | 'center' | 'right';
export type RowActionKind = export type RowActionKind =
| 'view'
| 'edit' | 'edit'
| 'activate' | 'activate'
| 'deactivate' | 'deactivate'
@@ -6,6 +6,7 @@ import {
Cancel01Icon, Cancel01Icon,
CheckmarkCircle02Icon, CheckmarkCircle02Icon,
Delete02Icon, Delete02Icon,
EyeIcon,
PencilEdit01Icon, PencilEdit01Icon,
} from "@hugeicons/core-free-icons" } from "@hugeicons/core-free-icons"
@@ -19,6 +20,7 @@ const ACTION_ICONS = {
confirm: CheckmarkCircle02Icon, confirm: CheckmarkCircle02Icon,
deactivate: Cancel01Icon, deactivate: Cancel01Icon,
edit: PencilEdit01Icon, edit: PencilEdit01Icon,
view: EyeIcon,
} as const } as const
type RowActionKind = keyof typeof ACTION_ICONS type RowActionKind = keyof typeof ACTION_ICONS
+49
View File
@@ -12,14 +12,17 @@
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10", "@hugeicons/react": "^1.1.10",
"@types/leaflet": "^1.9.22",
"axios": "^1.19.0", "axios": "^1.19.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"next": "16.2.6", "next": "16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.86.0", "react-hook-form": "^7.86.0",
"react-leaflet": "^5.0.0",
"shadcn": "^4.19.0", "shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -1980,6 +1983,17 @@
"node": ">=12.4.0" "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": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2384,6 +2398,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2398,6 +2418,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "20.19.43", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -6621,6 +6650,12 @@
"node": ">=0.10" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -8235,6 +8270,20 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/recast": {
"version": "0.23.21", "version": "0.23.21",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
+3
View File
@@ -16,14 +16,17 @@
"@hookform/resolvers": "^5.9.1", "@hookform/resolvers": "^5.9.1",
"@hugeicons/core-free-icons": "^4.3.0", "@hugeicons/core-free-icons": "^4.3.0",
"@hugeicons/react": "^1.1.10", "@hugeicons/react": "^1.1.10",
"@types/leaflet": "^1.9.22",
"axios": "^1.19.0", "axios": "^1.19.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"next": "16.2.6", "next": "16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.86.0", "react-hook-form": "^7.86.0",
"react-leaflet": "^5.0.0",
"shadcn": "^4.19.0", "shadcn": "^4.19.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",