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:
@@ -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='© <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> © <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='© <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'
|
||||
|
||||
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: () => <div className="h-[300px] 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'),
|
||||
@@ -43,13 +50,11 @@ type FormValues = z.infer<typeof schema>
|
||||
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<RouteResponse[]>([])
|
||||
|
||||
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(() => {
|
||||
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 (
|
||||
<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>
|
||||
<DialogTitle>{isEdit ? 'Edit Store' : 'Add Store'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'}
|
||||
</DialogDescription>
|
||||
<DialogTitle>Edit Store</DialogTitle>
|
||||
<DialogDescription>Update the store details below.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
@@ -211,43 +197,32 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="latitude"
|
||||
label="Latitude"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="latitude"
|
||||
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="e.g. 6.9271"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Location picker — replaces manual lat/lng inputs */}
|
||||
<div className="space-y-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>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="longitude"
|
||||
label="Longitude"
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="longitude"
|
||||
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="e.g. 79.8612"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
<LocationPicker
|
||||
lat={lat}
|
||||
lng={lng}
|
||||
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>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -309,7 +284,7 @@ export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps)
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Store'}
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -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<StoreResponse[]>([])
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Button onClick={() => router.push('/stores/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Store
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user