mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 02:43:12 +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 });
|
||||
};
|
||||
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue