Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/protected/settings/locations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { LocationTable } from "@/components/settings/locations/LocationTable"
import type { Counter } from "@/generated/prisma/client"
import { doesLocationCodeExist } from "@/lib/prisma/location/doesLocationCodeExist"
import { getAllLocations } from "@/lib/prisma/location/getAllLocations"
import { insertLocation } from "@/lib/prisma/location/insertLocation"
import { updateLocation } from "@/lib/prisma/location/updateLocation"
import { getAllServices } from "@/lib/prisma/service/getAllServices"
import { getAllStaffUsers } from "@/lib/prisma/staff_user/getAllStaffUsers"
Expand Down Expand Up @@ -30,6 +31,7 @@ export default async function Page() {
counters={counters}
staffUsers={staffUsers}
updateLocation={updateLocation}
insertLocation={insertLocation}
doesLocationCodeExist={doesLocationCodeExist}
revalidateTable={revalidateTable}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/components/geocoder/AddressAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const AddressAutocomplete = ({
<div className="relative">
<label htmlFor={id} className="block text-xs font-medium text-typography-primary">
{label}
{required && <span className="ml-xs text-error">*</span>}
{required && <span className="ml-xs text-red-600">*</span>}
</label>

<div className="relative mt-xs">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"use client"

import { useEffect, useState } from "react"
import { z } from "zod"
import {
CloseButton,
DialogActions,
DialogBody,
DialogHeader,
DialogTitle,
Modal,
} from "@/components/common/dialog"
import type { Counter, StaffUser } from "@/generated/prisma/client"
import type { LocationWithRelations } from "@/lib/prisma/location/types"
import type { ServiceWithRelations } from "@/lib/prisma/service/types"
import { LocationForm } from "../LocationForm"

type CreateLocationModalProps = {
open: boolean
onClose: () => void
services: ServiceWithRelations[]
counters: Counter[]
staffUsers: StaffUser[]
insertLocation: (
location: Partial<LocationWithRelations>
) => Promise<LocationWithRelations | null>
doesLocationCodeExist: (code: string) => Promise<boolean>
revalidateTable: () => Promise<void>
}

export const CreateLocationModal = ({
open,
onClose,
services,
counters,
staffUsers,
insertLocation,
doesLocationCodeExist,
revalidateTable,
}: CreateLocationModalProps) => {
const [isSaving, setIsSaving] = useState(false)
const [formData, setFormData] = useState<Partial<LocationWithRelations> | null>(null)
const [isFormValidState, setIsFormValidState] = useState<boolean>(false)
const [isFormValidating, setIsFormValidating] = useState<boolean>(false)

// initialize form data when the modal opens
useEffect(() => {
if (open) {
setFormData({
name: "",
code: "",
streetAddress: "",
mailAddress: null,
phoneNumber: null,
timezone: "America/Vancouver",
latitude: undefined,
longitude: undefined,
legacyOfficeNumber: null,
deletedAt: null,
services: [],
counters: [],
staffUsers: [],
})
} else {
setFormData(null)
setIsFormValidState(false)
setIsFormValidating(false)
}
}, [open])

const NewLocationWithRelationsSchema = z.object({
name: z.string().min(1, "Name is required"),
code: z
.string()
.min(1, "Code is required")
.refine(
async (code) => {
return !(await doesLocationCodeExist(code))
},
{ message: "Code already exists" }
),
streetAddress: z.string(),
mailAddress: z.string().nullable(),
timezone: z.string(),
phoneNumber: z
.string()
.nullable()
.refine(
(phone) => {
// Allow null or empty string (optional field)
if (!phone || phone.trim() === "") return true
// Must contain at least 10 digits for a valid phone number
const digits = phone.replace(/\D/g, "")
return digits.length >= 10
},
{ message: "Phone number must contain at least 10 digits" }
),
latitude: z.number(),
longitude: z.number(),
legacyOfficeNumber: z.number().nullable(),
services: z.array(z.any()),
counters: z.array(z.any()),
staffUsers: z.array(z.any()),
})

// Validate formData asynchronously and update local state instead of calling async validators during render
// biome-ignore lint/correctness/useExhaustiveDependencies: <>
useEffect(() => {
if (!formData) {
setIsFormValidState(false)
setIsFormValidating(false)
return
}

let active = true
setIsFormValidating(true)

NewLocationWithRelationsSchema.parseAsync(formData)
.then(() => {
if (active) setIsFormValidState(true)
})
.catch(() => {
if (active) setIsFormValidState(false)
})
.finally(() => {
if (active) setIsFormValidating(false)
})

return () => {
active = false
}
}, [formData, doesLocationCodeExist])

if (!formData) return null

const isArchived = formData.deletedAt !== null
const isReadonly = isArchived

const handleSave = async () => {
if (formData && !isReadonly) {
setIsSaving(true)
await insertLocation(formData)
await revalidateTable()
onClose()
setIsSaving(false)
}
}

return (
<Modal open={open} onClose={onClose} size="lg">
<DialogHeader trailing={<CloseButton onClick={onClose} />}>
<DialogTitle>Create Location</DialogTitle>
</DialogHeader>

<DialogBody>
<form className="space-y-5">
<LocationForm
location={formData}
services={services}
counters={counters}
staffUsers={staffUsers}
setFormData={setFormData}
doesLocationCodeExist={doesLocationCodeExist}
isReadonly={isReadonly}
/>
</form>
</DialogBody>

<DialogActions>
<button type="button" className="tertiary" onClick={onClose}>
Cancel
</button>
<button
type="button"
className="primary"
onClick={handleSave}
disabled={isSaving || isFormValidating || !isFormValidState}
>
{isSaving ? "Saving..." : "Save Changes"}
</button>
</DialogActions>
</Modal>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./CreateLocationModal"
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ export const EditLocationModal = ({
},
{ message: "Code already exists" }
),
streetAddress: z.string(),
mailAddress: z.string().nullable(),
phoneNumber: z
.string()
.nullable()
.refine(
(phone) => {
// Allow null or empty string (optional field)
if (!phone || phone.trim() === "") return true
// Must contain at least 10 digits for a valid phone number
const digits = phone.replace(/\D/g, "")
return digits.length >= 10
},
{ message: "Phone number must contain at least 10 digits" }
),
timezone: z.string(),
latitude: z.number(),
longitude: z.number(),
legacyOfficeNumber: z.number().nullable(),
deletedAt: z.date().nullable(),
createdAt: z.date(),
Expand Down
3 changes: 1 addition & 2 deletions src/components/settings/locations/LocationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const LocationForm = ({
<AddressAutocomplete
id="location-street-address"
label="Street Address"
required
value={location.streetAddress || ""}
onSelect={(suggestion: AddressSuggestion) => {
// Update the form with the selected address information
Expand All @@ -156,15 +157,13 @@ export const LocationForm = ({
value={location.latitude?.toString() || ""}
onChange={() => {}}
disabled
className="flex-1"
/>
<TextField
id="location-longitude"
label="Longitude"
value={location.longitude?.toString() || ""}
onChange={() => {}}
disabled
className="flex-1"
/>
<SelectInput
id="location-timezone"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Counter, StaffUser } from "@/generated/prisma/client"
import { useDialog } from "@/hooks/useDialog"
import type { LocationWithRelations } from "@/lib/prisma/location/types"
import type { ServiceWithRelations } from "@/lib/prisma/service/types"
import { CreateLocationModal } from "../CreateLocationModal"
import { EditLocationModal } from "../EditLocationModal"
import { columns } from "./columns"

Expand All @@ -19,6 +20,9 @@ export type LocationTableProps = {
location: Partial<LocationWithRelations>,
prevLocation: Partial<LocationWithRelations>
) => Promise<LocationWithRelations | null>
insertLocation: (
location: Partial<LocationWithRelations>
) => Promise<LocationWithRelations | null>
doesLocationCodeExist: (code: string) => Promise<boolean>
revalidateTable: () => Promise<void>
}
Expand All @@ -29,6 +33,7 @@ export const LocationTable = ({
counters,
staffUsers,
updateLocation,
insertLocation,
doesLocationCodeExist,
revalidateTable,
}: LocationTableProps) => {
Expand All @@ -37,6 +42,11 @@ export const LocationTable = ({
openDialog: openEditLocationModal,
closeDialog: closeEditLocationModal,
} = useDialog()
const {
open: createLocationModalOpen,
openDialog: openCreateLocationModal,
closeDialog: closeCreateLocationModal,
} = useDialog()

const [showArchived, setShowArchived] = useState<boolean>(false)
const [selectedLocation, setSelectedLocation] = useState<LocationWithRelations | null>(null)
Expand All @@ -55,7 +65,7 @@ export const LocationTable = ({
<div className="flex items-center justify-end mb-3 gap-4">
<h3 className="self-center text-sm font-medium text-gray-700">Show Archived</h3>
<Switch checked={showArchived} onChange={setShowArchived} />
<button type="button" className="primary">
<button type="button" className="primary" onClick={openCreateLocationModal}>
+ Create
</button>
</div>
Expand Down Expand Up @@ -85,6 +95,16 @@ export const LocationTable = ({
doesLocationCodeExist={doesLocationCodeExist}
revalidateTable={revalidateTable}
/>
<CreateLocationModal
open={createLocationModalOpen}
onClose={closeCreateLocationModal}
services={services}
counters={counters}
staffUsers={staffUsers}
insertLocation={insertLocation}
doesLocationCodeExist={doesLocationCodeExist}
revalidateTable={revalidateTable}
/>
</>
)
}