mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
642 lines
19 KiB
TypeScript
642 lines
19 KiB
TypeScript
import { useCallback, useState } from "react";
|
|
import { Link, useParams } from "react-router";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
|
import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
|
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|
import {
|
|
formatVendorPortalDateTime,
|
|
vendorPortalStatusClass,
|
|
} from "@/domain/vendor-portal/lib/status-helpers";
|
|
import type {
|
|
VendorPortalChecklistItem,
|
|
VendorPortalComment,
|
|
VendorPortalDispatchDetail,
|
|
VendorPortalSignoff,
|
|
VendorPortalUpliftRequest,
|
|
} from "@/domain/vendor-portal/types/vendor-portal";
|
|
import { queryKeys } from "@/infra/query-key/query-key";
|
|
|
|
function ChecklistSection({
|
|
token,
|
|
dispatchId,
|
|
items,
|
|
locked,
|
|
onItemUpdated,
|
|
}: {
|
|
token: string;
|
|
dispatchId: number;
|
|
items: VendorPortalChecklistItem[];
|
|
locked: boolean;
|
|
onItemUpdated: (item: VendorPortalChecklistItem) => void;
|
|
}) {
|
|
const toggle = async (item: VendorPortalChecklistItem) => {
|
|
if (locked) return;
|
|
try {
|
|
const updated = await vendorPortalApi.updateChecklistItem(
|
|
token,
|
|
dispatchId,
|
|
item.id,
|
|
!item.isCompleted,
|
|
);
|
|
onItemUpdated(updated);
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : "Failed to update checklist");
|
|
}
|
|
};
|
|
|
|
if (!items.length) {
|
|
return <div className="vp-muted">No checklist items for this dispatch.</div>;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{items.map((item) => (
|
|
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
|
<input
|
|
type="checkbox"
|
|
id={`cl-${item.id}`}
|
|
checked={item.isCompleted}
|
|
onChange={() => void toggle(item)}
|
|
disabled={locked}
|
|
/>
|
|
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
|
{item.itemText}
|
|
</label>
|
|
{item.isCompleted && item.completedAt && (
|
|
<span className="vp-muted" style={{ fontSize: 12 }}>
|
|
{formatVendorPortalDateTime(item.completedAt)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommentsSection({
|
|
token,
|
|
dispatchId,
|
|
comments,
|
|
onAdded,
|
|
}: {
|
|
token: string;
|
|
dispatchId: number;
|
|
comments: VendorPortalComment[];
|
|
onAdded: (comment: VendorPortalComment) => void;
|
|
}) {
|
|
const [text, setText] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const submit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
if (!text.trim()) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
|
|
onAdded(added);
|
|
setText("");
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : "Failed to post comment");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{comments.length === 0 && <div className="vp-muted">No comments yet.</div>}
|
|
{comments.map((comment) => (
|
|
<div key={comment.id} className={`vp-comment ${comment.commentType || ""}`}>
|
|
<div className="vp-comment-meta">
|
|
{comment.commenter || "Unknown"} · {formatVendorPortalDateTime(comment.createdDate)}
|
|
</div>
|
|
<div>{comment.commenttext}</div>
|
|
</div>
|
|
))}
|
|
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
|
<textarea
|
|
className="vp-textarea"
|
|
placeholder="Add a comment for the dispatcher…"
|
|
value={text}
|
|
onChange={(event) => setText(event.target.value)}
|
|
/>
|
|
<div className="vp-actions">
|
|
<button
|
|
type="submit"
|
|
className="vp-btn vp-btn-primary"
|
|
disabled={submitting || !text.trim()}
|
|
>
|
|
{submitting ? "Posting…" : "Post Comment"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function UpliftRequestsSection({
|
|
token,
|
|
dispatchId,
|
|
currentNte,
|
|
upliftRequests,
|
|
locked,
|
|
onChanged,
|
|
}: {
|
|
token: string;
|
|
dispatchId: number;
|
|
currentNte?: number;
|
|
upliftRequests: VendorPortalUpliftRequest[];
|
|
locked: boolean;
|
|
onChanged: () => Promise<void>;
|
|
}) {
|
|
const [requested, setRequested] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const pending = upliftRequests.find((request) => request.status === "Pending");
|
|
|
|
const submit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
const amount = Number.parseFloat(requested);
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|
alert("Enter a valid requested NTE amount.");
|
|
return;
|
|
}
|
|
if (currentNte != null && amount <= Number(currentNte)) {
|
|
alert(
|
|
`Requested NTE must be greater than the current NTE ($${Number(currentNte).toFixed(2)}).`,
|
|
);
|
|
return;
|
|
}
|
|
if (!reason.trim()) {
|
|
alert("Please include a reason for the uplift request.");
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
await vendorPortalApi.requestUplift(token, dispatchId, {
|
|
requestedNTE: amount,
|
|
reason: reason.trim(),
|
|
});
|
|
setRequested("");
|
|
setReason("");
|
|
await onChanged();
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : "Failed to request uplift");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const cancel = async (requestId: number) => {
|
|
if (!window.confirm("Cancel this uplift request?")) return;
|
|
try {
|
|
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
|
|
await onChanged();
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : "Failed to cancel uplift");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{upliftRequests.length === 0 && !pending && (
|
|
<div className="vp-muted" style={{ marginBottom: 12 }}>
|
|
No uplift requests yet. Use the form below if the work will exceed the current NTE.
|
|
</div>
|
|
)}
|
|
|
|
{upliftRequests.map((request) => (
|
|
<div key={request.id} className="vp-uplift-row">
|
|
<div className="vp-row">
|
|
<strong>Requested</strong>
|
|
<span>
|
|
${Number(request.requestedNTE).toFixed(2)} (was $
|
|
{Number(request.currentNTE ?? 0).toFixed(2)})
|
|
</span>
|
|
</div>
|
|
<div className="vp-row">
|
|
<strong>Status</strong>
|
|
<span>
|
|
<span className={`vp-uplift-status-${String(request.status).toLowerCase()}`}>
|
|
{request.status}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{request.vendorReason && (
|
|
<div className="vp-row">
|
|
<strong>Reason</strong>
|
|
<span>{request.vendorReason}</span>
|
|
</div>
|
|
)}
|
|
{request.status === "Pending" && (
|
|
<div className="vp-actions">
|
|
<button
|
|
type="button"
|
|
className="vp-btn vp-btn-ghost"
|
|
onClick={() => void cancel(request.id)}
|
|
>
|
|
Cancel Request
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{!locked && !pending && (
|
|
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
|
<div className="vp-row">
|
|
<strong>Current NTE</strong>
|
|
<span>${Number(currentNte ?? 0).toFixed(2)}</span>
|
|
</div>
|
|
<div className="vp-row">
|
|
<strong>New total NTE</strong>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
className="vp-input"
|
|
value={requested}
|
|
onChange={(event) => setRequested(event.target.value)}
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
<div className="vp-row">
|
|
<strong>Reason</strong>
|
|
<textarea
|
|
className="vp-textarea"
|
|
value={reason}
|
|
onChange={(event) => setReason(event.target.value)}
|
|
placeholder="Why is the uplift needed?"
|
|
/>
|
|
</div>
|
|
<div className="vp-actions">
|
|
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
|
|
{submitting ? "Submitting…" : "Request Uplift"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SignoffSection({
|
|
token,
|
|
dispatchId,
|
|
existing,
|
|
signoffType,
|
|
nameLabel,
|
|
onAdded,
|
|
}: {
|
|
token: string;
|
|
dispatchId: number;
|
|
existing: VendorPortalSignoff[];
|
|
signoffType: string;
|
|
nameLabel: string;
|
|
onAdded: () => void;
|
|
}) {
|
|
const [name, setName] = useState("");
|
|
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
|
const [typedSig, setTypedSig] = useState("");
|
|
const [drawnSig, setDrawnSig] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
|
|
|
|
if (existingSignoff) {
|
|
return (
|
|
<div>
|
|
<div className="vp-row">
|
|
<strong>Signed by</strong>
|
|
<span>{existingSignoff.name}</span>
|
|
</div>
|
|
<div className="vp-row">
|
|
<strong>Method</strong>
|
|
<span>{existingSignoff.signatureMethod}</span>
|
|
</div>
|
|
<div className="vp-row">
|
|
<strong>Signed at</strong>
|
|
<span>{formatVendorPortalDateTime(existingSignoff.signedAt)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const submit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
const signature = method === "drawn" ? drawnSig : typedSig;
|
|
if (!name.trim() || !signature) {
|
|
alert("Name and signature are required");
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
await vendorPortalApi.addSignoff(token, dispatchId, {
|
|
name: name.trim(),
|
|
signature,
|
|
signatureMethod: method,
|
|
signoffType,
|
|
});
|
|
onAdded();
|
|
} catch (error) {
|
|
alert(error instanceof Error ? error.message : "Failed to submit signoff");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={(event) => void submit(event)}>
|
|
<div className="vp-row">
|
|
<strong>{nameLabel}</strong>
|
|
<input
|
|
className="vp-input"
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="vp-row">
|
|
<strong>Method</strong>
|
|
<div>
|
|
<label style={{ marginRight: 12 }}>
|
|
<input type="radio" checked={method === "drawn"} onChange={() => setMethod("drawn")} />{" "}
|
|
Drawn
|
|
</label>
|
|
<label>
|
|
<input type="radio" checked={method === "typed"} onChange={() => setMethod("typed")} />{" "}
|
|
Typed
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{method === "drawn" ? (
|
|
<div style={{ marginTop: 8 }}>
|
|
<SignaturePad onChange={setDrawnSig} />
|
|
</div>
|
|
) : (
|
|
<div className="vp-row">
|
|
<strong>Signature</strong>
|
|
<input
|
|
className="vp-input"
|
|
value={typedSig}
|
|
onChange={(event) => setTypedSig(event.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="vp-actions">
|
|
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
|
|
{submitting ? "Signing…" : "Submit Signoff"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function VendorPortalDispatchPage() {
|
|
const { token } = useVendorPortal();
|
|
const { id = "" } = useParams();
|
|
const dispatchId = Number(id);
|
|
const queryClient = useQueryClient();
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const { data, isLoading, error, refetch } = useQuery({
|
|
queryKey: queryKeys.vendorPortal.dispatch(token, dispatchId),
|
|
queryFn: () => vendorPortalApi.getDispatch(token, dispatchId),
|
|
enabled: Boolean(token && dispatchId),
|
|
});
|
|
|
|
const refresh = useCallback(async () => {
|
|
await refetch();
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.vendorPortal.dispatches(token) });
|
|
}, [refetch, queryClient, token]);
|
|
|
|
const runAction = async (action: () => Promise<void>, confirmMsg?: string) => {
|
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
|
setBusy(true);
|
|
try {
|
|
await action();
|
|
await refresh();
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : "Action failed");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading && !data) {
|
|
return <div className="vp-card vp-muted">Loading dispatch…</div>;
|
|
}
|
|
|
|
if (error && !data) {
|
|
return <div className="vp-error">{error.message}</div>;
|
|
}
|
|
|
|
if (!data) return null;
|
|
|
|
const locked = data.status === "Verified" || data.status === "Cancelled";
|
|
|
|
const updateLocal = (
|
|
updater: (current: VendorPortalDispatchDetail) => VendorPortalDispatchDetail,
|
|
) => {
|
|
queryClient.setQueryData(
|
|
queryKeys.vendorPortal.dispatch(token, dispatchId),
|
|
(current: unknown) => {
|
|
if (!current) return current;
|
|
return updater(current as VendorPortalDispatchDetail);
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Link to={`/v/${token}/dashboard`} className="vp-back-link">
|
|
← Back to dispatches
|
|
</Link>
|
|
|
|
<div className="vp-card">
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
<div>
|
|
<h2>{data.dispatchNumber}</h2>
|
|
<div className="vp-muted">{data.workOrder?.workerOrderTitle}</div>
|
|
</div>
|
|
<span className={vendorPortalStatusClass(data.status)}>{data.status}</span>
|
|
</div>
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
{data.workOrder?.internalWONumber && (
|
|
<div className="vp-row">
|
|
<strong>WO #</strong>
|
|
<span>{data.workOrder.internalWONumber}</span>
|
|
</div>
|
|
)}
|
|
{data.poNumber && (
|
|
<div className="vp-row">
|
|
<strong>PO #</strong>
|
|
<span>{data.poNumber}</span>
|
|
</div>
|
|
)}
|
|
{data.nteAmount != null && (
|
|
<div className="vp-row">
|
|
<strong>NTE</strong>
|
|
<span>${Number(data.nteAmount).toFixed(2)}</span>
|
|
</div>
|
|
)}
|
|
{data.scheduledDate && (
|
|
<div className="vp-row">
|
|
<strong>Scheduled</strong>
|
|
<span>{formatVendorPortalDateTime(data.scheduledDate)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{data.location && (
|
|
<>
|
|
<h3 style={{ marginTop: 16 }}>Location</h3>
|
|
<div>{data.location.name}</div>
|
|
<div className="vp-muted">
|
|
{[
|
|
data.location.address,
|
|
data.location.city,
|
|
data.location.state,
|
|
data.location.zipCode,
|
|
]
|
|
.filter(Boolean)
|
|
.join(", ")}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{(data.description || data.workOrder?.description) && (
|
|
<>
|
|
<h3 style={{ marginTop: 16 }}>Description</h3>
|
|
<div>{data.description || data.workOrder?.description}</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="vp-actions">
|
|
{data.status === "Sent" && (
|
|
<button
|
|
type="button"
|
|
className="vp-btn vp-btn-success"
|
|
disabled={busy}
|
|
onClick={() => void runAction(() => vendorPortalApi.accept(token, dispatchId))}
|
|
>
|
|
Accept Dispatch
|
|
</button>
|
|
)}
|
|
{data.status === "Acknowledged" && (
|
|
<button
|
|
type="button"
|
|
className="vp-btn vp-btn-primary"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void runAction(() => vendorPortalApi.changeStatus(token, dispatchId, "In Progress"))
|
|
}
|
|
>
|
|
Start Work
|
|
</button>
|
|
)}
|
|
{data.status === "In Progress" && (
|
|
<button
|
|
type="button"
|
|
className="vp-btn vp-btn-primary"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void runAction(
|
|
() => vendorPortalApi.changeStatus(token, dispatchId, "Completed"),
|
|
"Mark this dispatch as completed?",
|
|
)
|
|
}
|
|
>
|
|
Mark Completed
|
|
</button>
|
|
)}
|
|
{!locked && data.status !== "Sent" && (
|
|
<button
|
|
type="button"
|
|
className="vp-btn vp-btn-warn"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
const reason = window.prompt("Reason for cancel request?");
|
|
if (reason == null) return;
|
|
void runAction(() => vendorPortalApi.requestCancel(token, dispatchId, reason));
|
|
}}
|
|
>
|
|
Request Cancel
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="vp-card">
|
|
<h3>Checklist</h3>
|
|
<ChecklistSection
|
|
token={token}
|
|
dispatchId={dispatchId}
|
|
items={data.checklist ?? []}
|
|
locked={locked}
|
|
onItemUpdated={(updated) =>
|
|
updateLocal((current) => ({
|
|
...current,
|
|
checklist: (current.checklist ?? []).map((item) =>
|
|
item.id === updated.id ? { ...item, ...updated } : item,
|
|
),
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="vp-card">
|
|
<h3>NTE Uplift Requests</h3>
|
|
<UpliftRequestsSection
|
|
token={token}
|
|
dispatchId={dispatchId}
|
|
currentNte={data.nteAmount}
|
|
upliftRequests={data.upliftRequests ?? []}
|
|
locked={locked}
|
|
onChanged={refresh}
|
|
/>
|
|
</div>
|
|
|
|
<div className="vp-card">
|
|
<h3>Customer Signoff</h3>
|
|
<SignoffSection
|
|
token={token}
|
|
dispatchId={dispatchId}
|
|
existing={data.signoffs ?? []}
|
|
signoffType="customer"
|
|
nameLabel="Customer name"
|
|
onAdded={() => void refresh()}
|
|
/>
|
|
</div>
|
|
|
|
<div className="vp-card">
|
|
<h3>Vendor Signoff</h3>
|
|
<SignoffSection
|
|
token={token}
|
|
dispatchId={dispatchId}
|
|
existing={data.signoffs ?? []}
|
|
signoffType="vendor"
|
|
nameLabel="Your name"
|
|
onAdded={() => void refresh()}
|
|
/>
|
|
</div>
|
|
|
|
<div className="vp-card">
|
|
<h3>Comments</h3>
|
|
<CommentsSection
|
|
token={token}
|
|
dispatchId={dispatchId}
|
|
comments={data.comments ?? []}
|
|
onAdded={(comment) =>
|
|
updateLocal((current) => ({
|
|
...current,
|
|
comments: [...(current.comments ?? []), comment],
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|