Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c86c30766 | |||
| 0d2b0d553d | |||
| 6263c99767 | |||
| 4a9ebe07b8 |
@@ -0,0 +1,5 @@
|
|||||||
|
import { InventoryView } from "@/components/hexa/views/inventory-view"
|
||||||
|
|
||||||
|
export default function InventoryPage() {
|
||||||
|
return <InventoryView />
|
||||||
|
}
|
||||||
@@ -5,11 +5,21 @@ import FullCalendar from "@fullcalendar/react"
|
|||||||
import dayGridPlugin from "@fullcalendar/daygrid"
|
import dayGridPlugin from "@fullcalendar/daygrid"
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid"
|
import timeGridPlugin from "@fullcalendar/timegrid"
|
||||||
import interactionPlugin from "@fullcalendar/interaction"
|
import interactionPlugin from "@fullcalendar/interaction"
|
||||||
import type { DatesSetArg, EventInput } from "@fullcalendar/core"
|
import type { DateSelectArg, DatesSetArg, EventClickArg, EventInput } from "@fullcalendar/core"
|
||||||
|
|
||||||
import { getBookings } from "@/lib/api/bookings"
|
import { getBookings } from "@/lib/api/bookings"
|
||||||
|
import { getFacilitySessions } from "@/lib/api/facilities"
|
||||||
import { ApiError } from "@/lib/api/core-http"
|
import { ApiError } from "@/lib/api/core-http"
|
||||||
import type { Booking, BookingStatus } from "@/lib/models/booking"
|
import type { Booking, BookingStatus } from "@/lib/models/booking"
|
||||||
|
import type { FacilitySession } from "@/lib/models/facility"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
|
||||||
const STATUS_COLORS: Record<BookingStatus, string> = {
|
const STATUS_COLORS: Record<BookingStatus, string> = {
|
||||||
Confirmed: "#2563eb",
|
Confirmed: "#2563eb",
|
||||||
@@ -19,41 +29,157 @@ const STATUS_COLORS: Record<BookingStatus, string> = {
|
|||||||
Cancelled: "#dc2626",
|
Cancelled: "#dc2626",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COLOR = "#7c3aed"
|
||||||
|
const NOT_HELD_COLOR = "#9ca3af"
|
||||||
|
|
||||||
function bookingToEvent(booking: Booking): EventInput {
|
function bookingToEvent(booking: Booking): EventInput {
|
||||||
const color = STATUS_COLORS[booking.status] ?? "#2563eb"
|
const color = STATUS_COLORS[booking.status] ?? "#2563eb"
|
||||||
return {
|
return {
|
||||||
id: booking.bookingId,
|
id: `booking-${booking.bookingId}`,
|
||||||
title: `${booking.facilityName ?? "Facility"} — ${booking.isGuest ? booking.guestName ?? "Guest" : "Member"}`,
|
title: `${booking.facilityName ?? "Facility"} — ${booking.isGuest ? booking.guestName ?? "Guest" : "Member"}`,
|
||||||
start: booking.startTime,
|
start: booking.startTime,
|
||||||
end: booking.endTime,
|
end: booking.endTime,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
|
extendedProps: { kind: "booking", booking },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read-only calendar view of bookings — reuses the existing roster's getBookings query,
|
function sessionToEvent(session: FacilitySession): EventInput {
|
||||||
* refetched for whatever range FullCalendar currently has visible. */
|
const notHeld = session.isCancelled
|
||||||
function BookingCalendar() {
|
const label = session.facilityClassName ?? session.facilityName ?? "Session"
|
||||||
|
const capacityLabel = session.isUnlimitedCapacity ? `${session.bookedCount} booked` : `${session.bookedCount}/${session.capacity} booked`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `session-${session.facilitySessionId}`,
|
||||||
|
title: notHeld ? `${label} — Not held` : `${label} — ${capacityLabel}`,
|
||||||
|
start: session.startTime,
|
||||||
|
end: session.endTime,
|
||||||
|
backgroundColor: notHeld ? "transparent" : SESSION_COLOR,
|
||||||
|
borderColor: notHeld ? NOT_HELD_COLOR : SESSION_COLOR,
|
||||||
|
textColor: notHeld ? NOT_HELD_COLOR : undefined,
|
||||||
|
classNames: notHeld ? ["opacity-70", "border-dashed"] : [],
|
||||||
|
extendedProps: { kind: "session", session },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectedEvent = { kind: "booking"; booking: Booking } | { kind: "session"; session: FacilitySession } | null
|
||||||
|
|
||||||
|
function EventDetailDialog({ event, onOpenChange }: { event: SelectedEvent; onOpenChange: (open: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={!!event} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{event?.kind === "booking" ? "Booking Details" : "Class Occurrence"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{event?.kind === "booking" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Facility</p>
|
||||||
|
<p className="font-medium">{event.booking.facilityName ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Status</p>
|
||||||
|
<StatusBadge status={event.booking.status === "Cancelled" || event.booking.status === "NoShow" ? "danger" : "success"}>
|
||||||
|
{event.booking.status}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Who</p>
|
||||||
|
<p className="font-medium">{event.booking.isGuest ? event.booking.guestName ?? "Guest" : "Member"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Payment</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{event.booking.paymentStatus}
|
||||||
|
{event.booking.amountCharged > 0 && ` · ${formatCurrency(event.booking.amountCharged)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event?.kind === "session" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Class</p>
|
||||||
|
<p className="font-medium">{event.session.facilityClassName ?? event.session.facilityName ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Status</p>
|
||||||
|
<StatusBadge status={event.session.isCancelled ? "neutral" : "success"}>
|
||||||
|
{event.session.isCancelled ? "Not held" : "Scheduled"}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Booked</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{event.session.bookedCount}
|
||||||
|
{!event.session.isUnlimitedCapacity && ` / ${event.session.capacity}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{event.session.isCancelled && event.session.notHeldReason && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">Reason</p>
|
||||||
|
<p className="font-medium">{event.session.notHeldReason}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingCalendarProps {
|
||||||
|
/** Fired when the user drags a time range in day/week view -- the parent switches to Book Now
|
||||||
|
* and pre-fills the dragged time. */
|
||||||
|
onQuickBook?: (draft: { startTime: string; endTime: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar view of bookings and class occurrences -- one merged view, no per-facility split;
|
||||||
|
* the facility is still chosen in Book Now, same as today. Refetches both for whatever range
|
||||||
|
* FullCalendar currently has visible. */
|
||||||
|
function BookingCalendar({ onQuickBook }: BookingCalendarProps) {
|
||||||
const [events, setEvents] = React.useState<EventInput[]>([])
|
const [events, setEvents] = React.useState<EventInput[]>([])
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState<string | null>(null)
|
const [error, setError] = React.useState<string | null>(null)
|
||||||
|
const [viewType, setViewType] = React.useState("dayGridMonth")
|
||||||
|
const [selectedEvent, setSelectedEvent] = React.useState<SelectedEvent>(null)
|
||||||
|
|
||||||
async function handleDatesSet(arg: DatesSetArg) {
|
async function handleDatesSet(arg: DatesSetArg) {
|
||||||
|
setViewType(arg.view.type)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const bookings = await getBookings({
|
const [bookings, sessions] = await Promise.all([
|
||||||
fromDate: arg.start.toISOString(),
|
getBookings({
|
||||||
toDate: arg.end.toISOString(),
|
fromDate: arg.start.toISOString(),
|
||||||
})
|
toDate: arg.end.toISOString(),
|
||||||
setEvents(bookings.map(bookingToEvent))
|
}),
|
||||||
|
getFacilitySessions({
|
||||||
|
fromDate: arg.start.toISOString(),
|
||||||
|
toDate: arg.end.toISOString(),
|
||||||
|
includeCancelled: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
setEvents([...bookings.map(bookingToEvent), ...sessions.map(sessionToEvent)])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "Couldn't load bookings for this range.")
|
setError(err instanceof ApiError ? err.message : "Couldn't load the calendar for this range.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEventClick(arg: EventClickArg) {
|
||||||
|
const props = arg.event.extendedProps as { kind: "booking"; booking: Booking } | { kind: "session"; session: FacilitySession }
|
||||||
|
setSelectedEvent(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(arg: DateSelectArg) {
|
||||||
|
onQuickBook?.({ startTime: arg.start.toISOString(), endTime: arg.end.toISOString() })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border p-3">
|
<div className="rounded-lg border p-3">
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||||
@@ -65,17 +191,32 @@ function BookingCalendar() {
|
|||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block size-2.5 rounded-full" style={{ backgroundColor: SESSION_COLOR }} />
|
||||||
|
Class
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block size-2.5 rounded-full border border-dashed" style={{ borderColor: NOT_HELD_COLOR }} />
|
||||||
|
Not held — open
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto">Drag a time range in Day/Week view to start a booking.</span>
|
||||||
</div>
|
</div>
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||||
initialView="timeGridWeek"
|
initialView="dayGridMonth"
|
||||||
headerToolbar={{ left: "prev,next today", center: "title", right: "timeGridDay,timeGridWeek,dayGridMonth" }}
|
headerToolbar={{ left: "prev,next today", center: "title", right: "timeGridDay,timeGridWeek,dayGridMonth" }}
|
||||||
height="auto"
|
height="auto"
|
||||||
nowIndicator
|
nowIndicator
|
||||||
events={events}
|
events={events}
|
||||||
datesSet={handleDatesSet}
|
datesSet={handleDatesSet}
|
||||||
eventDisplay="block"
|
eventDisplay="block"
|
||||||
|
eventClick={handleEventClick}
|
||||||
|
selectable={viewType !== "dayGridMonth"}
|
||||||
|
selectMirror
|
||||||
|
snapDuration="00:15:00"
|
||||||
|
select={handleSelect}
|
||||||
/>
|
/>
|
||||||
|
<EventDetailDialog event={selectedEvent} onOpenChange={(open) => !open && setSelectedEvent(null)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,18 @@ const BookingCalendar = dynamic(
|
|||||||
{
|
{
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div className="flex h-[500px] items-center justify-center rounded-lg border">
|
<div className="flex h-125 items-center justify-center rounded-lg border">
|
||||||
<Spinner className="size-6" />
|
<Spinner className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function BookingDashboardTab() {
|
interface BookingDashboardTabProps {
|
||||||
|
onQuickBook?: (draft: { startTime: string; endTime: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingDashboardTab({ onQuickBook }: BookingDashboardTabProps) {
|
||||||
const summaryFetch = useFetch(() => getBookingDashboardSummary())
|
const summaryFetch = useFetch(() => getBookingDashboardSummary())
|
||||||
const summary = summaryFetch.data
|
const summary = summaryFetch.data
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ function BookingDashboardTab() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BookingCalendar />
|
<BookingCalendar onQuickBook={onQuickBook} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ interface BookingRosterTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
function BookingRosterTab({ facilities }: BookingRosterTabProps) {
|
||||||
const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled" && f.isActive)
|
const scheduledFacilities = facilities.filter((f) => f.supportsClasses && f.isActive)
|
||||||
|
|
||||||
const [facilityFilter, setFacilityFilter] = React.useState("all")
|
const [facilityFilter, setFacilityFilter] = React.useState("all")
|
||||||
const [statusFilter, setStatusFilter] = React.useState("all")
|
const [statusFilter, setStatusFilter] = React.useState("all")
|
||||||
@@ -1012,7 +1012,7 @@ interface RescheduleDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogProps) {
|
function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogProps) {
|
||||||
const isAdhoc = booking?.bookingMode === "AdHoc"
|
const isWalkIn = booking?.bookingMode === "WalkIn"
|
||||||
|
|
||||||
const [startTime, setStartTime] = React.useState("")
|
const [startTime, setStartTime] = React.useState("")
|
||||||
const [duration, setDuration] = React.useState("60")
|
const [duration, setDuration] = React.useState("60")
|
||||||
@@ -1030,7 +1030,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionsFetch = useFetch(() =>
|
const sessionsFetch = useFetch(() =>
|
||||||
booking && !isAdhoc ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([])
|
booking && !isWalkIn ? getFacilitySessions({ facilityId: booking.facilityId }) : Promise.resolve([])
|
||||||
)
|
)
|
||||||
const sessionOptions = (sessionsFetch.data ?? [])
|
const sessionOptions = (sessionsFetch.data ?? [])
|
||||||
.filter(
|
.filter(
|
||||||
@@ -1039,7 +1039,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
|
|||||||
)
|
)
|
||||||
.map((s) => ({ label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`, value: s.facilitySessionId }))
|
.map((s) => ({ label: `${formatDateTime(s.startTime)} (${s.bookedCount}/${s.capacity})`, value: s.facilitySessionId }))
|
||||||
|
|
||||||
const canSubmit = isAdhoc
|
const canSubmit = isWalkIn
|
||||||
? !!booking && !!startTime && Number(duration) >= 1 && !busy
|
? !!booking && !!startTime && Number(duration) >= 1 && !busy
|
||||||
: !!booking && !!sessionId && !busy
|
: !!booking && !!sessionId && !busy
|
||||||
|
|
||||||
@@ -1050,7 +1050,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
|
|||||||
await run(() =>
|
await run(() =>
|
||||||
rescheduleBooking(
|
rescheduleBooking(
|
||||||
booking.bookingId,
|
booking.bookingId,
|
||||||
isAdhoc
|
isWalkIn
|
||||||
? { startTime: new Date(startTime).toISOString(), durationMinutes: Number(duration) }
|
? { startTime: new Date(startTime).toISOString(), durationMinutes: Number(duration) }
|
||||||
: { facilitySessionId: sessionId }
|
: { facilitySessionId: sessionId }
|
||||||
)
|
)
|
||||||
@@ -1072,7 +1072,7 @@ function RescheduleDialog({ booking, onOpenChange, onSaved }: RescheduleDialogPr
|
|||||||
<DialogDescription>Only a free slot can be chosen — same rule as creating a new booking.</DialogDescription>
|
<DialogDescription>Only a free slot can be chosen — same rule as creating a new booking.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isAdhoc ? (
|
{isWalkIn ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="reschedule-start">
|
<Label htmlFor="reschedule-start">
|
||||||
|
|||||||
@@ -1,79 +1,31 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { CalendarPlusIcon, Trash2Icon } from "lucide-react"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import {
|
import { getFacilities } from "@/lib/api/facilities"
|
||||||
cancelFacilitySession,
|
|
||||||
createFacilitySession,
|
|
||||||
getFacilities,
|
|
||||||
getFacilitySessions,
|
|
||||||
} from "@/lib/api/facilities"
|
|
||||||
import type { Facility, FacilitySession } from "@/lib/models/facility"
|
|
||||||
import { useFetch } from "@/lib/use-fetch"
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
import { useMutation } from "@/lib/use-mutation"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
|
||||||
import { PageHeader } from "@/components/hexa/page-header"
|
import { PageHeader } from "@/components/hexa/page-header"
|
||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
import { WalkInBookingTab } from "@/components/hexa/views/walkin-booking-tab"
|
||||||
import { AdhocBookingTab } from "@/components/hexa/views/adhoc-booking-tab"
|
|
||||||
import { BookingDashboardTab } from "@/components/hexa/views/booking-dashboard-tab"
|
import { BookingDashboardTab } from "@/components/hexa/views/booking-dashboard-tab"
|
||||||
import { BookingInvoicesTab } from "@/components/hexa/views/booking-invoices-tab"
|
import { BookingInvoicesTab } from "@/components/hexa/views/booking-invoices-tab"
|
||||||
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
import { BookingRosterTab } from "@/components/hexa/views/booking-roster-tab"
|
||||||
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
|
import { RfidTagsTab } from "@/components/hexa/views/rfid-tags-tab"
|
||||||
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
|
import { TimeBankCardsTab } from "@/components/hexa/views/time-bank-cards-tab"
|
||||||
|
|
||||||
function formatDateTime(iso: string) {
|
|
||||||
return new Date(iso).toLocaleString(undefined, {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `datetime-local` input value in local time, e.g. "2026-09-20T18:30". */
|
|
||||||
function toLocalInputValue(date: Date) {
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0")
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function BookingsView() {
|
function BookingsView() {
|
||||||
const facilitiesFetch = useFetch(() => getFacilities(true))
|
const facilitiesFetch = useFetch(() => getFacilities(true))
|
||||||
const facilities = facilitiesFetch.data ?? []
|
const facilities = facilitiesFetch.data ?? []
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = React.useState("dashboard")
|
||||||
|
const [quickBookDraft, setQuickBookDraft] = React.useState<{ startTime: string; endTime: string } | null>(null)
|
||||||
|
|
||||||
|
function handleQuickBook(draft: { startTime: string; endTime: string }) {
|
||||||
|
setQuickBookDraft(draft)
|
||||||
|
setActiveTab("book-now")
|
||||||
|
}
|
||||||
|
|
||||||
if (facilitiesFetch.loading) {
|
if (facilitiesFetch.loading) {
|
||||||
return <PageSkeleton variant="stats-table" />
|
return <PageSkeleton variant="stats-table" />
|
||||||
}
|
}
|
||||||
@@ -82,14 +34,13 @@ function BookingsView() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Bookings"
|
title="Bookings"
|
||||||
description="Dashboard, scheduled sessions, ad-hoc reservations, the booking roster, invoices, RFID tags and time-bank cards"
|
description="Dashboard, walk-in bookings, the booking roster, invoices, RFID tags and time-bank cards"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="dashboard" className="gap-4">
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v ?? "dashboard")} className="gap-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||||
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
<TabsTrigger value="book-now">Book Now</TabsTrigger>
|
||||||
<TabsTrigger value="adhoc">Book Now</TabsTrigger>
|
|
||||||
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
<TabsTrigger value="roster">Roster & Check-in</TabsTrigger>
|
||||||
<TabsTrigger value="invoices">Invoices</TabsTrigger>
|
<TabsTrigger value="invoices">Invoices</TabsTrigger>
|
||||||
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
|
<TabsTrigger value="rfid-tags">RFID Tags</TabsTrigger>
|
||||||
@@ -97,13 +48,10 @@ function BookingsView() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="dashboard">
|
<TabsContent value="dashboard">
|
||||||
<BookingDashboardTab />
|
<BookingDashboardTab onQuickBook={handleQuickBook} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="sessions">
|
<TabsContent value="book-now">
|
||||||
<SessionsTab facilities={facilities} />
|
<WalkInBookingTab facilities={facilities} prefill={quickBookDraft} onPrefillConsumed={() => setQuickBookDraft(null)} />
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="adhoc">
|
|
||||||
<AdhocBookingTab facilities={facilities} />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="roster">
|
<TabsContent value="roster">
|
||||||
<BookingRosterTab facilities={facilities} />
|
<BookingRosterTab facilities={facilities} />
|
||||||
@@ -122,315 +70,4 @@ function BookingsView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Sessions ----------
|
|
||||||
|
|
||||||
interface SessionsTabProps {
|
|
||||||
facilities: Facility[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionsTab({ facilities }: SessionsTabProps) {
|
|
||||||
const scheduledFacilities = facilities.filter((f) => f.bookingMode === "Scheduled")
|
|
||||||
|
|
||||||
const [facilityFilter, setFacilityFilter] = React.useState("all")
|
|
||||||
const sessionsFetch = useFetch(() =>
|
|
||||||
getFacilitySessions(facilityFilter === "all" ? {} : { facilityId: facilityFilter })
|
|
||||||
)
|
|
||||||
const sessions = sessionsFetch.data ?? []
|
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = React.useState(false)
|
|
||||||
const [cancelTarget, setCancelTarget] = React.useState<FacilitySession | null>(null)
|
|
||||||
const { busy: cancelling, run: runCancel } = useMutation()
|
|
||||||
|
|
||||||
const filterOptions = [
|
|
||||||
{ label: "All facilities", value: "all" },
|
|
||||||
...scheduledFacilities.map((f) => ({ label: f.name, value: f.facilityId })),
|
|
||||||
]
|
|
||||||
|
|
||||||
async function confirmCancel() {
|
|
||||||
if (!cancelTarget) return
|
|
||||||
const target = cancelTarget
|
|
||||||
setCancelTarget(null)
|
|
||||||
try {
|
|
||||||
await runCancel(() => cancelFacilitySession(target.facilitySessionId))
|
|
||||||
toast.success("Session cancelled")
|
|
||||||
sessionsFetch.reload()
|
|
||||||
} catch {
|
|
||||||
// run() already toasted the real error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: DataTableColumn<FacilitySession>[] = [
|
|
||||||
{ key: "facility", header: "Facility", cell: (row) => row.facilityName ?? "-" },
|
|
||||||
{ key: "start", header: "Start", cell: (row) => formatDateTime(row.startTime) },
|
|
||||||
{ key: "end", header: "End", cell: (row) => formatDateTime(row.endTime) },
|
|
||||||
{
|
|
||||||
key: "capacity",
|
|
||||||
header: "Booked / Capacity",
|
|
||||||
cell: (row) => `${row.bookedCount} / ${row.capacity}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: (row) =>
|
|
||||||
row.isCancelled ? (
|
|
||||||
<StatusBadge status="danger">Cancelled</StatusBadge>
|
|
||||||
) : (
|
|
||||||
<StatusBadge status="success">Scheduled</StatusBadge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: "Actions",
|
|
||||||
className: "text-center",
|
|
||||||
cell: (row) =>
|
|
||||||
row.isCancelled ? null : (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label="Cancel session"
|
|
||||||
onClick={() => setCancelTarget(row)}
|
|
||||||
>
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent>Cancel session</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>Facility</Label>
|
|
||||||
<Select items={filterOptions} value={facilityFilter} onValueChange={(v) => setFacilityFilter(v ?? "all")}>
|
|
||||||
<SelectTrigger className="w-56">
|
|
||||||
<SelectValue placeholder="All facilities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{filterOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => setDialogOpen(true)} disabled={scheduledFacilities.length === 0}>
|
|
||||||
<CalendarPlusIcon />
|
|
||||||
Schedule Session
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sessionsFetch.loading ? (
|
|
||||||
<div className="flex justify-center py-10">
|
|
||||||
<Spinner className="size-6" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
rows={sessions}
|
|
||||||
rowKey={(row) => row.facilitySessionId}
|
|
||||||
emptyMessage="No sessions scheduled yet."
|
|
||||||
renderMobileCard={(row) => (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{row.facilityName ?? "-"}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{formatDateTime(row.startTime)} - {formatDateTime(row.endTime)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{row.isCancelled ? (
|
|
||||||
<StatusBadge status="danger">Cancelled</StatusBadge>
|
|
||||||
) : (
|
|
||||||
<StatusBadge status="success">Scheduled</StatusBadge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-t pt-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{row.bookedCount} / {row.capacity} booked
|
|
||||||
</span>
|
|
||||||
{!row.isCancelled && (
|
|
||||||
<Button variant="outline" size="icon-sm" aria-label="Cancel session" onClick={() => setCancelTarget(row)}>
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScheduleSessionDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onOpenChange={setDialogOpen}
|
|
||||||
facilities={scheduledFacilities}
|
|
||||||
onScheduled={sessionsFetch.reload}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertDialog open={!!cancelTarget} onOpenChange={(open) => !open && setCancelTarget(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Cancel this session?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Existing bookings for this session will need to be handled separately. This cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Keep Session</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={confirmCancel} disabled={cancelling}>
|
|
||||||
Cancel Session
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScheduleSessionDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
facilities: Facility[]
|
|
||||||
onScheduled: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScheduleSessionDialog({ open, onOpenChange, facilities, onScheduled }: ScheduleSessionDialogProps) {
|
|
||||||
const [facilityId, setFacilityId] = React.useState("")
|
|
||||||
const [startTime, setStartTime] = React.useState("")
|
|
||||||
const [endTime, setEndTime] = React.useState("")
|
|
||||||
const [capacity, setCapacity] = React.useState("")
|
|
||||||
const { busy, run } = useMutation()
|
|
||||||
const [prevOpen, setPrevOpen] = React.useState(open)
|
|
||||||
|
|
||||||
if (open !== prevOpen) {
|
|
||||||
setPrevOpen(open)
|
|
||||||
if (open) {
|
|
||||||
const now = new Date()
|
|
||||||
now.setMinutes(0, 0, 0)
|
|
||||||
now.setHours(now.getHours() + 1)
|
|
||||||
const later = new Date(now.getTime() + 60 * 60 * 1000)
|
|
||||||
setFacilityId(facilities[0]?.facilityId ?? "")
|
|
||||||
setStartTime(toLocalInputValue(now))
|
|
||||||
setEndTime(toLocalInputValue(later))
|
|
||||||
setCapacity("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const facilityOptions = facilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
|
||||||
const canSubmit = !!facilityId && !!startTime && !!endTime && endTime > startTime && !busy
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!canSubmit) return
|
|
||||||
try {
|
|
||||||
await run(() =>
|
|
||||||
createFacilitySession({
|
|
||||||
facilityId,
|
|
||||||
startTime: new Date(startTime).toISOString(),
|
|
||||||
endTime: new Date(endTime).toISOString(),
|
|
||||||
capacity: capacity ? Number(capacity) : undefined,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
toast.success("Session scheduled")
|
|
||||||
onOpenChange(false)
|
|
||||||
onScheduled()
|
|
||||||
} catch {
|
|
||||||
// run() already toasted the real error; keep the dialog open for retry.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Schedule Session</DialogTitle>
|
|
||||||
<DialogDescription>Open a bookable time slot for a Scheduled-mode facility.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>
|
|
||||||
Facility <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
items={facilityOptions}
|
|
||||||
value={facilityId}
|
|
||||||
onValueChange={(v) => setFacilityId(v ?? "")}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Select facility" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{facilityOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="session-start">
|
|
||||||
Start <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="session-start"
|
|
||||||
type="datetime-local"
|
|
||||||
value={startTime}
|
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="session-end">
|
|
||||||
End <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="session-end"
|
|
||||||
type="datetime-local"
|
|
||||||
value={endTime}
|
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="session-capacity">Capacity override</Label>
|
|
||||||
<Input
|
|
||||||
id="session-capacity"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
placeholder="Uses the facility's default capacity if left blank"
|
|
||||||
value={capacity}
|
|
||||||
onChange={(e) => setCapacity(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!canSubmit}>
|
|
||||||
{busy && <Spinner className="size-4" />}
|
|
||||||
Schedule Session
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { BookingsView }
|
export { BookingsView }
|
||||||
|
|||||||
@@ -0,0 +1,780 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CalendarIcon, CheckIcon, EyeIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { getFacilities } from "@/lib/api/facilities"
|
||||||
|
import {
|
||||||
|
createFacilityClass,
|
||||||
|
getClassOccurrences,
|
||||||
|
getFacilityClasses,
|
||||||
|
getSchedulingConflicts,
|
||||||
|
markOccurrenceNotHeld,
|
||||||
|
updateFacilityClass,
|
||||||
|
} from "@/lib/api/facility-classes"
|
||||||
|
import { createGymPlan } from "@/lib/api/gym-plans"
|
||||||
|
import type { ClassOccurrence, CreateFacilityClassInput, FacilityClass, RecurrenceType } from "@/lib/models/facility-class"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
|
import { useMutation, runMutation } from "@/lib/use-mutation"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
|
||||||
|
const RECURRENCE_OPTIONS: { label: string; value: RecurrenceType }[] = [
|
||||||
|
{ label: "Daily", value: "Daily" },
|
||||||
|
{ label: "Weekly", value: "Weekly" },
|
||||||
|
{ label: "Monthly", value: "Monthly" },
|
||||||
|
{ label: "Custom (explicit dates)", value: "Custom" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||||
|
|
||||||
|
function formatDateTime(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function recurrenceSummary(cls: FacilityClass) {
|
||||||
|
if (cls.recurrenceType === "Custom") return "Custom dates"
|
||||||
|
if (cls.recurrenceType === "Daily") return `Daily @ ${cls.startTimeOfDay}`
|
||||||
|
if (cls.recurrenceType === "Monthly") return `Monthly (${cls.monthDays ?? "—"}) @ ${cls.startTimeOfDay}`
|
||||||
|
const days = WEEKDAYS.filter((_, i) => (cls.weekDaysMask & (1 << i)) !== 0).join(", ")
|
||||||
|
return `Weekly (${days || "—"}) @ ${cls.startTimeOfDay}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `datetime-local` input value in local time. */
|
||||||
|
function toLocalInputValue(date: Date) {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0")
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassFormValues {
|
||||||
|
facilityId: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
recurrenceType: RecurrenceType
|
||||||
|
startTimeOfDay: string
|
||||||
|
durationMinutes: string
|
||||||
|
weekDaysMask: number
|
||||||
|
monthDays: string
|
||||||
|
seriesStartDate: string
|
||||||
|
seriesEndDate: string
|
||||||
|
capacity: string
|
||||||
|
isUnlimitedCapacity: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_VALUES: ClassFormValues = {
|
||||||
|
facilityId: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
recurrenceType: "Weekly",
|
||||||
|
startTimeOfDay: "18:00",
|
||||||
|
durationMinutes: "60",
|
||||||
|
weekDaysMask: 0,
|
||||||
|
monthDays: "",
|
||||||
|
seriesStartDate: new Date().toISOString().slice(0, 10),
|
||||||
|
seriesEndDate: "",
|
||||||
|
capacity: "20",
|
||||||
|
isUnlimitedCapacity: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomOccurrenceRow {
|
||||||
|
localDateTime: string
|
||||||
|
durationMinutes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
facilities: { facilityId: string; name: string }[]
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClassDialog({ open, onOpenChange, facilities, onCreated }: ClassDialogProps) {
|
||||||
|
const [values, setValues] = React.useState<ClassFormValues>(EMPTY_VALUES)
|
||||||
|
const [customRows, setCustomRows] = React.useState<CustomOccurrenceRow[]>([])
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||||
|
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open)
|
||||||
|
if (open) {
|
||||||
|
setValues({ ...EMPTY_VALUES, facilityId: facilities[0]?.facilityId ?? "" })
|
||||||
|
setCustomRows([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set<K extends keyof ClassFormValues>(key: K, value: ClassFormValues[K]) {
|
||||||
|
setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWeekday(bit: number) {
|
||||||
|
setValues((v) => ({ ...v, weekDaysMask: v.weekDaysMask ^ (1 << bit) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCustom = values.recurrenceType === "Custom"
|
||||||
|
const canSubmit =
|
||||||
|
!!values.facilityId &&
|
||||||
|
!!values.name.trim() &&
|
||||||
|
(isCustom
|
||||||
|
? customRows.length > 0 && customRows.every((r) => !!r.localDateTime && Number(r.durationMinutes) >= 1)
|
||||||
|
: !!values.startTimeOfDay &&
|
||||||
|
Number(values.durationMinutes) >= 1 &&
|
||||||
|
(values.recurrenceType !== "Weekly" || values.weekDaysMask !== 0) &&
|
||||||
|
(values.recurrenceType !== "Monthly" || values.monthDays.trim() !== "")) &&
|
||||||
|
!busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
try {
|
||||||
|
await run(async () => {
|
||||||
|
const input: CreateFacilityClassInput = {
|
||||||
|
facilityId: values.facilityId,
|
||||||
|
name: values.name.trim(),
|
||||||
|
description: values.description.trim() || undefined,
|
||||||
|
recurrenceType: values.recurrenceType,
|
||||||
|
durationMinutes: Number(values.durationMinutes) || 60,
|
||||||
|
capacity: values.isUnlimitedCapacity ? undefined : Number(values.capacity) || 1,
|
||||||
|
isUnlimitedCapacity: values.isUnlimitedCapacity,
|
||||||
|
}
|
||||||
|
if (isCustom) {
|
||||||
|
input.customOccurrences = customRows.map((r) => ({
|
||||||
|
startTime: new Date(r.localDateTime).toISOString(),
|
||||||
|
durationMinutes: Number(r.durationMinutes) || 60,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
input.startTimeOfDay = values.startTimeOfDay
|
||||||
|
input.weekDaysMask = values.weekDaysMask
|
||||||
|
input.monthDays = values.monthDays.trim() || undefined
|
||||||
|
input.seriesStartDate = values.seriesStartDate || undefined
|
||||||
|
input.seriesEndDate = values.seriesEndDate || undefined
|
||||||
|
}
|
||||||
|
await createFacilityClass(input)
|
||||||
|
toast.success("Class created", { description: `"${input.name}" was added.` })
|
||||||
|
onCreated()
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Class</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A recurring class (Zumba, Yoga) that generates its own scheduled sessions. Members
|
||||||
|
book it like any other session; sell access to it from here via "Manage
|
||||||
|
Plan" afterward.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
Facility <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select items={facilities.map((f) => ({ label: f.name, value: f.facilityId }))} value={values.facilityId} onValueChange={(v) => set("facilityId", v ?? "")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select facility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{facilities.map((f) => (
|
||||||
|
<SelectItem key={f.facilityId} value={f.facilityId}>
|
||||||
|
{f.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{facilities.length === 0 && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
No facility supports classes yet -- check "Classes" on one under Facilities.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-name">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="class-name" placeholder="e.g. Zumba" value={values.name} onChange={(e) => set("name", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-description">Description</Label>
|
||||||
|
<Textarea id="class-description" maxLength={300} value={values.description} onChange={(e) => set("description", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Recurrence</Label>
|
||||||
|
<Select items={RECURRENCE_OPTIONS} value={values.recurrenceType} onValueChange={(v) => set("recurrenceType", (v as RecurrenceType) ?? "Weekly")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RECURRENCE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCustom && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-time">
|
||||||
|
Time <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="class-time" type="time" value={values.startTimeOfDay} onChange={(e) => set("startTimeOfDay", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-duration">
|
||||||
|
Duration (min) <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="class-duration" type="number" min={1} value={values.durationMinutes} onChange={(e) => set("durationMinutes", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{values.recurrenceType === "Weekly" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
Days <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{WEEKDAYS.map((day, i) => (
|
||||||
|
<label key={day} className="flex flex-col items-center gap-1 text-xs">
|
||||||
|
<Checkbox checked={(values.weekDaysMask & (1 << i)) !== 0} onCheckedChange={() => toggleWeekday(i)} />
|
||||||
|
{day}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.recurrenceType === "Monthly" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-monthdays">
|
||||||
|
Days of month <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="class-monthdays" placeholder="e.g. 1,15" value={values.monthDays} onChange={(e) => set("monthDays", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-start">Starts</Label>
|
||||||
|
<Input id="class-start" type="date" value={values.seriesStartDate} onChange={(e) => set("seriesStartDate", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-end">Ends (optional)</Label>
|
||||||
|
<Input id="class-end" type="date" value={values.seriesEndDate} onChange={(e) => set("seriesEndDate", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCustom && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>
|
||||||
|
Occurrences <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
{customRows.map((row, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
aria-label="Occurrence start"
|
||||||
|
value={row.localDateTime}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCustomRows((rows) => rows.map((r, i) => (i === idx ? { ...r, localDateTime: e.target.value } : r)))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
aria-label="Duration (minutes)"
|
||||||
|
className="w-24"
|
||||||
|
value={row.durationMinutes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCustomRows((rows) => rows.map((r, i) => (i === idx ? { ...r, durationMinutes: e.target.value } : r)))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="icon-sm" aria-label="Remove occurrence" onClick={() => setCustomRows((rows) => rows.filter((_, i) => i !== idx))}>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setCustomRows((rows) => [...rows, { localDateTime: toLocalInputValue(new Date()), durationMinutes: "60" }])}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add occurrence
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={values.isUnlimitedCapacity} onCheckedChange={(v) => set("isUnlimitedCapacity", v === true)} />
|
||||||
|
Unlimited capacity
|
||||||
|
</label>
|
||||||
|
{!values.isUnlimitedCapacity && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-capacity">Capacity</Label>
|
||||||
|
<Input id="class-capacity" type="number" min={1} value={values.capacity} onChange={(e) => set("capacity", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="-mx-4 -mt-4 -mb-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Add Class
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManagePlanDialogProps {
|
||||||
|
facilityClass: FacilityClass | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManagePlanDialog({ facilityClass, onOpenChange }: ManagePlanDialogProps) {
|
||||||
|
const [planName, setPlanName] = React.useState("")
|
||||||
|
const [planPrice, setPlanPrice] = React.useState("0")
|
||||||
|
const [durationInDays, setDurationInDays] = React.useState("30")
|
||||||
|
const [sessionsPerCycle, setSessionsPerCycle] = React.useState("")
|
||||||
|
const [grantsGymAccess, setGrantsGymAccess] = React.useState(false)
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevClass, setPrevClass] = React.useState(facilityClass)
|
||||||
|
|
||||||
|
if (facilityClass !== prevClass) {
|
||||||
|
setPrevClass(facilityClass)
|
||||||
|
if (facilityClass) {
|
||||||
|
setPlanName(`${facilityClass.name} Membership`)
|
||||||
|
setPlanPrice("0")
|
||||||
|
setDurationInDays("30")
|
||||||
|
setSessionsPerCycle("")
|
||||||
|
setGrantsGymAccess(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = !!facilityClass && !!planName.trim() && Number(planPrice) >= 0 && Number(durationInDays) >= 1 && !busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit || !facilityClass) return
|
||||||
|
try {
|
||||||
|
await run(async () => {
|
||||||
|
await createGymPlan({
|
||||||
|
planName: planName.trim(),
|
||||||
|
planPrice: Number(planPrice) || 0,
|
||||||
|
durationInDays: Number(durationInDays) || 30,
|
||||||
|
sessionsPerCycle: sessionsPerCycle ? Number(sessionsPerCycle) : undefined,
|
||||||
|
facilityClassId: facilityClass.facilityClassId,
|
||||||
|
grantsGymAccess,
|
||||||
|
})
|
||||||
|
toast.success("Plan created", { description: `Members can now subscribe to "${facilityClass.name}".` })
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!facilityClass} onOpenChange={(open) => !open && onOpenChange(false)} disablePointerDismissal>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage Plan — {facilityClass?.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Creates a new membership plan sold against this class. Price, duration and other
|
||||||
|
details can be adjusted afterward from the Plans tab.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="manage-plan-name">
|
||||||
|
Plan Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="manage-plan-name" value={planName} onChange={(e) => setPlanName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="manage-plan-price">
|
||||||
|
Price (Rs.) <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="manage-plan-price" type="number" min={0} step="0.01" value={planPrice} onChange={(e) => setPlanPrice(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="manage-plan-duration">
|
||||||
|
Duration (days) <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="manage-plan-duration" type="number" min={1} value={durationInDays} onChange={(e) => setDurationInDays(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="manage-plan-sessions">Sessions per cycle</Label>
|
||||||
|
<Input
|
||||||
|
id="manage-plan-sessions"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
placeholder="Leave blank for unlimited"
|
||||||
|
value={sessionsPerCycle}
|
||||||
|
onChange={(e) => setSessionsPerCycle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox checked={grantsGymAccess} onCheckedChange={(v) => setGrantsGymAccess(v === true)} />
|
||||||
|
Also grant general gym access (not just this class)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<DialogFooter className="-mx-4 -mt-4 -mb-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Create Plan
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotHeldDialogProps {
|
||||||
|
occurrence: ClassOccurrence | null
|
||||||
|
classId: string
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onDone: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotHeldDialog({ occurrence, classId, onOpenChange, onDone }: NotHeldDialogProps) {
|
||||||
|
const [reason, setReason] = React.useState("")
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!occurrence) return
|
||||||
|
try {
|
||||||
|
await run(async () => {
|
||||||
|
await markOccurrenceNotHeld(classId, occurrence.facilitySessionId, reason.trim() || undefined)
|
||||||
|
toast.success("Marked not held", { description: "Affected bookings were cancelled and refunded; the slot is open again." })
|
||||||
|
onDone()
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
setReason("")
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!occurrence} onOpenChange={(open) => !open && onOpenChange(false)} disablePointerDismissal>
|
||||||
|
<DialogContent className="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mark Not Held</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{occurrence && `${formatDateTime(occurrence.startTime)} won't run.`} Any confirmed
|
||||||
|
bookings on it are cancelled and refunded, and the slot opens up for walk-in booking.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="not-held-reason">Reason (optional)</Label>
|
||||||
|
<Textarea id="not-held-reason" maxLength={300} value={reason} onChange={(e) => setReason(e.target.value)} placeholder="e.g. Instructor unavailable" />
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="-mx-4 -mt-4 -mb-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="destructive" onClick={handleConfirm} disabled={busy}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OccurrencesDialogProps {
|
||||||
|
facilityClass: FacilityClass | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function OccurrencesDialog({ facilityClass, onOpenChange }: OccurrencesDialogProps) {
|
||||||
|
const occurrencesFetch = useFetch(() =>
|
||||||
|
facilityClass ? getClassOccurrences(facilityClass.facilityClassId) : Promise.resolve([])
|
||||||
|
)
|
||||||
|
const occurrences = occurrencesFetch.data ?? []
|
||||||
|
const [notHeldTarget, setNotHeldTarget] = React.useState<ClassOccurrence | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (facilityClass) occurrencesFetch.reload()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the class changing
|
||||||
|
}, [facilityClass, occurrencesFetch.reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!facilityClass} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Occurrences — {facilityClass?.name}</DialogTitle>
|
||||||
|
<DialogDescription>Generated up to 8 weeks ahead. Past and booked ones can't be deleted by editing the recurrence.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{occurrencesFetch.loading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner className="size-6" />
|
||||||
|
</div>
|
||||||
|
) : occurrences.length === 0 ? (
|
||||||
|
<p className="rounded-lg border p-4 text-center text-sm text-muted-foreground">No occurrences yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{occurrences.map((occ) => (
|
||||||
|
<div key={occ.facilitySessionId} className="flex items-center justify-between gap-3 p-2.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{formatDateTime(occ.startTime)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{occ.isUnlimitedCapacity ? "Unlimited" : `Capacity ${occ.capacity}`}
|
||||||
|
{occ.isCancelled && occ.notHeldReason && ` · ${occ.notHeldReason}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{occ.isCancelled ? (
|
||||||
|
<Badge variant="outline">Not held</Badge>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setNotHeldTarget(occ)}>
|
||||||
|
Mark not held
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{facilityClass && (
|
||||||
|
<NotHeldDialog
|
||||||
|
occurrence={notHeldTarget}
|
||||||
|
classId={facilityClass.facilityClassId}
|
||||||
|
onOpenChange={() => setNotHeldTarget(null)}
|
||||||
|
onDone={occurrencesFetch.reload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClassesView() {
|
||||||
|
const classesFetch = useFetch(() => getFacilityClasses(true))
|
||||||
|
const classes = classesFetch.data ?? []
|
||||||
|
const facilitiesFetch = useFetch(() => getFacilities(true))
|
||||||
|
const classFacilities = (facilitiesFetch.data ?? []).filter((f) => f.supportsClasses && f.isActive)
|
||||||
|
const conflictsFetch = useFetch(() => getSchedulingConflicts())
|
||||||
|
const conflicts = conflictsFetch.data ?? []
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
const [planTarget, setPlanTarget] = React.useState<FacilityClass | null>(null)
|
||||||
|
const [occurrencesTarget, setOccurrencesTarget] = React.useState<FacilityClass | null>(null)
|
||||||
|
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
async function toggleStatus(cls: FacilityClass) {
|
||||||
|
setStatusPendingId(cls.facilityClassId)
|
||||||
|
try {
|
||||||
|
await runMutation(() => updateFacilityClass(cls.facilityClassId, { isActive: !cls.isActive }))
|
||||||
|
toast.success(cls.isActive ? "Class deactivated" : "Class activated")
|
||||||
|
classesFetch.reload()
|
||||||
|
} catch {
|
||||||
|
// runMutation() already toasted the real error.
|
||||||
|
} finally {
|
||||||
|
setStatusPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumn<FacilityClass>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Class",
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<CalendarIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.facilityName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "recurrence", header: "Recurrence", cell: (row) => recurrenceSummary(row) },
|
||||||
|
{ key: "capacity", header: "Capacity", cell: (row) => (row.isUnlimitedCapacity ? "Unlimited" : row.capacity ?? "—") },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: (row) => <StatusBadge status={row.isActive ? "success" : "neutral"}>{row.isActive ? "Active" : "Inactive"}</StatusBadge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "text-center",
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex justify-center gap-1.5">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="View occurrences" onClick={() => setOccurrencesTarget(row)}>
|
||||||
|
<EyeIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>View occurrences</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="sm" aria-label="Manage plan" onClick={() => setPlanTarget(row)}>
|
||||||
|
Manage Plan
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Create a membership plan for this class</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={row.isActive ? "Deactivate class" : "Activate class"}
|
||||||
|
disabled={statusPendingId === row.facilityClassId}
|
||||||
|
onClick={() => toggleStatus(row)}
|
||||||
|
>
|
||||||
|
{statusPendingId === row.facilityClassId ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : row.isActive ? (
|
||||||
|
<XIcon className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>{row.isActive ? "Deactivate class" : "Activate class"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (classesFetch.loading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Classes</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Recurring sessions (Zumba, Yoga) that generate their own schedule automatically.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<PlusIcon />
|
||||||
|
Add Class
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conflicts.length > 0 && (
|
||||||
|
<InfoBanner>
|
||||||
|
{conflicts.length} walk-in booking{conflicts.length > 1 ? "s" : ""} now overlap a scheduled class. Move
|
||||||
|
{conflicts.length > 1 ? " them" : " it"} from the Roster tab's Reschedule action:{" "}
|
||||||
|
{conflicts.map((c) => `${c.facilityName ?? "Facility"} ${formatDateTime(c.startTime)}`).join("; ")}.
|
||||||
|
</InfoBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={classes}
|
||||||
|
rowKey={(row) => row.facilityClassId}
|
||||||
|
emptyMessage="No classes yet. Add one to start generating its schedule."
|
||||||
|
renderMobileCard={(row) => (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<CalendarIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{recurrenceSummary(row)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={row.isActive ? "success" : "neutral"}>{row.isActive ? "Active" : "Inactive"}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t pt-2.5">
|
||||||
|
<span className="text-sm text-muted-foreground">{row.isUnlimitedCapacity ? "Unlimited" : `Capacity ${row.capacity}`}</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="View occurrences" onClick={() => setOccurrencesTarget(row)}>
|
||||||
|
<EyeIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setPlanTarget(row)}>
|
||||||
|
Manage Plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClassDialog open={dialogOpen} onOpenChange={setDialogOpen} facilities={classFacilities} onCreated={classesFetch.reload} />
|
||||||
|
<ManagePlanDialog facilityClass={planTarget} onOpenChange={() => setPlanTarget(null)} />
|
||||||
|
<OccurrencesDialog facilityClass={occurrencesTarget} onOpenChange={() => setOccurrencesTarget(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ClassesView }
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -42,14 +43,10 @@ import { InfoBanner } from "@/components/hexa/info-banner"
|
|||||||
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
import { StatusBadge } from "@/components/hexa/status-badge"
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
|
||||||
const BOOKING_MODE_OPTIONS: { label: string; value: FacilityInput["bookingMode"] }[] = [
|
|
||||||
{ label: "Scheduled (classes — shared capacity per session)", value: "Scheduled" },
|
|
||||||
{ label: "Ad-hoc (courts/pool — independently bookable units)", value: "AdHoc" },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface FacilityFormValues {
|
interface FacilityFormValues {
|
||||||
name: string
|
name: string
|
||||||
bookingMode: FacilityInput["bookingMode"]
|
supportsClasses: boolean
|
||||||
|
supportsBooking: boolean
|
||||||
description: string
|
description: string
|
||||||
defaultCapacity: string
|
defaultCapacity: string
|
||||||
numberOfUnits: string
|
numberOfUnits: string
|
||||||
@@ -58,7 +55,8 @@ interface FacilityFormValues {
|
|||||||
|
|
||||||
const EMPTY_VALUES: FacilityFormValues = {
|
const EMPTY_VALUES: FacilityFormValues = {
|
||||||
name: "",
|
name: "",
|
||||||
bookingMode: "Scheduled",
|
supportsClasses: true,
|
||||||
|
supportsBooking: false,
|
||||||
description: "",
|
description: "",
|
||||||
defaultCapacity: "1",
|
defaultCapacity: "1",
|
||||||
numberOfUnits: "1",
|
numberOfUnits: "1",
|
||||||
@@ -87,9 +85,15 @@ function FacilityDialog({ open, onOpenChange, onSubmit, mode, initialValues }: F
|
|||||||
setValues((v) => ({ ...v, [key]: value }))
|
setValues((v) => ({ ...v, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdhoc = values.bookingMode === "AdHoc"
|
const unitsValid =
|
||||||
const unitsValid = isAdhoc ? Number(values.numberOfUnits) >= 1 : Number(values.defaultCapacity) >= 1
|
(!values.supportsClasses || Number(values.defaultCapacity) >= 1) &&
|
||||||
const canSubmit = !!values.name.trim() && unitsValid && Number(values.pricePerHour) >= 0 && !busy
|
(!values.supportsBooking || Number(values.numberOfUnits) >= 1)
|
||||||
|
const canSubmit =
|
||||||
|
!!values.name.trim() &&
|
||||||
|
(values.supportsClasses || values.supportsBooking) &&
|
||||||
|
unitsValid &&
|
||||||
|
Number(values.pricePerHour) >= 0 &&
|
||||||
|
!busy
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -128,41 +132,30 @@ function FacilityDialog({ open, onOpenChange, onSubmit, mode, initialValues }: F
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Booking Mode</Label>
|
<Label>
|
||||||
<Select
|
Supports <span className="text-destructive">*</span>
|
||||||
items={BOOKING_MODE_OPTIONS}
|
</Label>
|
||||||
value={values.bookingMode}
|
<label className="flex items-center gap-2 text-sm">
|
||||||
onValueChange={(v) => set("bookingMode", (v as FacilityInput["bookingMode"]) ?? "Scheduled")}
|
<Checkbox
|
||||||
>
|
checked={values.supportsClasses}
|
||||||
<SelectTrigger className="w-full">
|
onCheckedChange={(v) => set("supportsClasses", v === true)}
|
||||||
<SelectValue placeholder="Select booking mode" />
|
/>
|
||||||
</SelectTrigger>
|
Classes (scheduled sessions — shared capacity)
|
||||||
<SelectContent>
|
</label>
|
||||||
{BOOKING_MODE_OPTIONS.map((opt) => (
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<Checkbox
|
||||||
{opt.label}
|
checked={values.supportsBooking}
|
||||||
</SelectItem>
|
onCheckedChange={(v) => set("supportsBooking", v === true)}
|
||||||
))}
|
/>
|
||||||
</SelectContent>
|
Walk-in booking (courts/pool — independently bookable units)
|
||||||
</Select>
|
</label>
|
||||||
|
{!values.supportsClasses && !values.supportsBooking && (
|
||||||
|
<p className="text-xs text-destructive">At least one must be checked.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{isAdhoc ? (
|
{values.supportsClasses && (
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="facility-units">
|
|
||||||
Number of Units <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="facility-units"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={values.numberOfUnits}
|
|
||||||
onChange={(e) => set("numberOfUnits", e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="facility-capacity">
|
<Label htmlFor="facility-capacity">
|
||||||
Default Capacity <span className="text-destructive">*</span>
|
Default Capacity <span className="text-destructive">*</span>
|
||||||
@@ -177,6 +170,21 @@ function FacilityDialog({ open, onOpenChange, onSubmit, mode, initialValues }: F
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{values.supportsBooking && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="facility-units">
|
||||||
|
Number of Units <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="facility-units"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={values.numberOfUnits}
|
||||||
|
onChange={(e) => set("numberOfUnits", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="facility-price">
|
<Label htmlFor="facility-price">
|
||||||
Price / Hour (Rs.) <span className="text-destructive">*</span>
|
Price / Hour (Rs.) <span className="text-destructive">*</span>
|
||||||
@@ -234,7 +242,8 @@ function FacilitiesView() {
|
|||||||
async function handleCreate(values: FacilityFormValues) {
|
async function handleCreate(values: FacilityFormValues) {
|
||||||
const input: FacilityInput = {
|
const input: FacilityInput = {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
bookingMode: values.bookingMode,
|
supportsClasses: values.supportsClasses,
|
||||||
|
supportsBooking: values.supportsBooking,
|
||||||
description: values.description.trim() || undefined,
|
description: values.description.trim() || undefined,
|
||||||
defaultCapacity: Number(values.defaultCapacity) || 1,
|
defaultCapacity: Number(values.defaultCapacity) || 1,
|
||||||
numberOfUnits: Number(values.numberOfUnits) || 1,
|
numberOfUnits: Number(values.numberOfUnits) || 1,
|
||||||
@@ -249,7 +258,8 @@ function FacilitiesView() {
|
|||||||
if (!editTarget) return
|
if (!editTarget) return
|
||||||
await updateFacility(editTarget.facilityId, {
|
await updateFacility(editTarget.facilityId, {
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
bookingMode: values.bookingMode,
|
supportsClasses: values.supportsClasses,
|
||||||
|
supportsBooking: values.supportsBooking,
|
||||||
description: values.description.trim() || undefined,
|
description: values.description.trim() || undefined,
|
||||||
defaultCapacity: Number(values.defaultCapacity) || 1,
|
defaultCapacity: Number(values.defaultCapacity) || 1,
|
||||||
numberOfUnits: Number(values.numberOfUnits) || 1,
|
numberOfUnits: Number(values.numberOfUnits) || 1,
|
||||||
@@ -313,10 +323,18 @@ function FacilitiesView() {
|
|||||||
key: "mode",
|
key: "mode",
|
||||||
header: "Mode",
|
header: "Mode",
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-1">
|
||||||
<Badge variant={row.bookingMode === "AdHoc" ? "default" : "outline"}>{row.bookingMode}</Badge>
|
<div className="flex gap-1">
|
||||||
|
{row.supportsClasses && <Badge variant="outline">Classes</Badge>}
|
||||||
|
{row.supportsBooking && <Badge variant="default">Walk-in</Badge>}
|
||||||
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{row.bookingMode === "AdHoc" ? `${row.numberOfUnits} units` : `Capacity ${row.defaultCapacity}`}
|
{[
|
||||||
|
row.supportsClasses ? `Capacity ${row.defaultCapacity}` : null,
|
||||||
|
row.supportsBooking ? `${row.numberOfUnits} units` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -445,7 +463,10 @@ function FacilitiesView() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{row.name}</p>
|
<p className="font-medium">{row.name}</p>
|
||||||
<Badge variant={row.bookingMode === "AdHoc" ? "default" : "outline"}>{row.bookingMode}</Badge>
|
<div className="flex gap-1">
|
||||||
|
{row.supportsClasses && <Badge variant="outline">Classes</Badge>}
|
||||||
|
{row.supportsBooking && <Badge variant="default">Walk-in</Badge>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
||||||
@@ -454,7 +475,12 @@ function FacilitiesView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-t pt-2.5">
|
<div className="flex items-center justify-between border-t pt-2.5">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{row.bookingMode === "AdHoc" ? `${row.numberOfUnits} units` : `Capacity ${row.defaultCapacity}`} · Rs. {row.pricePerHour.toLocaleString()}/hr
|
{[
|
||||||
|
row.supportsClasses ? `Capacity ${row.defaultCapacity}` : null,
|
||||||
|
row.supportsBooking ? `${row.numberOfUnits} units` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")} · Rs. {row.pricePerHour.toLocaleString()}/hr
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => startEdit(row)}>
|
<Button variant="outline" size="icon-sm" aria-label="Edit facility" onClick={() => startEdit(row)}>
|
||||||
@@ -495,7 +521,8 @@ function FacilitiesView() {
|
|||||||
dialogMode === "edit" && editTarget
|
dialogMode === "edit" && editTarget
|
||||||
? {
|
? {
|
||||||
name: editTarget.name,
|
name: editTarget.name,
|
||||||
bookingMode: editTarget.bookingMode,
|
supportsClasses: editTarget.supportsClasses,
|
||||||
|
supportsBooking: editTarget.supportsBooking,
|
||||||
description: editTarget.description ?? "",
|
description: editTarget.description ?? "",
|
||||||
defaultCapacity: String(editTarget.defaultCapacity),
|
defaultCapacity: String(editTarget.defaultCapacity),
|
||||||
numberOfUnits: String(editTarget.numberOfUnits),
|
numberOfUnits: String(editTarget.numberOfUnits),
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CalendarClockIcon, DollarSignIcon, EyeIcon, PackageIcon, PlusIcon, TrashIcon, TruckIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { createGrn, getGrn, getGrns, getProducts, getSuppliers } from "@/lib/api/inventory"
|
||||||
|
import type { CreateGrnInput, GoodsReceivedNote, GrnItemInput } from "@/lib/models/inventory"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
|
import { useMutation } from "@/lib/use-mutation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrnLine {
|
||||||
|
productId: string
|
||||||
|
quantity: string
|
||||||
|
unitCost: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateGrnDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateGrnDialog({ open, onOpenChange, onCreated }: CreateGrnDialogProps) {
|
||||||
|
const suppliersFetch = useFetch(() => getSuppliers(false))
|
||||||
|
const suppliers = suppliersFetch.data ?? []
|
||||||
|
const productsFetch = useFetch(() => getProducts(false))
|
||||||
|
const products = productsFetch.data ?? []
|
||||||
|
|
||||||
|
const [supplierId, setSupplierId] = React.useState("")
|
||||||
|
const [notes, setNotes] = React.useState("")
|
||||||
|
const [lines, setLines] = React.useState<GrnLine[]>([{ productId: "", quantity: "1", unitCost: "0" }])
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||||
|
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open)
|
||||||
|
if (open) {
|
||||||
|
setSupplierId("")
|
||||||
|
setNotes("")
|
||||||
|
setLines([{ productId: "", quantity: "1", unitCost: "0" }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(idx: number, patch: Partial<GrnLine>) {
|
||||||
|
setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, { productId: "", quantity: "1", unitCost: "0" }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(idx: number) {
|
||||||
|
setLines((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = lines.reduce((sum, l) => sum + (Number(l.quantity) || 0) * (Number(l.unitCost) || 0), 0)
|
||||||
|
const linesValid = lines.length > 0 && lines.every((l) => !!l.productId && Number(l.quantity) > 0 && Number(l.unitCost) >= 0)
|
||||||
|
const canSubmit = linesValid && !busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
const input: CreateGrnInput = {
|
||||||
|
supplierId: supplierId || undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
items: lines.map<GrnItemInput>((l) => ({
|
||||||
|
productId: l.productId,
|
||||||
|
quantity: Number(l.quantity),
|
||||||
|
unitCost: Number(l.unitCost),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await run(async () => {
|
||||||
|
await createGrn(input)
|
||||||
|
toast.success("GRN recorded", { description: "Stock levels were updated." })
|
||||||
|
onCreated()
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Record Goods Received</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Stock coming in from a supplier — increases each product's quantity on hand.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Supplier</Label>
|
||||||
|
<Select
|
||||||
|
items={[{ label: "No supplier", value: "none" }, ...suppliers.map((s) => ({ label: s.name, value: s.supplierId }))]}
|
||||||
|
value={supplierId || "none"}
|
||||||
|
onValueChange={(v) => setSupplierId(v === "none" ? "" : v ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={suppliersFetch.loading ? "Loading…" : "No supplier"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No supplier</SelectItem>
|
||||||
|
{suppliers.map((s) => (
|
||||||
|
<SelectItem key={s.supplierId} value={s.supplierId}>
|
||||||
|
{s.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>
|
||||||
|
Items <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Product</span>
|
||||||
|
<span>Quantity</span>
|
||||||
|
<span>Unit Cost (Rs.)</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{lines.map((line, idx) => (
|
||||||
|
<div key={idx} className="grid grid-cols-[2fr_1fr_1fr_auto] items-center gap-2">
|
||||||
|
<Select
|
||||||
|
items={products.map((p) => ({ label: p.name, value: p.productId }))}
|
||||||
|
value={line.productId}
|
||||||
|
onValueChange={(v) => updateLine(idx, { productId: v ?? "" })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a product" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.productId} value={p.productId}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step="0.01"
|
||||||
|
aria-label="Quantity"
|
||||||
|
placeholder="Qty"
|
||||||
|
value={line.quantity}
|
||||||
|
onChange={(e) => updateLine(idx, { quantity: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
aria-label="Unit cost"
|
||||||
|
placeholder="Unit cost"
|
||||||
|
value={line.unitCost}
|
||||||
|
onChange={(e) => updateLine(idx, { unitCost: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Remove line"
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
onClick={() => removeLine(idx)}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" size="sm" className="self-start" onClick={addLine}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/40 p-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">Total</span>
|
||||||
|
<span className="font-semibold">{formatCurrency(total)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="grn-notes">Notes</Label>
|
||||||
|
<Textarea id="grn-notes" maxLength={300} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="-mx-4 -mt-4 -mb-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Record GRN
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GrnDetailDialogProps {
|
||||||
|
grnId: string | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GrnDetailDialog({ grnId, onOpenChange }: GrnDetailDialogProps) {
|
||||||
|
const detailFetch = useFetch(() => (grnId ? getGrn(grnId) : Promise.resolve(null)))
|
||||||
|
const grn = detailFetch.data
|
||||||
|
|
||||||
|
// This dialog stays mounted across opens (only `grnId` changes) -- useFetch only runs its
|
||||||
|
// fetcher on mount and on an explicit reload(), so a fresh id needs an explicit reload too.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (grnId) detailFetch.reload()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
|
||||||
|
}, [grnId, detailFetch.reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!grnId} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>GRN Details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailFetch.loading || !grn ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner className="size-6" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">GRN Number</p>
|
||||||
|
<p className="font-medium">{grn.grnNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Supplier</p>
|
||||||
|
<p className="font-medium">{grn.supplierName || "No supplier"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Received</p>
|
||||||
|
<p className="font-medium">{formatDate(grn.receivedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Total</p>
|
||||||
|
<p className="font-medium">{formatCurrency(grn.totalAmount)}</p>
|
||||||
|
</div>
|
||||||
|
{grn.notes && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">Notes</p>
|
||||||
|
<p className="font-medium">{grn.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Items</p>
|
||||||
|
<div className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{grn.items.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between gap-3 p-2.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.productName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{item.quantity} × {formatCurrency(item.unitCost)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{formatCurrency(item.lineTotal)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryGrnTab() {
|
||||||
|
const grnsFetch = useFetch(() => getGrns())
|
||||||
|
const grns = grnsFetch.data ?? []
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
const [detailGrnId, setDetailGrnId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const totalValue = grns.reduce((sum, g) => sum + g.totalAmount, 0)
|
||||||
|
const now = new Date()
|
||||||
|
const thisMonthGrns = grns.filter((g) => {
|
||||||
|
const d = new Date(g.receivedAt)
|
||||||
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
|
||||||
|
})
|
||||||
|
const thisMonthValue = thisMonthGrns.reduce((sum, g) => sum + g.totalAmount, 0)
|
||||||
|
const avgValue = grns.length ? totalValue / grns.length : 0
|
||||||
|
|
||||||
|
const columns: DataTableColumn<GoodsReceivedNote>[] = [
|
||||||
|
{
|
||||||
|
key: "grnNumber",
|
||||||
|
header: "GRN",
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<TruckIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.grnNumber}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.supplierName || "No supplier"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "received", header: "Received", cell: (row) => formatDate(row.receivedAt) },
|
||||||
|
{ key: "items", header: "Items", cell: (row) => row.items.length },
|
||||||
|
{ key: "total", header: "Total", cell: (row) => formatCurrency(row.totalAmount) },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "text-center",
|
||||||
|
cell: (row) => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="View details" onClick={() => setDetailGrnId(row.grnId)}>
|
||||||
|
<EyeIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>View details</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (grnsFetch.loading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Goods Received (GRN)</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Stock coming in from a supplier.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<PlusIcon />
|
||||||
|
Record GRN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={PackageIcon} tone="blue" label="Total GRNs" value={grns.length} />
|
||||||
|
<StatCard icon={DollarSignIcon} tone="purple" label="Total Received Value" value={formatCurrency(totalValue)} />
|
||||||
|
<StatCard icon={CalendarClockIcon} tone="amber" label="This Month" value={formatCurrency(thisMonthValue)} hint={`${thisMonthGrns.length} GRNs`} />
|
||||||
|
<StatCard icon={TruckIcon} tone="green" label="Avg. GRN Value" value={formatCurrency(avgValue)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={grns}
|
||||||
|
rowKey={(row) => row.grnId}
|
||||||
|
emptyMessage="No goods received yet."
|
||||||
|
onRowClick={(row) => setDetailGrnId(row.grnId)}
|
||||||
|
renderMobileCard={(row) => (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<TruckIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.grnNumber}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.supplierName || "No supplier"} · {formatDate(row.receivedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t pt-2.5">
|
||||||
|
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
|
||||||
|
<span className="font-medium">{formatCurrency(row.totalAmount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateGrnDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={grnsFetch.reload} />
|
||||||
|
<GrnDetailDialog grnId={detailGrnId} onOpenChange={() => setDetailGrnId(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InventoryGrnTab }
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
CheckCircle2Icon,
|
||||||
|
CheckIcon,
|
||||||
|
DollarSignIcon,
|
||||||
|
EyeIcon,
|
||||||
|
ImagePlusIcon,
|
||||||
|
PackageIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { createProduct, getProduct, getProducts, updateProduct, uploadProductImage } from "@/lib/api/inventory"
|
||||||
|
import type { Product, ProductDetail, ProductInput } from "@/lib/models/inventory"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
|
import { useMutation, runMutation } from "@/lib/use-mutation"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
|
||||||
|
interface ProductFormValues {
|
||||||
|
name: string
|
||||||
|
unitOfMeasure: string
|
||||||
|
sellingPrice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_VALUES: ProductFormValues = {
|
||||||
|
name: "",
|
||||||
|
unitOfMeasure: "pcs",
|
||||||
|
sellingPrice: "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (values: ProductFormValues, imageFile: File | null) => Promise<void> | void
|
||||||
|
mode: "create" | "edit"
|
||||||
|
initialValues?: Partial<ProductFormValues>
|
||||||
|
/** The product's current photo (edit mode only) -- shown as the starting preview. */
|
||||||
|
currentImageUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductDialog({ open, onOpenChange, onSubmit, mode, initialValues, currentImageUrl }: ProductDialogProps) {
|
||||||
|
const [values, setValues] = React.useState<ProductFormValues>(EMPTY_VALUES)
|
||||||
|
const [imageFile, setImageFile] = React.useState<File | null>(null)
|
||||||
|
const [imagePreview, setImagePreview] = React.useState<string | null>(null)
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||||
|
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open)
|
||||||
|
if (open) {
|
||||||
|
setValues({ ...EMPTY_VALUES, ...initialValues })
|
||||||
|
setImageFile(null)
|
||||||
|
setImagePreview(currentImageUrl ?? null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set<K extends keyof ProductFormValues>(key: K, value: ProductFormValues[K]) {
|
||||||
|
setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setImageFile(file)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => setImagePreview(reader.result as string)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = !!values.name.trim() && !!values.unitOfMeasure.trim() && Number(values.sellingPrice) >= 0 && !busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
try {
|
||||||
|
await run(async () => { await onSubmit(values, imageFile) })
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "create" ? "Add Product" : "Edit Product"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
A stocked item sold at the gym. SKU, cost price and quantity on hand are all
|
||||||
|
maintained automatically — SKU is assigned on creation, the other two from GRNs,
|
||||||
|
sales and returns.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Photo</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted"
|
||||||
|
aria-label="Choose product photo"
|
||||||
|
>
|
||||||
|
{imagePreview ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- a locally-chosen file / already-fetched data URL, not a Next-optimizable remote image
|
||||||
|
<img src={imagePreview} alt="" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<ImagePlusIcon className="size-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||||
|
{imagePreview ? "Change photo" : "Upload photo"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">JPG, PNG or WEBP, up to 5MB.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="product-name">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="product-name"
|
||||||
|
placeholder="e.g. Protein Bar"
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) => set("name", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="product-unit">
|
||||||
|
Unit <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="product-unit"
|
||||||
|
placeholder="pcs"
|
||||||
|
value={values.unitOfMeasure}
|
||||||
|
onChange={(e) => set("unitOfMeasure", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="product-price">
|
||||||
|
Selling Price (Rs.) <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="product-price"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={values.sellingPrice}
|
||||||
|
onChange={(e) => set("sellingPrice", e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
{mode === "create" ? "Add Product" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVEMENT_LABEL: Record<ProductDetail["movements"][number]["type"], string> = {
|
||||||
|
GRN: "Stock In (GRN)",
|
||||||
|
Sale: "Sale",
|
||||||
|
Return: "Return / Write-off",
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductDetailDialogProps {
|
||||||
|
productId: string | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Product detail page -- full record plus its stock-movement history (every GRN, sale and
|
||||||
|
* return that touched this product), fetched fresh per open via GET .../products/{id}. */
|
||||||
|
function ProductDetailDialog({ productId, onOpenChange }: ProductDetailDialogProps) {
|
||||||
|
const detailFetch = useFetch(() => (productId ? getProduct(productId) : Promise.resolve(null)))
|
||||||
|
const detail = detailFetch.data
|
||||||
|
|
||||||
|
// This dialog stays mounted across opens (only `productId` changes) -- useFetch only runs
|
||||||
|
// its fetcher on mount and on an explicit reload(), so a fresh id by itself wouldn't hit the
|
||||||
|
// API again without this.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (productId) detailFetch.reload()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
|
||||||
|
}, [productId, detailFetch.reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!productId} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Product Details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailFetch.loading || !detail ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner className="size-6" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-muted">
|
||||||
|
{detail.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- a base64 data URL from our own API, not a Next-optimizable remote image
|
||||||
|
<img src={detail.imageUrl} alt={detail.name} className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<PackageIcon className="size-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{detail.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{detail.sku || "No SKU"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Name</p>
|
||||||
|
<p className="font-medium">{detail.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">SKU</p>
|
||||||
|
<p className="font-medium">{detail.sku || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Unit</p>
|
||||||
|
<p className="font-medium">{detail.unitOfMeasure}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Quantity On Hand</p>
|
||||||
|
<p className="font-medium">{detail.quantityOnHand}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Cost Price</p>
|
||||||
|
<p className="font-medium">{formatCurrency(detail.costPrice)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Selling Price</p>
|
||||||
|
<p className="font-medium">{formatCurrency(detail.sellingPrice)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Stock Movements</p>
|
||||||
|
{detail.movements.length === 0 ? (
|
||||||
|
<p className="rounded-lg border p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No GRNs, sales or returns yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{detail.movements.map((m, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between gap-3 p-2.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{MOVEMENT_LABEL[m.type]}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{m.reference} · {formatDate(m.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={m.quantityChange >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||||
|
{m.quantityChange >= 0 ? "+" : ""}{m.quantityChange}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryProductsTab() {
|
||||||
|
const productsFetch = useFetch(() => getProducts(true))
|
||||||
|
const products = productsFetch.data ?? []
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
const [dialogMode, setDialogMode] = React.useState<"create" | "edit">("create")
|
||||||
|
const [editTarget, setEditTarget] = React.useState<Product | null>(null)
|
||||||
|
const [statusPendingId, setStatusPendingId] = React.useState<string | null>(null)
|
||||||
|
const [detailProductId, setDetailProductId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const activeProducts = products.filter((p) => p.isActive).length
|
||||||
|
const outOfStock = products.filter((p) => p.quantityOnHand <= 0).length
|
||||||
|
const inventoryValue = products.reduce((sum, p) => sum + p.costPrice * p.quantityOnHand, 0)
|
||||||
|
|
||||||
|
async function handleCreate(values: ProductFormValues, imageFile: File | null) {
|
||||||
|
const input: ProductInput = {
|
||||||
|
name: values.name.trim(),
|
||||||
|
unitOfMeasure: values.unitOfMeasure.trim(),
|
||||||
|
sellingPrice: Number(values.sellingPrice) || 0,
|
||||||
|
}
|
||||||
|
const created = await createProduct(input)
|
||||||
|
// Image is optional -- a product can be created with no photo and one added later via edit.
|
||||||
|
if (imageFile) await uploadProductImage(created.productId, imageFile)
|
||||||
|
toast.success("Product created", { description: `"${input.name}" was added.` })
|
||||||
|
productsFetch.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(values: ProductFormValues, imageFile: File | null) {
|
||||||
|
if (!editTarget) return
|
||||||
|
await updateProduct(editTarget.productId, {
|
||||||
|
name: values.name.trim(),
|
||||||
|
unitOfMeasure: values.unitOfMeasure.trim(),
|
||||||
|
sellingPrice: Number(values.sellingPrice) || 0,
|
||||||
|
})
|
||||||
|
if (imageFile) await uploadProductImage(editTarget.productId, imageFile)
|
||||||
|
toast.success("Product updated")
|
||||||
|
productsFetch.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(product: Product) {
|
||||||
|
setDialogMode("edit")
|
||||||
|
setEditTarget(product)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(product: Product) {
|
||||||
|
setStatusPendingId(product.productId)
|
||||||
|
try {
|
||||||
|
await runMutation(() => updateProduct(product.productId, { isActive: !product.isActive }))
|
||||||
|
toast.success(product.isActive ? "Product deactivated" : "Product activated")
|
||||||
|
productsFetch.reload()
|
||||||
|
} catch {
|
||||||
|
// runMutation() already toasted the real error.
|
||||||
|
} finally {
|
||||||
|
setStatusPendingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (productsFetch.loading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Products</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Stocked items sold at the gym — snacks, supplements, merchandise.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setDialogMode("create")
|
||||||
|
setEditTarget(null)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add Product
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={PackageIcon} tone="blue" label="Total Products" value={products.length} />
|
||||||
|
<StatCard icon={CheckCircle2Icon} tone="green" label="Active" value={activeProducts} />
|
||||||
|
<StatCard icon={TriangleAlertIcon} tone="amber" label="Out of Stock" value={outOfStock} />
|
||||||
|
<StatCard icon={DollarSignIcon} tone="purple" label="Inventory Value" value={formatCurrency(inventoryValue)} hint="At cost price" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border bg-card py-16 text-center">
|
||||||
|
<PackageIcon className="size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">No products yet. Add one to start receiving stock.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{products.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.productId}
|
||||||
|
className="flex flex-col overflow-hidden rounded-xl border bg-card transition-colors hover:border-primary/40"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative aspect-square w-full bg-muted"
|
||||||
|
onClick={() => setDetailProductId(row.productId)}
|
||||||
|
aria-label={`View ${row.name} details`}
|
||||||
|
>
|
||||||
|
{row.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- a base64 data URL from our own API, not a Next-optimizable remote image
|
||||||
|
<img src={row.imageUrl} alt={row.name} className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full items-center justify-center">
|
||||||
|
<PackageIcon className="size-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<StatusBadge status={row.isActive ? "success" : "neutral"} className="absolute top-2 right-2">
|
||||||
|
{row.isActive ? "Active" : "Inactive"}
|
||||||
|
</StatusBadge>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||||
|
<div className="min-w-0 cursor-pointer" onClick={() => setDetailProductId(row.productId)}>
|
||||||
|
<p className="truncate font-medium">{row.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{row.sku || "No SKU"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">{formatCurrency(row.sellingPrice)}</span>
|
||||||
|
<Badge variant={row.quantityOnHand <= 0 ? "destructive" : "outline"}>
|
||||||
|
{row.quantityOnHand} {row.unitOfMeasure}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-end gap-1.5 border-t pt-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="View details" onClick={() => setDetailProductId(row.productId)}>
|
||||||
|
<EyeIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>View details</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="Edit product" onClick={() => startEdit(row)}>
|
||||||
|
<PencilIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Edit product</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={row.isActive ? "Deactivate product" : "Activate product"}
|
||||||
|
disabled={statusPendingId === row.productId}
|
||||||
|
onClick={() => toggleStatus(row)}
|
||||||
|
>
|
||||||
|
{statusPendingId === row.productId ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : row.isActive ? (
|
||||||
|
<XIcon className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<CheckIcon className="text-emerald-600 dark:text-emerald-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>{row.isActive ? "Deactivate product" : "Activate product"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InfoBanner>
|
||||||
|
Product photos are optional — a product can be created without one and a photo can be
|
||||||
|
added or changed later from Edit.
|
||||||
|
</InfoBanner>
|
||||||
|
|
||||||
|
<ProductDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
onSubmit={dialogMode === "create" ? handleCreate : handleEdit}
|
||||||
|
mode={dialogMode}
|
||||||
|
initialValues={
|
||||||
|
dialogMode === "edit" && editTarget
|
||||||
|
? {
|
||||||
|
name: editTarget.name,
|
||||||
|
unitOfMeasure: editTarget.unitOfMeasure,
|
||||||
|
sellingPrice: String(editTarget.sellingPrice),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
currentImageUrl={dialogMode === "edit" ? editTarget?.imageUrl : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProductDetailDialog productId={detailProductId} onOpenChange={() => setDetailProductId(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InventoryProductsTab }
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CalendarClockIcon, EyeIcon, PackagePlusIcon, PlusIcon, RotateCcwIcon, TriangleAlertIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { createStockReturn, getGrns, getProducts, getSales, getStockReturn, getStockReturns } from "@/lib/api/inventory"
|
||||||
|
import type { CreateStockReturnInput, ReturnCategory, StockReturn } from "@/lib/models/inventory"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
|
import { useMutation } from "@/lib/use-mutation"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
|
const RETURN_CATEGORY_OPTIONS: { label: string; value: ReturnCategory }[] = [
|
||||||
|
{ label: "Expired", value: "Expired" },
|
||||||
|
{ label: "Damaged", value: "Damaged" },
|
||||||
|
{ label: "Lost", value: "Lost" },
|
||||||
|
{ label: "Sent back to supplier", value: "SupplierReturn" },
|
||||||
|
{ label: "Handed back by customer", value: "CustomerReturn" },
|
||||||
|
{ label: "Other", value: "Other" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function categoryLabel(category: ReturnCategory) {
|
||||||
|
return RETURN_CATEGORY_OPTIONS.find((o) => o.value === category)?.label ?? category
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only "CustomerReturn" adds the item back to stock -- it physically comes back after an
|
||||||
|
* earlier sale already removed it. Every other category removes from stock currently held. */
|
||||||
|
function addsBackToStock(category: ReturnCategory) {
|
||||||
|
return category === "CustomerReturn"
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateReturnDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateReturnDialog({ open, onOpenChange, onCreated }: CreateReturnDialogProps) {
|
||||||
|
const productsFetch = useFetch(() => getProducts(false))
|
||||||
|
const products = productsFetch.data ?? []
|
||||||
|
const salesFetch = useFetch(() => getSales())
|
||||||
|
const sales = salesFetch.data ?? []
|
||||||
|
const grnsFetch = useFetch(() => getGrns())
|
||||||
|
const grns = grnsFetch.data ?? []
|
||||||
|
|
||||||
|
const [productId, setProductId] = React.useState("")
|
||||||
|
const [quantity, setQuantity] = React.useState("1")
|
||||||
|
const [category, setCategory] = React.useState<ReturnCategory>("Damaged")
|
||||||
|
const [linkType, setLinkType] = React.useState<"none" | "sale" | "grn">("none")
|
||||||
|
const [saleId, setSaleId] = React.useState("")
|
||||||
|
const [grnId, setGrnId] = React.useState("")
|
||||||
|
const [notes, setNotes] = React.useState("")
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||||
|
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open)
|
||||||
|
if (open) {
|
||||||
|
setProductId("")
|
||||||
|
setQuantity("1")
|
||||||
|
setCategory("Damaged")
|
||||||
|
setLinkType("none")
|
||||||
|
setSaleId("")
|
||||||
|
setGrnId("")
|
||||||
|
setNotes("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedProduct = products.find((p) => p.productId === productId)
|
||||||
|
const increasesStock = addsBackToStock(category)
|
||||||
|
|
||||||
|
// Only sales/GRNs that actually contain the selected product are worth offering -- most
|
||||||
|
// returns (e.g. damaged shelf stock) trace back to neither, hence "None" in both.
|
||||||
|
const matchingSales = productId ? sales.filter((s) => s.items.some((i) => i.productId === productId)) : []
|
||||||
|
const matchingGrns = productId ? grns.filter((g) => g.items.some((i) => i.productId === productId)) : []
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
!!productId &&
|
||||||
|
Number(quantity) > 0 &&
|
||||||
|
!busy &&
|
||||||
|
(linkType === "none" || (linkType === "sale" && !!saleId) || (linkType === "grn" && !!grnId))
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
const input: CreateStockReturnInput = {
|
||||||
|
productId,
|
||||||
|
quantity: Number(quantity),
|
||||||
|
category,
|
||||||
|
saleId: saleId || undefined,
|
||||||
|
grnId: grnId || undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await run(async () => {
|
||||||
|
await createStockReturn(input)
|
||||||
|
toast.success("Return recorded", { description: "Stock levels were updated." })
|
||||||
|
onCreated()
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Record Return / Write-off</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Only a customer return adds the item back to stock — every other category removes
|
||||||
|
from stock you currently hold.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
Category <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select items={RETURN_CATEGORY_OPTIONS} value={category} onValueChange={(v) => setCategory((v as ReturnCategory) ?? "Other")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RETURN_CATEGORY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
Product <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
items={products.map((p) => ({ label: p.name, value: p.productId }))}
|
||||||
|
value={productId}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setProductId(v ?? "")
|
||||||
|
setLinkType("none")
|
||||||
|
setSaleId("")
|
||||||
|
setGrnId("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={productsFetch.loading ? "Loading…" : "Select a product"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.productId} value={p.productId} disabled={!increasesStock && p.quantityOnHand <= 0}>
|
||||||
|
{p.name} ({p.quantityOnHand} in stock)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{increasesStock && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This adds {quantity || 0} unit(s) back to stock — available even if current stock is 0.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="return-quantity">
|
||||||
|
Quantity <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="return-quantity"
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step="0.01"
|
||||||
|
max={increasesStock ? undefined : selectedProduct?.quantityOnHand}
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Linked to</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={linkType}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const next = (v as "none" | "sale" | "grn") ?? "none"
|
||||||
|
setLinkType(next)
|
||||||
|
if (next !== "sale") setSaleId("")
|
||||||
|
if (next !== "grn") setGrnId("")
|
||||||
|
}}
|
||||||
|
className="grid-cols-3"
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<RadioGroupItem value="none" /> None
|
||||||
|
</label>
|
||||||
|
<label className={cn("flex items-center gap-2 text-sm", !productId && "opacity-50")}>
|
||||||
|
<RadioGroupItem value="sale" disabled={!productId} /> A Sale
|
||||||
|
</label>
|
||||||
|
<label className={cn("flex items-center gap-2 text-sm", !productId && "opacity-50")}>
|
||||||
|
<RadioGroupItem value="grn" disabled={!productId} /> A GRN
|
||||||
|
</label>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{linkType === "sale" && (
|
||||||
|
<Select items={matchingSales.map((s) => ({ label: s.saleNumber, value: s.saleId }))} value={saleId} onValueChange={(v) => setSaleId(v ?? "")}>
|
||||||
|
<SelectTrigger className="mt-1 w-full">
|
||||||
|
<SelectValue placeholder={matchingSales.length ? "Select a sale" : "No sales found for this product"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{matchingSales.map((s) => (
|
||||||
|
<SelectItem key={s.saleId} value={s.saleId}>
|
||||||
|
{s.saleNumber}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{linkType === "grn" && (
|
||||||
|
<Select items={matchingGrns.map((g) => ({ label: g.grnNumber, value: g.grnId }))} value={grnId} onValueChange={(v) => setGrnId(v ?? "")}>
|
||||||
|
<SelectTrigger className="mt-1 w-full">
|
||||||
|
<SelectValue placeholder={matchingGrns.length ? "Select a GRN" : "No GRNs found for this product"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{matchingGrns.map((g) => (
|
||||||
|
<SelectItem key={g.grnId} value={g.grnId}>
|
||||||
|
{g.grnNumber}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional — most returns (e.g. damaged shelf stock) don't trace back to a
|
||||||
|
specific sale or GRN. Only ones containing this product are listed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="return-notes">Notes</Label>
|
||||||
|
<Textarea id="return-notes" maxLength={300} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Record Return
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReturnDetailDialogProps {
|
||||||
|
stockReturnId: string | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReturnDetailDialog({ stockReturnId, onOpenChange }: ReturnDetailDialogProps) {
|
||||||
|
const detailFetch = useFetch(() => (stockReturnId ? getStockReturn(stockReturnId) : Promise.resolve(null)))
|
||||||
|
const stockReturn = detailFetch.data
|
||||||
|
|
||||||
|
// This dialog stays mounted across opens (only `stockReturnId` changes) -- useFetch only
|
||||||
|
// runs its fetcher on mount and on an explicit reload(), so a fresh id needs a reload too.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (stockReturnId) detailFetch.reload()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
|
||||||
|
}, [stockReturnId, detailFetch.reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!stockReturnId} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Return Details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailFetch.loading || !stockReturn ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner className="size-6" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Product</p>
|
||||||
|
<p className="font-medium">{stockReturn.productName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Stock Impact</p>
|
||||||
|
<p className={addsBackToStock(stockReturn.category) ? "font-medium text-emerald-600 dark:text-emerald-400" : "font-medium text-destructive"}>
|
||||||
|
{addsBackToStock(stockReturn.category) ? "+" : "-"}{stockReturn.quantity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Category</p>
|
||||||
|
<Badge variant="outline">{categoryLabel(stockReturn.category)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Returned</p>
|
||||||
|
<p className="font-medium">{formatDate(stockReturn.returnedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Related Sale</p>
|
||||||
|
<p className="font-medium">{stockReturn.saleNumber || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Related GRN</p>
|
||||||
|
<p className="font-medium">{stockReturn.grnNumber || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Amount (P&L impact)</p>
|
||||||
|
<p className="font-medium">{formatCurrency(stockReturn.amount)}</p>
|
||||||
|
</div>
|
||||||
|
{stockReturn.notes && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-muted-foreground">Notes</p>
|
||||||
|
<p className="font-medium">{stockReturn.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryReturnsTab() {
|
||||||
|
const returnsFetch = useFetch(() => getStockReturns())
|
||||||
|
const returns = returnsFetch.data ?? []
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
const [detailReturnId, setDetailReturnId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const writeOffs = returns.filter((r) => !addsBackToStock(r.category))
|
||||||
|
const customerReturns = returns.filter((r) => addsBackToStock(r.category))
|
||||||
|
const writeOffValue = writeOffs.reduce((sum, r) => sum + r.amount, 0)
|
||||||
|
const now = new Date()
|
||||||
|
const thisMonthWriteOffValue = writeOffs
|
||||||
|
.filter((r) => {
|
||||||
|
const d = new Date(r.returnedAt)
|
||||||
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
|
||||||
|
})
|
||||||
|
.reduce((sum, r) => sum + r.amount, 0)
|
||||||
|
|
||||||
|
const columns: DataTableColumn<StockReturn>[] = [
|
||||||
|
{
|
||||||
|
key: "product",
|
||||||
|
header: "Product",
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<RotateCcwIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">{row.productName}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quantity",
|
||||||
|
header: "Stock Impact",
|
||||||
|
cell: (row) => (
|
||||||
|
<span className={addsBackToStock(row.category) ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||||
|
{addsBackToStock(row.category) ? "+" : "-"}{row.quantity}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "category", header: "Category", cell: (row) => <Badge variant="outline">{categoryLabel(row.category)}</Badge> },
|
||||||
|
{ key: "reference", header: "Reference", cell: (row) => row.saleNumber || row.grnNumber || "—" },
|
||||||
|
{ key: "returned", header: "Date", cell: (row) => formatDate(row.returnedAt) },
|
||||||
|
{ key: "amount", header: "Amount", cell: (row) => formatCurrency(row.amount) },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "text-center",
|
||||||
|
cell: (row) => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="View details" onClick={() => setDetailReturnId(row.stockReturnId)}>
|
||||||
|
<EyeIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>View details</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (returnsFetch.loading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Returns</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Stock adjustments — expired, damaged, lost, sent back, or handed back by a customer.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<PlusIcon />
|
||||||
|
Record Return
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={RotateCcwIcon} tone="blue" label="Total Returns" value={returns.length} />
|
||||||
|
<StatCard icon={TriangleAlertIcon} tone="amber" label="Write-off Value" value={formatCurrency(writeOffValue)} />
|
||||||
|
<StatCard icon={PackagePlusIcon} tone="green" label="Customer Returns" value={customerReturns.length} hint="Added back to stock" />
|
||||||
|
<StatCard icon={CalendarClockIcon} tone="purple" label="This Month Write-offs" value={formatCurrency(thisMonthWriteOffValue)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={returns}
|
||||||
|
rowKey={(row) => row.stockReturnId}
|
||||||
|
emptyMessage="No returns recorded yet."
|
||||||
|
onRowClick={(row) => setDetailReturnId(row.stockReturnId)}
|
||||||
|
renderMobileCard={(row) => (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<RotateCcwIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.productName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className={addsBackToStock(row.category) ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||||
|
{addsBackToStock(row.category) ? "+" : "-"}{row.quantity}
|
||||||
|
</span>{" "}
|
||||||
|
· {formatDate(row.returnedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t pt-2.5">
|
||||||
|
<Badge variant="outline">{categoryLabel(row.category)}</Badge>
|
||||||
|
<span className="font-medium">{formatCurrency(row.amount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InfoBanner>
|
||||||
|
A customer return adds the item back to stock; every other category removes it. The
|
||||||
|
Amount shown is calculated automatically from the product's cost price and booked
|
||||||
|
as an expense in Profit & Loss.
|
||||||
|
</InfoBanner>
|
||||||
|
|
||||||
|
<CreateReturnDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={returnsFetch.reload} />
|
||||||
|
<ReturnDetailDialog stockReturnId={detailReturnId} onOpenChange={() => setDetailReturnId(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InventoryReturnsTab }
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CalendarClockIcon, DollarSignIcon, EyeIcon, PlusIcon, ReceiptIcon, TrashIcon, TrendingUpIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { createSale, getProducts, getSale, getSales } from "@/lib/api/inventory"
|
||||||
|
import { getMembers } from "@/lib/api/customers"
|
||||||
|
import type { CreateSaleInput, Sale, SaleItemInput } from "@/lib/models/inventory"
|
||||||
|
import type { PaymentMethod } from "@/lib/models/booking"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
|
import { useMutation } from "@/lib/use-mutation"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
|
const PAYMENT_METHOD_OPTIONS: { label: string; value: PaymentMethod }[] = [
|
||||||
|
{ label: "Cash", value: "Cash" },
|
||||||
|
{ label: "Bank", value: "Bank" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaleLine {
|
||||||
|
productId: string
|
||||||
|
quantity: string
|
||||||
|
unitPrice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateSaleDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateSaleDialog({ open, onOpenChange, onCreated }: CreateSaleDialogProps) {
|
||||||
|
const productsFetch = useFetch(() => getProducts(false))
|
||||||
|
const products = productsFetch.data ?? []
|
||||||
|
const membersFetch = useFetch(() => getMembers())
|
||||||
|
const members = (membersFetch.data ?? []).filter((m) => !!m.customer)
|
||||||
|
const memberOptions = [
|
||||||
|
{ label: "Walk-in (not registered)", value: "none" },
|
||||||
|
...members.map((m) => ({ label: m.customer!.cusName ?? m.customer!.primaryPH ?? "Member", value: m.fkCustomerId })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const [customerId, setCustomerId] = React.useState("")
|
||||||
|
const [customerName, setCustomerName] = React.useState("")
|
||||||
|
const [paymentMethod, setPaymentMethod] = React.useState<PaymentMethod>("Cash")
|
||||||
|
const [lines, setLines] = React.useState<SaleLine[]>([{ productId: "", quantity: "1", unitPrice: "0" }])
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||||
|
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open)
|
||||||
|
if (open) {
|
||||||
|
setCustomerId("")
|
||||||
|
setCustomerName("")
|
||||||
|
setPaymentMethod("Cash")
|
||||||
|
setLines([{ productId: "", quantity: "1", unitPrice: "0" }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMember(id: string) {
|
||||||
|
setCustomerId(id === "none" ? "" : id)
|
||||||
|
const member = id !== "none" ? members.find((m) => m.fkCustomerId === id) : undefined
|
||||||
|
if (member?.customer) setCustomerName(member.customer.cusName ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(idx: number, patch: Partial<SaleLine>) {
|
||||||
|
setLines((prev) =>
|
||||||
|
prev.map((l, i) => {
|
||||||
|
if (i !== idx) return l
|
||||||
|
const next = { ...l, ...patch }
|
||||||
|
if (patch.productId) {
|
||||||
|
const product = products.find((p) => p.productId === patch.productId)
|
||||||
|
if (product) next.unitPrice = String(product.sellingPrice)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((prev) => [...prev, { productId: "", quantity: "1", unitPrice: "0" }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(idx: number) {
|
||||||
|
setLines((prev) => prev.filter((_, i) => i !== idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = lines.reduce((sum, l) => sum + (Number(l.quantity) || 0) * (Number(l.unitPrice) || 0), 0)
|
||||||
|
const linesValid = lines.length > 0 && lines.every((l) => !!l.productId && Number(l.quantity) > 0 && Number(l.unitPrice) >= 0)
|
||||||
|
const canSubmit = linesValid && !busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
const input: CreateSaleInput = {
|
||||||
|
paymentMethod,
|
||||||
|
customerId: customerId || undefined,
|
||||||
|
customerName: customerName.trim() || undefined,
|
||||||
|
items: lines.map<SaleItemInput>((l) => ({
|
||||||
|
productId: l.productId,
|
||||||
|
quantity: Number(l.quantity),
|
||||||
|
unitPrice: Number(l.unitPrice),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await run(async () => {
|
||||||
|
await createSale(input)
|
||||||
|
toast.success("Sale recorded", { description: "Stock levels were updated." })
|
||||||
|
onCreated()
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Record Sale</DialogTitle>
|
||||||
|
<DialogDescription>Stock going out to a customer — decreases quantity on hand.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Select existing member (optional)</Label>
|
||||||
|
<Select items={memberOptions} value={customerId || "none"} onValueChange={(v) => selectMember(v ?? "none")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={membersFetch.loading ? "Loading…" : "Walk-in (not registered)"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{memberOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="sale-customer-name">Customer Name</Label>
|
||||||
|
<Input
|
||||||
|
id="sale-customer-name"
|
||||||
|
placeholder="Optional"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>
|
||||||
|
Items <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-[2fr_1fr_1fr_auto] gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Product</span>
|
||||||
|
<span>Quantity</span>
|
||||||
|
<span>Unit Price (Rs.)</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{lines.map((line, idx) => (
|
||||||
|
<div key={idx} className="grid grid-cols-[2fr_1fr_1fr_auto] items-center gap-2">
|
||||||
|
<Select
|
||||||
|
items={products.map((p) => ({ label: p.name, value: p.productId }))}
|
||||||
|
value={line.productId}
|
||||||
|
onValueChange={(v) => updateLine(idx, { productId: v ?? "" })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a product" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{products.map((p) => (
|
||||||
|
<SelectItem key={p.productId} value={p.productId} disabled={p.quantityOnHand <= 0}>
|
||||||
|
{p.name} ({p.quantityOnHand} left)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step="0.01"
|
||||||
|
aria-label="Quantity"
|
||||||
|
placeholder="Qty"
|
||||||
|
value={line.quantity}
|
||||||
|
onChange={(e) => updateLine(idx, { quantity: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
aria-label="Unit price"
|
||||||
|
placeholder="Unit price"
|
||||||
|
value={line.unitPrice}
|
||||||
|
onChange={(e) => updateLine(idx, { unitPrice: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Remove line"
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
onClick={() => removeLine(idx)}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" size="sm" className="self-start" onClick={addLine}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>Payment Method</Label>
|
||||||
|
<Select items={PAYMENT_METHOD_OPTIONS} value={paymentMethod} onValueChange={(v) => setPaymentMethod((v as PaymentMethod) ?? "Cash")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAYMENT_METHOD_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/40 p-3 text-sm">
|
||||||
|
<span className="text-muted-foreground">Total</span>
|
||||||
|
<span className="font-semibold">{formatCurrency(total)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Record Sale
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaleDetailDialogProps {
|
||||||
|
saleId: string | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaleDetailDialog({ saleId, onOpenChange }: SaleDetailDialogProps) {
|
||||||
|
const detailFetch = useFetch(() => (saleId ? getSale(saleId) : Promise.resolve(null)))
|
||||||
|
const sale = detailFetch.data
|
||||||
|
|
||||||
|
// This dialog stays mounted across opens (only `saleId` changes) -- useFetch only runs its
|
||||||
|
// fetcher on mount and on an explicit reload(), so a fresh id needs an explicit reload too.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (saleId) detailFetch.reload()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only react to the id changing
|
||||||
|
}, [saleId, detailFetch.reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!saleId} onOpenChange={(open) => !open && onOpenChange(false)}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Sale Details</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailFetch.loading || !sale ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner className="size-6" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Sale Number</p>
|
||||||
|
<p className="font-medium">{sale.saleNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Sold</p>
|
||||||
|
<p className="font-medium">{formatDate(sale.soldAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Customer</p>
|
||||||
|
<p className="font-medium">{sale.customerName || "Walk-in"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Payment Method</p>
|
||||||
|
<Badge variant="outline">{sale.paymentMethod}</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Total</p>
|
||||||
|
<p className="font-medium">{formatCurrency(sale.totalAmount)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium">Items</p>
|
||||||
|
<div className="flex flex-col divide-y rounded-lg border">
|
||||||
|
{sale.items.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between gap-3 p-2.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.productName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{item.quantity} × {formatCurrency(item.unitPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{formatCurrency(item.lineTotal)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventorySalesTab() {
|
||||||
|
const salesFetch = useFetch(() => getSales())
|
||||||
|
const sales = salesFetch.data ?? []
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
const [detailSaleId, setDetailSaleId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const totalRevenue = sales.reduce((sum, s) => sum + s.totalAmount, 0)
|
||||||
|
const today = new Date()
|
||||||
|
const todaySales = sales.filter((s) => {
|
||||||
|
const d = new Date(s.soldAt)
|
||||||
|
return d.toDateString() === today.toDateString()
|
||||||
|
})
|
||||||
|
const todayRevenue = todaySales.reduce((sum, s) => sum + s.totalAmount, 0)
|
||||||
|
const avgSaleValue = sales.length ? totalRevenue / sales.length : 0
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Sale>[] = [
|
||||||
|
{
|
||||||
|
key: "saleNumber",
|
||||||
|
header: "Sale",
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<ReceiptIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.saleNumber}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.customerName || "Walk-in"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "sold", header: "Sold", cell: (row) => formatDate(row.soldAt) },
|
||||||
|
{ key: "items", header: "Items", cell: (row) => row.items.length },
|
||||||
|
{ key: "method", header: "Payment", cell: (row) => <Badge variant="outline">{row.paymentMethod}</Badge> },
|
||||||
|
{ key: "total", header: "Total", cell: (row) => formatCurrency(row.totalAmount) },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "text-center",
|
||||||
|
cell: (row) => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="icon-sm" aria-label="View details" onClick={() => setDetailSaleId(row.saleId)}>
|
||||||
|
<EyeIcon />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>View details</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (salesFetch.loading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Sales</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Stock going out to a customer.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<PlusIcon />
|
||||||
|
Record Sale
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={ReceiptIcon} tone="blue" label="Total Sales" value={sales.length} />
|
||||||
|
<StatCard icon={DollarSignIcon} tone="purple" label="Total Revenue" value={formatCurrency(totalRevenue)} />
|
||||||
|
<StatCard icon={CalendarClockIcon} tone="amber" label="Today" value={formatCurrency(todayRevenue)} hint={`${todaySales.length} sales`} />
|
||||||
|
<StatCard icon={TrendingUpIcon} tone="green" label="Avg. Sale Value" value={formatCurrency(avgSaleValue)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={sales}
|
||||||
|
rowKey={(row) => row.saleId}
|
||||||
|
emptyMessage="No sales yet."
|
||||||
|
onRowClick={(row) => setDetailSaleId(row.saleId)}
|
||||||
|
renderMobileCard={(row) => (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<ReceiptIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.saleNumber}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.customerName || "Walk-in"} · {formatDate(row.soldAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t pt-2.5">
|
||||||
|
<Badge variant="outline">{row.paymentMethod}</Badge>
|
||||||
|
<span className="font-medium">{formatCurrency(row.totalAmount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateSaleDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={salesFetch.reload} />
|
||||||
|
<SaleDetailDialog saleId={detailSaleId} onOpenChange={() => setDetailSaleId(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InventorySalesTab }
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ArrowDownCircleIcon, ArrowUpCircleIcon, ListIcon, ListOrderedIcon, RotateCcwIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { getProducts, getStockMovements } from "@/lib/api/inventory"
|
||||||
|
import type { ReturnCategory, StockMovement } from "@/lib/models/inventory"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
|
import { InfoBanner } from "@/components/hexa/info-banner"
|
||||||
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
|
||||||
|
const TYPE_OPTIONS: { label: string; value: "all" | StockMovement["type"] }[] = [
|
||||||
|
{ label: "All types", value: "all" },
|
||||||
|
{ label: "Stock in (GRN)", value: "GRN" },
|
||||||
|
{ label: "Stock out (Sale)", value: "Sale" },
|
||||||
|
{ label: "Returns / write-offs", value: "Return" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS: { label: string; value: "all" | ReturnCategory }[] = [
|
||||||
|
{ label: "All categories", value: "all" },
|
||||||
|
{ label: "Expired", value: "Expired" },
|
||||||
|
{ label: "Damaged", value: "Damaged" },
|
||||||
|
{ label: "Lost", value: "Lost" },
|
||||||
|
{ label: "Sent back to supplier", value: "SupplierReturn" },
|
||||||
|
{ label: "Handed back by customer", value: "CustomerReturn" },
|
||||||
|
{ label: "Other", value: "Other" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function categoryLabel(category: ReturnCategory) {
|
||||||
|
return CATEGORY_OPTIONS.find((o) => o.value === category)?.label ?? category
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type: StockMovement["type"]) {
|
||||||
|
return TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeIcon(type: StockMovement["type"]) {
|
||||||
|
if (type === "GRN") return <ArrowDownCircleIcon className="size-4" />
|
||||||
|
if (type === "Sale") return <ArrowUpCircleIcon className="size-4" />
|
||||||
|
return <RotateCcwIcon className="size-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryStockTrackingTab() {
|
||||||
|
const movementsFetch = useFetch(() => getStockMovements())
|
||||||
|
const movements = movementsFetch.data ?? []
|
||||||
|
const productsFetch = useFetch(() => getProducts(true))
|
||||||
|
const products = productsFetch.data ?? []
|
||||||
|
|
||||||
|
const [typeFilter, setTypeFilter] = React.useState<"all" | StockMovement["type"]>("all")
|
||||||
|
const [productFilter, setProductFilter] = React.useState("all")
|
||||||
|
const [categoryFilter, setCategoryFilter] = React.useState<"all" | ReturnCategory>("all")
|
||||||
|
const [dateFrom, setDateFrom] = React.useState("")
|
||||||
|
const [dateTo, setDateTo] = React.useState("")
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
typeFilter !== "all" || productFilter !== "all" || categoryFilter !== "all" || !!dateFrom || !!dateTo
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
setTypeFilter("all")
|
||||||
|
setProductFilter("all")
|
||||||
|
setCategoryFilter("all")
|
||||||
|
setDateFrom("")
|
||||||
|
setDateTo("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows every stock movement that ever happened by default -- all filters are opt-in.
|
||||||
|
const filtered = movements.filter((m) => {
|
||||||
|
if (typeFilter !== "all" && m.type !== typeFilter) return false
|
||||||
|
if (productFilter !== "all" && m.productId !== productFilter) return false
|
||||||
|
if (categoryFilter !== "all" && m.category !== categoryFilter) return false
|
||||||
|
if (dateFrom && new Date(m.date) < new Date(dateFrom)) return false
|
||||||
|
if (dateTo && new Date(m.date) > new Date(`${dateTo}T23:59:59`)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const productOptions = [{ label: "All products", value: "all" }, ...products.map((p) => ({ label: p.name, value: p.productId }))]
|
||||||
|
|
||||||
|
const stockInValue = filtered.filter((m) => m.type === "GRN").reduce((sum, m) => sum + m.amount, 0)
|
||||||
|
const stockOutValue = filtered.filter((m) => m.type === "Sale").reduce((sum, m) => sum + m.amount, 0)
|
||||||
|
const writeOffValue = filtered.filter((m) => m.type === "Return" && m.quantityChange < 0).reduce((sum, m) => sum + m.amount, 0)
|
||||||
|
|
||||||
|
const columns: DataTableColumn<StockMovement>[] = [
|
||||||
|
{ key: "date", header: "Date", cell: (row) => formatDate(row.date) },
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Type",
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{typeIcon(row.type)}
|
||||||
|
<span>{typeLabel(row.type)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "product", header: "Product", cell: (row) => row.productName },
|
||||||
|
{ key: "reference", header: "Reference", cell: (row) => row.reference },
|
||||||
|
{
|
||||||
|
key: "category",
|
||||||
|
header: "Category",
|
||||||
|
cell: (row) => (row.category ? <Badge variant="outline">{categoryLabel(row.category)}</Badge> : "—"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quantity",
|
||||||
|
header: "Quantity",
|
||||||
|
cell: (row) => (
|
||||||
|
<span className={row.quantityChange >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||||
|
{row.quantityChange >= 0 ? "+" : ""}
|
||||||
|
{row.quantityChange}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "amount", header: "Amount", cell: (row) => formatCurrency(row.amount) },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (movementsFetch.loading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Stock Tracking</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Every stock movement that ever happened — GRNs in, sales out, and returns — in one
|
||||||
|
ledger. Shows everything by default; use the filters to narrow it down.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard icon={ListOrderedIcon} tone="blue" label="Movements Shown" value={filtered.length} />
|
||||||
|
<StatCard icon={ArrowDownCircleIcon} tone="green" label="Stock In" value={formatCurrency(stockInValue)} hint="GRN value" />
|
||||||
|
<StatCard icon={ArrowUpCircleIcon} tone="purple" label="Stock Out" value={formatCurrency(stockOutValue)} hint="Sales value" />
|
||||||
|
<StatCard icon={RotateCcwIcon} tone="amber" label="Write-offs" value={formatCurrency(writeOffValue)} hint="Returns value" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border bg-card p-3 sm:flex-row sm:items-end sm:flex-wrap">
|
||||||
|
<div className="flex min-w-36 flex-col gap-1.5">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select items={TYPE_OPTIONS} value={typeFilter} onValueChange={(v) => setTypeFilter((v as typeof typeFilter) ?? "all")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-44 flex-col gap-1.5">
|
||||||
|
<Label>Product</Label>
|
||||||
|
<Select items={productOptions} value={productFilter} onValueChange={(v) => setProductFilter(v ?? "all")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={productsFetch.loading ? "Loading…" : "All products"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{productOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-44 flex-col gap-1.5">
|
||||||
|
<Label>Return category</Label>
|
||||||
|
<Select items={CATEGORY_OPTIONS} value={categoryFilter} onValueChange={(v) => setCategoryFilter((v as typeof categoryFilter) ?? "all")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-36 flex-col gap-1.5">
|
||||||
|
<Label>From</Label>
|
||||||
|
<Input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-36 flex-col gap-1.5">
|
||||||
|
<Label>To</Label>
|
||||||
|
<Input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button type="button" variant="outline" onClick={resetFilters}>
|
||||||
|
Reset filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={filtered}
|
||||||
|
rowKey={(row) => `${row.type}-${row.productId}-${row.reference}-${row.date}-${row.amount}`}
|
||||||
|
emptyMessage={hasActiveFilters ? "No movements match these filters." : "No stock movements recorded yet."}
|
||||||
|
renderMobileCard={(row) => (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
{typeIcon(row.type)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.productName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{typeLabel(row.type)} · {row.reference} · {formatDate(row.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t pt-2.5">
|
||||||
|
<span className={row.quantityChange >= 0 ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}>
|
||||||
|
{row.quantityChange >= 0 ? "+" : ""}
|
||||||
|
{row.quantityChange}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{formatCurrency(row.amount)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InfoBanner>
|
||||||
|
<ListIcon className="size-4" />
|
||||||
|
Read-only — this tab is a combined view of the GRN, Sales and Returns tabs. To record a
|
||||||
|
new movement, use the relevant tab directly.
|
||||||
|
</InfoBanner>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InventoryStockTrackingTab }
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckCircle2Icon, PlusIcon, TruckIcon, XCircleIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { createSupplier, getSuppliers } from "@/lib/api/inventory"
|
||||||
|
import type { SupplierInput } from "@/lib/models/inventory"
|
||||||
|
import { useFetch } from "@/lib/use-fetch"
|
||||||
|
import { useMutation } from "@/lib/use-mutation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { DataTable, type DataTableColumn } from "@/components/hexa/data-table"
|
||||||
|
import { PageSkeleton } from "@/components/hexa/page-skeleton"
|
||||||
|
import { StatCard } from "@/components/hexa/stat-card"
|
||||||
|
import { StatusBadge } from "@/components/hexa/status-badge"
|
||||||
|
import type { Supplier } from "@/lib/models/inventory"
|
||||||
|
|
||||||
|
interface SupplierFormValues {
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_VALUES: SupplierFormValues = { name: "", phone: "", email: "", address: "" }
|
||||||
|
|
||||||
|
interface SupplierDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSubmit: (values: SupplierFormValues) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupplierDialog({ open, onOpenChange, onSubmit }: SupplierDialogProps) {
|
||||||
|
const [values, setValues] = React.useState<SupplierFormValues>(EMPTY_VALUES)
|
||||||
|
const { busy, run } = useMutation()
|
||||||
|
const [prevOpen, setPrevOpen] = React.useState(open)
|
||||||
|
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open)
|
||||||
|
if (open) setValues(EMPTY_VALUES)
|
||||||
|
}
|
||||||
|
|
||||||
|
function set<K extends keyof SupplierFormValues>(key: K, value: SupplierFormValues[K]) {
|
||||||
|
setValues((v) => ({ ...v, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = !!values.name.trim() && !busy
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
try {
|
||||||
|
await run(() => Promise.resolve(onSubmit(values)))
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// run() already toasted the real error; keep the dialog open for retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Supplier</DialogTitle>
|
||||||
|
<DialogDescription>Who stock is received from on a GRN.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="supplier-name">
|
||||||
|
Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input id="supplier-name" value={values.name} onChange={(e) => set("name", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="supplier-phone">Phone</Label>
|
||||||
|
<Input id="supplier-phone" value={values.phone} onChange={(e) => set("phone", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="supplier-email">Email</Label>
|
||||||
|
<Input id="supplier-email" type="email" value={values.email} onChange={(e) => set("email", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="supplier-address">Address</Label>
|
||||||
|
<Input id="supplier-address" value={values.address} onChange={(e) => set("address", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit}>
|
||||||
|
{busy && <Spinner className="size-4" />}
|
||||||
|
Add Supplier
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventorySuppliersTab() {
|
||||||
|
const suppliersFetch = useFetch(() => getSuppliers(true))
|
||||||
|
const suppliers = suppliersFetch.data ?? []
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const activeSuppliers = suppliers.filter((s) => s.isActive).length
|
||||||
|
const inactiveSuppliers = suppliers.length - activeSuppliers
|
||||||
|
|
||||||
|
async function handleCreate(values: SupplierFormValues) {
|
||||||
|
const input: SupplierInput = {
|
||||||
|
name: values.name.trim(),
|
||||||
|
phone: values.phone.trim() || undefined,
|
||||||
|
email: values.email.trim() || undefined,
|
||||||
|
address: values.address.trim() || undefined,
|
||||||
|
}
|
||||||
|
await createSupplier(input)
|
||||||
|
toast.success("Supplier added", { description: `"${input.name}" was added.` })
|
||||||
|
suppliersFetch.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumn<Supplier>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Supplier",
|
||||||
|
cell: (row) => (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<TruckIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">{row.name}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: "phone", header: "Phone", cell: (row) => row.phone || "—" },
|
||||||
|
{ key: "email", header: "Email", cell: (row) => row.email || "—" },
|
||||||
|
{ key: "address", header: "Address", cell: (row) => row.address || "—" },
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: (row) => (
|
||||||
|
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
||||||
|
{row.isActive ? "Active" : "Inactive"}
|
||||||
|
</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (suppliersFetch.loading) {
|
||||||
|
return <PageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Suppliers</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Who stock is received from on a GRN.</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
|
<PlusIcon />
|
||||||
|
Add Supplier
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<StatCard icon={TruckIcon} tone="blue" label="Total Suppliers" value={suppliers.length} />
|
||||||
|
<StatCard icon={CheckCircle2Icon} tone="green" label="Active" value={activeSuppliers} />
|
||||||
|
<StatCard icon={XCircleIcon} tone="amber" label="Inactive" value={inactiveSuppliers} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={suppliers}
|
||||||
|
rowKey={(row) => row.supplierId}
|
||||||
|
emptyMessage="No suppliers yet."
|
||||||
|
renderMobileCard={(row) => (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<TruckIcon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{row.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.phone || row.email || "No contact info"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={row.isActive ? "success" : "neutral"}>
|
||||||
|
{row.isActive ? "Active" : "Inactive"}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SupplierDialog open={dialogOpen} onOpenChange={setDialogOpen} onSubmit={handleCreate} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InventorySuppliersTab }
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { InventoryGrnTab } from "@/components/hexa/views/inventory-grn-tab"
|
||||||
|
import { InventoryProductsTab } from "@/components/hexa/views/inventory-products-tab"
|
||||||
|
import { InventoryReturnsTab } from "@/components/hexa/views/inventory-returns-tab"
|
||||||
|
import { InventorySalesTab } from "@/components/hexa/views/inventory-sales-tab"
|
||||||
|
import { InventoryStockTrackingTab } from "@/components/hexa/views/inventory-stock-tracking-tab"
|
||||||
|
import { InventorySuppliersTab } from "@/components/hexa/views/inventory-suppliers-tab"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
|
||||||
|
function InventoryView() {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="products" className="gap-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="products">Products</TabsTrigger>
|
||||||
|
<TabsTrigger value="suppliers">Suppliers</TabsTrigger>
|
||||||
|
<TabsTrigger value="grn">GRN</TabsTrigger>
|
||||||
|
<TabsTrigger value="sales">Sales</TabsTrigger>
|
||||||
|
<TabsTrigger value="returns">Returns</TabsTrigger>
|
||||||
|
<TabsTrigger value="stock-tracking">Stock Tracking</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="products">
|
||||||
|
<InventoryProductsTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="suppliers">
|
||||||
|
<InventorySuppliersTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="grn">
|
||||||
|
<InventoryGrnTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sales">
|
||||||
|
<InventorySalesTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="returns">
|
||||||
|
<InventoryReturnsTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="stock-tracking">
|
||||||
|
<InventoryStockTrackingTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InventoryView }
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { ClassesView } from "@/components/hexa/views/classes-view"
|
||||||
import { FacilitiesView } from "@/components/hexa/views/facilities-view"
|
import { FacilitiesView } from "@/components/hexa/views/facilities-view"
|
||||||
import { GymPlansView } from "@/components/hexa/views/gym-plans-view"
|
import { GymPlansView } from "@/components/hexa/views/gym-plans-view"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
@@ -10,6 +11,7 @@ function MembershipsView() {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="plans">Plans</TabsTrigger>
|
<TabsTrigger value="plans">Plans</TabsTrigger>
|
||||||
<TabsTrigger value="facilities">Facilities</TabsTrigger>
|
<TabsTrigger value="facilities">Facilities</TabsTrigger>
|
||||||
|
<TabsTrigger value="classes">Classes</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="plans">
|
<TabsContent value="plans">
|
||||||
@@ -18,6 +20,9 @@ function MembershipsView() {
|
|||||||
<TabsContent value="facilities">
|
<TabsContent value="facilities">
|
||||||
<FacilitiesView />
|
<FacilitiesView />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="classes">
|
||||||
|
<ClassesView />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-24
@@ -5,7 +5,7 @@ import { CopyIcon, SearchIcon } from "lucide-react"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { getFacilityAvailability } from "@/lib/api/facilities"
|
import { getFacilityAvailability } from "@/lib/api/facilities"
|
||||||
import { createGuestAdhocBooking } from "@/lib/api/bookings"
|
import { createGuestWalkInBooking } from "@/lib/api/bookings"
|
||||||
import { getMembers } from "@/lib/api/customers"
|
import { getMembers } from "@/lib/api/customers"
|
||||||
import { ApiError } from "@/lib/api/core-http"
|
import { ApiError } from "@/lib/api/core-http"
|
||||||
import type { FacilityAvailability } from "@/lib/models/facility"
|
import type { FacilityAvailability } from "@/lib/models/facility"
|
||||||
@@ -32,18 +32,44 @@ function toLocalInputValue(date: Date) {
|
|||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdhocBookingTabProps {
|
interface WalkInBookingTabProps {
|
||||||
facilities: Facility[]
|
facilities: Facility[]
|
||||||
|
/** Set when the Dashboard calendar's drag-to-quick-book hands up a dragged time range --
|
||||||
|
* seeds the start/duration fields once, then must be cleared by the caller via
|
||||||
|
* onPrefillConsumed so it isn't re-applied on an unrelated re-render. */
|
||||||
|
prefill?: { startTime: string; endTime: string } | null
|
||||||
|
onPrefillConsumed?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
function WalkInBookingTab({ facilities, prefill, onPrefillConsumed }: WalkInBookingTabProps) {
|
||||||
const adhocFacilities = facilities.filter((f) => f.bookingMode === "AdHoc" && f.isActive)
|
const walkInFacilities = facilities.filter((f) => f.supportsBooking && f.isActive)
|
||||||
|
|
||||||
const [facilityId, setFacilityId] = React.useState(adhocFacilities[0]?.facilityId ?? "")
|
const [facilityId, setFacilityId] = React.useState(walkInFacilities[0]?.facilityId ?? "")
|
||||||
const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date()))
|
const [startTime, setStartTime] = React.useState(() => toLocalInputValue(new Date()))
|
||||||
const [hours, setHours] = React.useState("1")
|
const [hours, setHours] = React.useState("1")
|
||||||
const [minutes, setMinutes] = React.useState("0")
|
const [minutes, setMinutes] = React.useState("0")
|
||||||
const [pricePerHour, setPricePerHour] = React.useState(() => String(adhocFacilities[0]?.pricePerHour ?? 0))
|
|
||||||
|
// Same "reset on prop change" convention used by every dialog's prevOpen/prevBooking state in
|
||||||
|
// this codebase -- a plain render-body comparison, not an effect, so it can set local state
|
||||||
|
// directly without a cascading-render lint warning.
|
||||||
|
const [prevPrefill, setPrevPrefill] = React.useState(prefill)
|
||||||
|
if (prefill !== prevPrefill) {
|
||||||
|
setPrevPrefill(prefill)
|
||||||
|
if (prefill) {
|
||||||
|
setStartTime(toLocalInputValue(new Date(prefill.startTime)))
|
||||||
|
const totalMinutes = Math.max(15, Math.round((new Date(prefill.endTime).getTime() - new Date(prefill.startTime).getTime()) / 60000))
|
||||||
|
setHours(String(Math.floor(totalMinutes / 60)))
|
||||||
|
setMinutes(String(totalMinutes % 60))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifying the parent to clear its draft is a genuine side effect (a different component's
|
||||||
|
// state), so it stays in an effect rather than the render body above.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (prefill) onPrefillConsumed?.()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only fire when a new prefill arrives
|
||||||
|
}, [prefill])
|
||||||
|
const [pricePerHour, setPricePerHour] = React.useState(() => String(walkInFacilities[0]?.pricePerHour ?? 0))
|
||||||
const [partySize, setPartySize] = React.useState("")
|
const [partySize, setPartySize] = React.useState("")
|
||||||
const [customerId, setCustomerId] = React.useState("")
|
const [customerId, setCustomerId] = React.useState("")
|
||||||
const [guestName, setGuestName] = React.useState("")
|
const [guestName, setGuestName] = React.useState("")
|
||||||
@@ -73,14 +99,14 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
const durationMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0)
|
const durationMinutes = (Number(hours) || 0) * 60 + (Number(minutes) || 0)
|
||||||
const bookingPrice = ((Number(pricePerHour) || 0) * durationMinutes) / 60
|
const bookingPrice = ((Number(pricePerHour) || 0) * durationMinutes) / 60
|
||||||
|
|
||||||
const facilityOptions = adhocFacilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
const facilityOptions = walkInFacilities.map((f) => ({ label: f.name, value: f.facilityId }))
|
||||||
const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking
|
const canCheck = !!facilityId && !!startTime && durationMinutes >= 1 && !checking
|
||||||
const canSubmit = canCheck && Number(pricePerHour) >= 0 && !!guestName.trim() && !!guestPhone.trim() && !booking
|
const canSubmit = canCheck && Number(pricePerHour) >= 0 && !!guestName.trim() && !!guestPhone.trim() && !booking
|
||||||
|
|
||||||
function selectFacility(id: string) {
|
function selectFacility(id: string) {
|
||||||
setFacilityId(id)
|
setFacilityId(id)
|
||||||
setAvailability(null)
|
setAvailability(null)
|
||||||
const facility = adhocFacilities.find((f) => f.facilityId === id)
|
const facility = walkInFacilities.find((f) => f.facilityId === id)
|
||||||
setPricePerHour(String(facility?.pricePerHour ?? 0))
|
setPricePerHour(String(facility?.pricePerHour ?? 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +133,7 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
try {
|
try {
|
||||||
const bookingResult = await runBook(() =>
|
const bookingResult = await runBook(() =>
|
||||||
createGuestAdhocBooking({
|
createGuestWalkInBooking({
|
||||||
facilityId,
|
facilityId,
|
||||||
startTime: new Date(startTime).toISOString(),
|
startTime: new Date(startTime).toISOString(),
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
@@ -139,10 +165,10 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adhocFacilities.length === 0) {
|
if (walkInFacilities.length === 0) {
|
||||||
return (
|
return (
|
||||||
<InfoBanner>
|
<InfoBanner>
|
||||||
No ad-hoc facilities yet. Add a facility with Booking Mode set to "Ad-hoc" under
|
No walk-in facilities yet. Add a facility and check "Walk-in booking" under
|
||||||
Memberships → Facilities (e.g. courts or a pool with multiple units) to book here.
|
Memberships → Facilities (e.g. courts or a pool with multiple units) to book here.
|
||||||
</InfoBanner>
|
</InfoBanner>
|
||||||
)
|
)
|
||||||
@@ -170,11 +196,11 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="adhoc-start">
|
<Label htmlFor="walkin-start">
|
||||||
Start <span className="text-destructive">*</span>
|
Start <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="adhoc-start"
|
id="walkin-start"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -222,11 +248,11 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="adhoc-price-per-hour">
|
<Label htmlFor="walkin-price-per-hour">
|
||||||
Price / Hour (Rs.) <span className="text-destructive">*</span>
|
Price / Hour (Rs.) <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="adhoc-price-per-hour"
|
id="walkin-price-per-hour"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -244,9 +270,9 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="adhoc-party-size">Party Size</Label>
|
<Label htmlFor="walkin-party-size">Party Size</Label>
|
||||||
<Input
|
<Input
|
||||||
id="adhoc-party-size"
|
id="walkin-party-size"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
placeholder="Optional headcount"
|
placeholder="Optional headcount"
|
||||||
@@ -262,8 +288,8 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
{availabilityError && <p className="text-sm text-destructive">{availabilityError}</p>}
|
{availabilityError && <p className="text-sm text-destructive">{availabilityError}</p>}
|
||||||
{availability && (
|
{availability && (
|
||||||
<StatusBadge status={availability.unitsFree > 0 ? "success" : "danger"}>
|
<StatusBadge status={(availability.bookingUnitsFree ?? 0) > 0 ? "success" : "danger"}>
|
||||||
{availability.unitsFree} / {availability.totalUnits} units free
|
{availability.bookingUnitsFree ?? 0} / {availability.bookingTotalUnits ?? 0} units free
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -286,16 +312,16 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="adhoc-guest-name">
|
<Label htmlFor="walkin-guest-name">
|
||||||
Guest Name <span className="text-destructive">*</span>
|
Guest Name <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="adhoc-guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
|
<Input id="walkin-guest-name" value={guestName} onChange={(e) => setGuestName(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="adhoc-guest-phone">
|
<Label htmlFor="walkin-guest-phone">
|
||||||
Mobile Number <span className="text-destructive">*</span>
|
Mobile Number <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="adhoc-guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
|
<Input id="walkin-guest-phone" value={guestPhone} onChange={(e) => setGuestPhone(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -315,4 +341,4 @@ function AdhocBookingTab({ facilities }: AdhocBookingTabProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { AdhocBookingTab }
|
export { WalkInBookingTab }
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
Rss,
|
Rss,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -27,6 +28,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
Rss,
|
Rss,
|
||||||
Settings,
|
Settings,
|
||||||
|
|||||||
+12
-12
@@ -3,16 +3,16 @@ import type { BookingDashboardSummary } from "@/lib/models/booking-dashboard"
|
|||||||
import type {
|
import type {
|
||||||
Booking,
|
Booking,
|
||||||
CompleteBookingInput,
|
CompleteBookingInput,
|
||||||
CreateAdhocBookingInput,
|
|
||||||
CreateGuestAdhocBookingInput,
|
|
||||||
CreateGuestBookingInput,
|
CreateGuestBookingInput,
|
||||||
|
CreateGuestWalkInBookingInput,
|
||||||
|
CreateWalkInBookingInput,
|
||||||
GetBookingsFilters,
|
GetBookingsFilters,
|
||||||
MarkGuestBookingPaidInput,
|
MarkGuestBookingPaidInput,
|
||||||
ReassignBookingInput,
|
ReassignBookingInput,
|
||||||
RescheduleBookingInput,
|
RescheduleBookingInput,
|
||||||
} from "@/lib/models/booking"
|
} from "@/lib/models/booking"
|
||||||
|
|
||||||
/** Unified roster across both Scheduled and AdHoc bookings. */
|
/** Unified roster across both Scheduled and WalkIn bookings. */
|
||||||
function getBookings(filters: GetBookingsFilters = {}) {
|
function getBookings(filters: GetBookingsFilters = {}) {
|
||||||
return apiRequest<Booking[]>("client/bookings", { query: filters })
|
return apiRequest<Booking[]>("client/bookings", { query: filters })
|
||||||
}
|
}
|
||||||
@@ -27,14 +27,14 @@ function createGuestBooking(input: CreateGuestBookingInput) {
|
|||||||
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
|
return apiRequest<Booking>("public/bookings/guest", { method: "POST", body: input })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Member self-service AdHoc booking. */
|
/** Member self-service walk-in booking. */
|
||||||
function createAdhocBooking(input: CreateAdhocBookingInput) {
|
function createWalkInBooking(input: CreateWalkInBookingInput) {
|
||||||
return apiRequest<Booking>("app/bookings/adhoc", { method: "POST", body: input })
|
return apiRequest<Booking>("app/bookings/walkin", { method: "POST", body: input })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Staff-initiated walk-in AdHoc booking — same public endpoint the guest AdHoc flow uses. */
|
/** Staff-initiated walk-in booking — same public endpoint the guest walk-in flow uses. */
|
||||||
function createGuestAdhocBooking(input: CreateGuestAdhocBookingInput) {
|
function createGuestWalkInBooking(input: CreateGuestWalkInBookingInput) {
|
||||||
return apiRequest<Booking>("public/bookings/guest/adhoc", { method: "POST", body: input })
|
return apiRequest<Booking>("public/bookings/guest/walkin", { method: "POST", body: input })
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkInBooking(otpCode: string, rfidTagId?: string) {
|
function checkInBooking(otpCode: string, rfidTagId?: string) {
|
||||||
@@ -60,7 +60,7 @@ function reassignBooking(id: string, input: ReassignBookingInput) {
|
|||||||
return apiRequest<Booking>(`client/bookings/${id}/reassign`, { method: "PUT", body: input })
|
return apiRequest<Booking>(`client/bookings/${id}/reassign`, { method: "PUT", body: input })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves a Confirmed booking to a new time (AdHoc) or a new session (Scheduled) — only onto a free slot. */
|
/** Moves a Confirmed booking to a new time (WalkIn) or a new session (Scheduled) — only onto a free slot. */
|
||||||
function rescheduleBooking(id: string, input: RescheduleBookingInput) {
|
function rescheduleBooking(id: string, input: RescheduleBookingInput) {
|
||||||
return apiRequest<Booking>(`client/bookings/${id}/reschedule`, { method: "PUT", body: input })
|
return apiRequest<Booking>(`client/bookings/${id}/reschedule`, { method: "PUT", body: input })
|
||||||
}
|
}
|
||||||
@@ -81,8 +81,8 @@ export {
|
|||||||
getBookings,
|
getBookings,
|
||||||
getSessionBookings,
|
getSessionBookings,
|
||||||
createGuestBooking,
|
createGuestBooking,
|
||||||
createAdhocBooking,
|
createWalkInBooking,
|
||||||
createGuestAdhocBooking,
|
createGuestWalkInBooking,
|
||||||
checkInBooking,
|
checkInBooking,
|
||||||
markGuestBookingPaid,
|
markGuestBookingPaid,
|
||||||
cancelBooking,
|
cancelBooking,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function assignFacilityDevice(id: string, deviceId: string | null) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string } = {}) {
|
function getFacilitySessions(params: { facilityId?: string; fromDate?: string; toDate?: string; includeCancelled?: boolean } = {}) {
|
||||||
return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params })
|
return apiRequest<FacilitySession[]>("client/facilities/sessions", { query: params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { apiRequest } from "@/lib/api/core-http"
|
||||||
|
import type {
|
||||||
|
ClassOccurrence,
|
||||||
|
CreateFacilityClassInput,
|
||||||
|
FacilityClass,
|
||||||
|
SchedulingConflict,
|
||||||
|
UpdateFacilityClassInput,
|
||||||
|
} from "@/lib/models/facility-class"
|
||||||
|
|
||||||
|
function getFacilityClasses(includeInactive = true) {
|
||||||
|
return apiRequest<FacilityClass[]>("client/facility-classes", { query: { includeInactive } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFacilityClass(input: CreateFacilityClassInput) {
|
||||||
|
return apiRequest<FacilityClass>("client/facility-classes", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFacilityClass(id: string, input: UpdateFacilityClassInput) {
|
||||||
|
return apiRequest<FacilityClass>(`client/facility-classes/${id}`, { method: "PUT", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClassOccurrences(id: string, includeCancelled = true) {
|
||||||
|
return apiRequest<ClassOccurrence[]>(`client/facility-classes/${id}/occurrences`, { query: { includeCancelled } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Mark not held" -- cascades to cancel + refund every Confirmed booking on the occurrence,
|
||||||
|
* and frees the slot back up for walk-in booking. */
|
||||||
|
function markOccurrenceNotHeld(classId: string, occurrenceId: string, reason?: string) {
|
||||||
|
return apiRequest<unknown>(`client/facility-classes/${classId}/occurrences/${occurrenceId}/not-held`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { reason },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSchedulingConflicts() {
|
||||||
|
return apiRequest<SchedulingConflict[]>("client/facility-classes/scheduling-conflicts")
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getFacilityClasses,
|
||||||
|
createFacilityClass,
|
||||||
|
updateFacilityClass,
|
||||||
|
getClassOccurrences,
|
||||||
|
markOccurrenceNotHeld,
|
||||||
|
getSchedulingConflicts,
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { apiRequest } from "@/lib/api/core-http"
|
||||||
|
import type {
|
||||||
|
CreateGrnInput,
|
||||||
|
CreateSaleInput,
|
||||||
|
CreateStockReturnInput,
|
||||||
|
GoodsReceivedNote,
|
||||||
|
Product,
|
||||||
|
ProductDetail,
|
||||||
|
ProductInput,
|
||||||
|
Sale,
|
||||||
|
StockMovement,
|
||||||
|
StockReturn,
|
||||||
|
Supplier,
|
||||||
|
SupplierInput,
|
||||||
|
} from "@/lib/models/inventory"
|
||||||
|
|
||||||
|
function getProducts(includeInactive = true) {
|
||||||
|
return apiRequest<Product[]>("client/inventory/products", { query: { includeInactive } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProduct(id: string) {
|
||||||
|
return apiRequest<ProductDetail>(`client/inventory/products/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProduct(input: ProductInput) {
|
||||||
|
return apiRequest<Product>("client/inventory/products", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProduct(id: string, input: Partial<ProductInput> & { isActive?: boolean }) {
|
||||||
|
return apiRequest<Product>(`client/inventory/products/${id}`, { method: "PUT", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadProductImage(id: string, file: File) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append("image", file)
|
||||||
|
return apiRequest<Product>(`client/inventory/products/${id}/image`, { method: "POST", formData: fd })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSuppliers(includeInactive = true) {
|
||||||
|
return apiRequest<Supplier[]>("client/inventory/suppliers", { query: { includeInactive } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSupplier(input: SupplierInput) {
|
||||||
|
return apiRequest<Supplier>("client/inventory/suppliers", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGrns() {
|
||||||
|
return apiRequest<GoodsReceivedNote[]>("client/inventory/grn")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGrn(id: string) {
|
||||||
|
return apiRequest<GoodsReceivedNote>(`client/inventory/grn/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGrn(input: CreateGrnInput) {
|
||||||
|
return apiRequest<GoodsReceivedNote>("client/inventory/grn", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSales() {
|
||||||
|
return apiRequest<Sale[]>("client/inventory/sales")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSale(id: string) {
|
||||||
|
return apiRequest<Sale>(`client/inventory/sales/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSale(input: CreateSaleInput) {
|
||||||
|
return apiRequest<Sale>("client/inventory/sales", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStockReturns() {
|
||||||
|
return apiRequest<StockReturn[]>("client/inventory/returns")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStockReturn(id: string) {
|
||||||
|
return apiRequest<StockReturn>(`client/inventory/returns/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStockReturn(input: CreateStockReturnInput) {
|
||||||
|
return apiRequest<StockReturn>("client/inventory/returns", { method: "POST", body: input })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStockMovements() {
|
||||||
|
return apiRequest<StockMovement[]>("client/inventory/stock-movements")
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getProducts,
|
||||||
|
getProduct,
|
||||||
|
createProduct,
|
||||||
|
updateProduct,
|
||||||
|
uploadProductImage,
|
||||||
|
getSuppliers,
|
||||||
|
createSupplier,
|
||||||
|
getGrns,
|
||||||
|
getGrn,
|
||||||
|
createGrn,
|
||||||
|
getSales,
|
||||||
|
getSale,
|
||||||
|
createSale,
|
||||||
|
getStockReturns,
|
||||||
|
getStockReturn,
|
||||||
|
createStockReturn,
|
||||||
|
getStockMovements,
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
|
export type BookingStatus = "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed"
|
||||||
export type PaymentMethod = "Cash" | "Bank"
|
export type PaymentMethod = "Cash" | "Bank"
|
||||||
|
|
||||||
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and AdHoc
|
/** Mirrors BookingDto. Covers both Scheduled (facilitySessionId set) and WalkIn
|
||||||
* (facilitySessionId null, exclusive-unit) bookings. */
|
* (facilitySessionId null, exclusive-unit) bookings. */
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
bookingId: string
|
bookingId: string
|
||||||
/** Null for an AdHoc booking — no pre-created session. */
|
/** Null for a WalkIn booking — no pre-created session. */
|
||||||
facilitySessionId?: string | null
|
facilitySessionId?: string | null
|
||||||
facilityId: string
|
facilityId: string
|
||||||
facilityName?: string | null
|
facilityName?: string | null
|
||||||
bookingMode: "Scheduled" | "AdHoc"
|
bookingMode: "Scheduled" | "WalkIn"
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
/** Informational headcount, AdHoc only. */
|
/** Informational headcount, WalkIn only. */
|
||||||
partySize?: number | null
|
partySize?: number | null
|
||||||
|
|
||||||
isGuest: boolean
|
isGuest: boolean
|
||||||
@@ -59,14 +59,14 @@ export interface CreateGuestBookingInput {
|
|||||||
customerId?: string
|
customerId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAdhocBookingInput {
|
export interface CreateWalkInBookingInput {
|
||||||
facilityId: string
|
facilityId: string
|
||||||
startTime: string
|
startTime: string
|
||||||
durationMinutes: number
|
durationMinutes: number
|
||||||
partySize?: number
|
partySize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateGuestAdhocBookingInput {
|
export interface CreateGuestWalkInBookingInput {
|
||||||
facilityId: string
|
facilityId: string
|
||||||
startTime: string
|
startTime: string
|
||||||
durationMinutes: number
|
durationMinutes: number
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
export type RecurrenceType = "Daily" | "Weekly" | "Monthly" | "Custom"
|
||||||
|
|
||||||
|
/** Mirrors FacilityClassDto. A recurring class definition (Zumba, Yoga) that generates
|
||||||
|
* FacilitySession "occurrences" on a schedule. */
|
||||||
|
export interface FacilityClass {
|
||||||
|
facilityClassId: string
|
||||||
|
facilityId: string
|
||||||
|
facilityName?: string | null
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
recurrenceType: RecurrenceType
|
||||||
|
/** "HH:mm" local time -- null for Custom. */
|
||||||
|
startTimeOfDay?: string | null
|
||||||
|
durationMinutes: number
|
||||||
|
/** Weekly only. Bit flags, Sunday = 1 (bit 0) through Saturday = 64 (bit 6). */
|
||||||
|
weekDaysMask: number
|
||||||
|
/** Monthly only. Comma-separated day-of-month list, e.g. "1,15". */
|
||||||
|
monthDays?: string | null
|
||||||
|
seriesStartDate: string
|
||||||
|
seriesEndDate?: string | null
|
||||||
|
capacity?: number | null
|
||||||
|
isUnlimitedCapacity: boolean
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomOccurrenceInput {
|
||||||
|
/** ISO UTC instant (e.g. new Date(...).toISOString()), same convention as every other
|
||||||
|
* datetime input in this app -- the browser resolves local time to UTC before sending. */
|
||||||
|
startTime: string
|
||||||
|
durationMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFacilityClassInput {
|
||||||
|
facilityId: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
recurrenceType: RecurrenceType
|
||||||
|
/** "HH:mm" local time -- required unless recurrenceType is "Custom". */
|
||||||
|
startTimeOfDay?: string
|
||||||
|
durationMinutes: number
|
||||||
|
weekDaysMask?: number
|
||||||
|
monthDays?: string
|
||||||
|
seriesStartDate?: string
|
||||||
|
seriesEndDate?: string
|
||||||
|
capacity?: number
|
||||||
|
isUnlimitedCapacity?: boolean
|
||||||
|
/** "Custom" only -- explicit occurrence windows. */
|
||||||
|
customOccurrences?: CustomOccurrenceInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFacilityClassInput {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
recurrenceType?: RecurrenceType
|
||||||
|
startTimeOfDay?: string
|
||||||
|
durationMinutes?: number
|
||||||
|
weekDaysMask?: number
|
||||||
|
monthDays?: string
|
||||||
|
seriesStartDate?: string
|
||||||
|
seriesEndDate?: string
|
||||||
|
capacity?: number
|
||||||
|
isUnlimitedCapacity?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
/** "Custom" only -- adds more explicit occurrences without touching existing ones. */
|
||||||
|
addCustomOccurrences?: CustomOccurrenceInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in a class's occurrence history. */
|
||||||
|
export interface ClassOccurrence {
|
||||||
|
facilitySessionId: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
capacity?: number | null
|
||||||
|
isUnlimitedCapacity: boolean
|
||||||
|
isCancelled: boolean
|
||||||
|
notHeldReason?: string | null
|
||||||
|
notHeldAt?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A confirmed walk-in booking whose window overlaps an active class occurrence on the same
|
||||||
|
* facility -- surfaced so staff can resolve it via the Roster tab's Reschedule action. */
|
||||||
|
export interface SchedulingConflict {
|
||||||
|
bookingId: string
|
||||||
|
facilityId: string
|
||||||
|
facilityName?: string | null
|
||||||
|
guestName?: string | null
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
}
|
||||||
+25
-11
@@ -1,16 +1,17 @@
|
|||||||
export type BookingMode = "Scheduled" | "AdHoc"
|
|
||||||
|
|
||||||
/** Mirrors FacilityDto. A bookable area (gym floor, pool, court, class) that's also the
|
/** Mirrors FacilityDto. A bookable area (gym floor, pool, court, class) that's also the
|
||||||
* access-control zone for its optional fingerprint/face terminal — merged from the former
|
* access-control zone for its optional fingerprint/face terminal — merged from the former
|
||||||
* separate Facility/Place concepts. */
|
* separate Facility/Place concepts. The two flags are independent: a court can support classes
|
||||||
|
* AND ad-hoc walk-in booking at once (a scheduled class occupies the whole facility for its
|
||||||
|
* window; ad-hoc booking is only available outside that window). At least one is always true. */
|
||||||
export interface Facility {
|
export interface Facility {
|
||||||
facilityId: string
|
facilityId: string
|
||||||
name: string
|
name: string
|
||||||
bookingMode: BookingMode
|
supportsClasses: boolean
|
||||||
|
supportsBooking: boolean
|
||||||
description?: string | null
|
description?: string | null
|
||||||
/** Meaningful when bookingMode === "Scheduled" — shared capacity per session. */
|
/** Meaningful when supportsClasses — shared capacity per session. */
|
||||||
defaultCapacity: number
|
defaultCapacity: number
|
||||||
/** Meaningful when bookingMode === "AdHoc" — e.g. 3 for "3 courts", each independently bookable. */
|
/** Meaningful when supportsBooking — e.g. 3 for "3 courts", each independently bookable. */
|
||||||
numberOfUnits: number
|
numberOfUnits: number
|
||||||
pricePerHour: number
|
pricePerHour: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
@@ -21,7 +22,8 @@ export interface Facility {
|
|||||||
|
|
||||||
export interface FacilityInput {
|
export interface FacilityInput {
|
||||||
name: string
|
name: string
|
||||||
bookingMode: BookingMode
|
supportsClasses: boolean
|
||||||
|
supportsBooking: boolean
|
||||||
description?: string
|
description?: string
|
||||||
defaultCapacity: number
|
defaultCapacity: number
|
||||||
numberOfUnits: number
|
numberOfUnits: number
|
||||||
@@ -36,8 +38,15 @@ export interface FacilitySession {
|
|||||||
startTime: string
|
startTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
capacity: number
|
capacity: number
|
||||||
|
/** When true, capacity above is meaningless -- this occurrence has no cap. */
|
||||||
|
isUnlimitedCapacity: boolean
|
||||||
bookedCount: number
|
bookedCount: number
|
||||||
isCancelled: boolean
|
isCancelled: boolean
|
||||||
|
/** Null for a manually-created session; set for a class occurrence. */
|
||||||
|
facilityClassId?: string | null
|
||||||
|
facilityClassName?: string | null
|
||||||
|
notHeldReason?: string | null
|
||||||
|
notHeldAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FacilitySessionInput {
|
export interface FacilitySessionInput {
|
||||||
@@ -48,12 +57,17 @@ export interface FacilitySessionInput {
|
|||||||
capacity?: number
|
capacity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors FacilityAvailabilityDto. */
|
/** Mirrors FacilityAvailabilityDto. A facility can support both booking and classes at once, so
|
||||||
|
* the two number pairs are independently nullable — null means "this facility doesn't offer that
|
||||||
|
* mode," not zero. bookingUnitsFree is forced to 0 when an active class occupies the window. */
|
||||||
export interface FacilityAvailability {
|
export interface FacilityAvailability {
|
||||||
facilityId: string
|
facilityId: string
|
||||||
facilityName: string
|
facilityName: string
|
||||||
bookingMode: BookingMode
|
supportsClasses: boolean
|
||||||
totalUnits: number
|
supportsBooking: boolean
|
||||||
unitsFree: number
|
bookingTotalUnits?: number | null
|
||||||
|
bookingUnitsFree?: number | null
|
||||||
|
classTotalCapacity?: number | null
|
||||||
|
classUnitsFree?: number | null
|
||||||
pricePerHour: number
|
pricePerHour: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,12 @@ export interface GymPlanInput {
|
|||||||
installmentCount?: number
|
installmentCount?: number
|
||||||
/** Replaces the plan's place set when provided; omit to leave unchanged, [] to clear. */
|
/** Replaces the plan's place set when provided; omit to leave unchanged, [] to clear. */
|
||||||
placeIds?: string[]
|
placeIds?: string[]
|
||||||
|
/** Null/omitted = unlimited-access plan. A number = this plan grants that many bookable
|
||||||
|
* sessions per billing cycle -- used by the Classes tab's "Manage Plan" action. */
|
||||||
|
sessionsPerCycle?: number
|
||||||
|
/** Classes tab's "Manage Plan" action only -- links the new plan to one class. */
|
||||||
|
facilityClassId?: string
|
||||||
|
/** Only meaningful with facilityClassId -- whether this class plan also grants general
|
||||||
|
* gym-floor access instead of class-only. Defaults to false (class-only) when omitted. */
|
||||||
|
grantsGymAccess?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import type { PaymentMethod } from "@/lib/models/booking"
|
||||||
|
|
||||||
|
/** Mirrors ProductDto. */
|
||||||
|
export interface Product {
|
||||||
|
productId: string
|
||||||
|
name: string
|
||||||
|
/** System-generated at creation (e.g. "PRD-0004") -- never user-entered or editable. */
|
||||||
|
sku?: string | null
|
||||||
|
/** Base64 data URL, or null when no photo has been uploaded -- set via a separate upload call. */
|
||||||
|
imageUrl?: string | null
|
||||||
|
unitOfMeasure: string
|
||||||
|
/** Set from the most recent GRN line's unit cost -- read-only, system-maintained. */
|
||||||
|
costPrice: number
|
||||||
|
sellingPrice: number
|
||||||
|
/** A maintained running balance -- read-only, system-maintained. */
|
||||||
|
quantityOnHand: number
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductInput {
|
||||||
|
name: string
|
||||||
|
unitOfMeasure: string
|
||||||
|
sellingPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in a Product's detail-page stock-movement history. Mirrors ProductMovementDto. */
|
||||||
|
export interface ProductMovement {
|
||||||
|
type: "GRN" | "Sale" | "Return"
|
||||||
|
reference: string
|
||||||
|
/** Signed: positive for a GRN (stock in), negative for a Sale or Return (stock out). */
|
||||||
|
quantityChange: number
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors ProductDetailDto. */
|
||||||
|
export interface ProductDetail extends Product {
|
||||||
|
movements: ProductMovement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors SupplierDto. */
|
||||||
|
export interface Supplier {
|
||||||
|
supplierId: string
|
||||||
|
name: string
|
||||||
|
phone?: string | null
|
||||||
|
email?: string | null
|
||||||
|
address?: string | null
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierInput {
|
||||||
|
name: string
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
address?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoodsReceivedNoteItem {
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
quantity: number
|
||||||
|
unitCost: number
|
||||||
|
lineTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors GoodsReceivedNoteDto. */
|
||||||
|
export interface GoodsReceivedNote {
|
||||||
|
grnId: string
|
||||||
|
grnNumber: string
|
||||||
|
/** Null when this GRN was recorded without a supplier. */
|
||||||
|
supplierId?: string | null
|
||||||
|
supplierName?: string | null
|
||||||
|
receivedAt: string
|
||||||
|
notes?: string | null
|
||||||
|
totalAmount: number
|
||||||
|
items: GoodsReceivedNoteItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrnItemInput {
|
||||||
|
productId: string
|
||||||
|
quantity: number
|
||||||
|
unitCost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGrnInput {
|
||||||
|
/** Optional -- some GRNs don't have a supplier on file. */
|
||||||
|
supplierId?: string
|
||||||
|
receivedAt?: string
|
||||||
|
notes?: string
|
||||||
|
items: GrnItemInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaleItem {
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
lineTotal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors SaleDto. */
|
||||||
|
export interface Sale {
|
||||||
|
saleId: string
|
||||||
|
saleNumber: string
|
||||||
|
soldAt: string
|
||||||
|
paymentMethod: PaymentMethod
|
||||||
|
customerId?: string | null
|
||||||
|
customerName?: string | null
|
||||||
|
totalAmount: number
|
||||||
|
items: SaleItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaleItemInput {
|
||||||
|
productId: string
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSaleInput {
|
||||||
|
soldAt?: string
|
||||||
|
paymentMethod: PaymentMethod
|
||||||
|
customerId?: string
|
||||||
|
customerName?: string
|
||||||
|
items: SaleItemInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReturnCategory = "Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other"
|
||||||
|
|
||||||
|
/** Mirrors StockReturnDto. Only "CustomerReturn" adds back to stock; every other category
|
||||||
|
* removes from it. */
|
||||||
|
export interface StockReturn {
|
||||||
|
stockReturnId: string
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
quantity: number
|
||||||
|
category: ReturnCategory
|
||||||
|
/** Optional traceability link -- which sale this return came from, if any. */
|
||||||
|
saleId?: string | null
|
||||||
|
saleNumber?: string | null
|
||||||
|
/** Optional traceability link -- which GRN this return relates to, if any. */
|
||||||
|
grnId?: string | null
|
||||||
|
grnNumber?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
returnedAt: string
|
||||||
|
/** Quantity * the product's cost price at the moment of the return -- auto-computed. */
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row in the Stock Tracking tab's unified ledger. Mirrors StockMovementDto. */
|
||||||
|
export interface StockMovement {
|
||||||
|
type: "GRN" | "Sale" | "Return"
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
reference: string
|
||||||
|
/** Only set when type === "Return". */
|
||||||
|
category?: ReturnCategory | null
|
||||||
|
/** Signed: positive for a GRN or a CustomerReturn, negative for a Sale or any other return. */
|
||||||
|
quantityChange: number
|
||||||
|
amount: number
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateStockReturnInput {
|
||||||
|
productId: string
|
||||||
|
quantity: number
|
||||||
|
category: ReturnCategory
|
||||||
|
/** Optional traceability link -- e.g. a customer return that came from a specific sale. */
|
||||||
|
saleId?: string
|
||||||
|
/** Optional traceability link -- e.g. stock sent back to the supplier from a specific GRN. */
|
||||||
|
grnId?: string
|
||||||
|
notes?: string
|
||||||
|
returnedAt?: string
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
Rss,
|
Rss,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -34,6 +35,7 @@ export const NAV_ITEMS: DashboardNavItem[] = [
|
|||||||
{ key: "members", label: "Members", href: "/dashboard/members", icon: Users },
|
{ key: "members", label: "Members", href: "/dashboard/members", icon: Users },
|
||||||
{ key: "memberships", label: "Memberships", href: "/dashboard/memberships", icon: CreditCard },
|
{ key: "memberships", label: "Memberships", href: "/dashboard/memberships", icon: CreditCard },
|
||||||
{ key: "customer-payments", label: "Customer Payments", shortLabel: "Payments", href: "/dashboard/customer-payments", icon: Receipt },
|
{ key: "customer-payments", label: "Customer Payments", shortLabel: "Payments", href: "/dashboard/customer-payments", icon: Receipt },
|
||||||
|
{ key: "inventory", label: "Inventory", href: "/dashboard/inventory", icon: Package },
|
||||||
{ key: "bookings", label: "Bookings", href: "/dashboard/bookings", icon: CalendarClock },
|
{ key: "bookings", label: "Bookings", href: "/dashboard/bookings", icon: CalendarClock },
|
||||||
{ key: "workout-plans", label: "Workout Plans", href: "/dashboard/workout-plans", icon: Activity },
|
{ key: "workout-plans", label: "Workout Plans", href: "/dashboard/workout-plans", icon: Activity },
|
||||||
{ key: "meal-plans", label: "Meal Plans", href: "/dashboard/meal-plans", icon: Utensils },
|
{ key: "meal-plans", label: "Meal Plans", href: "/dashboard/meal-plans", icon: Utensils },
|
||||||
|
|||||||
Reference in New Issue
Block a user