From 5c24b9249d4cd9516e6b6b8f054e12ae389a1f70 Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Fri, 17 Apr 2026 15:51:20 -0400
Subject: [PATCH] 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
---
src/components/SignatureCapture.css | 113 ++++++++++++++++++
src/components/SignatureCapture.js | 112 +++++++++++++++++
src/pages/workorders/api.js | 10 ++
.../workorders/view/WorkOrderViewPage.css | 69 +++++++++++
.../workorders/view/WorkOrderViewPage.js | 55 ++++++++-
5 files changed, 358 insertions(+), 1 deletion(-)
create mode 100644 src/components/SignatureCapture.css
create mode 100644 src/components/SignatureCapture.js
diff --git a/src/components/SignatureCapture.css b/src/components/SignatureCapture.css
new file mode 100644
index 00000000..68a3236e
--- /dev/null
+++ b/src/components/SignatureCapture.css
@@ -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;
+}
diff --git a/src/components/SignatureCapture.js b/src/components/SignatureCapture.js
new file mode 100644
index 00000000..ef51ebf5
--- /dev/null
+++ b/src/components/SignatureCapture.js
@@ -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(
+
+
e.stopPropagation()}>
+
+
{title || "Capture Signature"}
+
+
+
+
+
+
+
+ {mode === "draw" ? (
+
+
+
+
+ ) : (
+
+
setTypedName(e.target.value)}
+ placeholder="Type your name"
+ />
+ {typedName &&
{typedName}
}
+
+ )}
+
+
+
+
+
+
+
,
+ document.body
+ );
+}
diff --git a/src/pages/workorders/api.js b/src/pages/workorders/api.js
index b906bd73..49b034e4 100644
--- a/src/pages/workorders/api.js
+++ b/src/pages/workorders/api.js
@@ -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}`);
diff --git a/src/pages/workorders/view/WorkOrderViewPage.css b/src/pages/workorders/view/WorkOrderViewPage.css
index 49afdccf..c39ee98a 100644
--- a/src/pages/workorders/view/WorkOrderViewPage.css
+++ b/src/pages/workorders/view/WorkOrderViewPage.css
@@ -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;
diff --git a/src/pages/workorders/view/WorkOrderViewPage.js b/src/pages/workorders/view/WorkOrderViewPage.js
index e3da056e..2382208e 100644
--- a/src/pages/workorders/view/WorkOrderViewPage.js
+++ b/src/pages/workorders/view/WorkOrderViewPage.js
@@ -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 }) {
>
)}
+ Sign-offs
+
+ {["Customer", "Vendor"].map((type) => {
+ const signoff = (dispatch.signoffs || []).find((s) => s.signoffType === type);
+ return (
+
+
{type}
+ {signoff ? (
+
+ {signoff.signatureMethod === "drawn" && signoff.signature && (
+

+ )}
+ {signoff.signatureMethod === "typed" && (
+
{signoff.signature}
+ )}
+
{signoff.name} · {signoff.signedAt ? formatDateTime(signoff.signedAt) : ""}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+ {(dispatch.signoffs || []).length >= 2 &&
All Signed ✓
}
+
+
+ {sigCapture && (
+ 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");
+ }
+ }}
+ />
+ )}
+
Vendor Communication