mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): include table cells so board-core slice builds
Bring cell modules and minimal wizard helpers into table-core for a self-contained slice build.
This commit is contained in:
parent
811497c435
commit
3452382981
28 changed files with 2942 additions and 95 deletions
|
|
@ -1,20 +1,36 @@
|
|||
import { Link as RouterLink, useNavigate } from "react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Alert,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Link,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||
import { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections";
|
||||
import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu";
|
||||
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddWorkOrderComment,
|
||||
|
|
@ -22,20 +38,167 @@ import {
|
|||
useChangeWorkOrderStatus,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
const STATUS_COLORS: Record<
|
||||
string,
|
||||
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
|
||||
> = {
|
||||
Open: "primary",
|
||||
"In Progress": "info",
|
||||
"On Hold": "warning",
|
||||
Done: "success",
|
||||
Cancelled: "default",
|
||||
};
|
||||
|
||||
type ActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...comments].sort(
|
||||
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
|
||||
),
|
||||
[comments],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No comments yet.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((comment) => (
|
||||
<Box key={String(comment.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{comment.userName || comment.commenter || "System"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">{comment.commenttext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No changes recorded.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} className="max-h-72 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mb-1"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2">
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkOrderViewPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const workOrderId = id ?? "";
|
||||
|
||||
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
|
||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
||||
const { data: users = [] } = useWorkOrderUsers();
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
||||
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
|
||||
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const customerComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const internalComments = useMemo(
|
||||
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||
[wo?.comments],
|
||||
);
|
||||
const auditLog = wo?.auditLog ?? [];
|
||||
|
||||
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "customer" && activeTab !== "internal") {
|
||||
return;
|
||||
}
|
||||
|
||||
commentMutation.mutate(
|
||||
{ text: commentText.trim(), commentType: activeTab },
|
||||
{ onSuccess: () => setCommentText("") },
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -61,9 +224,14 @@ export default function WorkOrderViewPage() {
|
|||
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||
Work Orders
|
||||
</Link>
|
||||
<Typography color="text.primary">{wo.internalWONumber}</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.primary",
|
||||
}}
|
||||
>
|
||||
{wo.internalWONumber}
|
||||
</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
spacing={2}
|
||||
|
|
@ -76,13 +244,41 @@ export default function WorkOrderViewPage() {
|
|||
<Typography variant="h5" component="h1">
|
||||
{wo.title}
|
||||
</Typography>
|
||||
<WorkOrderStatusMenu
|
||||
status={wo.status}
|
||||
onStatusChange={(status) => statusMutation.mutate(status)}
|
||||
<Chip
|
||||
label={wo.status}
|
||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||
onClick={(event) => setStatusAnchor(event.currentTarget)}
|
||||
className="w-fit cursor-pointer"
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={statusAnchor}
|
||||
open={Boolean(statusAnchor)}
|
||||
onClose={() => setStatusAnchor(null)}
|
||||
>
|
||||
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
selected={status === wo.status}
|
||||
onClick={() => {
|
||||
setStatusAnchor(null);
|
||||
if (status !== wo.status) {
|
||||
statusMutation.mutate(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
|
||||
Dispatch
|
||||
</Button>
|
||||
|
|
@ -94,27 +290,283 @@ export default function WorkOrderViewPage() {
|
|||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Details
|
||||
</Typography>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="Internal WO" value={wo.internalWONumber} />
|
||||
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||
<Field label="Priority" value={wo.priority || "—"} />
|
||||
<Field label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Assigned To
|
||||
</Typography>
|
||||
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
|
||||
{wo.assignedTo || "Unassigned"}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={assignAnchor}
|
||||
open={Boolean(assignAnchor)}
|
||||
onClose={() => setAssignAnchor(null)}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
assignmentMutation.mutate({ userId: "", userName: "" });
|
||||
}}
|
||||
>
|
||||
Unassigned
|
||||
</MenuItem>
|
||||
{users.map((user) => (
|
||||
<MenuItem
|
||||
key={String(user.id)}
|
||||
selected={wo.assignedTo === user.name}
|
||||
onClick={() => {
|
||||
setAssignAnchor(null);
|
||||
assignmentMutation.mutate({ userId: user.id, userName: user.name });
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Box>
|
||||
<Field label="Source" value={wo.source || "—"} />
|
||||
<Field label="TT" value={wo.tt || "—"} />
|
||||
</Box>
|
||||
</Paper>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Classification
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field label="Problem" value={wo.problem || "—"} />
|
||||
<Field label="Trade" value={wo.trade || "—"} />
|
||||
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Schedule
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Field
|
||||
label="Created"
|
||||
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||
/>
|
||||
<Field label="Due Date" value={wo.dueDate || "—"} />
|
||||
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||
<Field label="Completed" value={wo.completedDate || "—"} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Financial
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="Vendor NTE"
|
||||
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||
/>
|
||||
<Field label="Customer PO" value={wo.po || "—"} />
|
||||
<Field label="Customer NTE" value="Not connected" />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Dispatches ({wo.dispatches.length})
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{wo.dispatches.length ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Dispatch #</TableCell>
|
||||
<TableCell>Vendor</TableCell>
|
||||
<TableCell>NTE</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Wait</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{wo.dispatches.map((dispatch) => {
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
return (
|
||||
<TableRow
|
||||
key={String(dispatch.id)}
|
||||
hover
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDispatchDetailId(dispatch.id)}
|
||||
>
|
||||
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||
<TableCell>{dispatch.vendorName}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{dispatch.status}</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dispatch.status === "Sent" ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
className={
|
||||
waitColor === "green"
|
||||
? "text-green-600"
|
||||
: waitColor === "yellow"
|
||||
? "text-yellow-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{timeSince(dispatch.dispatchedAt)}
|
||||
</Typography>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No dispatches yet.
|
||||
</Typography>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Paper variant="outlined" className="p-4">
|
||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||
Activity
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||
className="mb-3"
|
||||
>
|
||||
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||
</Tabs>
|
||||
|
||||
<WorkOrderDetailsSection
|
||||
wo={wo}
|
||||
users={users}
|
||||
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
|
||||
onDispatchClick={setDispatchDetailId}
|
||||
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
|
||||
isCommentPending={commentMutation.isPending}
|
||||
/>
|
||||
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
|
||||
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
||||
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||
|
||||
{activeTab === "internal" && (
|
||||
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Type a comment..."
|
||||
disabled={commentMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
className="self-end"
|
||||
disabled={commentMutation.isPending || !commentText.trim()}
|
||||
>
|
||||
{commentMutation.isPending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
{dispatchOpen && (
|
||||
<DispatchCreateModal workOrder={wo} onClose={() => setDispatchOpen(false)} />
|
||||
<DispatchCreateModal
|
||||
workOrder={wo}
|
||||
onClose={() => setDispatchOpen(false)}
|
||||
onDispatched={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dispatchDetailId && (
|
||||
<DispatchDetailModal
|
||||
dispatchId={dispatchDetailId}
|
||||
workOrderId={workOrderId}
|
||||
onClose={() => setDispatchDetailId(null)}
|
||||
onUpdated={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2">{value}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export function DispatchCreateModal({
|
|||
.filter(
|
||||
(item) =>
|
||||
!woSearch ||
|
||||
item.number.includes(woSearch) ||
|
||||
item.woNumber.includes(woSearch) ||
|
||||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
|
||||
)
|
||||
.slice(0, 10),
|
||||
|
|
@ -180,13 +180,13 @@ export function DispatchCreateModal({
|
|||
onClick={() => {
|
||||
setSelectedWOs((prev) => [
|
||||
...prev,
|
||||
{ id: item.id, number: item.number, title: item.title },
|
||||
{ id: item.id, number: item.woNumber, title: item.title },
|
||||
]);
|
||||
setWoSearch("");
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
<strong>{item.number}</strong> — {item.title}
|
||||
<strong>{item.woNumber}</strong> — {item.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { Button, Popover } from "@mui/material";
|
||||
import {
|
||||
calcDuration,
|
||||
fmt12,
|
||||
formatApptRange,
|
||||
formatApptTimeValue,
|
||||
parseApptTime,
|
||||
TIME_SLOTS,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
type ApptTimeCellProps = {
|
||||
apptTime: string;
|
||||
onSave: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
|
||||
const [end, setEnd] = useState(() => parseApptTime(apptTime).end);
|
||||
|
||||
useEffect(() => {
|
||||
const p = parseApptTime(apptTime);
|
||||
setStart(p.start);
|
||||
setEnd(p.end);
|
||||
}, [apptTime]);
|
||||
|
||||
const parsed = parseApptTime(apptTime);
|
||||
const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime;
|
||||
|
||||
const slotStyle = (active: boolean): React.CSSProperties => ({
|
||||
background: active ? "var(--color-primary-tint)" : "transparent",
|
||||
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
|
||||
const done = () => {
|
||||
onSave(formatApptTimeValue(start, end));
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Clock size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
{display && display !== "—" ? (
|
||||
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
|
||||
{display}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
|
||||
>
|
||||
<div className="grid grid-cols-2 border-b border-border">
|
||||
<div className="border-r border-border">
|
||||
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Start
|
||||
</p>
|
||||
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
||||
{TIME_SLOTS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStart(t);
|
||||
if (end && end <= t) {
|
||||
setEnd("");
|
||||
}
|
||||
}}
|
||||
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
|
||||
style={slotStyle(start === t)}
|
||||
>
|
||||
{fmt12(t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
End <span className="font-normal normal-case">(optional)</span>
|
||||
</p>
|
||||
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
||||
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setEnd(t)}
|
||||
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
|
||||
style={slotStyle(end === t)}
|
||||
>
|
||||
{fmt12(t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
|
||||
</span>
|
||||
<Button size="small" variant="contained" onClick={done} disabled={!start}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useState } from "react";
|
||||
import { Check, Users } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import { DispatcherPill } from "./dispatcher-pill";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
type AssignedToCellProps = {
|
||||
dispatcherId: string;
|
||||
users: WorkOrderUser[];
|
||||
highlight?: boolean;
|
||||
onSave: (id: string, name: string) => void;
|
||||
};
|
||||
|
||||
export function AssignedToCell({ dispatcherId, users, highlight, onSave }: AssignedToCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const selected = users.find((d) => String(d.id) === dispatcherId);
|
||||
const filtered = search
|
||||
? users.filter((d) => d.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: users;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||
style={
|
||||
highlight
|
||||
? {
|
||||
boxShadow: "0 0 0 2px var(--color-accent)",
|
||||
background: "var(--color-primary-tint)",
|
||||
padding: "2px 6px",
|
||||
margin: "0 -6px",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
<DispatcherPill user={selected} />
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||
<Users
|
||||
size={13}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
Select …
|
||||
</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => {
|
||||
setAnchorEl(null);
|
||||
setSearch("");
|
||||
}}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "w-[212px] rounded-lg border border-border p-1.5" } }}
|
||||
>
|
||||
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search dispatcher…"
|
||||
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
{filtered.map((d) => (
|
||||
<button
|
||||
key={String(d.id)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(String(d.id), d.name);
|
||||
setAnchorEl(null);
|
||||
setSearch("");
|
||||
}}
|
||||
className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<DispatcherPill user={d} />
|
||||
{dispatcherId === String(d.id) && (
|
||||
<Check size={10} className="shrink-0 text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
|
||||
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
function isMediaWO(type: WOType | ""): boolean {
|
||||
return type === "Emergency" || type === "Reactive";
|
||||
}
|
||||
|
||||
type CompDocCellProps = {
|
||||
status: CompletionDocStatus;
|
||||
woType: WOType | "";
|
||||
mediaCount?: number;
|
||||
onOpen: () => void;
|
||||
onOpenMedia: () => void;
|
||||
};
|
||||
|
||||
export function CompDocCell({
|
||||
status,
|
||||
woType,
|
||||
mediaCount = 0,
|
||||
onOpen,
|
||||
onOpenMedia,
|
||||
}: CompDocCellProps) {
|
||||
if (isMediaWO(woType)) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenMedia();
|
||||
}}
|
||||
title="Photos & videos"
|
||||
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold text-[var(--color-primary)] transition-opacity hover:opacity-80"
|
||||
>
|
||||
<ImageIcon size={15} className="shrink-0 text-[var(--color-primary)]" />
|
||||
{mediaCount > 0 ? `${mediaCount} file${mediaCount !== 1 ? "s" : ""}` : "Media"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const generated = status === "Yes";
|
||||
const notRequired = status === "NN";
|
||||
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
|
||||
const Icon = notRequired ? Ban : FileText;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
title={
|
||||
generated
|
||||
? "View completion document"
|
||||
: notRequired
|
||||
? "Completion document — not required"
|
||||
: "Completion document — draft"
|
||||
}
|
||||
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold transition-opacity hover:opacity-80"
|
||||
style={{ color }}
|
||||
>
|
||||
<Icon size={15} className="shrink-0" style={{ color }} />
|
||||
{generated ? "View Doc" : notRequired ? "N/N" : "Draft"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { useState } from "react";
|
||||
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type DateCellProps = {
|
||||
value: string;
|
||||
q?: string;
|
||||
highlight?: boolean;
|
||||
onSave: (date: string) => void;
|
||||
allowWeekMode?: boolean;
|
||||
targetWeek?: string;
|
||||
onSaveWeek?: (weekStart: string) => void;
|
||||
defaultMode?: "week" | "date";
|
||||
};
|
||||
|
||||
export function DateCell({
|
||||
value,
|
||||
q,
|
||||
highlight,
|
||||
onSave,
|
||||
allowWeekMode,
|
||||
targetWeek = "",
|
||||
onSaveWeek,
|
||||
defaultMode,
|
||||
}: DateCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
|
||||
);
|
||||
const showWeek = allowWeekMode && !value && !!targetWeek;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
|
||||
style={
|
||||
highlight
|
||||
? {
|
||||
boxShadow: "0 0 0 2px var(--color-accent)",
|
||||
background: "var(--color-primary-tint)",
|
||||
padding: "2px 8px",
|
||||
margin: "0 -8px",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
{showWeek ? (
|
||||
<>
|
||||
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
{fmtWeekOf(targetWeek)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||
>
|
||||
{allowWeekMode && (
|
||||
<div className="mb-2.5 flex items-center gap-1.5">
|
||||
{(["week", "date"] as const).map((m) => {
|
||||
const active = cellMode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setCellMode(m)}
|
||||
className="rounded-full border px-3 text-[11.5px] font-medium"
|
||||
style={{
|
||||
height: 26,
|
||||
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
|
||||
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
|
||||
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{m === "week" ? "Week" : "Specific date"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{allowWeekMode && cellMode === "week" ? (
|
||||
<WeekPicker
|
||||
selected={!value ? targetWeek : ""}
|
||||
onPick={(mon) => {
|
||||
onSave("");
|
||||
onSaveWeek?.(mon);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MiniCalendar
|
||||
value={value}
|
||||
onChange={(d) => {
|
||||
onSave(d);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
|
||||
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type DispatcherPillProps = {
|
||||
user: WorkOrderUser;
|
||||
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export function DispatcherPill({ user, q }: DispatcherPillProps) {
|
||||
const color = getDispatcherAvatarColor(user);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex max-w-full items-center truncate whitespace-nowrap leading-none"
|
||||
style={{
|
||||
height: 26,
|
||||
|
||||
padding: "0 11px",
|
||||
|
||||
borderRadius: 9999,
|
||||
|
||||
border: `1.5px solid ${color}`,
|
||||
|
||||
background: "var(--card)",
|
||||
|
||||
color,
|
||||
|
||||
fontSize: 12.5,
|
||||
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<HighlightText text={user.name} q={q} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type EditableWONumberCellProps = {
|
||||
value: string;
|
||||
|
||||
rescheduleCount: number;
|
||||
|
||||
carriedOver?: number;
|
||||
|
||||
q?: string;
|
||||
|
||||
currentId: string | number;
|
||||
|
||||
allOrders: WorkOrderTableRow[];
|
||||
|
||||
onSave: (v: string) => void;
|
||||
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
function CounterPill({ label, count }: { label: string; count: number }) {
|
||||
if (count < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
|
||||
color: "var(--color-text-secondary)",
|
||||
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableWONumberCell({
|
||||
value,
|
||||
|
||||
rescheduleCount,
|
||||
|
||||
carriedOver = 0,
|
||||
|
||||
q,
|
||||
|
||||
currentId,
|
||||
|
||||
allOrders,
|
||||
|
||||
onSave,
|
||||
|
||||
onDuplicateFound,
|
||||
}: EditableWONumberCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(value);
|
||||
}
|
||||
}, [value, editing]);
|
||||
|
||||
const findDuplicate = (d: string) =>
|
||||
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
|
||||
|
||||
const commit = () => {
|
||||
if (draft === value) {
|
||||
setEditing(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (draft.length !== 11) {
|
||||
toast("Work order number must be exactly 11 digits");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicate = findDuplicate(draft);
|
||||
|
||||
if (duplicate) {
|
||||
onDuplicateFound?.(duplicate);
|
||||
|
||||
setDraft(value);
|
||||
|
||||
setEditing(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(draft);
|
||||
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setDraft(value);
|
||||
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (draft !== value && draft.length === 11) {
|
||||
const duplicate = findDuplicate(draft);
|
||||
|
||||
if (duplicate) {
|
||||
onDuplicateFound?.(duplicate);
|
||||
|
||||
setDraft(value);
|
||||
|
||||
setEditing(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(draft);
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const invalid = editing && draft.length > 0 && draft.length !== 11;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
||||
commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
placeholder="12345678901"
|
||||
aria-label="Work order number"
|
||||
aria-invalid={invalid}
|
||||
className="font-mono focus:outline-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
|
||||
fontWeight: 700,
|
||||
|
||||
color: "var(--foreground)",
|
||||
|
||||
background: "var(--color-bg-surface)",
|
||||
|
||||
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
|
||||
|
||||
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
|
||||
|
||||
borderRadius: 4,
|
||||
|
||||
padding: "2px 4px",
|
||||
|
||||
width: 120,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setEditing(true);
|
||||
}}
|
||||
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
|
||||
fontWeight: 700,
|
||||
|
||||
color: value ? "var(--foreground)" : "var(--color-text-muted)",
|
||||
|
||||
borderRadius: 4,
|
||||
|
||||
padding: "2px 4px",
|
||||
}}
|
||||
>
|
||||
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
||||
|
||||
<CounterPill label="Carried over" count={carriedOver} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { useState } from "react";
|
||||
import { Flag, X } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
const FLAG_COLORS: { name: string; value: string }[] = [
|
||||
{ name: "Red", value: "#EF4444" },
|
||||
{ name: "Orange", value: "#F97316" },
|
||||
{ name: "Amber", value: "#F59E0B" },
|
||||
{ name: "Yellow", value: "#EAB308" },
|
||||
{ name: "Green", value: "#22C55E" },
|
||||
{ name: "Teal", value: "#14B8A6" },
|
||||
{ name: "Blue", value: "#3B82F6" },
|
||||
{ name: "Purple", value: "#8B5CF6" },
|
||||
];
|
||||
|
||||
type FlagCellProps = {
|
||||
color?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (color: string | null) => void;
|
||||
};
|
||||
|
||||
export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className="flex h-[22px] w-[22px] items-center justify-center opacity-30">
|
||||
<Flag size={14} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title={color ? "Flagged — click to change" : "Add color flag"}
|
||||
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Flag
|
||||
size={14}
|
||||
strokeWidth={color ? 1.5 : 1.75}
|
||||
style={{
|
||||
color: color ?? "var(--color-text-muted)",
|
||||
fill: color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{FLAG_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
title={c.name}
|
||||
onClick={() => {
|
||||
onChange(c.value);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110"
|
||||
>
|
||||
<span
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
background: c.value,
|
||||
boxShadow:
|
||||
color === c.value ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="mt-2 flex w-full items-center gap-1.5 border-t border-border px-1 pt-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:opacity-80"
|
||||
>
|
||||
<X size={12} /> Clear flag
|
||||
</button>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
type HighlightTextProps = {
|
||||
text: string;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export function HighlightText({ text, q }: HighlightTextProps) {
|
||||
if (!q?.trim() || !text) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
const query = q.trim();
|
||||
const lower = text.toLowerCase();
|
||||
const idx = lower.indexOf(query.toLowerCase());
|
||||
if (idx < 0) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
const before = text.slice(0, idx);
|
||||
const match = text.slice(idx, idx + query.length);
|
||||
const after = text.slice(idx + query.length);
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<mark className="rounded-sm bg-[var(--color-primary-tint)] px-0.5 text-inherit">{match}</mark>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function HoverChevron() {
|
||||
return (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="ml-auto shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
import { PmTypeIcon } from "../pm-type-icons";
|
||||
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
type PMTypeCellProps = {
|
||||
value: string;
|
||||
|
||||
pmTypes: string[];
|
||||
|
||||
q?: string;
|
||||
|
||||
onSave: (v: string) => void;
|
||||
};
|
||||
|
||||
export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = search
|
||||
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
|
||||
: pmTypes;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<PmTypeIcon
|
||||
pmType={value}
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
<HighlightText text={value} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
|
||||
)}
|
||||
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => {
|
||||
setAnchorEl(null);
|
||||
|
||||
setSearch("");
|
||||
}}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }}
|
||||
>
|
||||
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search PM type…"
|
||||
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-52 overflow-y-auto">
|
||||
{filtered.map((pm) => (
|
||||
<button
|
||||
key={pm}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(pm);
|
||||
|
||||
setAnchorEl(null);
|
||||
|
||||
setSearch("");
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
|
||||
|
||||
<span
|
||||
className="flex-1 truncate text-left text-xs"
|
||||
style={{ color: "var(--color-text-primary)" }}
|
||||
>
|
||||
{pm}
|
||||
</span>
|
||||
|
||||
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { useState } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { SiteDialog, type SitePatch } from "./site-dialog";
|
||||
|
||||
type SiteCellProps = {
|
||||
value: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
pocName?: string;
|
||||
pocPhone?: string;
|
||||
pocNotes?: string;
|
||||
sites: LocationOption[];
|
||||
q?: string;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
export function SiteCell({
|
||||
value,
|
||||
location,
|
||||
locationId,
|
||||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
sites,
|
||||
q,
|
||||
onSave,
|
||||
}: SiteCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
className="group/cell flex w-full min-w-0 items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{value ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
|
||||
<MapPin size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span
|
||||
className="shrink-0 font-mono text-[12.5px] font-bold tracking-wide"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
<HighlightText text={value} q={q} />
|
||||
</span>
|
||||
{location && (
|
||||
<>
|
||||
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
|
||||
<span className="truncate text-xs text-[var(--muted-foreground)]">
|
||||
<HighlightText text={location} q={q} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
<SiteDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
value={value}
|
||||
locationId={locationId}
|
||||
pocName={pocName}
|
||||
pocPhone={pocPhone}
|
||||
pocNotes={pocNotes}
|
||||
sites={sites}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export type SitePatch = {
|
||||
site: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
};
|
||||
|
||||
type SiteDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
value: string;
|
||||
locationId: string | number;
|
||||
pocName?: string;
|
||||
pocPhone?: string;
|
||||
pocNotes?: string;
|
||||
sites: LocationOption[];
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
function FieldLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<label
|
||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function extractSiteCode(name: string): string {
|
||||
const dash = name.indexOf(" — ");
|
||||
if (dash > 0) {
|
||||
return name.slice(0, dash).trim();
|
||||
}
|
||||
return name.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function extractLocationLabel(name: string): string {
|
||||
const dash = name.indexOf(" — ");
|
||||
if (dash > 0) {
|
||||
return name.slice(dash + 3).trim();
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function buildMapsUrl(location: Location): string {
|
||||
const parts = [location.address, location.city, location.state, location.zipCode]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
|
||||
}
|
||||
|
||||
export function SiteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
locationId,
|
||||
pocName = "",
|
||||
pocPhone = "",
|
||||
pocNotes = "",
|
||||
sites,
|
||||
onSave,
|
||||
}: SiteDialogProps) {
|
||||
const [code, setCode] = useState(value);
|
||||
const [locId, setLocId] = useState(String(locationId || ""));
|
||||
const [pn, setPn] = useState(pocName);
|
||||
const [pp, setPp] = useState(pocPhone);
|
||||
const [notes, setNotes] = useState(pocNotes);
|
||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||
|
||||
const isMockLocation = locId.startsWith("mock-");
|
||||
const { data: apiLocationDetail } = useLocationDetail(
|
||||
isMockLocation || !locId ? undefined : locId,
|
||||
);
|
||||
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCode(value);
|
||||
setLocId(String(locationId || ""));
|
||||
setPn(pocName);
|
||||
setPp(pocPhone);
|
||||
setNotes(pocNotes);
|
||||
setPocFilledFor(String(locationId || ""));
|
||||
}
|
||||
}, [open, value, locationId, pocName, pocPhone, pocNotes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
return;
|
||||
}
|
||||
setPn(locationDetail.contact ?? "");
|
||||
setPp(locationDetail.phone ?? "");
|
||||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor]);
|
||||
|
||||
const selected = sites.find((s) => String(s.id) === locId);
|
||||
|
||||
const handlePick = (id: string) => {
|
||||
const next = sites.find((s) => String(s.id) === id);
|
||||
setLocId(id);
|
||||
if (next) {
|
||||
setCode(extractSiteCode(next.name));
|
||||
}
|
||||
if (id !== pocFilledFor) {
|
||||
setPocFilledFor("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Site
|
||||
</DialogTitle>
|
||||
<p className="sr-only">Select a site and edit its point of contact</p>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<FieldLabel>Site</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={locId}
|
||||
placeholder="Select site …"
|
||||
icon={
|
||||
<MapPin
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
}
|
||||
searchPlaceholder="Search site code or city…"
|
||||
options={sites.map((site) => ({
|
||||
key: String(site.id),
|
||||
label: site.name,
|
||||
}))}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{locationDetail && locId && (
|
||||
<div
|
||||
className="space-y-1.5 rounded-lg p-3"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<Detail
|
||||
icon={MapPin}
|
||||
text={
|
||||
[
|
||||
locationDetail.address,
|
||||
locationDetail.city,
|
||||
locationDetail.state,
|
||||
locationDetail.zipCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "—"
|
||||
}
|
||||
/>
|
||||
{locationDetail.phone && <Detail icon={Phone} text={locationDetail.phone} />}
|
||||
{locationDetail.contactEmail && (
|
||||
<Detail icon={Mail} text={locationDetail.contactEmail} />
|
||||
)}
|
||||
{locationDetail.address && (
|
||||
<a
|
||||
href={buildMapsUrl(locationDetail)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<ExternalLink size={12} /> Open in Google Maps
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-1">
|
||||
<FieldLabel>Point of Contact</FieldLabel>
|
||||
<p className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
Auto-filled from records — edit if outdated
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={pn}
|
||||
onChange={(e) => setPn(e.target.value)}
|
||||
placeholder="POC name"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
<input
|
||||
value={pp}
|
||||
onChange={(e) => setPp(e.target.value)}
|
||||
placeholder="POC phone"
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({
|
||||
site: code || (selected ? extractSiteCode(selected.name) : ""),
|
||||
location: selected ? extractLocationLabel(selected.name) : "",
|
||||
locationId: locId,
|
||||
pocName: pn,
|
||||
pocPhone: pp,
|
||||
pocNotes: notes,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { AlertTriangle, Check } from "lucide-react";
|
||||
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
||||
type StatusCellProps = {
|
||||
wo: WorkOrderTableRow;
|
||||
|
||||
onChangeStatus: (status: WizardWOStatus) => void;
|
||||
|
||||
onOpenSlideOver: () => void;
|
||||
|
||||
onPastDueAttempt?: () => void;
|
||||
};
|
||||
|
||||
export function StatusCell({
|
||||
wo,
|
||||
|
||||
onChangeStatus,
|
||||
|
||||
onOpenSlideOver,
|
||||
|
||||
onPastDueAttempt,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
if (wo.isPastDue) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onPastDueAttempt?.();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its service window. Reschedule to unlock status updates."
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||
Past Due
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{
|
||||
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-2 p-3"
|
||||
style={{ background: "var(--status-pending-bg)" }}
|
||||
>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] leading-snug text-[var(--status-pending-text)]">
|
||||
This work order is past due. A new date must be scheduled before the status can be
|
||||
updated.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
onOpenSlideOver();
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Reschedule →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{wo.status ? (
|
||||
<WoTableStatusLabel status={wo.status} />
|
||||
) : (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{ALL_WIZARD_STATUSES.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChangeStatus(status);
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
||||
{wo.status === status && (
|
||||
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Popover } from "@mui/material";
|
||||
|
||||
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
||||
import { WoTableTypeLabel } from "./wo-table-type-label";
|
||||
|
||||
type TypeCellProps = {
|
||||
value: WOType | "";
|
||||
|
||||
q?: string;
|
||||
|
||||
onSave: (v: WOType) => void;
|
||||
};
|
||||
|
||||
export function TypeCell({ value, q, onSave }: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{value ? (
|
||||
<WoTableTypeLabel type={value} q={q} />
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||
)}
|
||||
|
||||
<HoverChevron />
|
||||
</button>
|
||||
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{WO_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSave(t);
|
||||
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
<WoTableTypeLabel type={t} />
|
||||
|
||||
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
|
||||
</button>
|
||||
))}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { useState } from "react";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
|
||||
|
||||
type VendorCellProps = {
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone?: string;
|
||||
vendors: VendorDropdownItem[];
|
||||
q?: string;
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasAny = !!(tech || company);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
{hasAny ? (
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<div className="truncate text-[13px] font-semibold text-[var(--foreground)]">
|
||||
{tech ? <HighlightText text={tech} q={q} /> : <HighlightText text={company} q={q} />}
|
||||
</div>
|
||||
{tech && company && (
|
||||
<div className="truncate text-[11px] text-[var(--muted-foreground)]">
|
||||
<HighlightText text={company} q={q} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex-1 text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
</button>
|
||||
<VendorDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
company={company}
|
||||
tech={tech}
|
||||
techPhone={techPhone}
|
||||
vendors={vendors}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { ExternalLink, Mail, Wrench } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
export type VendorPatch = {
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone: string;
|
||||
};
|
||||
|
||||
type VendorDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
company: string;
|
||||
tech: string;
|
||||
techPhone?: string;
|
||||
vendors: VendorDropdownItem[];
|
||||
onSave: (patch: VendorPatch) => void;
|
||||
};
|
||||
|
||||
type TechnicianOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
name: string;
|
||||
company: string;
|
||||
phone: string;
|
||||
vendor?: VendorDropdownItem;
|
||||
};
|
||||
|
||||
/** Known tech ↔ company pairs from seeded table mock data. */
|
||||
const MOCK_TECH_BY_COMPANY: Record<string, { name: string; phone: string }> = {
|
||||
"Midwest HVAC Co.": { name: "Mike Torres", phone: "(312) 555-0198" },
|
||||
"Great Lakes Plumbing": { name: "Jen Walsh", phone: "(317) 555-4412" },
|
||||
"SparkLine Electric": { name: "Pat Nguyen", phone: "" },
|
||||
};
|
||||
|
||||
function FieldLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<label
|
||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTechnicianOptions(
|
||||
vendors: VendorDropdownItem[],
|
||||
tech: string,
|
||||
company: string,
|
||||
): TechnicianOption[] {
|
||||
const options = new Map<string, TechnicianOption>();
|
||||
|
||||
const add = (option: TechnicianOption) => {
|
||||
if (!option.key) {
|
||||
return;
|
||||
}
|
||||
options.set(option.key, option);
|
||||
};
|
||||
|
||||
for (const vendor of vendors) {
|
||||
const mockTech = MOCK_TECH_BY_COMPANY[vendor.companyName];
|
||||
if (mockTech) {
|
||||
add({
|
||||
key: mockTech.name,
|
||||
label: `${mockTech.name} · ${vendor.companyName}`,
|
||||
name: mockTech.name,
|
||||
company: vendor.companyName,
|
||||
phone: mockTech.phone,
|
||||
vendor,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
add({
|
||||
key: vendor.companyName,
|
||||
label: vendor.companyName,
|
||||
name: vendor.companyName,
|
||||
company: vendor.companyName,
|
||||
phone: "",
|
||||
vendor,
|
||||
});
|
||||
}
|
||||
|
||||
if (tech) {
|
||||
const vendor = vendors.find((v) => v.companyName === company);
|
||||
add({
|
||||
key: tech,
|
||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||
name: tech,
|
||||
company: company || tech,
|
||||
phone: "",
|
||||
vendor,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(options.values());
|
||||
}
|
||||
|
||||
function phoneForOption(name: string, options: TechnicianOption[], fallback = ""): string {
|
||||
return options.find((o) => o.name === name)?.phone ?? fallback;
|
||||
}
|
||||
|
||||
export function VendorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
company,
|
||||
tech,
|
||||
techPhone = "",
|
||||
vendors,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const [t, setT] = useState(tech);
|
||||
const [phone, setPhone] = useState(techPhone);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const technicianOptions = useMemo(
|
||||
() => buildTechnicianOptions(vendors, tech, company),
|
||||
[vendors, tech, company],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setT(tech);
|
||||
setPhone(techPhone || phoneForOption(tech, technicianOptions));
|
||||
setNotes("");
|
||||
}
|
||||
}, [open, tech, techPhone, technicianOptions]);
|
||||
|
||||
const selected = technicianOptions.find((o) => o.name === t);
|
||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
||||
const companyEmail = selectedVendor?.email?.trim() || "";
|
||||
|
||||
const handlePick = (name: string) => {
|
||||
setT(name);
|
||||
setPhone(phoneForOption(name, technicianOptions));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Technician
|
||||
</DialogTitle>
|
||||
<p className="sr-only">Search and select the technician</p>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<FieldLabel>Technician</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={t}
|
||||
placeholder="Search technician …"
|
||||
icon={
|
||||
<Wrench
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
}))}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{t && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className={DLG_INPUT_CLS}
|
||||
style={DLG_INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{t && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<div
|
||||
className="space-y-1.5 rounded-lg p-3"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||
{resolvedCompany || "—"}
|
||||
</div>
|
||||
{companyEmail && <Detail icon={Mail} text={companyEmail} />}
|
||||
{selectedVendor?.address && (
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedVendor.address)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<ExternalLink size={12} /> Open in Google Maps
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Contact preferences, availability, etc."
|
||||
rows={3}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="min-h-0 px-2 py-1 text-[12px]"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
onClick={() => {
|
||||
onSave({ company: resolvedCompany, tech: t, techPhone: phone });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { getWoTableStatusStyle } from "../wo-table-status-style";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type WoTableStatusLabelProps = {
|
||||
status: string;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export function WoTableStatusLabel({ status, q }: WoTableStatusLabelProps) {
|
||||
const style = getWoTableStatusStyle(status);
|
||||
const label = status || "—";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap leading-none"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: style.text }}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 rounded-full"
|
||||
style={{ width: 8, height: 8, background: style.dot }}
|
||||
/>
|
||||
<HighlightText text={label} q={q} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { getWoTableTypeColor } from "../wo-table-type-style";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type WoTableTypeLabelProps = {
|
||||
type: WOType;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export function WoTableTypeLabel({ type, q }: WoTableTypeLabelProps) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center whitespace-nowrap leading-none"
|
||||
style={{ fontSize: 12.5, fontWeight: 700, color: getWoTableTypeColor(type) }}
|
||||
>
|
||||
<HighlightText text={type} q={q} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
type MiniCalendarProps = {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
};
|
||||
|
||||
export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
|
||||
const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso();
|
||||
const [iy, im] = init.split("-").map(Number);
|
||||
const [viewY, setViewY] = useState(iy);
|
||||
const [viewM, setViewM] = useState(im);
|
||||
|
||||
const firstWeekday = new Date(viewY, viewM - 1, 1).getDay();
|
||||
const daysInMonth = new Date(viewY, viewM, 0).getDate();
|
||||
const cells: (number | null)[] = [
|
||||
...Array(firstWeekday).fill(null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||
];
|
||||
|
||||
const step = (delta: number) => {
|
||||
let m = viewM + delta;
|
||||
let y = viewY;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y -= 1;
|
||||
}
|
||||
if (m > 12) {
|
||||
m = 1;
|
||||
y += 1;
|
||||
}
|
||||
setViewM(m);
|
||||
setViewY(y);
|
||||
};
|
||||
|
||||
const today = todayIso();
|
||||
|
||||
return (
|
||||
<div style={{ width: 224 }}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
|
||||
{MONTH_NAMES[viewM - 1]} {viewY}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-1 grid grid-cols-7 gap-0.5">
|
||||
{WEEKDAY_HEADER.map((d, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="py-0.5 text-center text-[10px] font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) {
|
||||
return <span key={i} />;
|
||||
}
|
||||
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
|
||||
const selected = iso === value;
|
||||
const isToday = iso === today;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onChange(iso)}
|
||||
className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
|
||||
style={{
|
||||
height: 26,
|
||||
borderRadius: 4,
|
||||
fontWeight: selected || isToday ? 600 : 400,
|
||||
background: selected ? "var(--color-primary)" : "transparent",
|
||||
color: selected
|
||||
? "var(--color-primary-foreground)"
|
||||
: isToday
|
||||
? "var(--color-primary)"
|
||||
: "var(--color-text-primary)",
|
||||
border:
|
||||
isToday && !selected
|
||||
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
MONTH_NAMES,
|
||||
mondayOf,
|
||||
todayIso,
|
||||
weekRangeLabel,
|
||||
weeksOfMonth,
|
||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
type WeekPickerProps = {
|
||||
selected: string;
|
||||
onPick: (mondayIso: string) => void;
|
||||
};
|
||||
|
||||
export function WeekPicker({ selected, onPick }: WeekPickerProps) {
|
||||
const init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
|
||||
const [iy, im] = init.split("-").map(Number);
|
||||
const [viewY, setViewY] = useState(iy);
|
||||
const [viewM, setViewM] = useState(im);
|
||||
|
||||
const step = (delta: number) => {
|
||||
let m = viewM + delta;
|
||||
let y = viewY;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y -= 1;
|
||||
}
|
||||
if (m > 12) {
|
||||
m = 1;
|
||||
y += 1;
|
||||
}
|
||||
setViewM(m);
|
||||
setViewY(y);
|
||||
};
|
||||
|
||||
const weeks = weeksOfMonth(viewY, viewM);
|
||||
const thisMonday = mondayOf(todayIso());
|
||||
|
||||
return (
|
||||
<div style={{ width: 240 }}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
aria-label="Previous month"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
|
||||
{MONTH_NAMES[viewM - 1]} {viewY}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
aria-label="Next month"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 200, overflowY: "auto" }} className="flex flex-col gap-1.5">
|
||||
{weeks.map((mon) => {
|
||||
const isSel = Boolean(selected) && selected === mon;
|
||||
const isThis = mon === thisMonday;
|
||||
return (
|
||||
<button
|
||||
key={mon}
|
||||
type="button"
|
||||
onClick={() => onPick(mon)}
|
||||
className="flex items-center transition-colors"
|
||||
style={{
|
||||
height: 36,
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: isSel ? 600 : 400,
|
||||
padding: "0 12px",
|
||||
background: isSel ? "var(--color-primary-tint)" : "transparent",
|
||||
border: `1px solid ${isSel ? "var(--color-primary)" : "var(--color-border)"}`,
|
||||
color: isSel ? "var(--color-primary)" : "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{weekRangeLabel(mon)}
|
||||
{isThis && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: isSel ? "var(--color-primary)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
This week
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||
|
||||
export interface WizardMenuOption {
|
||||
key: string;
|
||||
label: string;
|
||||
left?: ReactNode;
|
||||
}
|
||||
|
||||
type WizardFieldSelectProps = {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
icon?: ReactNode;
|
||||
options: WizardMenuOption[];
|
||||
onPick: (key: string) => void;
|
||||
search?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function WizardFieldSelect({
|
||||
value,
|
||||
placeholder,
|
||||
icon,
|
||||
options,
|
||||
onPick,
|
||||
search = true,
|
||||
searchPlaceholder = "Search…",
|
||||
disabled = false,
|
||||
}: WizardFieldSelectProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
const [q, setQ] = useState("");
|
||||
const selected = options.find((o) => o.key === value);
|
||||
const filtered = q
|
||||
? options.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(q.toLowerCase()) ||
|
||||
o.key.toLowerCase().includes(q.toLowerCase()),
|
||||
)
|
||||
: options;
|
||||
|
||||
const handleClose = () => {
|
||||
close();
|
||||
setQ("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuAnchorButton
|
||||
disabled={disabled}
|
||||
onAnchor={setAnchor}
|
||||
open={open}
|
||||
className="flex w-full items-center gap-2 transition-colors"
|
||||
style={{
|
||||
height: 36,
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 6,
|
||||
background: "var(--color-bg-surface)",
|
||||
padding: "0 10px",
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
color: "var(--foreground)",
|
||||
outline: "none",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
|
||||
}}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{icon != null && <span className="flex shrink-0 items-center">{icon}</span>}
|
||||
{icon == null && selected?.left != null && (
|
||||
<span className="flex shrink-0 items-center">{selected.left}</span>
|
||||
)}
|
||||
{selected ? (
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-left"
|
||||
style={{ fontSize: 13, color: "var(--foreground)" }}
|
||||
>
|
||||
{selected.label}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-left"
|
||||
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
|
||||
</MenuAnchorButton>
|
||||
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
transformOrigin={{ vertical: "top", horizontal: "left" }}
|
||||
slotProps={{
|
||||
paper: {
|
||||
className: "mt-1 rounded-lg border border-border p-1.5 shadow-md",
|
||||
style: { width: anchorEl?.offsetWidth ?? 280 },
|
||||
onClick: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{search && (
|
||||
<div
|
||||
className="mb-1 px-1 pb-1.5"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full rounded px-2 py-1 text-[12px] focus:outline-none"
|
||||
style={{
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ maxHeight: 220, overflowY: "auto" }}>
|
||||
{filtered.length === 0 ? (
|
||||
<p
|
||||
className="px-2 py-2 text-center text-[12px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
No matches
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(o.key);
|
||||
handleClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||
>
|
||||
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
|
||||
<span
|
||||
className="flex-1 truncate text-left text-[12px]"
|
||||
style={{ color: "var(--color-text-primary)" }}
|
||||
>
|
||||
{o.label}
|
||||
</span>
|
||||
{value === o.key && (
|
||||
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export const DLG_INPUT_CLS =
|
||||
"w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary";
|
||||
|
||||
export const DLG_INPUT_STYLE: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
};
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import type { CSSProperties, ElementType, ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export function StepCircle({ n, done, active }: { n: number; done: boolean; active: boolean }) {
|
||||
const base: React.CSSProperties = {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 9999,
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<span
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{ ...base, background: "var(--color-success)", color: "#fff" }}
|
||||
>
|
||||
<Check size={14} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<span
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{ ...base, background: "var(--color-primary)", color: "#fff" }}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
...base,
|
||||
background: "var(--color-bg-muted)",
|
||||
color: "var(--muted-foreground)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const WIZ_LABEL_STYLE: CSSProperties = {
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
function WizOptionalTag() {
|
||||
return (
|
||||
<span
|
||||
className="shrink-0 leading-none"
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 400,
|
||||
letterSpacing: 0,
|
||||
textTransform: "none",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
optional
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WizLabel({
|
||||
children,
|
||||
optional,
|
||||
required,
|
||||
leading,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
optional?: boolean;
|
||||
required?: boolean;
|
||||
leading?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className="mb-1.5 inline-flex w-full flex-nowrap items-center gap-1.5"
|
||||
style={WIZ_LABEL_STYLE}
|
||||
>
|
||||
{leading != null && <span className="flex shrink-0 items-center">{leading}</span>}
|
||||
<span className="shrink-0">{children}</span>
|
||||
{required && <span style={{ color: "var(--destructive)" }}>*</span>}
|
||||
{optional && <WizOptionalTag />}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function WizSubLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-sans)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WizLabelRow({
|
||||
children,
|
||||
optional,
|
||||
onClear,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
optional?: boolean;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2" style={{ minHeight: 16 }}>
|
||||
<span
|
||||
className="inline-flex min-w-0 flex-nowrap items-baseline gap-1.5"
|
||||
style={WIZ_LABEL_STYLE}
|
||||
>
|
||||
<span className="shrink-0">{children}</span>
|
||||
{optional && <WizOptionalTag />}
|
||||
</span>
|
||||
{onClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="inline-flex items-center gap-0.5 transition-colors"
|
||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--foreground)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--color-text-muted)";
|
||||
}}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Detail({ icon: Icon, text }: { icon: ElementType; text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
<Icon size={12} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
<span className="truncate">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColorDot({ color }: { color: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-flex shrink-0 rounded-full"
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
minWidth: 8,
|
||||
minHeight: 8,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
|
|
@ -20,7 +16,6 @@ import {
|
|||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
||||
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
|
@ -28,12 +23,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
|||
|
||||
export default function WorkOrdersListPage() {
|
||||
const { user } = useAuthContext();
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||
const filters = useWorkOrdersListFilters(user?.id);
|
||||
const tableData = useWorkOrdersTableData(filters);
|
||||
|
|
@ -52,29 +42,14 @@ export default function WorkOrdersListPage() {
|
|||
[problemOptions],
|
||||
);
|
||||
|
||||
const vendorTechs = useMemo(
|
||||
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
|
||||
[vendors],
|
||||
);
|
||||
|
||||
const totalCount = tableData.totalCount;
|
||||
const scopeTotalCount = tableData.scopeTotalCount;
|
||||
|
||||
const activeDocRow = useMemo(() => {
|
||||
if (!docRow) return null;
|
||||
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
|
||||
}, [docRow, tableData.rows]);
|
||||
|
||||
const activeMediaRow = useMemo(() => {
|
||||
if (!mediaRow) return null;
|
||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||
}, [mediaRow, tableData.rows]);
|
||||
|
||||
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||
setSlideOverRow(row);
|
||||
setSlideOverEdit(edit);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
<PageHeader
|
||||
|
|
@ -86,7 +61,7 @@ export default function WorkOrdersListPage() {
|
|||
search={filters.search}
|
||||
onSearchChange={filters.handleSearchChange}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onNewWorkOrder={() => setWizardOpen(true)}
|
||||
onNewWorkOrder={() => undefined}
|
||||
activeFilterCount={filters.activeFilterCount}
|
||||
isFetching={tableData.isFetching && !tableData.isLoading}
|
||||
/>
|
||||
|
|
@ -123,38 +98,13 @@ export default function WorkOrdersListPage() {
|
|||
sites={locations}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
onOpenSlideOver={handleOpenSlideOver}
|
||||
onOpenCompDoc={setDocRow}
|
||||
onOpenSlideOver={(row) => tableData.setSelectedId(row.id)}
|
||||
onOpenCompDoc={() => undefined}
|
||||
onOpenMedia={setMediaRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={filters.advOpen}
|
||||
onOpenChange={filters.setAdvOpen}
|
||||
initial={filters.advInitial}
|
||||
onApply={filters.applyAdvancedFilters}
|
||||
onClearAll={filters.clearAdvancedFilters}
|
||||
sites={locations}
|
||||
users={users}
|
||||
pmTypes={pmTypes}
|
||||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<WorkOrderSlideOver
|
||||
row={slideOverRow}
|
||||
editMode={slideOverEdit}
|
||||
users={users}
|
||||
onClose={() => {
|
||||
setSlideOverRow(null);
|
||||
setSlideOverEdit(false);
|
||||
tableData.setSelectedId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeMediaRow && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
|
|
@ -169,23 +119,6 @@ export default function WorkOrdersListPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDocRow && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={!!activeDocRow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDocRow(null);
|
||||
}}
|
||||
data={completionDocs[String(activeDocRow.id)]}
|
||||
onSave={(data, status) => {
|
||||
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
||||
patchField(activeDocRow.id, { docStatus: status });
|
||||
}}
|
||||
onStatusChange={(status) => patchField(activeDocRow.id, { status })}
|
||||
onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -411,6 +411,7 @@ export function mapEmployeeOptions(rawItems: unknown[]): EmployeeOption[] {
|
|||
|
||||
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
||||
const item = asRecord(raw);
|
||||
const email = readString(item, "email", "Email");
|
||||
|
||||
return {
|
||||
id: readId(item),
|
||||
|
|
@ -418,6 +419,7 @@ export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
|||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||
address: readString(item, "address", "Address"),
|
||||
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
||||
email: email || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export interface VendorDropdownItem {
|
|||
tradeSpecialties: string;
|
||||
address: string;
|
||||
distanceMiles: number | null;
|
||||
/** Present when the dropdown/API provides a contact email; never synthesized client-side. */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface TaskListTemplate {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue