mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 22:52:06 +00:00
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:
parent
31479029e3
commit
5c24b9249d
5 changed files with 358 additions and 1 deletions
113
src/components/SignatureCapture.css
Normal file
113
src/components/SignatureCapture.css
Normal 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;
|
||||||
|
}
|
||||||
112
src/components/SignatureCapture.js
Normal file
112
src/components/SignatureCapture.js
Normal 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}>×</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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -134,6 +134,16 @@ export const addChecklistItem = async (dispatchId, itemText, workOrderId) => {
|
||||||
return post(`${API_URL}/WorkOrder/AddChecklistItem`, { DispatchId: dispatchId, ItemText: itemText, WorkOrderId: 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) => {
|
export const getDispatchById = async (id) => {
|
||||||
const { get: apiGet } = await import("../../apiUtil");
|
const { get: apiGet } = await import("../../apiUtil");
|
||||||
return apiGet(`${API_URL}/WorkOrder/GetDispatch/${id}`);
|
return apiGet(`${API_URL}/WorkOrder/GetDispatch/${id}`);
|
||||||
|
|
|
||||||
|
|
@ -748,6 +748,75 @@
|
||||||
font-weight: 600;
|
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 {
|
.dispatchChecklist {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import React, { useState, useEffect, useRef } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { useParams, useNavigate } from "react-router";
|
import { useParams, useNavigate } from "react-router";
|
||||||
import { toast } from "react-toastify";
|
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 { getCurrentUser } from "../../../services/authService";
|
||||||
import { timeSince, timeBetween, waitTimeColor } from "../../../utils/timeSince";
|
import { timeSince, timeBetween, waitTimeColor } from "../../../utils/timeSince";
|
||||||
|
import SignatureCapture from "../../../components/SignatureCapture";
|
||||||
|
import "../../../components/SignatureCapture.css";
|
||||||
import "./WorkOrderViewPage.css";
|
import "./WorkOrderViewPage.css";
|
||||||
|
|
||||||
function AssignDropdown({ value, onAssign }) {
|
function AssignDropdown({ value, onAssign }) {
|
||||||
|
|
@ -173,6 +175,7 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
|
||||||
const [commentText, setCommentText] = useState("");
|
const [commentText, setCommentText] = useState("");
|
||||||
const [sendEmail, setSendEmail] = useState(true);
|
const [sendEmail, setSendEmail] = useState(true);
|
||||||
const [sendingComment, setSendingComment] = useState(false);
|
const [sendingComment, setSendingComment] = useState(false);
|
||||||
|
const [sigCapture, setSigCapture] = useState(null);
|
||||||
const bottomRef = useRef(null);
|
const bottomRef = useRef(null);
|
||||||
|
|
||||||
const loadDispatch = async () => {
|
const loadDispatch = async () => {
|
||||||
|
|
@ -373,6 +376,56 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
|
||||||
</div>
|
</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>
|
<h4>Vendor Communication</h4>
|
||||||
<div className="dispatchThreadContainer">
|
<div className="dispatchThreadContainer">
|
||||||
<div className="dispatchThreadScroll">
|
<div className="dispatchThreadScroll">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue