mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
feat(sites): Add Site modal and site detail panel with in-place edit
- Sites page: Add Site modal with Site Code, Client, address, State/Area, optional Site Phone and Email, points of contact - Duplicate Site Code shows inline under the field and keeps the modal open - Row opens a non-modal detail panel; Street Address links to Google Maps; key-less map preview - Edit switches the panel in place with Site Code read-only; Save needs a valid change, Cancel makes no call - Site Phone is sent independently of the contacts; zip and title are carried through edits - Retire the separate new/edit location pages
This commit is contained in:
parent
358ec3e890
commit
a75b9c8be6
34 changed files with 1244 additions and 518 deletions
|
|
@ -26,7 +26,7 @@
|
||||||
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
|
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
|
||||||
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
|
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
|
||||||
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
|
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
|
||||||
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts |
|
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts |
|
||||||
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
|
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
|
||||||
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
|
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
|
||||||
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |
|
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import {
|
||||||
|
locationDefaultValues,
|
||||||
|
locationSchema,
|
||||||
|
type LocationFormValues,
|
||||||
|
} from "@/domain/locations/schemas/location-schema";
|
||||||
|
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
|
||||||
|
import {
|
||||||
|
DUPLICATE_SITE_CODE_MESSAGE,
|
||||||
|
isDuplicateSiteCodeError,
|
||||||
|
} from "@/domain/locations/utils/site-code-conflict";
|
||||||
|
import { SiteFormFields } from "./site-form-fields";
|
||||||
|
|
||||||
|
interface AddSiteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */
|
||||||
|
export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) {
|
||||||
|
const createLocation = useCreateLocation();
|
||||||
|
const form = useForm<LocationFormValues>({
|
||||||
|
resolver: zodResolver(locationSchema),
|
||||||
|
defaultValues: locationDefaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
form.reset(locationDefaultValues);
|
||||||
|
createLocation.reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: LocationFormValues) => {
|
||||||
|
createLocation.mutate(values, {
|
||||||
|
onSuccess: close,
|
||||||
|
onError: (error) => {
|
||||||
|
if (isDuplicateSiteCodeError(error)) {
|
||||||
|
form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
if (!createLocation.isPending) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="sm"
|
||||||
|
aria-labelledby="add-site-title"
|
||||||
|
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
|
||||||
|
>
|
||||||
|
<Box component="form" noValidate onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<DialogTitle id="add-site-title">
|
||||||
|
Add Site
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="span"
|
||||||
|
className="block"
|
||||||
|
sx={{ color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Create a new client site record.
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<SiteFormFields form={form} />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="text" onClick={close} disabled={createLocation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={createLocation.isPending}>
|
||||||
|
{createLocation.isPending ? "Adding..." : "Add Site"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/(protected)/locations/_components/client-select.tsx
Normal file
64
src/app/(protected)/locations/_components/client-select.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Controller, type Control, type FieldError } from "react-hook-form";
|
||||||
|
import { Autocomplete, TextField } from "@mui/material";
|
||||||
|
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||||
|
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
||||||
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
|
||||||
|
type ClientOption = Pick<AccountListItem, "id" | "name">;
|
||||||
|
|
||||||
|
interface ClientSelectProps {
|
||||||
|
control: Control<LocationFormValues>;
|
||||||
|
error?: FieldError;
|
||||||
|
/** Stored client, shown even when it is not in the first page of search results. */
|
||||||
|
initialClient?: ClientOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Searchable client (account) picker bound to `accountId`. */
|
||||||
|
export function ClientSelect({ control, error, initialClient }: ClientSelectProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 });
|
||||||
|
|
||||||
|
const options = useMemo<ClientOption[]>(() => {
|
||||||
|
const items: ClientOption[] = data?.items ?? [];
|
||||||
|
const hasInitial =
|
||||||
|
initialClient !== undefined &&
|
||||||
|
items.some((item) => String(item.id) === String(initialClient.id));
|
||||||
|
return initialClient === undefined || hasInitial ? items : [initialClient, ...items];
|
||||||
|
}, [data?.items, initialClient]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name="accountId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete<ClientOption>
|
||||||
|
options={options}
|
||||||
|
loading={isLoading}
|
||||||
|
getOptionLabel={(option) => option.name || String(option.id)}
|
||||||
|
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
|
||||||
|
value={options.find((option) => String(option.id) === field.value) ?? null}
|
||||||
|
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
onInputChange={(_event, value, reason) => {
|
||||||
|
if (reason === "input") {
|
||||||
|
setSearch(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Client"
|
||||||
|
required
|
||||||
|
placeholder="Search clients..."
|
||||||
|
error={Boolean(error)}
|
||||||
|
helperText={error?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -202,7 +202,7 @@ export function LocationContactsFieldArray({
|
||||||
<Box ref={rootRef}>
|
<Box ref={rootRef}>
|
||||||
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<Typography variant="subtitle2" className="font-semibold uppercase">
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
||||||
Contacts
|
Points of contact
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -210,7 +210,7 @@ export function LocationContactsFieldArray({
|
||||||
startIcon={<AddIcon fontSize="small" />}
|
startIcon={<AddIcon fontSize="small" />}
|
||||||
onClick={() => append({ name: "", phone: "" })}
|
onClick={() => append({ name: "", phone: "" })}
|
||||||
>
|
>
|
||||||
Add Contact
|
Add point of contact
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack spacing={1.5} className="mt-2">
|
<Stack spacing={1.5} className="mt-2">
|
||||||
|
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
Autocomplete,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
|
||||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
|
||||||
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
|
||||||
import { LocationContactsFieldArray } from "./location-contacts-field-array";
|
|
||||||
import { StateSelect } from "./state-select";
|
|
||||||
|
|
||||||
interface SharedFieldsProps {
|
|
||||||
control: Control<LocationFormValues>;
|
|
||||||
errors: FieldErrors<LocationFormValues>;
|
|
||||||
register: UseFormRegister<LocationFormValues>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationDetailsFieldsProps extends SharedFieldsProps {
|
|
||||||
accountOptions: AccountListItem[];
|
|
||||||
isLoadingAccounts: boolean;
|
|
||||||
onAccountSearchChange: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocationDetailsFields({
|
|
||||||
accountOptions,
|
|
||||||
control,
|
|
||||||
errors,
|
|
||||||
isLoadingAccounts,
|
|
||||||
onAccountSearchChange,
|
|
||||||
register,
|
|
||||||
}: LocationDetailsFieldsProps) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Typography variant="subtitle1" className="font-semibold">
|
|
||||||
Location Information
|
|
||||||
</Typography>
|
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
{...register("name")}
|
|
||||||
label="Name"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.name)}
|
|
||||||
helperText={errors.name?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="accountId"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Autocomplete
|
|
||||||
options={accountOptions}
|
|
||||||
loading={isLoadingAccounts}
|
|
||||||
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
|
|
||||||
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
|
|
||||||
value={accountOptions.find((account) => String(account.id) === field.value) ?? null}
|
|
||||||
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
|
|
||||||
onInputChange={(_event, value, reason) => {
|
|
||||||
if (reason === "input") {
|
|
||||||
onAccountSearchChange(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label="Account" placeholder="Search accounts..." />
|
|
||||||
)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<TextField
|
|
||||||
{...register("address")}
|
|
||||||
label="Address"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.address)}
|
|
||||||
helperText={errors.address?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
{...register("city")}
|
|
||||||
label="City"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.city)}
|
|
||||||
helperText={errors.city?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<StateSelect control={control} error={errors.state} />
|
|
||||||
<TextField
|
|
||||||
{...register("zipCode")}
|
|
||||||
label="Zip Code"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.zipCode)}
|
|
||||||
helperText={errors.zipCode?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
|
|
||||||
return (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Typography variant="subtitle1" className="font-semibold">
|
|
||||||
Contact Information
|
|
||||||
</Typography>
|
|
||||||
<LocationContactsFieldArray control={control} errors={errors} register={register} />
|
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
{...register("contactEmail")}
|
|
||||||
label="Contact Email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.contactEmail)}
|
|
||||||
helperText={errors.contactEmail?.message}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
name="status"
|
|
||||||
control={control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormControl fullWidth required error={Boolean(errors.status)}>
|
|
||||||
<InputLabel id="status-label">Status</InputLabel>
|
|
||||||
<Select {...field} labelId="status-label" label="Status" value={field.value}>
|
|
||||||
{LOCATION_STATUS_OPTIONS.map((status) => (
|
|
||||||
<MenuItem key={status} value={status}>
|
|
||||||
{status}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
|
|
||||||
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
|
|
||||||
import { normalizeStateCode } from "@/data/areas";
|
|
||||||
import {
|
|
||||||
locationDefaultValues,
|
|
||||||
locationSchema,
|
|
||||||
type LocationFormValues,
|
|
||||||
} from "@/domain/locations/schemas/location-schema";
|
|
||||||
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
|
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
|
||||||
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
|
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
|
||||||
import { LocationContactFields, LocationDetailsFields } from "./location-form-fields";
|
|
||||||
|
|
||||||
export default function LocationFormPage() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const isEdit = Boolean(id);
|
|
||||||
|
|
||||||
const [accountSearch, setAccountSearch] = useState("");
|
|
||||||
const debouncedAccountSearch = useDebounce(accountSearch, 300);
|
|
||||||
|
|
||||||
const { data: location, isLoading, error } = useLocationDetail(id);
|
|
||||||
const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({
|
|
||||||
search: debouncedAccountSearch,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 50,
|
|
||||||
});
|
|
||||||
const createLocation = useCreateLocation();
|
|
||||||
const updateLocation = useUpdateLocation();
|
|
||||||
|
|
||||||
const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<LocationFormValues>({
|
|
||||||
resolver: zodResolver(locationSchema),
|
|
||||||
defaultValues: locationDefaultValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (location && isEdit) {
|
|
||||||
reset({
|
|
||||||
name: location.name ?? "",
|
|
||||||
accountId: location.accountId != null ? String(location.accountId) : "",
|
|
||||||
address: location.address ?? "",
|
|
||||||
city: location.city ?? "",
|
|
||||||
state: normalizeStateCode(location.state) ?? "",
|
|
||||||
zipCode: location.zipCode ?? "",
|
|
||||||
contacts:
|
|
||||||
location.contacts && location.contacts.length > 0
|
|
||||||
? location.contacts
|
|
||||||
: [{ name: location.contact ?? "", phone: location.phone ?? "" }],
|
|
||||||
contactEmail: location.contactEmail ?? "",
|
|
||||||
status: (location.status as LocationFormValues["status"]) || "Active",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [location, isEdit, reset]);
|
|
||||||
|
|
||||||
const isSaving = createLocation.isPending || updateLocation.isPending;
|
|
||||||
|
|
||||||
const onSubmit = (values: LocationFormValues) => {
|
|
||||||
if (isEdit && id) {
|
|
||||||
updateLocation.mutate({ id, values });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createLocation.mutate(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEdit && isLoading) {
|
|
||||||
return (
|
|
||||||
<Box className="flex justify-center p-10">
|
|
||||||
<CircularProgress />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="mx-auto max-w-4xl p-4">
|
|
||||||
<Paper variant="outlined" className="p-6">
|
|
||||||
<Stack spacing={4}>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
{isEdit ? "Edit Location" : "Create a Location"}
|
|
||||||
</Typography>
|
|
||||||
<Button variant="text" onClick={() => navigate("/locations")}>
|
|
||||||
Back to list
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{Boolean(error) && (
|
|
||||||
<Alert severity="error">
|
|
||||||
{error instanceof Error ? error.message : "Failed to load location"}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
|
||||||
<LocationDetailsFields
|
|
||||||
accountOptions={accountOptions}
|
|
||||||
control={control}
|
|
||||||
errors={errors}
|
|
||||||
isLoadingAccounts={isLoadingAccounts}
|
|
||||||
onAccountSearchChange={setAccountSearch}
|
|
||||||
register={register}
|
|
||||||
/>
|
|
||||||
<LocationContactFields control={control} errors={errors} register={register} />
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="contained" disabled={isSaving}>
|
|
||||||
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
|
@ -21,83 +20,100 @@ import { StateAreaCell } from "./state-area-cell";
|
||||||
interface LocationsTableProps {
|
interface LocationsTableProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onDelete: (row: LocationListItem) => void;
|
onDelete: (row: LocationListItem) => void;
|
||||||
onEdit: (row: LocationListItem) => void;
|
onOpen: (row: LocationListItem) => void;
|
||||||
rows: LocationListItem[];
|
rows: LocationListItem[];
|
||||||
|
selectedId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) {
|
const COLUMN_COUNT = 8;
|
||||||
|
|
||||||
|
export function LocationsTable({
|
||||||
|
isLoading,
|
||||||
|
onDelete,
|
||||||
|
onOpen,
|
||||||
|
rows,
|
||||||
|
selectedId = null,
|
||||||
|
}: LocationsTableProps) {
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<TableContainer component={Paper} variant="outlined">
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Location Name</TableCell>
|
<TableCell className="font-semibold">Site Code</TableCell>
|
||||||
<TableCell className="font-semibold">Address</TableCell>
|
<TableCell className="font-semibold">Client</TableCell>
|
||||||
|
<TableCell className="font-semibold">Street Address</TableCell>
|
||||||
<TableCell className="font-semibold">City</TableCell>
|
<TableCell className="font-semibold">City</TableCell>
|
||||||
<TableCell className="font-semibold">State</TableCell>
|
<TableCell className="font-semibold">State</TableCell>
|
||||||
<TableCell className="font-semibold">Phone</TableCell>
|
<TableCell className="font-semibold">POC Name</TableCell>
|
||||||
<TableCell className="font-semibold">Contact</TableCell>
|
<TableCell className="font-semibold">POC Phone</TableCell>
|
||||||
<TableCell className="font-semibold">Status</TableCell>
|
|
||||||
<TableCell align="right" className="font-semibold">
|
<TableCell align="right" className="font-semibold">
|
||||||
Actions
|
<span className="sr-only">Actions</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} align="center" className="py-10">
|
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
|
||||||
<CircularProgress size={28} />
|
<CircularProgress size={28} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
)}
|
||||||
|
{!isLoading && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} align="center" className="py-10">
|
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
|
||||||
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
|
<Typography sx={{ color: "text.secondary" }}>No sites found</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
)}
|
||||||
|
{!isLoading &&
|
||||||
rows.map((row) => (
|
rows.map((row) => (
|
||||||
<TableRow key={String(row.id)} hover>
|
<TableRow
|
||||||
|
key={String(row.id)}
|
||||||
|
hover
|
||||||
|
selected={String(row.id) === selectedId}
|
||||||
|
onClick={() => onOpen(row)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
onClick={() => onEdit(row)}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onOpen(row);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{row.name || "—"}
|
{row.name || "—"}
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{row.clientName || "—"}</TableCell>
|
||||||
<TableCell>{row.address || "—"}</TableCell>
|
<TableCell>{row.address || "—"}</TableCell>
|
||||||
<TableCell>{row.city || "—"}</TableCell>
|
<TableCell>{row.city || "—"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StateAreaCell state={row.state} />
|
<StateAreaCell state={row.state} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.phone || "—"}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<LocationsContactsCell row={row} />
|
<LocationsContactsCell row={row} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.status || "—"}</TableCell>
|
<TableCell>{row.phone || "—"}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Tooltip title="Edit">
|
|
||||||
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
|
|
||||||
<EditOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Delete">
|
<Tooltip title="Delete">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label="Delete location"
|
aria-label={`Delete site ${row.name}`}
|
||||||
onClick={() => onDelete(row)}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete(row);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Box, Button, Stack } from "@mui/material";
|
||||||
|
import {
|
||||||
|
locationSchema,
|
||||||
|
type LocationFormValues,
|
||||||
|
} from "@/domain/locations/schemas/location-schema";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
|
||||||
|
import { SiteFormFields } from "./site-form-fields";
|
||||||
|
import { toSiteFormValues } from "./site-form-values";
|
||||||
|
|
||||||
|
interface SiteDetailEditProps {
|
||||||
|
siteId: string;
|
||||||
|
location: Location;
|
||||||
|
/** Back to view mode: after Cancel (no API call) or after a successful save. */
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */
|
||||||
|
export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) {
|
||||||
|
const updateLocation = useUpdateLocation();
|
||||||
|
const form = useForm<LocationFormValues>({
|
||||||
|
resolver: zodResolver(locationSchema),
|
||||||
|
defaultValues: toSiteFormValues(location),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
const { isDirty, isValid } = form.formState;
|
||||||
|
const initialClient =
|
||||||
|
location.accountId != null && location.clientName
|
||||||
|
? { id: location.accountId, name: location.clientName }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const onSubmit = (values: LocationFormValues) => {
|
||||||
|
updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
noValidate
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
aria-label="Edit site"
|
||||||
|
>
|
||||||
|
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
<SiteFormFields form={form} siteCodeReadOnly initialClient={initialClient} />
|
||||||
|
</Box>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
className="border-t px-4 py-3"
|
||||||
|
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
<Button variant="text" onClick={onDone} disabled={updateLocation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!isDirty || !isValid || updateLocation.isPending}
|
||||||
|
>
|
||||||
|
{updateLocation.isPending ? "Saving..." : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/app/(protected)/locations/_components/site-detail-panel.tsx
Normal file
115
src/app/(protected)/locations/_components/site-detail-panel.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||||
|
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { SiteDetailEdit } from "./site-detail-edit";
|
||||||
|
import { SiteDetailView } from "./site-detail-view";
|
||||||
|
|
||||||
|
export const SITE_PANEL_WIDTH = 420;
|
||||||
|
|
||||||
|
interface SiteDetailPanelProps {
|
||||||
|
/** Selected site id; `null` closes the panel. */
|
||||||
|
siteId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
className="border-b px-4 py-3"
|
||||||
|
sx={{ alignItems: "flex-start", justifyContent: "space-between", borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
{location?.name || "Site"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{location?.clientName || " "}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconCloseButton label="Close site details" onClick={onClose} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
|
||||||
|
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||||
|
const { data: location, isLoading, error } = useLocationDetail(siteId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PanelHeader location={location} onClose={onClose} />
|
||||||
|
{isLoading && (
|
||||||
|
<Box className="flex justify-center p-8">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{Boolean(error) && (
|
||||||
|
<Alert severity="error" className="m-4">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load site"}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{location !== undefined && mode === "edit" && (
|
||||||
|
<SiteDetailEdit siteId={siteId} location={location} onDone={() => setMode("view")} />
|
||||||
|
)}
|
||||||
|
{location !== undefined && mode === "view" && (
|
||||||
|
<>
|
||||||
|
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
<SiteDetailView location={location} />
|
||||||
|
</Box>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
className="border-t px-4 py-3"
|
||||||
|
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
<Button variant="contained" onClick={() => setMode("edit")}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-modal site panel: the table stays interactive, so clicking another row swaps the
|
||||||
|
* content in place. Escape closes it unless a popup inside already handled the key.
|
||||||
|
*/
|
||||||
|
export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) {
|
||||||
|
const open = siteId !== null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
variant="persistent"
|
||||||
|
open={open}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
role: "region",
|
||||||
|
"aria-label": "Site details",
|
||||||
|
className: "flex h-full flex-col border-l shadow-lg",
|
||||||
|
sx: { width: SITE_PANEL_WIDTH, maxWidth: "100vw" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{siteId !== null && <PanelBody key={siteId} siteId={siteId} onClose={onClose} />}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link, Stack, Typography } from "@mui/material";
|
||||||
|
import { getAreaForState } from "@/data/areas";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
|
||||||
|
import { SiteMapPreview } from "./site-map-preview";
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={0.25}>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" component="div">
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatState(state?: string): string {
|
||||||
|
const area = getAreaForState(state);
|
||||||
|
const code = state?.trim() ?? "";
|
||||||
|
if (!code) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return area === null ? code : `${code} · Area: ${area}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only site details, in the prototype order. */
|
||||||
|
export function SiteDetailView({ location }: { location: Location }) {
|
||||||
|
const contacts = location.contacts ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Field label="Site Code">{location.name || "—"}</Field>
|
||||||
|
<Field label="Client">{location.clientName || "—"}</Field>
|
||||||
|
<Field label="Street Address">
|
||||||
|
{Boolean(location.address) && (
|
||||||
|
<Link
|
||||||
|
href={buildSiteMapsUrl(location)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
{location.address}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!location.address && "—"}
|
||||||
|
</Field>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<Field label="City">{location.city || "—"}</Field>
|
||||||
|
<Field label="State">{formatState(location.state)}</Field>
|
||||||
|
</Stack>
|
||||||
|
<SiteMapPreview
|
||||||
|
address={location.address}
|
||||||
|
city={location.city}
|
||||||
|
state={location.state}
|
||||||
|
zipCode={location.zipCode}
|
||||||
|
/>
|
||||||
|
<Field label="Site Phone">{location.sitePhone || "—"}</Field>
|
||||||
|
<Field label="Site Email">{location.contactEmail || "—"}</Field>
|
||||||
|
<Field label="Points of contact">
|
||||||
|
{contacts.length === 0 && "—"}
|
||||||
|
<Stack component="ul" spacing={0.5} className="m-0 list-none p-0">
|
||||||
|
{contacts.map((contact, index) => (
|
||||||
|
<li key={contact.id ?? `${contact.name}-${index}`}>
|
||||||
|
{index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name}
|
||||||
|
{Boolean(contact.phone) && ` · ${contact.phone}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Field>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
|
||||||
|
import { Stack, TextField } from "@mui/material";
|
||||||
|
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||||
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
|
import { ClientSelect } from "./client-select";
|
||||||
|
import { LocationContactsFieldArray } from "./location-contacts-field-array";
|
||||||
|
import { SiteMapPreview } from "./site-map-preview";
|
||||||
|
import { StateSelect } from "./state-select";
|
||||||
|
|
||||||
|
interface SiteFormFieldsProps {
|
||||||
|
form: UseFormReturn<LocationFormValues>;
|
||||||
|
/** Edit mode: Site Code is immutable after creation. */
|
||||||
|
siteCodeReadOnly?: boolean;
|
||||||
|
initialClient?: Pick<AccountListItem, "id" | "name">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared Add Site / edit-mode fields, in the prototype order. */
|
||||||
|
export function SiteFormFields({
|
||||||
|
form,
|
||||||
|
siteCodeReadOnly = false,
|
||||||
|
initialClient,
|
||||||
|
}: SiteFormFieldsProps) {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Controller
|
||||||
|
name="name"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
onChange={(event) => field.onChange(event.target.value.toUpperCase())}
|
||||||
|
label="Site Code"
|
||||||
|
placeholder="e.g. AB12"
|
||||||
|
required={!siteCodeReadOnly}
|
||||||
|
disabled={siteCodeReadOnly}
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
helperText={
|
||||||
|
errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined)
|
||||||
|
}
|
||||||
|
slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
|
||||||
|
<TextField
|
||||||
|
{...register("address")}
|
||||||
|
label="Street Address"
|
||||||
|
placeholder="Street address"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.address)}
|
||||||
|
helperText={errors.address?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("city")}
|
||||||
|
label="City"
|
||||||
|
required
|
||||||
|
error={Boolean(errors.city)}
|
||||||
|
helperText={errors.city?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<StateSelect control={control} error={errors.state} />
|
||||||
|
</Stack>
|
||||||
|
<SiteMapPreview address={address} city={city} state={state} />
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
{...register("sitePhone")}
|
||||||
|
label="Site Phone (optional)"
|
||||||
|
placeholder="(000) 000-0000"
|
||||||
|
error={Boolean(errors.sitePhone)}
|
||||||
|
helperText={errors.sitePhone?.message}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{ htmlInput: { maxLength: 20 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
{...register("contactEmail")}
|
||||||
|
label="Site Email (optional)"
|
||||||
|
placeholder="site@company.com"
|
||||||
|
type="email"
|
||||||
|
error={Boolean(errors.contactEmail)}
|
||||||
|
helperText={errors.contactEmail?.message}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<LocationContactsFieldArray control={control} errors={errors} register={register} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { normalizeStateCode } from "@/data/areas";
|
||||||
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
|
||||||
|
/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */
|
||||||
|
export function toSiteFormValues(location: Location): LocationFormValues {
|
||||||
|
const contacts =
|
||||||
|
location.contacts && location.contacts.length > 0
|
||||||
|
? location.contacts
|
||||||
|
: [{ name: location.contact ?? "", phone: location.phone ?? "" }];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: location.name ?? "",
|
||||||
|
accountId: location.accountId != null ? String(location.accountId) : "",
|
||||||
|
address: location.address ?? "",
|
||||||
|
city: location.city ?? "",
|
||||||
|
state: normalizeStateCode(location.state) ?? "",
|
||||||
|
sitePhone: location.sitePhone ?? "",
|
||||||
|
contactEmail: location.contactEmail ?? "",
|
||||||
|
contacts,
|
||||||
|
zipCode: location.zipCode ?? "",
|
||||||
|
title: location.title ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps";
|
||||||
|
|
||||||
|
type SiteMapPreviewProps = Pick<Location, "address" | "city" | "state" | "zipCode">;
|
||||||
|
|
||||||
|
/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */
|
||||||
|
export function SiteMapPreview(props: SiteMapPreviewProps) {
|
||||||
|
const complete = hasCompleteSiteAddress(props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
Location preview
|
||||||
|
</Typography>
|
||||||
|
{complete && (
|
||||||
|
<Box
|
||||||
|
component="iframe"
|
||||||
|
title="Site location map"
|
||||||
|
src={buildSiteMapEmbedUrl(props)}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
className="mt-1 block w-full rounded border-0"
|
||||||
|
sx={{ height: 140 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!complete && (
|
||||||
|
<Box
|
||||||
|
className="mt-1 flex items-center justify-center rounded border border-dashed"
|
||||||
|
sx={{ height: 140, borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Enter an address to preview the location
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from "../_components/location-form";
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -15,20 +14,24 @@ import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-locat
|
||||||
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
||||||
|
import { AddSiteDialog } from "./_components/add-site-dialog";
|
||||||
import { AreaFacet } from "./_components/area-facet";
|
import { AreaFacet } from "./_components/area-facet";
|
||||||
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
|
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
|
||||||
import { LocationsTable } from "./_components/locations-table";
|
import { LocationsTable } from "./_components/locations-table";
|
||||||
|
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
||||||
|
|
||||||
export default function LocationsListPage() {
|
export default function LocationsListPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
||||||
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const deleteLocation = useDeleteLocation();
|
const deleteLocation = useDeleteLocation();
|
||||||
|
const closePanel = useCallback(() => setSelectedId(null), []);
|
||||||
|
|
||||||
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
||||||
|
|
||||||
|
|
@ -52,13 +55,20 @@ export default function LocationsListPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deletedId = String(deleteTarget.id);
|
||||||
deleteLocation.mutate(deleteTarget.id, {
|
deleteLocation.mutate(deleteTarget.id, {
|
||||||
onSuccess: () => setDeleteTarget(null),
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setSelectedId((current) => (current === deletedId ? null : current));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box
|
||||||
|
className="flex flex-col gap-4 p-4"
|
||||||
|
sx={{ pr: { md: selectedId === null ? 2 : `${SITE_PANEL_WIDTH + 16}px` } }}
|
||||||
|
>
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ xs: "column", md: "row" }}
|
direction={{ xs: "column", md: "row" }}
|
||||||
spacing={2}
|
spacing={2}
|
||||||
|
|
@ -67,34 +77,17 @@ export default function LocationsListPage() {
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
Locations
|
Sites
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totalCount} total
|
{totalCount} total
|
||||||
</Typography>
|
</Typography>
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ alignItems: "center" }}>
|
||||||
direction={{ xs: "column", sm: "row" }}
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AreaFacet
|
<AreaFacet
|
||||||
value={selectedAreas}
|
value={selectedAreas}
|
||||||
onChange={(zones) => {
|
onChange={(zones) => {
|
||||||
|
|
@ -109,23 +102,24 @@ export default function LocationsListPage() {
|
||||||
setSearch(event.target.value);
|
setSearch(event.target.value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
placeholder="Search locations..."
|
placeholder="Search sites..."
|
||||||
className="min-w-[220px]"
|
className="min-w-[220px]"
|
||||||
/>
|
/>
|
||||||
<Button variant="contained" onClick={() => navigate("/locations/new")}>
|
<Button variant="contained" onClick={() => setIsAddOpen(true)}>
|
||||||
+ New Location
|
Add Site
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{Boolean(error) && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load locations"}
|
{error instanceof Error ? error.message : "Failed to load sites"}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<LocationsTable
|
<LocationsTable
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
|
selectedId={selectedId}
|
||||||
|
onOpen={(row) => setSelectedId(String(row.id))}
|
||||||
onDelete={setDeleteTarget}
|
onDelete={setDeleteTarget}
|
||||||
/>
|
/>
|
||||||
<TablePagination
|
<TablePagination
|
||||||
|
|
@ -140,6 +134,8 @@ export default function LocationsListPage() {
|
||||||
}}
|
}}
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
/>
|
/>
|
||||||
|
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
||||||
|
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
||||||
<LocationDeleteDialog
|
<LocationDeleteDialog
|
||||||
target={deleteTarget}
|
target={deleteTarget}
|
||||||
isPending={deleteLocation.isPending}
|
isPending={deleteLocation.isPending}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from "./_components/location-form";
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
|
||||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
|
@ -40,6 +40,5 @@ export function resolveLocationId(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMapsUrl(location: Location): string {
|
export function buildMapsUrl(location: Location): string {
|
||||||
const query = formatLocationAddressPreview(location);
|
return buildSiteMapsUrl(location);
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,9 @@ import { Text } from "@/components/ui/text";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
|
||||||
function buildMapsUrl(location: Location): string {
|
|
||||||
const query = formatLocationAddressPreview(location);
|
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WizardStepLocationSiteDetailsProps = {
|
type WizardStepLocationSiteDetailsProps = {
|
||||||
selectedLoc: LocationOption | undefined;
|
selectedLoc: LocationOption | undefined;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
|
|
@ -58,7 +54,7 @@ export function WizardStepLocationSiteDetails({
|
||||||
)}
|
)}
|
||||||
{Boolean(addressPreview) && (
|
{Boolean(addressPreview) && (
|
||||||
<a
|
<a
|
||||||
href={buildMapsUrl(locationDetail)}
|
href={buildSiteMapsUrl(locationDetail)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ export function mapLocationListItem(raw: unknown): LocationListItem {
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
name: readString(item, "name", "Name"),
|
name: readString(item, "name", "Name"),
|
||||||
|
clientName: readString(item, "clientName", "ClientName", "accountName", "AccountName"),
|
||||||
|
sitePhone: readString(item, "phone", "Phone"),
|
||||||
address: readString(item, "address", "Address"),
|
address: readString(item, "address", "Address"),
|
||||||
city: readString(item, "city", "City"),
|
city: readString(item, "city", "City"),
|
||||||
state: readString(item, "state", "State"),
|
state: readString(item, "state", "State"),
|
||||||
|
|
@ -161,6 +163,11 @@ export function mapLocation(raw: unknown): Location {
|
||||||
return {
|
return {
|
||||||
id: readId(item) || undefined,
|
id: readId(item) || undefined,
|
||||||
name: readString(item, "name", "Name") || undefined,
|
name: readString(item, "name", "Name") || undefined,
|
||||||
|
title: readString(item, "title", "Title") || undefined,
|
||||||
|
clientName:
|
||||||
|
readString(item, "clientName", "ClientName", "accountName", "AccountName") || undefined,
|
||||||
|
sitePhone: legacyPhone,
|
||||||
|
notes: readString(item, "notes", "Notes") || undefined,
|
||||||
address: address || undefined,
|
address: address || undefined,
|
||||||
city: readString(item, "city", "City") || undefined,
|
city: readString(item, "city", "City") || undefined,
|
||||||
state: readString(item, "state", "State") || undefined,
|
state: readString(item, "state", "State") || undefined,
|
||||||
|
|
@ -196,6 +203,10 @@ export function mapContactsToBackend(raw: unknown): LocationContact[] {
|
||||||
.filter((contact) => contact.name !== "" || contact.phone !== "");
|
.filter((contact) => contact.name !== "" || contact.phone !== "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create/update payload. `phone` is the Site Phone (independent of the contacts); `status`,
|
||||||
|
* `title` and `notes` are sent only when present so an update keeps the stored values.
|
||||||
|
*/
|
||||||
export function mapLocationToBackend(location: unknown): Record<string, unknown> {
|
export function mapLocationToBackend(location: unknown): Record<string, unknown> {
|
||||||
const item = (typeof location === "object" && location !== null ? location : {}) as Record<
|
const item = (typeof location === "object" && location !== null ? location : {}) as Record<
|
||||||
string,
|
string,
|
||||||
|
|
@ -206,19 +217,24 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
|
||||||
const mainContact = contacts[0];
|
const mainContact = contacts[0];
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
name: item.name ?? "",
|
name: typeof item.name === "string" ? item.name.trim() : "",
|
||||||
address: item.address ?? "",
|
address: item.address ?? "",
|
||||||
city: item.city ?? "",
|
city: item.city ?? "",
|
||||||
state: item.state ?? "",
|
state: item.state ?? "",
|
||||||
zipCode: item.zipCode ?? "",
|
zipCode: item.zipCode ?? "",
|
||||||
phone: mainContact ? mainContact.phone : (item.phone ?? ""),
|
phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "",
|
||||||
contact: mainContact ? mainContact.name : (item.contact ?? ""),
|
contact: mainContact ? mainContact.name : "",
|
||||||
contactEmail: item.contactEmail ?? "",
|
contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "",
|
||||||
status: item.status ?? "Active",
|
|
||||||
accountId: item.accountId ?? "",
|
accountId: item.accountId ?? "",
|
||||||
contacts,
|
contacts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (const key of ["status", "title", "notes"] as const) {
|
||||||
|
if (typeof item[key] === "string") {
|
||||||
|
payload[key] = item[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = item.id ?? item.Id;
|
const id = item.id ?? item.Id;
|
||||||
if (typeof id === "string" || typeof id === "number") {
|
if (typeof id === "string" || typeof id === "number") {
|
||||||
payload.id = id;
|
payload.id = id;
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,43 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { US_STATE_CODES } from "@/data/areas";
|
import { US_STATE_CODES } from "@/data/areas";
|
||||||
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
|
|
||||||
|
|
||||||
export const locationContactSchema = z.object({
|
export const locationContactSchema = z.object({
|
||||||
id: z.number().int().positive().optional(),
|
id: z.number().int().positive().optional(),
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, "Contact person is required")
|
.min(1, "Contact name is required")
|
||||||
.max(100, "Contact person cannot exceed 100 characters"),
|
.max(100, "Contact name cannot exceed 100 characters"),
|
||||||
phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"),
|
phone: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Contact phone is required")
|
||||||
|
.max(20, "Contact phone cannot exceed 20 characters"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Add Site / Site detail edit form. `zipCode` and `title` are carried through, not edited. */
|
||||||
export const locationSchema = z.object({
|
export const locationSchema = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z
|
||||||
accountId: z.string().optional(),
|
.string()
|
||||||
address: z.string().min(1, "Address is required"),
|
.trim()
|
||||||
city: z.string().min(1, "City is required"),
|
.min(1, "Site Code is required")
|
||||||
|
.max(50, "Site Code cannot exceed 50 characters"),
|
||||||
|
accountId: z.string().min(1, "Client is required"),
|
||||||
|
address: z.string().trim().min(1, "Street Address is required"),
|
||||||
|
city: z.string().trim().min(1, "City is required"),
|
||||||
state: z
|
state: z
|
||||||
.union([z.enum(US_STATE_CODES), z.literal("")])
|
.union([z.enum(US_STATE_CODES), z.literal("")])
|
||||||
.refine((state): boolean => state !== "", { message: "State is required" }),
|
.refine((state): boolean => state !== "", { message: "State is required" }),
|
||||||
zipCode: z.string().min(1, "Zip code is required"),
|
sitePhone: z.string().trim().max(20, "Site Phone cannot exceed 20 characters"),
|
||||||
|
contactEmail: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => value === "" || z.email().safeParse(value).success, {
|
||||||
|
message: "Invalid email address",
|
||||||
|
}),
|
||||||
contacts: z.array(locationContactSchema).min(1, "At least one contact is required"),
|
contacts: z.array(locationContactSchema).min(1, "At least one contact is required"),
|
||||||
contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"),
|
zipCode: z.string(),
|
||||||
status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }),
|
title: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LocationFormValues = z.infer<typeof locationSchema>;
|
export type LocationFormValues = z.infer<typeof locationSchema>;
|
||||||
|
|
@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = {
|
||||||
address: "",
|
address: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
zipCode: "",
|
sitePhone: "",
|
||||||
contacts: [{ name: "", phone: "" }],
|
|
||||||
contactEmail: "",
|
contactEmail: "",
|
||||||
status: "Active",
|
contacts: [{ name: "", phone: "" }],
|
||||||
|
zipCode: "",
|
||||||
|
title: "",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ export interface LocationContact {
|
||||||
export interface LocationListItem {
|
export interface LocationListItem {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Client (account) display name. */
|
||||||
|
clientName: string;
|
||||||
|
/** Site Phone, independent of the contacts. */
|
||||||
|
sitePhone: string;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
|
@ -26,6 +30,10 @@ export interface LocationListItem {
|
||||||
export interface Location {
|
export interface Location {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
clientName?: string;
|
||||||
|
sitePhone?: string;
|
||||||
|
notes?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
|
|
@ -61,5 +69,3 @@ export interface LocationsListResult {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const;
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,27 @@
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import { isDuplicateSiteCodeError } from "@/domain/locations/utils/site-code-conflict";
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
export function useCreateLocation(): UseMutationResult<Location, Error, LocationFormValues> {
|
/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */
|
||||||
const navigate = useNavigate();
|
export function useCreateLocation(): UseMutationResult<void, Error, LocationFormValues> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (values: LocationFormValues) => locationsApi.create(values),
|
mutationFn: async (values: LocationFormValues) => {
|
||||||
|
await locationsApi.create({ ...values, status: "Active" });
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
||||||
toast.success("Location created successfully!");
|
toast.success("Site added");
|
||||||
navigate("/locations");
|
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to create location");
|
if (isDuplicateSiteCodeError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(error.message || "Failed to add site");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
interface UpdateLocationInput {
|
interface UpdateLocationInput {
|
||||||
|
|
@ -11,20 +9,25 @@ interface UpdateLocationInput {
|
||||||
values: LocationFormValues;
|
values: LocationFormValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateLocation(): UseMutationResult<Location, Error, UpdateLocationInput> {
|
/** Saves a site. The response carries no site, so the list and detail are refetched. */
|
||||||
const navigate = useNavigate();
|
export function useUpdateLocation(): UseMutationResult<void, Error, UpdateLocationInput> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values),
|
mutationFn: async ({ id, values }: UpdateLocationInput) => {
|
||||||
onSuccess: (_data, variables) => {
|
await locationsApi.update(id, values);
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
|
},
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) });
|
onSuccess: async (_data, variables) => {
|
||||||
toast.success("Location updated successfully!");
|
await Promise.all([
|
||||||
navigate("/locations");
|
queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.locations.detail(String(variables.id)),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
toast.success("Site updated");
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message || "Failed to update location");
|
toast.error(error.message || "Failed to update site");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
src/domain/locations/utils/site-code-conflict.ts
Normal file
14
src/domain/locations/utils/site-code-conflict.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { isHTTPError } from "ky";
|
||||||
|
|
||||||
|
export const DUPLICATE_SITE_CODE_MESSAGE = "This site code already exists.";
|
||||||
|
|
||||||
|
/** The API answers a duplicate Site Code (same client, any case) with 409 DuplicateSiteCode. */
|
||||||
|
export function isDuplicateSiteCodeError(error: unknown): boolean {
|
||||||
|
if (!isHTTPError(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const data = error.data;
|
||||||
|
const code =
|
||||||
|
data && typeof data === "object" ? (data as Record<string, unknown>).code : undefined;
|
||||||
|
return error.response.status === 409 || code === "DuplicateSiteCode";
|
||||||
|
}
|
||||||
23
src/domain/locations/utils/site-maps.ts
Normal file
23
src/domain/locations/utils/site-maps.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
|
||||||
|
type SiteAddress = Pick<Location, "address" | "city" | "state" | "zipCode">;
|
||||||
|
|
||||||
|
/** Google Maps search link for a site address; opens in a new tab. */
|
||||||
|
export function buildSiteMapsUrl(location: SiteAddress): string {
|
||||||
|
const query = formatLocationAddressPreview(location);
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once Street, City and State are all filled, so a map preview can render. */
|
||||||
|
export function hasCompleteSiteAddress(location: SiteAddress): boolean {
|
||||||
|
return [location.address, location.city, location.state].every(
|
||||||
|
(part) => (part?.trim() ?? "") !== "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key-less Google Maps embed URL for the read-only preview. */
|
||||||
|
export function buildSiteMapEmbedUrl(location: SiteAddress): string {
|
||||||
|
const query = formatLocationAddressPreview(location);
|
||||||
|
return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`;
|
||||||
|
}
|
||||||
164
src/test/app/(protected)/locations/add-site-dialog.test.tsx
Normal file
164
src/test/app/(protected)/locations/add-site-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { HTTPError } from "ky";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog";
|
||||||
|
import { normalizeApiRequestError } from "@/api/api-error";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const create = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/api/locations-api", () => ({
|
||||||
|
locationsApi: { create: (...args: unknown[]) => create(...args) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
|
||||||
|
useAccountsList: () => ({
|
||||||
|
data: { items: [{ id: 7, name: "Acme Logistics" }], totalCount: 1, page: 1, pageSize: 50 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-debounce", () => ({
|
||||||
|
useDebounce: (value: string) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function duplicateCodeError(): HTTPError {
|
||||||
|
const body = {
|
||||||
|
status: "Conflict",
|
||||||
|
message: "This site code already exists.",
|
||||||
|
code: "DuplicateSiteCode",
|
||||||
|
};
|
||||||
|
const error = new HTTPError(
|
||||||
|
new Response(JSON.stringify(body), { status: 409 }),
|
||||||
|
new Request("https://api.example.test/api/locations"),
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
error.data = body;
|
||||||
|
return normalizeApiRequestError(error) as HTTPError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialog() {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderWithProviders(<AddSiteDialog open onClose={onClose} />, { withAuth: false });
|
||||||
|
return { onClose, dialog: screen.getByRole("dialog", { name: /add site/i }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickOption(label: string, optionName: string, search: string) {
|
||||||
|
const input = screen.getByRole("combobox", { name: label });
|
||||||
|
input.focus();
|
||||||
|
fireEvent.mouseDown(input);
|
||||||
|
fireEvent.change(input, { target: { value: search } });
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: optionName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillRequiredFields(dialog: HTMLElement) {
|
||||||
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "Site Code" }), {
|
||||||
|
target: { value: "dal1" },
|
||||||
|
});
|
||||||
|
await pickOption("Client", "Acme Logistics", "Acme");
|
||||||
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "Street Address" }), {
|
||||||
|
target: { value: "3811 Distribution Dr" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), {
|
||||||
|
target: { value: "Dallas" },
|
||||||
|
});
|
||||||
|
await pickOption("State", "Texas (TX)", "texas");
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/^Contact Name \(Main\)/), {
|
||||||
|
target: { value: "Jane" },
|
||||||
|
});
|
||||||
|
fireEvent.change(within(dialog).getByLabelText(/^Phone/), {
|
||||||
|
target: { value: "(421) 433-0032" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AddSiteDialog", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
create.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks submission and names every missing required field", async () => {
|
||||||
|
const { dialog } = renderDialog();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" }));
|
||||||
|
|
||||||
|
for (const message of [
|
||||||
|
"Site Code is required",
|
||||||
|
"Client is required",
|
||||||
|
"Street Address is required",
|
||||||
|
"City is required",
|
||||||
|
"State is required",
|
||||||
|
"Contact name is required",
|
||||||
|
"Contact phone is required",
|
||||||
|
]) {
|
||||||
|
expect(await within(dialog).findByText(message)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the map placeholder until Street, City and State are filled, then the preview", async () => {
|
||||||
|
const { dialog } = renderDialog();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText("Enter an address to preview the location"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await fillRequiredFields(dialog);
|
||||||
|
|
||||||
|
const map = within(dialog).getByTitle("Site location map");
|
||||||
|
expect(map).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX&output=embed",
|
||||||
|
);
|
||||||
|
expect(within(dialog).getByText("Area: West")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the site with an upper-cased code, optional fields blank, then closes", async () => {
|
||||||
|
create.mockResolvedValue({});
|
||||||
|
const { dialog, onClose } = renderDialog();
|
||||||
|
|
||||||
|
await fillRequiredFields(dialog);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||||
|
expect(create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "DAL1",
|
||||||
|
accountId: "7",
|
||||||
|
address: "3811 Distribution Dr",
|
||||||
|
city: "Dallas",
|
||||||
|
state: "TX",
|
||||||
|
sitePhone: "",
|
||||||
|
contactEmail: "",
|
||||||
|
status: "Active",
|
||||||
|
contacts: [{ name: "Jane", phone: "(421) 433-0032" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the modal open with an inline error when the Site Code already exists", async () => {
|
||||||
|
create.mockRejectedValue(duplicateCodeError());
|
||||||
|
const toastError = vi.spyOn(toast, "error");
|
||||||
|
const { dialog, onClose } = renderDialog();
|
||||||
|
|
||||||
|
await fillRequiredFields(dialog);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByText("This site code already exists.")).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole("textbox", { name: "Site Code" })).toHaveAttribute(
|
||||||
|
"aria-invalid",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(toastError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cancel closes without calling the API", async () => {
|
||||||
|
const { dialog, onClose } = renderDialog();
|
||||||
|
|
||||||
|
await fillRequiredFields(dialog);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -37,6 +37,7 @@ function ContactsHarness({
|
||||||
state: "TX",
|
state: "TX",
|
||||||
zipCode: "70068",
|
zipCode: "70068",
|
||||||
contactEmail: "jane@example.com",
|
contactEmail: "jane@example.com",
|
||||||
|
accountId: "7",
|
||||||
contacts: initialContacts,
|
contacts: initialContacts,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -78,7 +79,7 @@ describe("LocationContactsFieldArray", () => {
|
||||||
{ withAuth: false },
|
{ withAuth: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add contact/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add point of contact/i }));
|
||||||
|
|
||||||
expect(nameInputs()).toHaveLength(2);
|
expect(nameInputs()).toHaveLength(2);
|
||||||
expect(screen.getByLabelText("Remove contact 1")).toBeEnabled();
|
expect(screen.getByLabelText("Remove contact 1")).toBeEnabled();
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { screen } from "@testing-library/react";
|
|
||||||
import { Route, Routes } from "react-router";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import LocationFormPage from "@/app/(protected)/locations/_components/location-form";
|
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
|
||||||
|
|
||||||
const useLocationDetail = vi.fn();
|
|
||||||
const useAccountsList = vi.fn();
|
|
||||||
const useCreateLocation = vi.fn();
|
|
||||||
const useUpdateLocation = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
|
||||||
useLocationDetail: (...args: unknown[]) => useLocationDetail(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
|
|
||||||
useAccountsList: (...args: unknown[]) => useAccountsList(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/domain/locations/use-cases/use-create-location", () => ({
|
|
||||||
useCreateLocation: (...args: unknown[]) => useCreateLocation(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/domain/locations/use-cases/use-update-location", () => ({
|
|
||||||
useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function mockHooks(location: Record<string, unknown> | null) {
|
|
||||||
useLocationDetail.mockReturnValue({
|
|
||||||
data: location,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
useAccountsList.mockReturnValue({
|
|
||||||
data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
|
|
||||||
useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEditPage(locationId: string) {
|
|
||||||
return renderWithProviders(
|
|
||||||
<Routes>
|
|
||||||
<Route path="/locations/edit/:id" element={<LocationFormPage />} />
|
|
||||||
</Routes>,
|
|
||||||
{
|
|
||||||
route: `/locations/edit/${locationId}`,
|
|
||||||
routerProps: { initialEntries: [`/locations/edit/${locationId}`] },
|
|
||||||
withAuth: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("LocationFormPage state field", () => {
|
|
||||||
it("normalizes legacy API state values for edit", () => {
|
|
||||||
mockHooks({
|
|
||||||
id: "1",
|
|
||||||
name: "Dallas DC",
|
|
||||||
address: "3811 Distribution Dr",
|
|
||||||
city: "Dallas",
|
|
||||||
state: "texas",
|
|
||||||
zipCode: "70068",
|
|
||||||
phone: "(421) 433-0032",
|
|
||||||
contact: "Jane",
|
|
||||||
contactEmail: "jane@example.com",
|
|
||||||
status: "Active",
|
|
||||||
accountId: "7",
|
|
||||||
});
|
|
||||||
|
|
||||||
renderEditPage("1");
|
|
||||||
|
|
||||||
const stateInput = screen.getByLabelText(/^State/);
|
|
||||||
expect(stateInput).toHaveValue("Texas (TX)");
|
|
||||||
expect(screen.getByText("Area: West")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes code values and keeps their derived area", () => {
|
|
||||||
mockHooks({
|
|
||||||
id: "2",
|
|
||||||
state: "IL",
|
|
||||||
status: "Active",
|
|
||||||
});
|
|
||||||
|
|
||||||
renderEditPage("2");
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)");
|
|
||||||
expect(screen.getByText("Area: Central")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts empty in create mode with no implicit IL default", () => {
|
|
||||||
mockHooks(null);
|
|
||||||
|
|
||||||
renderWithProviders(<LocationFormPage />, {
|
|
||||||
route: "/locations/new",
|
|
||||||
routerProps: { initialEntries: ["/locations/new"] },
|
|
||||||
withAuth: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByLabelText(/^State/)).toHaveValue("");
|
|
||||||
expect(screen.queryByText(/Area: /)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -8,6 +8,8 @@ function rowWithContacts(overrides: Partial<LocationListItem>): LocationListItem
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Dallas DC",
|
name: "Dallas DC",
|
||||||
|
clientName: "Acme",
|
||||||
|
sitePhone: "",
|
||||||
address: "3811 Distribution Dr",
|
address: "3811 Distribution Dr",
|
||||||
city: "Dallas",
|
city: "Dallas",
|
||||||
state: "TX",
|
state: "TX",
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ describe("LocationsListPage area wiring", () => {
|
||||||
|
|
||||||
fireEvent.mouseDown(screen.getByLabelText("Area"));
|
fireEvent.mouseDown(screen.getByLabelText("Area"));
|
||||||
fireEvent.click(screen.getByRole("option", { name: "West" }));
|
fireEvent.click(screen.getByRole("option", { name: "West" }));
|
||||||
fireEvent.change(screen.getByPlaceholderText("Search locations..."), {
|
fireEvent.change(screen.getByPlaceholderText("Search sites..."), {
|
||||||
target: { value: "dal" },
|
target: { value: "dal" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
205
src/test/app/(protected)/locations/site-detail-panel.test.tsx
Normal file
205
src/test/app/(protected)/locations/site-detail-panel.test.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import LocationsListPage from "@/app/(protected)/locations/index";
|
||||||
|
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const getList = vi.fn();
|
||||||
|
const getById = vi.fn();
|
||||||
|
const update = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/api/locations-api", () => ({
|
||||||
|
locationsApi: {
|
||||||
|
getList: (...args: unknown[]) => getList(...args),
|
||||||
|
getById: (...args: unknown[]) => getById(...args),
|
||||||
|
update: (...args: unknown[]) => update(...args),
|
||||||
|
create: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
|
||||||
|
useAccountsList: () => ({
|
||||||
|
data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-debounce", () => ({
|
||||||
|
useDebounce: (value: string) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dallas: Location = {
|
||||||
|
id: 1,
|
||||||
|
name: "DAL1",
|
||||||
|
clientName: "Acme Logistics",
|
||||||
|
accountId: 7,
|
||||||
|
address: "3811 Distribution Dr",
|
||||||
|
city: "Dallas",
|
||||||
|
state: "TX",
|
||||||
|
zipCode: "75201",
|
||||||
|
sitePhone: "(214) 555-0100",
|
||||||
|
contactEmail: "dal1@acme.test",
|
||||||
|
contacts: [
|
||||||
|
{ id: 31, name: "Jane", phone: "(421) 433-0032" },
|
||||||
|
{ id: 32, name: "Bob", phone: "(421) 433-0033" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const chicago: Location = {
|
||||||
|
id: 2,
|
||||||
|
name: "CHI2",
|
||||||
|
clientName: "Globex",
|
||||||
|
accountId: 8,
|
||||||
|
address: "100 Main St",
|
||||||
|
city: "Chicago",
|
||||||
|
state: "IL",
|
||||||
|
contacts: [{ id: 41, name: "Carol", phone: "(312) 555-0100" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function listRow(location: Location): LocationListItem {
|
||||||
|
return {
|
||||||
|
id: location.id ?? "",
|
||||||
|
name: location.name ?? "",
|
||||||
|
clientName: location.clientName ?? "",
|
||||||
|
sitePhone: location.sitePhone ?? "",
|
||||||
|
address: location.address ?? "",
|
||||||
|
city: location.city ?? "",
|
||||||
|
state: location.state ?? "",
|
||||||
|
zipCode: location.zipCode ?? "",
|
||||||
|
phone: location.contacts?.[0]?.phone ?? "",
|
||||||
|
contact: location.contacts?.[0]?.name ?? "",
|
||||||
|
contactEmail: location.contactEmail ?? "",
|
||||||
|
status: "Active",
|
||||||
|
accountId: location.accountId ?? "",
|
||||||
|
contacts: location.contacts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function panel(): HTMLElement {
|
||||||
|
return screen.getByRole("region", { name: "Site details" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSite(code: string): Promise<HTMLElement> {
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: code }));
|
||||||
|
const region = panel();
|
||||||
|
await within(region).findByRole("heading", { name: code });
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Sites page detail panel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getList.mockReset();
|
||||||
|
getById.mockReset();
|
||||||
|
update.mockReset();
|
||||||
|
getList.mockResolvedValue({
|
||||||
|
items: [listRow(dallas), listRow(chicago)],
|
||||||
|
totalCount: 2,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
});
|
||||||
|
getById.mockImplementation((id: string) =>
|
||||||
|
Promise.resolve(String(id) === "2" ? chicago : dallas),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a row in view mode with the address linked to Google Maps and a map preview", async () => {
|
||||||
|
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||||
|
|
||||||
|
const region = await openSite("DAL1");
|
||||||
|
|
||||||
|
expect(within(region).getAllByText("Acme Logistics").length).toBeGreaterThan(0);
|
||||||
|
const address = within(region).getByRole("link", { name: "3811 Distribution Dr" });
|
||||||
|
expect(address).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201",
|
||||||
|
);
|
||||||
|
expect(address).toHaveAttribute("target", "_blank");
|
||||||
|
expect(within(region).getByTitle("Site location map")).toBeInTheDocument();
|
||||||
|
expect(within(region).getByText("TX · Area: West")).toBeInTheDocument();
|
||||||
|
expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument();
|
||||||
|
expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument();
|
||||||
|
expect(within(region).getByText(/Main: Jane/)).toBeInTheDocument();
|
||||||
|
expect(within(region).getByText(/Contact 2: Bob/)).toBeInTheDocument();
|
||||||
|
expect(within(region).queryByText(/status/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to edit in place, saves, and returns to view showing the saved values", async () => {
|
||||||
|
update.mockResolvedValue({});
|
||||||
|
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||||
|
const region = await openSite("DAL1");
|
||||||
|
|
||||||
|
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
|
||||||
|
|
||||||
|
const siteCode = within(region).getByRole("textbox", { name: "Site Code" });
|
||||||
|
expect(siteCode).toHaveValue("DAL1");
|
||||||
|
expect(siteCode).toBeDisabled();
|
||||||
|
const save = within(region).getByRole("button", { name: "Save changes" });
|
||||||
|
expect(save).toBeDisabled();
|
||||||
|
|
||||||
|
getById.mockResolvedValue({ ...dallas, city: "Fort Worth" });
|
||||||
|
fireEvent.change(within(region).getByRole("textbox", { name: "City" }), {
|
||||||
|
target: { value: "Fort Worth" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(save).toBeEnabled());
|
||||||
|
fireEvent.click(save);
|
||||||
|
|
||||||
|
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
"1",
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "DAL1",
|
||||||
|
city: "Fort Worth",
|
||||||
|
sitePhone: "(214) 555-0100",
|
||||||
|
zipCode: "75201",
|
||||||
|
contacts: [
|
||||||
|
{ id: 31, name: "Jane", phone: "(421) 433-0032" },
|
||||||
|
{ id: 32, name: "Bob", phone: "(421) 433-0033" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(await within(region).findByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||||
|
expect(within(region).getByText("Fort Worth")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cancel discards edits and returns to view without an API call", async () => {
|
||||||
|
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||||
|
const region = await openSite("DAL1");
|
||||||
|
|
||||||
|
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
|
||||||
|
fireEvent.change(within(region).getByRole("textbox", { name: "City" }), {
|
||||||
|
target: { value: "Houston" },
|
||||||
|
});
|
||||||
|
fireEvent.click(within(region).getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||||
|
expect(within(region).getByText("Dallas")).toBeInTheDocument();
|
||||||
|
expect(within(region).queryByText("Houston")).not.toBeInTheDocument();
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the table usable: another row swaps the panel content in place", async () => {
|
||||||
|
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||||
|
const region = await openSite("DAL1");
|
||||||
|
fireEvent.click(within(region).getByRole("button", { name: "Edit" }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "CHI2" }));
|
||||||
|
|
||||||
|
await within(panel()).findByRole("heading", { name: "CHI2" });
|
||||||
|
expect(panel()).toBe(region);
|
||||||
|
expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||||
|
expect(within(region).getAllByText("Globex").length).toBeGreaterThan(0);
|
||||||
|
expect(within(region).queryByText("Acme Logistics")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape closes the panel", async () => {
|
||||||
|
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
||||||
|
await openSite("DAL1");
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,7 +29,7 @@ function StateSelectHarness({
|
||||||
zipCode: "70068",
|
zipCode: "70068",
|
||||||
contacts: [{ name: "Jane", phone: "(421) 433-0032" }],
|
contacts: [{ name: "Jane", phone: "(421) 433-0032" }],
|
||||||
contactEmail: "jane@example.com",
|
contactEmail: "jane@example.com",
|
||||||
status: "Active",
|
accountId: "7",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,16 +56,15 @@ describe("location contacts hydration", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mapLocationToBackend contacts payload", () => {
|
describe("mapLocationToBackend site payload", () => {
|
||||||
it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => {
|
it("sends trimmed contacts in displayed order and names the first as the legacy contact", () => {
|
||||||
const payload = mapLocationToBackend({
|
const payload = mapLocationToBackend({
|
||||||
name: "Dallas DC",
|
name: " dal1 ",
|
||||||
address: "3811 Distribution Dr",
|
address: "3811 Distribution Dr",
|
||||||
city: "Dallas",
|
city: "Dallas",
|
||||||
state: "TX",
|
state: "TX",
|
||||||
zipCode: "70068",
|
zipCode: "70068",
|
||||||
contactEmail: "jane@example.com",
|
contactEmail: "jane@example.com",
|
||||||
status: "Active",
|
|
||||||
contacts: [
|
contacts: [
|
||||||
{ id: 41, name: " Jane ", phone: " (421) 433-0032 " },
|
{ id: 41, name: " Jane ", phone: " (421) 433-0032 " },
|
||||||
{ name: "Bob", phone: "(421) 433-0033" },
|
{ name: "Bob", phone: "(421) 433-0033" },
|
||||||
|
|
@ -77,18 +76,59 @@ describe("mapLocationToBackend contacts payload", () => {
|
||||||
{ name: "Bob", phone: "(421) 433-0033" },
|
{ name: "Bob", phone: "(421) 433-0033" },
|
||||||
]);
|
]);
|
||||||
expect(payload.contact).toBe("Jane");
|
expect(payload.contact).toBe("Jane");
|
||||||
expect(payload.phone).toBe("(421) 433-0032");
|
expect(payload.name).toBe("dal1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps legacy contact/phone untouched when no contacts are provided", () => {
|
it("sends the Site Phone as phone, never the main contact's phone", () => {
|
||||||
const payload = mapLocationToBackend({
|
const payload = mapLocationToBackend({
|
||||||
name: "Legacy DC",
|
sitePhone: " (214) 555-0100 ",
|
||||||
contact: "Legacy Person",
|
contacts: [{ name: "Jane", phone: "(421) 433-0032" }],
|
||||||
phone: "555-0199",
|
});
|
||||||
|
const withoutSitePhone = mapLocationToBackend({
|
||||||
|
contacts: [{ name: "Jane", phone: "(421) 433-0032" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.contact).toBe("Legacy Person");
|
expect(payload.phone).toBe("(214) 555-0100");
|
||||||
expect(payload.phone).toBe("555-0199");
|
expect(withoutSitePhone.phone).toBe("");
|
||||||
expect(payload.contacts).toEqual([]);
|
});
|
||||||
|
|
||||||
|
it("omits status, title and notes unless given, so an update keeps the stored values", () => {
|
||||||
|
const bare = mapLocationToBackend({ name: "DAL1", contacts: [] });
|
||||||
|
const full = mapLocationToBackend({
|
||||||
|
name: "DAL1",
|
||||||
|
status: "Active",
|
||||||
|
title: "Dallas DC",
|
||||||
|
notes: "Gate code 1234",
|
||||||
|
contacts: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bare).not.toHaveProperty("status");
|
||||||
|
expect(bare).not.toHaveProperty("title");
|
||||||
|
expect(bare).not.toHaveProperty("notes");
|
||||||
|
expect(full).toMatchObject({ status: "Active", title: "Dallas DC", notes: "Gate code 1234" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapLocation site fields", () => {
|
||||||
|
it("reads client name, site phone, title and notes separately from the main contact", () => {
|
||||||
|
const detail = mapLocation({
|
||||||
|
id: 9,
|
||||||
|
name: "DAL1",
|
||||||
|
title: "Dallas DC",
|
||||||
|
clientName: "Acme",
|
||||||
|
phone: "(214) 555-0100",
|
||||||
|
notes: "Gate code 1234",
|
||||||
|
contacts: [{ id: 31, name: "Carol", phone: "555-0143" }],
|
||||||
|
});
|
||||||
|
const row = mapLocationListItem({ id: 9, accountName: "Acme", phone: "(214) 555-0100" });
|
||||||
|
|
||||||
|
expect(detail).toMatchObject({
|
||||||
|
clientName: "Acme",
|
||||||
|
sitePhone: "(214) 555-0100",
|
||||||
|
title: "Dallas DC",
|
||||||
|
notes: "Gate code 1234",
|
||||||
|
phone: "555-0143",
|
||||||
|
});
|
||||||
|
expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import {
|
||||||
|
|
||||||
const validBase: LocationFormValues = {
|
const validBase: LocationFormValues = {
|
||||||
...locationDefaultValues,
|
...locationDefaultValues,
|
||||||
name: "Dallas DC",
|
name: "DAL1",
|
||||||
|
accountId: "7",
|
||||||
address: "3811 Distribution Dr",
|
address: "3811 Distribution Dr",
|
||||||
city: "Dallas",
|
city: "Dallas",
|
||||||
state: "TX",
|
state: "TX",
|
||||||
|
|
@ -46,8 +47,8 @@ describe("locationSchema contacts", () => {
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const messages = result.error.issues.map((issue) => issue.message);
|
const messages = result.error.issues.map((issue) => issue.message);
|
||||||
expect(messages).toContain("Phone is required");
|
expect(messages).toContain("Contact phone is required");
|
||||||
expect(messages).toContain("Contact person is required");
|
expect(messages).toContain("Contact name is required");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,3 +64,41 @@ describe("locationSchema contacts", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("locationSchema site fields", () => {
|
||||||
|
const validSite: LocationFormValues = {
|
||||||
|
...validBase,
|
||||||
|
contacts: [{ name: "Jane", phone: "(421) 433-0032" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function messagesFor(values: LocationFormValues): string[] {
|
||||||
|
const result = locationSchema.safeParse(values);
|
||||||
|
return result.success ? [] : result.error.issues.map((issue) => issue.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("requires Site Code, Client, Street Address, City, State and the first contact", () => {
|
||||||
|
const messages = messagesFor({ ...locationDefaultValues });
|
||||||
|
|
||||||
|
expect(messages).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"Site Code is required",
|
||||||
|
"Client is required",
|
||||||
|
"Street Address is required",
|
||||||
|
"City is required",
|
||||||
|
"State is required",
|
||||||
|
"Contact name is required",
|
||||||
|
"Contact phone is required",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Site Phone, Site Email and zip optional but checks the email format", () => {
|
||||||
|
expect(
|
||||||
|
locationSchema.safeParse({ ...validSite, sitePhone: "", contactEmail: "", zipCode: "" })
|
||||||
|
.success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(messagesFor({ ...validSite, contactEmail: "not-an-email" })).toContain(
|
||||||
|
"Invalid email address",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue