mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat(work-orders): board slide-over detail with comments, audit and media
This commit is contained in:
parent
7c7ed57577
commit
46b8184d62
17 changed files with 2285 additions and 290 deletions
|
|
@ -1,6 +1,16 @@
|
|||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { Ban, CheckCircle2, Download, FileText, Save, Sparkles, Undo2, X } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileText,
|
||||
Save,
|
||||
Sparkles,
|
||||
Undo2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
|
|
@ -13,7 +23,8 @@ import type {
|
|||
} from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
||||
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||
import {
|
||||
|
|
@ -22,6 +33,11 @@ import {
|
|||
fmtDocNow,
|
||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderFileTooLarge,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { CompDocPreview } from "./comp-doc-preview";
|
||||
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||
|
||||
|
|
@ -54,13 +70,20 @@ export function CompDocDialog({
|
|||
onDateCompletedChange,
|
||||
}: CompDocDialogProps) {
|
||||
const locId = String(row.locationId || "");
|
||||
const isMockLocation = locId.startsWith("mock-");
|
||||
const { data: apiLocationDetail } = useLocationDetail(
|
||||
isMockLocation || !locId ? undefined : locId,
|
||||
);
|
||||
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||
const { data: locationDetail } = useLocationDetail(locId || undefined);
|
||||
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
|
||||
const isUploading = uploadCompletionMutation.isPending;
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const site = useMemo(() => resolveCompDocSite(row, locationDetail), [row, locationDetail]);
|
||||
const info = boardDetail?.info ?? row;
|
||||
const completion = boardDetail?.completion;
|
||||
const templateUrl = completion?.template?.templateUrl;
|
||||
const signOffAttachment = completion?.signOffAttachment;
|
||||
const rowVersion = info.rowVersion || row.rowVersion;
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
|
||||
|
||||
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
|
||||
|
||||
const [notes, setNotes] = useState("");
|
||||
const [docState, setDocState] = useState<DocState>("draft");
|
||||
|
|
@ -70,16 +93,24 @@ export function CompDocDialog({
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setNotes(data?.notes ?? "");
|
||||
setDocState(docStateOf(row.docStatus));
|
||||
setDocState(docStateOf(completion?.docStatus || info.docStatus));
|
||||
setGeneratedAt(data?.generatedAt ?? "");
|
||||
setEditDateCompleted(row.scheduledOn || "");
|
||||
}, [open, row.id, row.docStatus, row.scheduledOn, data?.notes, data?.generatedAt]);
|
||||
setEditDateCompleted(info.scheduledOn || "");
|
||||
}, [
|
||||
open,
|
||||
info.id,
|
||||
info.docStatus,
|
||||
info.scheduledOn,
|
||||
completion?.docStatus,
|
||||
data?.notes,
|
||||
data?.generatedAt,
|
||||
]);
|
||||
|
||||
const amazonRep = row.pocName || "";
|
||||
const seaRep = row.tech || "";
|
||||
const amazonRep = info.pocName || "";
|
||||
const seaRep = info.tech || "";
|
||||
const cfg = DOC_STATE_CFG[docState];
|
||||
const isNotRequired = docState === "not-required";
|
||||
const isGenerated = docState === "generated";
|
||||
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
|
||||
|
||||
const docFields: DocFields = {
|
||||
street: site.street,
|
||||
|
|
@ -107,7 +138,7 @@ export function CompDocDialog({
|
|||
|
||||
function handleSaveDraft() {
|
||||
persist({}, "No");
|
||||
if (editDateCompleted !== row.scheduledOn) {
|
||||
if (editDateCompleted !== info.scheduledOn) {
|
||||
onDateCompletedChange?.(editDateCompleted);
|
||||
}
|
||||
toast(
|
||||
|
|
@ -132,7 +163,6 @@ export function CompDocDialog({
|
|||
closeButton: false,
|
||||
icon: false,
|
||||
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||
bodyClassName: "!p-0",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -142,20 +172,58 @@ export function CompDocDialog({
|
|||
setGeneratedAt(ts);
|
||||
setDocState("generated");
|
||||
persist({ generatedAt: ts }, "Yes");
|
||||
if (editDateCompleted !== row.scheduledOn) {
|
||||
if (editDateCompleted !== info.scheduledOn) {
|
||||
onDateCompletedChange?.(editDateCompleted);
|
||||
}
|
||||
onStatusChange?.("Completed");
|
||||
}
|
||||
|
||||
function handleDownloadPdf() {
|
||||
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
||||
if (signOffAttachment) {
|
||||
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
|
||||
}
|
||||
|
||||
function handleUploadPdf(files: FileList | null) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
return;
|
||||
}
|
||||
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
toast.error("Please upload a PDF file.");
|
||||
return;
|
||||
}
|
||||
if (!rowVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
uploadCompletionMutation.mutate(
|
||||
{ file, workOrderVersion: rowVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
const ts = fmtDocNow();
|
||||
setGeneratedAt(ts);
|
||||
setDocState("generated");
|
||||
persist({ generatedAt: ts }, "Yes");
|
||||
if (editDateCompleted !== info.scheduledOn) {
|
||||
onDateCompletedChange?.(editDateCompleted);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleClose = isUploading ? undefined : () => onOpenChange(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onClose={handleClose}
|
||||
disableEscapeKeyDown={isUploading}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
|
|
@ -181,7 +249,7 @@ export function CompDocDialog({
|
|||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
||||
<DialogTitle className="sr-only">Completion Document — WO #{info.woNumber}</DialogTitle>
|
||||
<p className="sr-only">
|
||||
Generate, save or mark not-required the completion document for this work order.
|
||||
</p>
|
||||
|
|
@ -223,10 +291,10 @@ export function CompDocDialog({
|
|||
className="font-mono"
|
||||
style={{ color: "var(--color-primary)", fontWeight: 600 }}
|
||||
>
|
||||
#{row.woNumber}
|
||||
#{info.woNumber}
|
||||
</span>
|
||||
{" · "}
|
||||
{serviceTitle(row)}
|
||||
{serviceTitle(info)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -255,9 +323,12 @@ export function CompDocDialog({
|
|||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={() => {
|
||||
if (!isUploading) onOpenChange(false);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
aria-label="Close"
|
||||
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
|
@ -270,9 +341,44 @@ export function CompDocDialog({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{(templateUrl || signOffAttachment) && (
|
||||
<div
|
||||
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
{templateUrl && (
|
||||
<a
|
||||
href={templateUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<Download size={13} />
|
||||
Download blank template
|
||||
</a>
|
||||
)}
|
||||
{signOffAttachment && (
|
||||
<a
|
||||
href={signOffAttachment}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<FileText size={13} />
|
||||
Open uploaded PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<CompDocPreview
|
||||
row={row}
|
||||
row={info}
|
||||
site={site}
|
||||
notes={notes}
|
||||
generatedAt={generatedAt}
|
||||
|
|
@ -280,7 +386,7 @@ export function CompDocDialog({
|
|||
isNotRequired={isNotRequired}
|
||||
/>
|
||||
<CompDocSidebar
|
||||
row={row}
|
||||
row={info}
|
||||
site={site}
|
||||
docState={docState}
|
||||
generatedAt={generatedAt}
|
||||
|
|
@ -302,7 +408,17 @@ export function CompDocDialog({
|
|||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
{docState === "draft" && (
|
||||
<input
|
||||
ref={pdfInputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
handleUploadPdf(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{docState === "draft" && !isDocumentsReadOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -324,6 +440,19 @@ export function CompDocDialog({
|
|||
>
|
||||
<Save size={14} /> Save draft
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={FOOTER_BTN_SX}
|
||||
disabled={!rowVersion}
|
||||
loading={isUploading}
|
||||
loadingText="Uploading…"
|
||||
onClick={() => pdfInputRef.current?.click()}
|
||||
startIcon={!isUploading ? <Upload size={14} /> : undefined}
|
||||
>
|
||||
Upload PDF
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
|
|
@ -336,27 +465,44 @@ export function CompDocDialog({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{docState === "generated" && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||
>
|
||||
<CheckCircle2 size={14} /> Generated {generatedAt}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={FOOTER_BTN_SX}
|
||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||
onClick={handleDownloadPdf}
|
||||
{(docState === "generated" || Boolean(signOffAttachment)) &&
|
||||
docState !== "not-required" && (
|
||||
<>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||
>
|
||||
<Download size={14} /> Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CheckCircle2 size={14} />{" "}
|
||||
{signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isDocumentsReadOnly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={FOOTER_BTN_SX}
|
||||
disabled={!rowVersion}
|
||||
loading={isUploading}
|
||||
loadingText="Uploading…"
|
||||
onClick={() => pdfInputRef.current?.click()}
|
||||
startIcon={!isUploading ? <Upload size={14} /> : undefined}
|
||||
>
|
||||
Replace PDF
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={FOOTER_BTN_SX}
|
||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||
onClick={handleDownloadPdf}
|
||||
>
|
||||
<Download size={14} /> Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{docState === "not-required" && (
|
||||
<>
|
||||
<span
|
||||
|
|
@ -367,15 +513,17 @@ export function CompDocDialog({
|
|||
<b style={{ fontWeight: 600 }}>not required</b> for this work order
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={FOOTER_BTN_SX}
|
||||
onClick={handleUndo}
|
||||
>
|
||||
<Undo2 size={14} /> Undo
|
||||
</Button>
|
||||
{!isDocumentsReadOnly && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||
sx={FOOTER_BTN_SX}
|
||||
onClick={handleUndo}
|
||||
>
|
||||
<Undo2 size={14} /> Undo
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="default"
|
||||
disabled
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { useMemo } from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
type AuditTimelineProps = {
|
||||
entries: WorkOrderAuditEntry[];
|
||||
emptyMessage?: string;
|
||||
maxHeightClass?: string;
|
||||
};
|
||||
|
||||
export function AuditTimeline({
|
||||
entries,
|
||||
emptyMessage = "No changes recorded.",
|
||||
maxHeightClass = "max-h-72",
|
||||
}: AuditTimelineProps) {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
if (!sorted.length) {
|
||||
return <Typography color="text.secondary">{emptyMessage}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={`${maxHeightClass} space-y-3 overflow-y-auto`}>
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Box className="mb-1 flex justify-between gap-2">
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2">
|
||||
{entry.action ? (
|
||||
entry.action
|
||||
) : (
|
||||
<>
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
{entry.action && entry.fieldName ? (
|
||||
<Typography variant="caption" color="text.secondary" display="block" className="mt-1">
|
||||
{entry.fieldName}
|
||||
{entry.oldValue || entry.newValue
|
||||
? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
|
||||
: ""}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { Settings } from "lucide-react";
|
||||
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
getAvatarColorFromName,
|
||||
getDispatcherAvatarColor,
|
||||
getInitialsFromName,
|
||||
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
|
||||
|
||||
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
||||
if (entry.type === "system") return true;
|
||||
if (entry.type === "manual") return false;
|
||||
const name = entry.userName?.trim();
|
||||
return !name || name.toLowerCase() === "system";
|
||||
}
|
||||
|
||||
function formatClock(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual (image): `· 9:48 AM`
|
||||
* System (image): `05/13 · 8:00 AM`
|
||||
* Prefer API display strings that already include · / AM|PM.
|
||||
*/
|
||||
function formatAuditTime(value: string, isSystem: boolean): string {
|
||||
if (!value) return "";
|
||||
if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
const timePart = formatClock(date);
|
||||
if (isSystem) {
|
||||
const datePart = date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
return `${datePart} · ${timePart}`;
|
||||
}
|
||||
return `· ${timePart}`;
|
||||
}
|
||||
|
||||
type SlideOverAuditListProps = {
|
||||
entries: WorkOrderAuditEntry[];
|
||||
users?: WorkOrderUser[];
|
||||
};
|
||||
|
||||
export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListProps) {
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className="mb-3 text-[10px] font-medium uppercase"
|
||||
style={{ letterSpacing: "0.06em", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Audit Log
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||
No changes recorded
|
||||
</p>
|
||||
) : (
|
||||
entries.map((entry, index) => {
|
||||
const isSys = isSystemEntry(entry);
|
||||
const dispatcher = users.find(
|
||||
(user) =>
|
||||
entry.dispatcherId != null && String(user.id) === String(entry.dispatcherId),
|
||||
);
|
||||
const name =
|
||||
dispatcher?.name ||
|
||||
entry.userName?.trim() ||
|
||||
(entry.dispatcherId ? String(entry.dispatcherId) : "Unknown");
|
||||
const color = dispatcher
|
||||
? getDispatcherAvatarColor(dispatcher)
|
||||
: getAvatarColorFromName(name);
|
||||
const initials = dispatcher
|
||||
? getInitialsFromName(dispatcher.name)
|
||||
: getInitialsFromName(name);
|
||||
const action = formatBoardAuditAction(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(entry.id) || index}
|
||||
className="flex items-start gap-2.5"
|
||||
style={
|
||||
isSys
|
||||
? {
|
||||
background: "#F8FAFC",
|
||||
borderRadius: 6,
|
||||
padding: "10px",
|
||||
}
|
||||
: {
|
||||
background: "transparent",
|
||||
padding: "10px 0",
|
||||
}
|
||||
}
|
||||
>
|
||||
{isSys ? (
|
||||
<div
|
||||
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded"
|
||||
style={{ background: "#E2E8F0" }}
|
||||
>
|
||||
<Settings size={10} style={{ color: "#64748B" }} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[8px] font-bold"
|
||||
style={
|
||||
dispatcher || name !== "Unknown"
|
||||
? { background: `${color}25`, color }
|
||||
: {
|
||||
background: "var(--color-bg-muted)",
|
||||
color: "var(--color-text-muted)",
|
||||
}
|
||||
}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{isSys ? (
|
||||
<p className="text-[12px] leading-snug" style={{ color: "#374151" }}>
|
||||
<span style={{ fontStyle: "italic", color: "#6B7280" }}>System</span>
|
||||
<span style={{ color: "#6B7280" }}> — </span>
|
||||
{action}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[12px] leading-snug">
|
||||
<span className="font-semibold" style={{ color: "#1E293B" }}>
|
||||
{name}
|
||||
</span>
|
||||
<span style={{ color: "#D1D5DB" }}> · </span>
|
||||
<span style={{ color: "#374151" }}>{action}</span>
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className="mt-0.5 text-[10px]"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: "#9CA3AF",
|
||||
}}
|
||||
>
|
||||
{formatAuditTime(entry.createdAt, isSys)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type SlideOverFooterProps = {
|
||||
isCancelled: boolean;
|
||||
editing: boolean;
|
||||
isDirty: boolean;
|
||||
showUnsaved: boolean;
|
||||
saving?: boolean;
|
||||
onCancelWO: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onKeepEditing: () => void;
|
||||
};
|
||||
|
||||
export function SlideOverFooter({
|
||||
isCancelled,
|
||||
editing,
|
||||
isDirty,
|
||||
showUnsaved,
|
||||
saving,
|
||||
onCancelWO,
|
||||
onCancelEdit,
|
||||
onClose,
|
||||
onEdit,
|
||||
onSave,
|
||||
onDiscard,
|
||||
onKeepEditing,
|
||||
}: SlideOverFooterProps) {
|
||||
if (showUnsaved) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between gap-2 px-5 py-3"
|
||||
style={{
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[12px]"
|
||||
style={{ color: "var(--color-warning)" }}
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
You have unsaved changes.
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" className="text-[12px]" onClick={onDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-1.5 text-[12px]"
|
||||
onClick={onSave}
|
||||
loading={saving}
|
||||
>
|
||||
<Check size={13} />
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-[12px]" onClick={onKeepEditing}>
|
||||
Keep editing
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
|
||||
<p className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||
This work order has been cancelled and is read-only.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
||||
style={{
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelWO}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
|
||||
style={{ color: "var(--destructive)" }}
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
Cancel WO
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={editing ? onCancelEdit : onClose}
|
||||
disabled={saving}
|
||||
className="text-[12px] transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = "var(--color-text-primary)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = "var(--color-text-muted)";
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{editing ? (
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-1.5 text-[12px]"
|
||||
onClick={onSave}
|
||||
disabled={!isDirty}
|
||||
loading={saving}
|
||||
>
|
||||
<Check size={13} />
|
||||
Save
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" className="gap-1.5 text-[12px]" onClick={onEdit}>
|
||||
<Pencil size={13} />
|
||||
Edit WO
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { CircularProgress } from "@mui/material";
|
||||
import { X } from "lucide-react";
|
||||
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
||||
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type SlideOverHeaderProps = {
|
||||
row: WorkOrderTableRow;
|
||||
isCancelled: boolean;
|
||||
isFetching?: boolean;
|
||||
closeDisabled?: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SlideOverHeader({
|
||||
row,
|
||||
isCancelled,
|
||||
isFetching = false,
|
||||
closeDisabled = false,
|
||||
onClose,
|
||||
}: SlideOverHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3.5"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<h2
|
||||
className="text-[15px] font-semibold leading-none"
|
||||
style={{ color: "var(--color-text-primary)" }}
|
||||
>
|
||||
#{row.woNumber || "—"}
|
||||
</h2>
|
||||
|
||||
{isCancelled ? (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[12px] font-medium"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
color: "var(--muted-foreground)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
Cancelled
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{row.site ? (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 font-mono text-[11px]"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
color: "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{row.site}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{row.isPastDue ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-medium leading-none"
|
||||
style={{
|
||||
background: "var(--status-pastdue-bg)",
|
||||
color: "var(--status-pastdue-text)",
|
||||
borderRadius: 4,
|
||||
padding: "3px 8px",
|
||||
}}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#BE123C]" />
|
||||
Past Due
|
||||
</span>
|
||||
) : row.status ? (
|
||||
<WoTableStatusLabel status={row.status} />
|
||||
) : null}
|
||||
|
||||
{row.type ? <WoTableTypeLabel type={row.type as WOType} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isFetching && <CircularProgress size={14} aria-label="Refreshing details" />}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
disabled={closeDisabled}
|
||||
className="shrink-0 rounded p-1.5 transition-colors disabled:opacity-40"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!closeDisabled) {
|
||||
e.currentTarget.style.background = "var(--color-bg-muted)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
type SlideOverInfoRowProps = {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5"
|
||||
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
|
||||
>
|
||||
<span className="w-28 shrink-0 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-[12.5px]" style={{ color: "var(--color-text-primary)" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
|
||||
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
type SlideOverAuditTabProps = {
|
||||
entries: WorkOrderAuditEntry[];
|
||||
users?: WorkOrderUser[];
|
||||
};
|
||||
|
||||
export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
|
||||
return <SlideOverAuditList entries={entries} users={users} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import { useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||
import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
|
||||
import {
|
||||
getAvatarColorFromName,
|
||||
getInitialsFromName,
|
||||
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
|
||||
function formatCommentTime(value: string): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const textareaStyle = {
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
} as const;
|
||||
|
||||
type SlideOverCommentItemProps = {
|
||||
comment: WorkOrderComment;
|
||||
canEdit: boolean;
|
||||
isEditing: boolean;
|
||||
draftText: string;
|
||||
isUpdatePending: boolean;
|
||||
onStartEdit: () => void;
|
||||
onDraftChange: (value: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
};
|
||||
|
||||
function SlideOverCommentItem({
|
||||
comment,
|
||||
canEdit,
|
||||
isEditing,
|
||||
draftText,
|
||||
isUpdatePending,
|
||||
onStartEdit,
|
||||
onDraftChange,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
}: SlideOverCommentItemProps) {
|
||||
const author = comment.userName || comment.commenter || "Unknown";
|
||||
const color = getAvatarColorFromName(author);
|
||||
const initials = getInitialsFromName(author);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5 py-2.5">
|
||||
<span
|
||||
className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
|
||||
style={{ background: `${color}25`, color }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="truncate text-[12px] font-semibold"
|
||||
style={{ color: "var(--color-text-primary)" }}
|
||||
>
|
||||
{author}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||
{formatCommentTime(comment.createdDate)}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && !isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
title="Edit comment"
|
||||
aria-label="Edit comment"
|
||||
onClick={onStartEdit}
|
||||
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-1.5 space-y-2">
|
||||
<textarea
|
||||
value={draftText}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
rows={3}
|
||||
disabled={isUpdatePending}
|
||||
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
|
||||
style={textareaStyle}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
className="h-8 text-[12px]"
|
||||
disabled={isUpdatePending}
|
||||
onClick={onCancelEdit}
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
className="h-8 text-[12px]"
|
||||
disabled={!draftText.trim() || isUpdatePending}
|
||||
loading={isUpdatePending}
|
||||
onClick={onSaveEdit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="mt-0.5 text-[12px] leading-snug"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{comment.commenttext}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SlideOverCommentsTabProps = {
|
||||
comments: WorkOrderComment[];
|
||||
isReadOnly: boolean;
|
||||
isPending: boolean;
|
||||
currentUserId?: string | number;
|
||||
isUpdatePending?: boolean;
|
||||
onSubmit: (text: string) => void;
|
||||
onUpdateComment?: (
|
||||
commentId: string | number,
|
||||
text: string,
|
||||
options?: { onSuccess?: () => void },
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function SlideOverCommentsTab({
|
||||
comments,
|
||||
isReadOnly,
|
||||
isPending,
|
||||
currentUserId,
|
||||
isUpdatePending = false,
|
||||
onSubmit,
|
||||
onUpdateComment,
|
||||
}: SlideOverCommentsTabProps) {
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
|
||||
const [draftText, setDraftText] = useState("");
|
||||
|
||||
const handlePost = () => {
|
||||
const text = commentText.trim();
|
||||
if (!text || isReadOnly || isPending) return;
|
||||
onSubmit(text);
|
||||
setCommentText("");
|
||||
};
|
||||
|
||||
const startEdit = (comment: WorkOrderComment) => {
|
||||
setEditingCommentId(comment.id);
|
||||
setDraftText(comment.commenttext);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingCommentId(null);
|
||||
setDraftText("");
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingCommentId == null || !onUpdateComment) return;
|
||||
const text = draftText.trim();
|
||||
if (!text || isUpdatePending) return;
|
||||
onUpdateComment(editingCommentId, text, {
|
||||
onSuccess: () => {
|
||||
setEditingCommentId(null);
|
||||
setDraftText("");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Add comment…"
|
||||
rows={3}
|
||||
disabled={isPending}
|
||||
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
|
||||
style={textareaStyle}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="default"
|
||||
className="h-8 text-[12px]"
|
||||
disabled={!commentText.trim() || isPending}
|
||||
onClick={handlePost}
|
||||
loading={isPending}
|
||||
>
|
||||
Post
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid var(--color-bg-muted)",
|
||||
pointerEvents: isReadOnly ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
{comments.length === 0 ? (
|
||||
<p className="py-6 text-center text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||
No comments yet
|
||||
</p>
|
||||
) : (
|
||||
comments.map((comment) => {
|
||||
const canEdit =
|
||||
!isReadOnly &&
|
||||
Boolean(onUpdateComment) &&
|
||||
isOwnWorkOrderComment(comment, currentUserId);
|
||||
const isEditing = editingCommentId === comment.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(comment.id)}
|
||||
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
|
||||
>
|
||||
<SlideOverCommentItem
|
||||
comment={comment}
|
||||
canEdit={canEdit}
|
||||
isEditing={isEditing}
|
||||
draftText={isEditing ? draftText : comment.commenttext}
|
||||
isUpdatePending={isUpdatePending && isEditing}
|
||||
onStartEdit={() => startEdit(comment)}
|
||||
onDraftChange={setDraftText}
|
||||
onCancelEdit={cancelEdit}
|
||||
onSaveEdit={saveEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import { useRef } from "react";
|
||||
import { Ban, CheckCircle2, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
function isMediaWO(type: WOType | ""): boolean {
|
||||
return type === "Emergency" || type === "Reactive";
|
||||
}
|
||||
|
||||
type SlideOverCompletionTabProps = {
|
||||
row: WorkOrderTableRow;
|
||||
isReadOnly: boolean;
|
||||
completion?: WorkOrderBoardCompletion;
|
||||
media: MediaFile[];
|
||||
mediaPending?: boolean;
|
||||
completionPending?: boolean;
|
||||
onUploadMedia?: (files: File[], category: UploadableMediaCategory) => void;
|
||||
onRemoveMedia?: (id: string) => void;
|
||||
onUploadCompletionPdf?: (file: File) => void;
|
||||
onOpenCompletionDoc: () => void;
|
||||
};
|
||||
|
||||
export function SlideOverCompletionTab({
|
||||
row,
|
||||
isReadOnly,
|
||||
completion,
|
||||
media,
|
||||
mediaPending = false,
|
||||
completionPending = false,
|
||||
onUploadMedia,
|
||||
onRemoveMedia,
|
||||
onUploadCompletionPdf,
|
||||
onOpenCompletionDoc,
|
||||
}: SlideOverCompletionTabProps) {
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
const docStatus = completion?.docStatus || row.docStatus;
|
||||
const templateUrl = completion?.template?.templateUrl;
|
||||
const signOffAttachment = completion?.signOffAttachment;
|
||||
|
||||
if (isMediaWO(row.type)) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||
Photos & Videos
|
||||
</span>
|
||||
</div>
|
||||
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||
<MediaUploader
|
||||
uploads={media}
|
||||
onUpload={onUploadMedia}
|
||||
onRemove={onRemoveMedia}
|
||||
readOnly={isReadOnly}
|
||||
pending={mediaPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePdfSelected = (files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (!file || !onUploadCompletionPdf) return;
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
return;
|
||||
}
|
||||
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
toast.error("Please upload a PDF file.");
|
||||
return;
|
||||
}
|
||||
onUploadCompletionPdf(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText size={14} style={{ color: "var(--foreground)" }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||
Completion Document
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{templateUrl && (
|
||||
<a
|
||||
href={templateUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<Download size={13} />
|
||||
Download blank template
|
||||
</a>
|
||||
)}
|
||||
|
||||
{signOffAttachment && (
|
||||
<a
|
||||
href={signOffAttachment}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<FileText size={13} />
|
||||
View uploaded document
|
||||
</a>
|
||||
)}
|
||||
|
||||
{isReadOnly ? (
|
||||
docStatus === "Yes" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[12px]"
|
||||
onClick={onOpenCompletionDoc}
|
||||
>
|
||||
<CheckCircle2 size={13} />
|
||||
View Document
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
background: "var(--color-bg-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<FileText size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||
{docStatus === "NN"
|
||||
? "No completion document was required for this work order."
|
||||
: "No completion document was generated."}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{onUploadCompletionPdf && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[12px]"
|
||||
disabled={!row.rowVersion}
|
||||
loading={completionPending}
|
||||
loadingText="Uploading…"
|
||||
onClick={() => pdfInputRef.current?.click()}
|
||||
startIcon={!completionPending ? <Upload size={13} /> : undefined}
|
||||
>
|
||||
Upload signed PDF
|
||||
</Button>
|
||||
<input
|
||||
ref={pdfInputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
handlePdfSelected(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{!row.rowVersion && (
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Waiting for work order version before upload is available.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-1.5 text-[12px]"
|
||||
onClick={onOpenCompletionDoc}
|
||||
>
|
||||
{docStatus === "Yes" ? (
|
||||
<>
|
||||
<CheckCircle2 size={13} />
|
||||
View / Edit Document
|
||||
</>
|
||||
) : docStatus === "NN" ? (
|
||||
<>
|
||||
<Ban size={13} />
|
||||
Document Not Required — Review
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText size={13} />
|
||||
Open completion document
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completion && completion.dispatchSignoffs.length > 0 && (
|
||||
<div className="space-y-1 pt-2">
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
Dispatch sign-offs
|
||||
</span>
|
||||
{completion.dispatchSignoffs.map((signoff, index) => (
|
||||
<div
|
||||
key={`${signoff.dispatchId ?? "d"}-${index}`}
|
||||
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{signoff.signoffType || "Sign-off"}
|
||||
{signoff.name ? ` — ${signoff.name}` : ""}
|
||||
{signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { useRef } from "react";
|
||||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
image: ImageIcon,
|
||||
video: Film,
|
||||
pdf: FileText,
|
||||
doc: FileIcon,
|
||||
other: Paperclip,
|
||||
};
|
||||
|
||||
const ACCEPT = "image/*,video/*,.pdf,.doc,.docx";
|
||||
|
||||
type SlideOverExtrasTabProps = {
|
||||
uploads: MediaFile[];
|
||||
isReadOnly: boolean;
|
||||
pending?: boolean;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
};
|
||||
|
||||
export function SlideOverExtrasTab({
|
||||
uploads,
|
||||
isReadOnly,
|
||||
pending = false,
|
||||
onUpload,
|
||||
onRemove,
|
||||
}: SlideOverExtrasTabProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
}
|
||||
if (accepted.length > 0) {
|
||||
onUpload(accepted);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||
Extra Documents
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 100,
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span
|
||||
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
||||
aria-busy={pending || undefined}
|
||||
>
|
||||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit()} per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{uploads.length === 0 ? (
|
||||
<p style={{ fontSize: 12, color: "var(--color-text-muted)" }}>No extra documents yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{uploads.map((file) => {
|
||||
const ItemIcon = FILETYPE_ICON[file.fileType];
|
||||
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
|
||||
return (
|
||||
<div key={file.id} className="flex items-center gap-2 py-1">
|
||||
<ItemIcon
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
/>
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 flex-1 truncate hover:underline"
|
||||
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
{showDelete && (
|
||||
<button
|
||||
type="button"
|
||||
title="Remove"
|
||||
disabled={pending}
|
||||
onClick={() => onRemove(file.id)}
|
||||
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
import { useState } from "react";
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
||||
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
|
||||
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
||||
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
|
||||
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
|
||||
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
|
||||
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
||||
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType, WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
|
||||
function isMediaWO(type: WOType | ""): boolean {
|
||||
return type === "Emergency" || type === "Reactive";
|
||||
}
|
||||
|
||||
const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
|
||||
Yes: { bg: "#F0FDF4", text: "#166534" },
|
||||
No: { bg: "#FFF1F2", text: "#BE123C" },
|
||||
NN: { bg: "#F3F4F6", text: "#6B7280" },
|
||||
};
|
||||
|
||||
function DocBadge({ row }: { row: WorkOrderTableRow }) {
|
||||
if (isMediaWO(row.type)) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<ImageIcon size={14} />
|
||||
{row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
|
||||
const style = DOC_BADGE_STYLE[key];
|
||||
const label = key === "NN" ? "NN" : key;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 text-[11px] font-medium leading-none"
|
||||
style={{
|
||||
background: style.bg,
|
||||
color: style.text,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlideOverInfoTabProps = {
|
||||
row: WorkOrderTableRow;
|
||||
draft: WorkOrderTableRow;
|
||||
editing: boolean;
|
||||
isCancelled: boolean;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function SlideOverInfoTab({
|
||||
row,
|
||||
draft,
|
||||
editing,
|
||||
isCancelled,
|
||||
users,
|
||||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
allOrders,
|
||||
onDraftChange,
|
||||
onDuplicateFound,
|
||||
}: SlideOverInfoTabProps) {
|
||||
const [vendorDlg, setVendorDlg] = useState(false);
|
||||
const source = editing && !isCancelled ? draft : row;
|
||||
const appt = parseApptTime(source.apptTime);
|
||||
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
|
||||
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
||||
|
||||
if (editing && !isCancelled) {
|
||||
return (
|
||||
<div>
|
||||
<SlideOverInfoRow label="WO #">
|
||||
<EditableWONumberCell
|
||||
value={draft.woNumber}
|
||||
rescheduleCount={draft.rescheduleCount}
|
||||
carriedOver={draft.carriedOver}
|
||||
currentId={draft.id}
|
||||
allOrders={allOrders}
|
||||
onSave={(woNumber) => onDraftChange({ woNumber })}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Site">
|
||||
<SiteCell
|
||||
value={draft.site}
|
||||
location={draft.location}
|
||||
locationId={draft.locationId}
|
||||
pocName={draft.pocName}
|
||||
pocPhone={draft.pocPhone}
|
||||
pocNotes={draft.pocNotes}
|
||||
sites={sites}
|
||||
onSave={(patch) =>
|
||||
onDraftChange({
|
||||
site: patch.site,
|
||||
location: patch.location,
|
||||
locationId: patch.locationId,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Type of WO">
|
||||
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Assigned To">
|
||||
<AssignedToCell
|
||||
dispatcherId={draft.dispatcherId}
|
||||
users={users}
|
||||
onSave={(id, name) => onDraftChange({ dispatcherId: id, dispatcherName: name })}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Schedule On">
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||
onSave={(scheduledOn) =>
|
||||
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
|
||||
}
|
||||
onSaveWeek={(weekStart) =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
targetWeek: weekStart,
|
||||
scheduleWeekOnly: true,
|
||||
isPastDue: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Due Date">
|
||||
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Appt Time">
|
||||
<ApptTimeCell
|
||||
apptTime={draft.apptTime}
|
||||
onSave={(apptTime) => onDraftChange({ apptTime })}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Service">
|
||||
<PMTypeCell
|
||||
value={draft.pm}
|
||||
pmTypes={pmTypes}
|
||||
serviceNotes={draft.serviceNotes}
|
||||
extraServices={draft.extraServices}
|
||||
woNumber={draft.woNumber}
|
||||
onSave={(pm) => onDraftChange({ pm })}
|
||||
onSaveDetails={(details) =>
|
||||
onDraftChange({
|
||||
serviceNotes: details.serviceNotes,
|
||||
extraServices: details.extraServices,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Vendor">
|
||||
<VendorCell
|
||||
company={draft.company}
|
||||
tech={draft.tech}
|
||||
techPhone={draft.techPhone}
|
||||
vendors={vendors}
|
||||
onSave={(patch) =>
|
||||
onDraftChange({
|
||||
vendorId: patch.vendorId,
|
||||
company: patch.company,
|
||||
tech: patch.tech,
|
||||
techPhone: patch.techPhone,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Status">
|
||||
<StatusCell
|
||||
wo={draft}
|
||||
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Comp Doc">
|
||||
<DocBadge row={draft} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="POC">
|
||||
<span className="text-[13px]" style={{ color: "var(--color-text-secondary)" }}>
|
||||
{draft.pocName || "—"}
|
||||
{draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
|
||||
</span>
|
||||
</SlideOverInfoRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||
<SlideOverInfoRow label="WO #">
|
||||
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Site">
|
||||
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
|
||||
{source.site || "—"}
|
||||
</span>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Type of WO">
|
||||
{source.type ? (
|
||||
<WoTableTypeLabel type={source.type as WOType} />
|
||||
) : (
|
||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Assigned To">
|
||||
{dispatcher || source.dispatcherName ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||
{dispatcher ? (
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ background: getDispatcherAvatarColor(dispatcher) }}
|
||||
/>
|
||||
) : null}
|
||||
{dispatcher?.name || source.dispatcherName}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Schedule On">
|
||||
<span className="text-[12.5px]">
|
||||
{source.scheduleWeekOnly && source.targetWeek
|
||||
? `Week of ${source.targetWeek}`
|
||||
: source.scheduledOn || "—"}
|
||||
</span>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Due Date">
|
||||
<span className="text-[12.5px]">{source.dueDate || "—"}</span>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Appt Time">
|
||||
<span className="text-[12.5px]">{apptDisplay}</span>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Service">
|
||||
{source.pm ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||
<PmTypeIcon pmType={source.pm} size={14} />
|
||||
{source.pm}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Vendor">
|
||||
{source.tech || source.company ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVendorDlg(true)}
|
||||
className="text-left transition-opacity hover:opacity-80"
|
||||
>
|
||||
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
|
||||
{source.tech || source.company}
|
||||
</div>
|
||||
{source.tech && source.company ? (
|
||||
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
|
||||
{source.company}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
<VendorDialog
|
||||
open={vendorDlg}
|
||||
onOpenChange={setVendorDlg}
|
||||
company={source.company}
|
||||
tech={source.tech}
|
||||
techPhone={source.techPhone}
|
||||
vendors={vendors}
|
||||
onSave={() => setVendorDlg(false)}
|
||||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Status">
|
||||
{source.isPastDue ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
|
||||
style={{ color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||
Past Due
|
||||
</span>
|
||||
) : source.status ? (
|
||||
<WoTableStatusLabel status={source.status} />
|
||||
) : (
|
||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||
)}
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Comp Doc">
|
||||
<DocBadge row={source} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="POC">
|
||||
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
|
||||
{source.pocName || "—"}
|
||||
{source.pocPhone ? ` · ${source.pocPhone}` : ""}
|
||||
</span>
|
||||
</SlideOverInfoRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,19 +5,31 @@ import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
|||
type WorkOrderAssigneeMenuProps = {
|
||||
assignedTo: string;
|
||||
users: WorkOrderUser[];
|
||||
disabled?: boolean;
|
||||
onAssign: (userId: string | number, userName: string) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderAssigneeMenu({ assignedTo, users, onAssign }: WorkOrderAssigneeMenuProps) {
|
||||
export function WorkOrderAssigneeMenu({
|
||||
assignedTo,
|
||||
users,
|
||||
disabled = false,
|
||||
onAssign,
|
||||
}: WorkOrderAssigneeMenuProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="small" onClick={(event) => setAnchor(event.currentTarget)}>
|
||||
{assignedTo || "Unassigned"}
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
aria-busy={disabled || undefined}
|
||||
onClick={(event) => setAnchor(event.currentTarget)}
|
||||
>
|
||||
{disabled ? "Updating…" : assignedTo || "Unassigned"}
|
||||
</Button>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||
<MenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
close();
|
||||
onAssign("", "");
|
||||
|
|
@ -29,6 +41,7 @@ export function WorkOrderAssigneeMenu({ assignedTo, users, onAssign }: WorkOrder
|
|||
<MenuItem
|
||||
key={String(user.id)}
|
||||
selected={assignedTo === user.name}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
close();
|
||||
onAssign(user.id, user.name);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Tab,
|
||||
Tabs,
|
||||
|
|
@ -21,54 +20,19 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AuditTimeline } from "./audit-timeline";
|
||||
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
|
||||
import type {
|
||||
WorkOrderAuditEntry,
|
||||
WorkOrderDetail,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||
|
||||
type ActivityTab = "customer" | "internal" | "audit";
|
||||
|
||||
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 color="text.secondary">No changes recorded.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="max-h-72 space-y-4 overflow-y-auto">
|
||||
{sorted.map((entry) => (
|
||||
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||
<Box className="mb-1 flex justify-between">
|
||||
<Typography variant="caption" className="font-semibold">
|
||||
{entry.userName || "System"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2">
|
||||
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type WorkOrderDetailsSectionProps = {
|
||||
wo: WorkOrderDetail;
|
||||
users: WorkOrderUser[];
|
||||
onAssign: (userId: string | number, userName: string) => void;
|
||||
isAssignPending?: boolean;
|
||||
onDispatchClick: (dispatchId: string | number) => void;
|
||||
onCommentSubmit: (text: string, commentType: "customer" | "internal") => void;
|
||||
isCommentPending: boolean;
|
||||
|
|
@ -78,6 +42,7 @@ export function WorkOrderDetailsSection({
|
|||
wo,
|
||||
users,
|
||||
onAssign,
|
||||
isAssignPending = false,
|
||||
onDispatchClick,
|
||||
onCommentSubmit,
|
||||
isCommentPending,
|
||||
|
|
@ -119,7 +84,12 @@ export function WorkOrderDetailsSection({
|
|||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Assigned To
|
||||
</Typography>
|
||||
<WorkOrderAssigneeMenu assignedTo={wo.assignedTo} users={users} onAssign={onAssign} />
|
||||
<WorkOrderAssigneeMenu
|
||||
assignedTo={wo.assignedTo}
|
||||
users={users}
|
||||
disabled={isAssignPending}
|
||||
onAssign={onAssign}
|
||||
/>
|
||||
</Box>
|
||||
<DetailField label="Source" value={wo.source || "—"} />
|
||||
<DetailField label="TT" value={wo.tt || "—"} />
|
||||
|
|
@ -279,11 +249,12 @@ export function WorkOrderDetailsSection({
|
|||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
className="self-end"
|
||||
disabled={isCommentPending || !commentText.trim()}
|
||||
disabled={!commentText.trim()}
|
||||
loading={isCommentPending}
|
||||
loadingText="Sending…"
|
||||
>
|
||||
{isCommentPending ? "Sending..." : "Send"}
|
||||
Send
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,142 +1,378 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Box, Button, CircularProgress, IconButton, Tab, Tabs, Typography } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
import { SideSheet } from "@/components/ui/side-sheet";
|
||||
import { WorkOrderDetailsSection } from "@/app/(protected)/workorders/_components/detail/work-order-detail-sections";
|
||||
import { WorkOrderStatusMenu } from "@/app/(protected)/workorders/_components/detail/work-order-status-menu";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
|
||||
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
||||
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
|
||||
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
|
||||
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
|
||||
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
||||
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||
import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
WorkOrderUser,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddWorkOrderComment,
|
||||
useChangeWorkOrderAssignment,
|
||||
useChangeWorkOrderStatus,
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderComment,
|
||||
useUploadCompletionDoc,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
buildSlideOverPatch,
|
||||
isSlideOverDraftDirty,
|
||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
|
||||
|
||||
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
|
||||
{ id: "info", label: "Info" },
|
||||
{ id: "comments", label: "Comments" },
|
||||
{ id: "audit", label: "Audit Log" },
|
||||
{ id: "completion", label: "Completion Doc" },
|
||||
{ id: "extras", label: "Extra Docs" },
|
||||
];
|
||||
|
||||
type WorkOrderSlideOverProps = {
|
||||
row: WorkOrderTableRow | null;
|
||||
editMode: boolean;
|
||||
users: WorkOrderUser[];
|
||||
sites: LocationOption[];
|
||||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onSave: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
|
||||
onCancelWO: (row: WorkOrderTableRow) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onClose: () => void;
|
||||
saving?: boolean;
|
||||
};
|
||||
|
||||
export function WorkOrderSlideOver({ row, editMode, users, onClose }: WorkOrderSlideOverProps) {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
export function WorkOrderSlideOver({
|
||||
row,
|
||||
editMode,
|
||||
users,
|
||||
sites,
|
||||
vendors,
|
||||
pmTypes,
|
||||
allOrders,
|
||||
onSave,
|
||||
onOpenCompletionDoc,
|
||||
onCancelWO,
|
||||
onDuplicateFound,
|
||||
onClose,
|
||||
saving,
|
||||
}: WorkOrderSlideOverProps) {
|
||||
const { user } = useAuthContext();
|
||||
const workOrderId = row?.id;
|
||||
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
|
||||
const statusMutation = useChangeWorkOrderStatus(workOrderId ?? "");
|
||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId ?? "");
|
||||
const {
|
||||
data: detail,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
|
||||
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
|
||||
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
|
||||
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
|
||||
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
|
||||
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
||||
|
||||
const [tab, setTab] = useState<SlideOverTab>("info");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
||||
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||
|
||||
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
|
||||
const detailForRow =
|
||||
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
|
||||
const infoSource = detailForRow?.info ?? row;
|
||||
const isCancelled = (infoSource?.status as string) === "Canceled";
|
||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
|
||||
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
setDraft(row);
|
||||
setEditing(editMode && (row.status as string) !== "Canceled");
|
||||
setTab("info");
|
||||
setShowUnsaved(false);
|
||||
}, [row, editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!infoSource) return;
|
||||
setDraft((prev) => {
|
||||
if (editing && prev && isSlideOverDraftDirty(prev, infoSource)) return prev;
|
||||
return infoSource;
|
||||
});
|
||||
}, [infoSource, editing]);
|
||||
|
||||
const activeDraft = draft ?? infoSource;
|
||||
const isDirty = useMemo(() => {
|
||||
if (!infoSource || !activeDraft || !editing) return false;
|
||||
return isSlideOverDraftDirty(activeDraft, infoSource);
|
||||
}, [activeDraft, editing, infoSource]);
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detailForRow?.media ?? []).filter(
|
||||
(item) =>
|
||||
item.category === "Before" ||
|
||||
item.category === "After" ||
|
||||
item.category === "Completion",
|
||||
),
|
||||
),
|
||||
[detailForRow?.media],
|
||||
);
|
||||
|
||||
const extrasUploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
|
||||
),
|
||||
[detailForRow?.media],
|
||||
);
|
||||
|
||||
const mediaPending = uploadMediaMutation.isPending || deleteMediaMutation.isPending;
|
||||
const completionPending = uploadCompletionMutation.isPending;
|
||||
const closeDisabled = Boolean(saving || mediaPending || completionPending);
|
||||
|
||||
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
|
||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (!row || !activeDraft || !infoSource) return;
|
||||
const patch = buildSlideOverPatch(activeDraft, infoSource);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
onSave(row.id, patch);
|
||||
}
|
||||
setEditing(false);
|
||||
setShowUnsaved(false);
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
if (!infoSource) return;
|
||||
setDraft(infoSource);
|
||||
setEditing(false);
|
||||
setShowUnsaved(false);
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (closeDisabled) return;
|
||||
if (editing && isDirty) {
|
||||
setShowUnsaved(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (isDirty) {
|
||||
setShowUnsaved(true);
|
||||
return;
|
||||
}
|
||||
if (infoSource) setDraft(infoSource);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const showDetailSkeleton = isLoading && !detailForRow;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to load work order details.";
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
open={Boolean(row)}
|
||||
onClose={onClose}
|
||||
widthClass="w-full max-w-xl"
|
||||
onClose={requestClose}
|
||||
closeDisabled={closeDisabled}
|
||||
widthClass="w-[480px] max-w-[480px]"
|
||||
aria-label="Work order details"
|
||||
>
|
||||
<Box className="flex h-full flex-col">
|
||||
<Box className="flex items-start justify-between border-b border-border px-4 py-3">
|
||||
<Box>
|
||||
<Typography variant="overline" color="text.secondary">
|
||||
Work Order
|
||||
</Typography>
|
||||
<Typography variant="h6" component="h2">
|
||||
{row?.woNumber || row?.title || "—"}
|
||||
</Typography>
|
||||
{wo && (
|
||||
<Box className="mt-1">
|
||||
<WorkOrderStatusMenu
|
||||
status={wo.status}
|
||||
onStatusChange={(status) => statusMutation.mutate(status)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<IconButton aria-label="Close" onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{row && infoSource && activeDraft && (
|
||||
<Box className="flex h-full flex-col overflow-hidden">
|
||||
<SlideOverHeader
|
||||
row={infoSource}
|
||||
isCancelled={isCancelled}
|
||||
isFetching={isFetching && !isLoading}
|
||||
closeDisabled={closeDisabled}
|
||||
onClose={requestClose}
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} className="border-b border-border px-2">
|
||||
<Tab label="Info" />
|
||||
<Tab label="Activity" />
|
||||
</Tabs>
|
||||
|
||||
<Box className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading && (
|
||||
<Box className="flex justify-center py-12">
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Typography color="error" variant="body2">
|
||||
{error instanceof Error ? error.message : "Failed to load work order."}
|
||||
</Typography>
|
||||
)}
|
||||
{wo && tab === 0 && (
|
||||
<Box className="space-y-3">
|
||||
<Typography variant="body2">
|
||||
<strong>Title:</strong> {wo.title}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Location:</strong> {wo.location}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Assigned:</strong> {wo.assignedTo || "Unassigned"}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Due:</strong> {wo.dueDate || "—"}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Scheduled:</strong> {wo.scheduledDate || "—"}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Problem:</strong> {wo.problem || "—"}
|
||||
</Typography>
|
||||
{wo.description && (
|
||||
<Typography variant="body2" className="whitespace-pre-wrap">
|
||||
{wo.description}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(`/workorders/${wo.id}`);
|
||||
}}
|
||||
>
|
||||
Open full page
|
||||
</Button>
|
||||
{editMode && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(`/workorders/edit/${wo.id}`);
|
||||
<div
|
||||
className="mt-2 flex shrink-0 gap-4 overflow-x-auto px-4"
|
||||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||
role="tablist"
|
||||
aria-label="Work order sections"
|
||||
>
|
||||
{SLIDE_OVER_TABS.map((item) => {
|
||||
const active = tab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
disabled={Boolean(saving)}
|
||||
onClick={() => setTab(item.id)}
|
||||
className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
borderBottomColor: active ? "var(--color-primary)" : "transparent",
|
||||
color: active ? "var(--color-primary)" : "var(--color-text-muted)",
|
||||
fontWeight: active ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
Edit work order
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{wo && tab === 1 && (
|
||||
<WorkOrderDetailsSection
|
||||
wo={wo}
|
||||
users={users}
|
||||
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
|
||||
onDispatchClick={() => {}}
|
||||
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
|
||||
isCommentPending={commentMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Box className="flex-1 overflow-y-auto p-5">
|
||||
{tab === "info" && (
|
||||
<SlideOverInfoTab
|
||||
row={infoSource}
|
||||
draft={activeDraft}
|
||||
editing={editing && !saving}
|
||||
isCancelled={isCancelled}
|
||||
users={users}
|
||||
sites={sites}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allOrders={allOrders}
|
||||
onDraftChange={handleDraftChange}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "comments" && (
|
||||
<>
|
||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="comments" />}
|
||||
{error && !detailForRow && (
|
||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
||||
)}
|
||||
{detailForRow && (
|
||||
<SlideOverCommentsTab
|
||||
comments={detailForRow.comments}
|
||||
isReadOnly={isCancelled}
|
||||
isPending={commentMutation.isPending}
|
||||
currentUserId={user?.id}
|
||||
isUpdatePending={updateCommentMutation.isPending}
|
||||
onSubmit={(text) => commentMutation.mutate({ text })}
|
||||
onUpdateComment={(commentId, text, options) =>
|
||||
updateCommentMutation.mutate({ commentId, text }, options)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "audit" && (
|
||||
<>
|
||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="audit" />}
|
||||
{error && !detailForRow && (
|
||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
||||
)}
|
||||
{detailForRow && <SlideOverAuditTab entries={detailForRow.audit} users={users} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "completion" && (
|
||||
<>
|
||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="completion" />}
|
||||
{error && !detailForRow && (
|
||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
||||
)}
|
||||
{(detailForRow || !isLoading) && (
|
||||
<SlideOverCompletionTab
|
||||
row={infoSource}
|
||||
isReadOnly={isDocumentsReadOnly}
|
||||
completion={detailForRow?.completion}
|
||||
media={completionMedia}
|
||||
mediaPending={mediaPending}
|
||||
completionPending={completionPending}
|
||||
onUploadMedia={(files, category) => {
|
||||
for (const file of files) {
|
||||
uploadMediaMutation.mutate({ file, category });
|
||||
}
|
||||
}}
|
||||
onRemoveMedia={(id) => deleteMediaMutation.mutate(id)}
|
||||
onUploadCompletionPdf={(file) => {
|
||||
const version = infoSource.rowVersion;
|
||||
if (!version) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
uploadCompletionMutation.mutate({
|
||||
file,
|
||||
workOrderVersion: version,
|
||||
});
|
||||
}}
|
||||
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "extras" && (
|
||||
<>
|
||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="extras" />}
|
||||
{error && !detailForRow && (
|
||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
||||
)}
|
||||
{(detailForRow || !isLoading) && (
|
||||
<SlideOverExtrasTab
|
||||
uploads={extrasUploads}
|
||||
isReadOnly={isDocumentsReadOnly}
|
||||
pending={mediaPending}
|
||||
onUpload={(files) => {
|
||||
for (const file of files) {
|
||||
uploadMediaMutation.mutate({ file, category: "Extra" });
|
||||
}
|
||||
}}
|
||||
onRemove={(id) => deleteMediaMutation.mutate(id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<SlideOverFooter
|
||||
isCancelled={isCancelled}
|
||||
editing={editing}
|
||||
isDirty={isDirty}
|
||||
showUnsaved={showUnsaved}
|
||||
saving={saving}
|
||||
onCancelWO={() => onCancelWO(infoSource)}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onClose={requestClose}
|
||||
onEdit={() => {
|
||||
setDraft(infoSource);
|
||||
setEditing(true);
|
||||
setTab("info");
|
||||
}}
|
||||
onSave={commit}
|
||||
onDiscard={() => {
|
||||
discard();
|
||||
onClose();
|
||||
}}
|
||||
onKeepEditing={() => setShowUnsaved(false)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,26 @@ import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
|||
|
||||
type WorkOrderStatusMenuProps = {
|
||||
status: string;
|
||||
disabled?: boolean;
|
||||
onStatusChange: (status: string) => void;
|
||||
};
|
||||
|
||||
export function WorkOrderStatusMenu({ status, onStatusChange }: WorkOrderStatusMenuProps) {
|
||||
export function WorkOrderStatusMenu({
|
||||
status,
|
||||
disabled = false,
|
||||
onStatusChange,
|
||||
}: WorkOrderStatusMenuProps) {
|
||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuAnchorButton onAnchor={setAnchor} open={open} aria-label="Change status">
|
||||
<MenuAnchorButton
|
||||
onAnchor={setAnchor}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
aria-label="Change status"
|
||||
aria-busy={disabled || undefined}
|
||||
>
|
||||
<WorkOrderStatusBadge status={status} />
|
||||
</MenuAnchorButton>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||
|
|
@ -22,6 +33,7 @@ export function WorkOrderStatusMenu({ status, onStatusChange }: WorkOrderStatusM
|
|||
<MenuItem
|
||||
key={option}
|
||||
selected={option === status}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
close();
|
||||
if (option !== status) {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,53 @@
|
|||
import { type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { type MouseEvent, useMemo } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
import { MediaUploader } from "./media-uploader";
|
||||
|
||||
type MediaUploadModalProps = {
|
||||
row: WorkOrderTableRow;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
uploads: MediaFile[];
|
||||
onChange: (next: MediaFile[]) => void;
|
||||
};
|
||||
|
||||
export function MediaUploadModal({
|
||||
row,
|
||||
open,
|
||||
onOpenChange,
|
||||
uploads,
|
||||
onChange,
|
||||
}: MediaUploadModalProps) {
|
||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
|
||||
const uploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detail?.media ?? []).filter(
|
||||
(item) =>
|
||||
item.category === "Before" ||
|
||||
item.category === "After" ||
|
||||
item.category === "Completion",
|
||||
),
|
||||
),
|
||||
[detail?.media],
|
||||
);
|
||||
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
onClose={handleClose}
|
||||
disableEscapeKeyDown={pending}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
|
|
@ -46,7 +68,27 @@ export function MediaUploadModal({
|
|||
<p className="sr-only">Upload photos and videos for this work order.</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<MediaUploader uploads={uploads} onChange={onChange} />
|
||||
{isLoading && !detail ? (
|
||||
<LoadingRegion label="Loading media">
|
||||
<Stack spacing={1.5}>
|
||||
<Skeleton variant="rounded" height={100} animation="wave" />
|
||||
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||
</Stack>
|
||||
</LoadingRegion>
|
||||
) : (
|
||||
<MediaUploader
|
||||
uploads={uploads}
|
||||
onUpload={(files, category) => {
|
||||
for (const file of files) {
|
||||
uploadMutation.mutate({ file, category });
|
||||
}
|
||||
}}
|
||||
onRemove={(id) => deleteMutation.mutate(id)}
|
||||
readOnly={isReadOnly}
|
||||
pending={pending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
|
|
@ -54,6 +96,7 @@ export function MediaUploadModal({
|
|||
variant="outline"
|
||||
size="default"
|
||||
className="text-[12px]"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
|
|
@ -9,7 +9,14 @@ import {
|
|||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -20,58 +27,140 @@ const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
|||
other: Paperclip,
|
||||
};
|
||||
|
||||
const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
|
||||
|
||||
type MediaUploaderProps = {
|
||||
uploads: MediaFile[];
|
||||
onChange: (next: MediaFile[]) => void;
|
||||
/** When provided, uploads go to the API with the selected Before/After category. */
|
||||
onUpload?: (files: File[], category: UploadableMediaCategory) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
/** Legacy local-only mode (table modal fallback). */
|
||||
onChange?: (next: MediaFile[]) => void;
|
||||
readOnly?: boolean;
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
|
||||
export function MediaUploader({
|
||||
uploads,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [category, setCategory] = useState<UploadableMediaCategory>("Before");
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
|
||||
if (readOnly || pending || !files || files.length === 0) return;
|
||||
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
}
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
if (onUpload) {
|
||||
onUpload(accepted, category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange([...uploads, ...accepted.map(makeMediaFile)]);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
|
||||
const remove = (id: string) => {
|
||||
if (readOnly || pending) return;
|
||||
const target = uploads.find((u) => u.id === id);
|
||||
if (target && !canDeleteWorkOrderMedia(target)) return;
|
||||
if (onRemove) {
|
||||
onRemove(id);
|
||||
return;
|
||||
}
|
||||
onChange?.(uploads.filter((u) => u.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
Drag files here or click to browse
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => addFiles(e.target.files)}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<>
|
||||
{onUpload && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<label
|
||||
className="text-[11px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
htmlFor="media-upload-category"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="media-upload-category"
|
||||
value={category}
|
||||
disabled={pending}
|
||||
onChange={(event) => setCategory(event.target.value as UploadableMediaCategory)}
|
||||
className="rounded border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
{PHOTO_CATEGORIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
addFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
onChange={(e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{uploads.map((u) => {
|
||||
const TypeIcon = FILETYPE_ICON[u.fileType];
|
||||
const showDelete = !readOnly && canDeleteWorkOrderMedia(u);
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
|
|
@ -106,24 +195,27 @@ export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
|
|||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(u.id)}
|
||||
title="Remove"
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
{showDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(u.id)}
|
||||
title="Remove"
|
||||
disabled={pending}
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<div
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue