shoc-frontend-new/src/app/v/[token]/dispatch/[id].tsx
2026-07-13 03:10:41 -03:00

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>
</>
);
}