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 && ( + 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