Add sign-off UI with signature capture component

- SignatureCapture component: canvas drawing pad + typed mode with cursive preview
- Two sign-off slots in dispatch detail modal: Customer and Vendor
- Collect Signature button opens capture modal, prompts for name
- Signed slots show signature thumbnail, name, and timestamp
- Green "All Signed" badge when both complete
- addDispatchSignoff API function
This commit is contained in:
Adam Moussa 2026-04-17 15:51:20 -04:00
parent 31479029e3
commit 5c24b9249d
5 changed files with 358 additions and 1 deletions

View file

@ -0,0 +1,113 @@
.sigOverlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
}
.sigModal {
background: #fff;
border-radius: 8px;
width: 520px;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
.sigHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #eee;
}
.sigHeader h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.sigBody {
padding: 20px;
}
.sigModeTabs {
display: flex;
gap: 0;
margin-bottom: 16px;
border-bottom: 2px solid #e5e5e5;
}
.sigModeTab {
padding: 6px 16px;
border: none;
background: none;
font-size: 13px;
font-weight: 600;
color: #999;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.sigModeTab.active {
color: #007bff;
border-bottom-color: #007bff;
}
.sigCanvasWrap {
position: relative;
}
.sigCanvas {
border: 1px solid #ddd;
border-radius: 4px;
cursor: crosshair;
display: block;
width: 100%;
touch-action: none;
}
.sigClearBtn {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 10px;
font-size: 11px;
border: 1px solid #ddd;
background: #fff;
border-radius: 3px;
cursor: pointer;
color: #666;
}
.sigTypeWrap {
display: flex;
flex-direction: column;
gap: 12px;
}
.sigTypeInput {
font-size: 16px;
}
.sigTypePreview {
padding: 20px;
text-align: center;
font-family: "Brush Script MT", "Segoe Script", cursive;
font-size: 36px;
color: #333;
border: 1px solid #eee;
border-radius: 4px;
background: #fafafa;
}
.sigFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid #eee;
}

View file

@ -0,0 +1,112 @@
import React, { useState, useRef, useEffect } from "react";
import ReactDOM from "react-dom";
export default function SignatureCapture({ onSave, onClose, title }) {
const [mode, setMode] = useState("draw");
const [typedName, setTypedName] = useState("");
const canvasRef = useRef(null);
const isDrawing = useRef(false);
useEffect(() => {
if (mode === "draw" && canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#333";
ctx.lineWidth = 2;
ctx.lineCap = "round";
}
}, [mode]);
const getPos = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
};
const startDraw = (e) => {
e.preventDefault();
isDrawing.current = true;
const ctx = canvasRef.current.getContext("2d");
const pos = getPos(e);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y);
};
const draw = (e) => {
e.preventDefault();
if (!isDrawing.current) return;
const ctx = canvasRef.current.getContext("2d");
const pos = getPos(e);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
};
const stopDraw = () => { isDrawing.current = false; };
const clearCanvas = () => {
const ctx = canvasRef.current.getContext("2d");
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height);
};
const handleSave = () => {
if (mode === "draw") {
const dataUrl = canvasRef.current.toDataURL("image/png");
onSave(dataUrl, "drawn");
} else {
onSave(typedName, "typed");
}
};
return ReactDOM.createPortal(
<div className="sigOverlay" onClick={onClose}>
<div className="sigModal" onClick={(e) => e.stopPropagation()}>
<div className="sigHeader">
<h3>{title || "Capture Signature"}</h3>
<button className="woDispatchClose" onClick={onClose}>&times;</button>
</div>
<div className="sigBody">
<div className="sigModeTabs">
<button className={`sigModeTab ${mode === "draw" ? "active" : ""}`} onClick={() => setMode("draw")}>Draw</button>
<button className={`sigModeTab ${mode === "type" ? "active" : ""}`} onClick={() => setMode("type")}>Type</button>
</div>
{mode === "draw" ? (
<div className="sigCanvasWrap">
<canvas
ref={canvasRef}
width={460}
height={180}
className="sigCanvas"
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={stopDraw}
onMouseLeave={stopDraw}
onTouchStart={startDraw}
onTouchMove={draw}
onTouchEnd={stopDraw}
/>
<button className="sigClearBtn" onClick={clearCanvas}>Clear</button>
</div>
) : (
<div className="sigTypeWrap">
<input
className="form-control sigTypeInput"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder="Type your name"
/>
{typedName && <div className="sigTypePreview">{typedName}</div>}
</div>
)}
</div>
<div className="sigFooter">
<button className="btn btn-default" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={handleSave} disabled={mode === "type" && !typedName.trim()}>Save Signature</button>
</div>
</div>
</div>,
document.body
);
}

View file

@ -134,6 +134,16 @@ export const addChecklistItem = async (dispatchId, itemText, workOrderId) => {
return post(`${API_URL}/WorkOrder/AddChecklistItem`, { DispatchId: dispatchId, ItemText: itemText, WorkOrderId: workOrderId });
};
export const addDispatchSignoff = async (dispatchId, signoffType, name, signature, signatureMethod) => {
return post(`${API_URL}/WorkOrder/AddDispatchSignoff`, {
DispatchId: dispatchId,
SignoffType: signoffType,
Name: name,
Signature: signature,
SignatureMethod: signatureMethod,
});
};
export const getDispatchById = async (id) => {
const { get: apiGet } = await import("../../apiUtil");
return apiGet(`${API_URL}/WorkOrder/GetDispatch/${id}`);

View file

@ -748,6 +748,75 @@
font-weight: 600;
}
.dispatchSignoffs {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.dispatchSignoffSlot {
flex: 1;
border: 1px solid #eee;
border-radius: 6px;
padding: 12px;
text-align: center;
}
.dispatchSignoffSlot.signed {
border-color: #28a745;
background: #f0fff4;
}
.dispatchSignoffLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: #999;
margin-bottom: 8px;
}
.dispatchSignoffDone {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.dispatchSignoffImg {
max-width: 160px;
max-height: 60px;
border: 1px solid #eee;
border-radius: 4px;
}
.dispatchSignoffTyped {
font-family: "Brush Script MT", "Segoe Script", cursive;
font-size: 24px;
color: #333;
}
.dispatchSignoffMeta {
font-size: 11px;
color: #28a745;
}
.dispatchSignoffBtn {
font-size: 12px;
}
.dispatchSignoffBadge {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: #28a745;
color: #fff;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
min-width: 100px;
}
.dispatchChecklist {
display: flex;
flex-direction: column;

View file

@ -2,9 +2,11 @@ import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor, getDispatchById, updateDispatch, addDispatchComment, fetchTaskTemplates, updateChecklistItem, addChecklistItem } from "../api";
import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor, getDispatchById, updateDispatch, addDispatchComment, fetchTaskTemplates, updateChecklistItem, addChecklistItem, addDispatchSignoff } from "../api";
import { getCurrentUser } from "../../../services/authService";
import { timeSince, timeBetween, waitTimeColor } from "../../../utils/timeSince";
import SignatureCapture from "../../../components/SignatureCapture";
import "../../../components/SignatureCapture.css";
import "./WorkOrderViewPage.css";
function AssignDropdown({ value, onAssign }) {
@ -173,6 +175,7 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
const [commentText, setCommentText] = useState("");
const [sendEmail, setSendEmail] = useState(true);
const [sendingComment, setSendingComment] = useState(false);
const [sigCapture, setSigCapture] = useState(null);
const bottomRef = useRef(null);
const loadDispatch = async () => {
@ -373,6 +376,56 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
</div>
</>
)}
<h4>Sign-offs</h4>
<div className="dispatchSignoffs">
{["Customer", "Vendor"].map((type) => {
const signoff = (dispatch.signoffs || []).find((s) => s.signoffType === type);
return (
<div key={type} className={`dispatchSignoffSlot ${signoff ? "signed" : ""}`}>
<div className="dispatchSignoffLabel">{type}</div>
{signoff ? (
<div className="dispatchSignoffDone">
{signoff.signatureMethod === "drawn" && signoff.signature && (
<img src={signoff.signature} alt="Signature" className="dispatchSignoffImg" />
)}
{signoff.signatureMethod === "typed" && (
<span className="dispatchSignoffTyped">{signoff.signature}</span>
)}
<span className="dispatchSignoffMeta">{signoff.name} · {signoff.signedAt ? formatDateTime(signoff.signedAt) : ""}</span>
</div>
) : (
<button className="btn btn-default dispatchSignoffBtn" onClick={() => setSigCapture(type)}>
Collect Signature
</button>
)}
</div>
);
})}
{(dispatch.signoffs || []).length >= 2 && <div className="dispatchSignoffBadge">All Signed ✓</div>}
</div>
{sigCapture && (
<SignatureCapture
title={`${sigCapture} Sign-off`}
onClose={() => setSigCapture(null)}
onSave={async (signature, method) => {
const name = prompt(`Enter ${sigCapture.toLowerCase()} name:`);
if (!name) return;
try {
const result = await addDispatchSignoff(dispatchId, sigCapture, name, signature, method);
setDispatch((prev) => ({
...prev,
signoffs: [...(prev.signoffs || []), { ...result, signature, signatureMethod: method }],
}));
setSigCapture(null);
toast.success(`${sigCapture} sign-off collected`);
} catch {
toast.error("Failed to save sign-off");
}
}}
/>
)}
<h4>Vendor Communication</h4>
<div className="dispatchThreadContainer">
<div className="dispatchThreadScroll">