mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 21:23:14 +00:00
Some checks are pending
* feat(locations): derive site areas from state * chore(locations): keep ticket keys out of source comments * feat(locations): manage ordered site contacts --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
237 lines
6.6 KiB
TypeScript
237 lines
6.6 KiB
TypeScript
import { useCallback, useRef, useState, type KeyboardEvent } from "react";
|
|
import {
|
|
useFieldArray,
|
|
type Control,
|
|
type FieldErrors,
|
|
type UseFormRegister,
|
|
} from "react-hook-form";
|
|
import AddIcon from "@mui/icons-material/Add";
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
IconButton,
|
|
Paper,
|
|
Stack,
|
|
TextField,
|
|
Tooltip,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import type {
|
|
LocationContactFormValues,
|
|
LocationFormValues,
|
|
} from "@/domain/locations/schemas/location-schema";
|
|
|
|
interface ContactRowProps {
|
|
canRemove: boolean;
|
|
dragIndex: number | null;
|
|
errors: FieldErrors<LocationContactFormValues>;
|
|
fieldId: string;
|
|
index: number;
|
|
isLast: boolean;
|
|
register: UseFormRegister<LocationFormValues>;
|
|
onDragEnd: () => void;
|
|
onDragStart: (index: number) => void;
|
|
onDrop: (index: number) => void;
|
|
onRemove: (index: number) => void;
|
|
onKeyboardMove: (from: number, to: number) => void;
|
|
}
|
|
|
|
function ContactRow({
|
|
canRemove,
|
|
dragIndex,
|
|
errors,
|
|
fieldId,
|
|
index,
|
|
isLast,
|
|
register,
|
|
onDragEnd,
|
|
onDragStart,
|
|
onDrop,
|
|
onRemove,
|
|
onKeyboardMove,
|
|
}: ContactRowProps) {
|
|
const handleRef = useRef<HTMLSpanElement | null>(null);
|
|
const isDragging = dragIndex === index;
|
|
const isDropTarget = dragIndex !== null && dragIndex !== index;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
|
|
if (event.key === "ArrowUp" && index > 0) {
|
|
event.preventDefault();
|
|
onKeyboardMove(index, index - 1);
|
|
}
|
|
if (event.key === "ArrowDown" && !isLast) {
|
|
event.preventDefault();
|
|
onKeyboardMove(index, index + 1);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Paper
|
|
variant="outlined"
|
|
component="div"
|
|
onDragOver={(event) => {
|
|
if (dragIndex !== null) {
|
|
event.preventDefault();
|
|
}
|
|
}}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
onDrop(index);
|
|
}}
|
|
className="transition-opacity"
|
|
sx={{ opacity: isDragging ? 0.5 : 1, borderStyle: isDropTarget ? "dashed" : "solid" }}
|
|
>
|
|
<Stack
|
|
direction={{ xs: "column", sm: "row" }}
|
|
spacing={1.5}
|
|
sx={{ alignItems: "center", p: 1.5 }}
|
|
>
|
|
<Tooltip title="Drag to reorder. Use arrow keys to move up or down.">
|
|
<Box
|
|
component="span"
|
|
ref={handleRef}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Reorder contact ${index + 1}. Use arrow up or down to move.`}
|
|
draggable
|
|
data-field-id={fieldId}
|
|
onDragStart={(event) => {
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", String(index));
|
|
onDragStart(index);
|
|
}}
|
|
onDragEnd={onDragEnd}
|
|
onKeyDown={handleKeyDown}
|
|
sx={{ cursor: "grab", display: "inline-flex", color: "text.secondary" }}
|
|
>
|
|
<DragIndicatorIcon fontSize="small" />
|
|
</Box>
|
|
</Tooltip>
|
|
{index === 0 && <Chip label="Main" size="small" color="primary" variant="outlined" />}
|
|
<TextField
|
|
{...register(`contacts.${index}.name` as const)}
|
|
label={index === 0 ? "Contact Name (Main)" : "Contact Name"}
|
|
required
|
|
error={Boolean(errors?.name)}
|
|
helperText={errors?.name?.message}
|
|
fullWidth
|
|
size="small"
|
|
slotProps={{ htmlInput: { maxLength: 100 } }}
|
|
/>
|
|
<TextField
|
|
{...register(`contacts.${index}.phone` as const)}
|
|
label="Phone"
|
|
required
|
|
error={Boolean(errors?.phone)}
|
|
helperText={errors?.phone?.message}
|
|
fullWidth
|
|
size="small"
|
|
slotProps={{ htmlInput: { maxLength: 20 } }}
|
|
/>
|
|
<Tooltip title={canRemove ? "Remove" : "At least one contact is required"}>
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
aria-label={`Remove contact ${index + 1}`}
|
|
disabled={!canRemove}
|
|
onClick={() => onRemove(index)}
|
|
>
|
|
<DeleteOutlineIcon fontSize="small" />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
interface LocationContactsFieldArrayProps {
|
|
control: Control<LocationFormValues>;
|
|
errors: FieldErrors<LocationFormValues>;
|
|
register: UseFormRegister<LocationFormValues>;
|
|
}
|
|
|
|
export function LocationContactsFieldArray({
|
|
control,
|
|
errors,
|
|
register,
|
|
}: LocationContactsFieldArrayProps) {
|
|
const { fields, append, remove, move } = useFieldArray({
|
|
control,
|
|
name: "contacts",
|
|
keyName: "fieldKey",
|
|
});
|
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const focusHandle = useCallback((fieldId: string) => {
|
|
requestAnimationFrame(() => {
|
|
const handle = rootRef.current?.querySelector<HTMLElement>(`[data-field-id="${fieldId}"]`);
|
|
handle?.focus();
|
|
});
|
|
}, []);
|
|
|
|
const handleKeyboardMove = useCallback(
|
|
(from: number, to: number) => {
|
|
if (to < 0 || to >= fields.length) {
|
|
return;
|
|
}
|
|
const fieldId = fields[from]?.fieldKey;
|
|
move(from, to);
|
|
if (fieldId) {
|
|
focusHandle(fieldId);
|
|
}
|
|
},
|
|
[fields, move, focusHandle],
|
|
);
|
|
|
|
const handleDrop = useCallback(
|
|
(index: number) => {
|
|
if (dragIndex !== null && dragIndex !== index) {
|
|
move(dragIndex, index);
|
|
}
|
|
setDragIndex(null);
|
|
},
|
|
[dragIndex, move],
|
|
);
|
|
|
|
return (
|
|
<Box ref={rootRef}>
|
|
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
|
|
<Typography variant="subtitle2" className="font-semibold uppercase">
|
|
Contacts
|
|
</Typography>
|
|
<Button
|
|
size="small"
|
|
variant="text"
|
|
startIcon={<AddIcon fontSize="small" />}
|
|
onClick={() => append({ name: "", phone: "" })}
|
|
>
|
|
Add Contact
|
|
</Button>
|
|
</Stack>
|
|
<Stack spacing={1.5} className="mt-2">
|
|
{fields.map((field, index) => (
|
|
<ContactRow
|
|
key={field.fieldKey}
|
|
canRemove={fields.length > 1}
|
|
dragIndex={dragIndex}
|
|
errors={errors.contacts?.[index] ?? {}}
|
|
fieldId={field.fieldKey}
|
|
index={index}
|
|
isLast={index === fields.length - 1}
|
|
register={register}
|
|
onDragEnd={() => setDragIndex(null)}
|
|
onDragStart={setDragIndex}
|
|
onDrop={handleDrop}
|
|
onRemove={remove}
|
|
onKeyboardMove={handleKeyboardMove}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
}
|