472 lines
20 KiB
JavaScript
472 lines
20 KiB
JavaScript
import React, { useState, useEffect } from "react";
|
||
import { Form, Button, Flex, Typography, Steps, Tag, Card, Table, message, Popover, Image, Checkbox } from "antd";
|
||
import { DownloadOutlined, QuestionOutlined, PictureOutlined } from "@ant-design/icons";
|
||
import { Connect } from "@cqsjjb/jjb-dva-runtime";
|
||
import { NS_SAFETY_EVAL_BUSINESS } from "~/enumerate/namespace";
|
||
import { tools } from "@cqsjjb/jjb-common-lib";
|
||
import AttachmentUpload from "~/components/AttachmentUpload";
|
||
import { ROLE_DEFINITIONS_MAP, ROLE_DEFINITIONS } from '~/enumerate/constant'
|
||
import { QUALIFICATION_INDUSTRY_OPTIONS_MAP } from "~/enumerate/enterpriseOptions";
|
||
import LayoutFooterPortal from "~/components/LayoutFooterPortal";
|
||
import { fillDocxTemplate } from "~/utils/fillDocxTemplate";
|
||
import "./index.less";
|
||
import dayjs from "dayjs";
|
||
|
||
const { router } = tools;
|
||
|
||
const STAGES = [
|
||
{ key: "PRACTICE_NOTIFICATION", index: "01", label: "从业告知" },
|
||
{ key: "SITE_INSPECTION_FORM", index: "02", label: "现场勘查" },
|
||
{ key: "RECTIFICATION_NOTICE", index: "03", label: "整改通知" },
|
||
{ key: "ENTERPRISE_RECTIFICATION", index: "04", label: "企业整改" },
|
||
{ key: "RECTIFICATION_REVIEW", index: "05", label: "整改复查" },
|
||
];
|
||
|
||
/** 各步骤附件必传校验:至少一个文件上传完成;有文件上传中时提示稍后 */
|
||
const attachRequiredRules = (label) => [
|
||
{
|
||
required: true,
|
||
validator: (_, value) => {
|
||
const fileList = value || [];
|
||
if (fileList.some((f) => f.status === "uploading")) {
|
||
return Promise.reject(new Error("文件上传中,请稍后再操作"));
|
||
}
|
||
return fileList.some((f) => f.status === "done" && f.url)
|
||
? Promise.resolve()
|
||
: Promise.reject(new Error(`请上传${label}`));
|
||
},
|
||
},
|
||
];
|
||
|
||
|
||
|
||
const NoticePanel = ({ readOnly, downloadTemplate }) => (
|
||
<div>
|
||
<div className="pf-section-header">
|
||
<div>
|
||
<span style={{ fontSize: 20, fontWeight: 600, color: "#1677ff", marginRight: 8 }}>01</span>
|
||
<Typography.Text strong style={{ fontSize: 15 }}>安全评价检测检验机构从业告知书</Typography.Text>
|
||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>支持在线填写后打印;阶段试行期间,以机构盖章版上传归档作为完成依据。</Typography.Paragraph>
|
||
</div>
|
||
</div>
|
||
<div style={{ background: "#f0f5ff", padding: "8px 12px", borderRadius: 6, marginBottom: 16, display: "flex", gap: 16, fontSize: 14 }}>
|
||
<strong>办理方式</strong>
|
||
<span>在线填写用于生成、打印标准告知书</span>
|
||
<span>打印盖章后上传扫描件或清晰照片</span>
|
||
<Tag color="blue">完成节点必须留存盖章件</Tag>
|
||
</div>
|
||
<div className="pf-template-card">
|
||
<div className="pf-template-left">
|
||
<span className="pf-template-icon">📄</span>
|
||
<div>
|
||
<div className="pf-template-name">从业告知书模板</div>
|
||
{/* <div className="pf-template-desc">PDF 格式,可下载后在线填写并打印盖章</div> */}
|
||
</div>
|
||
</div>
|
||
<Button type="primary" ghost size="small" icon={<DownloadOutlined />} onClick={downloadTemplate}>
|
||
下载模板
|
||
</Button>
|
||
</div>
|
||
<AttachmentUpload
|
||
name="PRACTICE_NOTIFICATION"
|
||
label="从业告知书"
|
||
maxCount={1}
|
||
accept=".docx,.pdf"
|
||
disabled={readOnly}
|
||
rules={attachRequiredRules("从业告知书")}
|
||
extra="仅支持上传 Word、PDF 格式文件"
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
/** 打卡时间后追加人脸照片图标:faceUrl 逗号分隔,第1张为签到、第2张为签退 */
|
||
const renderTimeWithFace = (time, record, idx) => {
|
||
const url = record?.faceUrl?.split(",")[idx];
|
||
if (!url) return time || "-";
|
||
return (
|
||
<span>
|
||
{time}
|
||
<Popover
|
||
content={<Image src={url} width={240} />}
|
||
title="人脸照片"
|
||
trigger="hover"
|
||
>
|
||
<PictureOutlined style={{ marginLeft: 6, color: "#1677ff", cursor: "pointer" }} />
|
||
</Popover>
|
||
</span>
|
||
);
|
||
};
|
||
|
||
const InspectionPanel = ({ dataSource = [], evalProjectDetailData = {}, readOnly }) => {
|
||
const calcDuration = (signIn, signOut) => {
|
||
if (!signIn || !signOut) return '-';
|
||
const start = new Date(signIn);
|
||
const end = new Date(signOut);
|
||
const diff = (end - start) / 60000;
|
||
if (diff <= 0) return '-';
|
||
return `${Math.floor(diff / 60)}h${Math.round(diff % 60)}m`;
|
||
};
|
||
return (
|
||
<div>
|
||
<div className="pf-section-header">
|
||
<div>
|
||
<span style={{ fontSize: 20, fontWeight: 600, color: "#1677ff", marginRight: 8 }}>02</span>
|
||
<Typography.Text strong style={{ fontSize: 15 }}>现场人员打卡与检查表</Typography.Text>
|
||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>主要核验项目组人员是否真实到场,留存APP人脸、GPS、签到签退、现场照片及本人签字记录。</Typography.Paragraph>
|
||
</div>
|
||
<Tag color="success">打卡与检查表完整</Tag>
|
||
</div>
|
||
|
||
<Card type="inner" size="small" title="人员现场打卡情况" style={{ marginBottom: 12 }} extra={<Popover content='项目组人员成立后,会自动向项目组全体人员手机端推送现场勘察、打卡任务请在手机端上传勘察数据和定位打卡。(根据法规要求,最低要求项目组组长和任意一名组员到达现场打卡。两人均完成后认定打卡完成)' trigger="hover">
|
||
<QuestionOutlined />
|
||
</Popover>}>
|
||
<Table
|
||
dataSource={dataSource}
|
||
columns={[
|
||
{ title: "人员", dataIndex: "personnelName" },
|
||
{ title: "项目角色", dataIndex: "role", render: (v) => Array.isArray(v) ? v.map(item => ROLE_DEFINITIONS_MAP[item]).join('、') : v, ellipsis: true },
|
||
{ title: "人脸识别", dataIndex: "faceState", render: (v) => <Tag color={v === 1 ? "success" : "error"}>{v === 1 ? "通过" : "未通过"}</Tag> },
|
||
{ title: "GPS定位", dataIndex: "gpsState", render: (v) => <Tag color={v === 1 ? "success" : "error"}>{v === 1 ? "正常" : "异常"}</Tag> },
|
||
{ title: "签到时间", dataIndex: "singInTime", render: (time, record) => { return renderTimeWithFace(time, record, 0); } },
|
||
{ title: "签退时间", dataIndex: "signOutTime", render: (time, record) => { return renderTimeWithFace(time, record, 1); } },
|
||
{ title: "在场时长", render: (_, r) => calcDuration(r.singInTime, r.signOutTime) },
|
||
{
|
||
title: "现场照片", dataIndex: "sceneUrl", render: (v) => {
|
||
const urls = v ? v.split(',').filter(Boolean) : [];
|
||
if (urls.length === 0) return <Button type="link" size="small">0张</Button>;
|
||
const content = (
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', maxWidth: 400 }}>
|
||
{urls.map((url, i) => (
|
||
<Image key={i} src={url} width={120} height={90} style={{ objectFit: 'cover' }} />
|
||
))}
|
||
</div>
|
||
);
|
||
return (
|
||
<Popover content={content} title="现场照片" trigger="hover">
|
||
<Button type="link" size="small">{urls.length}张</Button>
|
||
</Popover>
|
||
);
|
||
}
|
||
},
|
||
{ title: "本人签字", dataIndex: "signatureUrl", render: (v) => <Tag color={v ? "success" : "default"}>{v ? "已签字" : "未签字"}</Tag> },
|
||
]}
|
||
rowKey="id"
|
||
pagination={false}
|
||
size="small"
|
||
/>
|
||
<div style={{ display: "flex", gap: 24, marginTop: 8, fontSize: 14 }}>
|
||
<span>打卡企业地址:{evalProjectDetailData?.customerAddress || '-'}</span>
|
||
<span>允许打卡范围:企业地址周边{evalProjectDetailData?.checkinRadiusMeters || '-'}米</span>
|
||
<span>签到日期:{dataSource?.[0]?.singInTime ? dayjs(dataSource?.[0]?.singInTime).format('YYYY-MM-DD') : '-'}</span>
|
||
</div>
|
||
</Card>
|
||
<AttachmentUpload
|
||
name="SITE_INSPECTION_FORM"
|
||
label="现场检查表"
|
||
maxCount={10}
|
||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
|
||
disabled={readOnly}
|
||
rules={attachRequiredRules("现场检查表")}
|
||
extra="检查表应包含项目名称、检查日期、检查人员、检查依据、逐项检查结果和不符合项记录;上传后与人员打卡记录共同作为现场踏勘凭证。
|
||
支持 PDF、Word、Excel、PNG、JPG 格式,最多上传 10 个文件"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const RectificationNoticePanel = ({ readOnly }) => (
|
||
<div>
|
||
<div className="pf-section-header">
|
||
<div>
|
||
<span style={{ fontSize: 20, fontWeight: 600, color: "#1677ff", marginRight: 8 }}>03</span>
|
||
<Typography.Text strong style={{ fontSize: 15 }}>整改通知单上传</Typography.Text>
|
||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>本节点不在线编制整改通知,只归档机构已出具的《整改通知单》文件;上传成功即完成,后续可替换修改。</Typography.Paragraph>
|
||
</div>
|
||
</div>
|
||
<Form.Item name="majorHazardCheckbox" valuePropName="checked" style={{ marginBottom: 16 }}>
|
||
<Checkbox disabled={readOnly}>是否存在重大隐患</Checkbox>
|
||
</Form.Item>
|
||
<AttachmentUpload
|
||
name="RECTIFICATION_NOTICE"
|
||
label="整改通知单"
|
||
maxCount={10}
|
||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
|
||
disabled={readOnly}
|
||
rules={attachRequiredRules("整改通知单")}
|
||
extra="支持 PDF、Word、Excel、PNG、JPG 格式,最多上传 10 个文件"
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
const EnterpriseRectificationPanel = ({ readOnly }) => (
|
||
<div>
|
||
<div className="pf-section-header">
|
||
<div>
|
||
<span style={{ fontSize: 20, fontWeight: 600, color: "#1677ff", marginRight: 8 }}>04</span>
|
||
<Typography.Text strong style={{ fontSize: 15 }}>企业整改报告上传</Typography.Text>
|
||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>企业线下提交《整改报告》后,由机构人员上传归档;本系统不提供企业在线提交和逐项整改流程。</Typography.Paragraph>
|
||
</div>
|
||
</div>
|
||
<AttachmentUpload
|
||
name="ENTERPRISE_RECTIFICATION"
|
||
label="企业整改报告"
|
||
maxCount={10}
|
||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
|
||
disabled={readOnly}
|
||
rules={attachRequiredRules("企业整改报告")}
|
||
extra="支持 PDF、Word、Excel、PNG、JPG 格式,最多上传 10 个文件"
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
const RectificationReviewPanel = ({ readOnly }) => (
|
||
<div>
|
||
<div className="pf-section-header">
|
||
<div>
|
||
<span style={{ fontSize: 20, fontWeight: 600, color: "#1677ff", marginRight: 8 }}>05</span>
|
||
<Typography.Text strong style={{ fontSize: 15 }}>整改复查单上传</Typography.Text>
|
||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>机构人员现场复查后,将经双方签字确认的《整改复查单》上传归档;上传后本节点自动标记完成。</Typography.Paragraph>
|
||
</div>
|
||
</div>
|
||
<AttachmentUpload
|
||
name="RECTIFICATION_REVIEW"
|
||
label="整改情况复查单"
|
||
maxCount={10}
|
||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
|
||
disabled={readOnly}
|
||
rules={attachRequiredRules("整改情况复查单")}
|
||
extra="支持 PDF、Word、Excel、PNG、JPG 格式,最多上传 10 个文件"
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
const SurveyContent = ({ readOnly, ...props }) => {
|
||
const [form] = Form.useForm();
|
||
const [activeStage, setActiveStage] = useState("PRACTICE_NOTIFICATION");
|
||
const [attachMap, setAttachMap] = useState({});
|
||
const [surveyPersonnel, setSurveyPersonnel] = useState([]);
|
||
const { evalSurveySaveAttachLoading, evalProjectDetailData } = props.safetyEvalBusiness;
|
||
const projectId = router.query?.id;
|
||
const currentIdx = STAGES.findIndex((s) => s.key === activeStage);
|
||
const isFirst = currentIdx === 0;
|
||
const isLast = currentIdx === STAGES.length - 1;
|
||
/** 各步骤附件表单值(按 STAGES 顺序),用于 Steps 完成态判断;
|
||
* 注意:useWatch 传数组会被解析为嵌套字段路径,多字段需用函数选择器(入参为表单 values) */
|
||
const stepFiles = Form.useWatch(
|
||
(values) => STAGES.map((s) => values?.[s.key]),
|
||
form,
|
||
);
|
||
|
||
const loadAttachments = async () => {
|
||
if (!projectId) return;
|
||
const res = await props.evalSurveyList({ projectId });
|
||
if (res?.success !== false) {
|
||
const map = {};
|
||
(res?.data || []).forEach((item) => {
|
||
const key = item.attachTypeCode;
|
||
try {
|
||
const parsed = JSON.parse(item.fileUrl);
|
||
map[key] = Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
map[key] = [];
|
||
}
|
||
});
|
||
setAttachMap(map);
|
||
// 回填所有步骤的附件到表单
|
||
const values = {};
|
||
STAGES.forEach((s) => {
|
||
values[s.key] = map[s.key] || [];
|
||
});
|
||
// 回填整改通知单的 checkbox 字段
|
||
const rectItem = (res?.data || []).find((item) => item.attachTypeCode === "RECTIFICATION_NOTICE");
|
||
values.majorHazardCheckbox = rectItem?.checkbox === 1;
|
||
form.setFieldsValue(values);
|
||
}
|
||
};
|
||
|
||
const downloadTemplate = async () => {
|
||
const orgInfo = JSON.parse(sessionStorage.getItem('orgInfo'));
|
||
const industryText = String(evalProjectDetailData.industryCode || "")
|
||
.split(",")
|
||
.map((code) => QUALIFICATION_INDUSTRY_OPTIONS_MAP[code])
|
||
.join("、");
|
||
// 项目组成员(同项目组 tab 页数据):姓名/专业/工作任务
|
||
const memberRes = await props.evalProjectMemberPage({ projectId });
|
||
const members = (memberRes?.data || []).map((item) => ({
|
||
name: item.personnelName,
|
||
major: item.capacity || "",
|
||
task:
|
||
item.responsibility ||
|
||
(item.role || [])
|
||
.map((r) => ROLE_DEFINITIONS.find((d) => d.value === r)?.summary)
|
||
.filter(Boolean)
|
||
.join("、"),
|
||
isLeader: (item.role || []).includes("PROJECT_LEAD"),
|
||
}));
|
||
// 项目组组长取自项目组成员中的 PROJECT_LEAD,展示同 namesByRole:如"张三(化工)"
|
||
const projectLeader = members
|
||
.filter((m) => m.isLeader)
|
||
.map((m) => `${m.name}${m.major ? `(${m.major})` : ""}`)
|
||
.join("、");
|
||
const data = {
|
||
account: orgInfo.account,
|
||
qualificationCertNo: orgInfo.qualificationCertNo,
|
||
infoDisclosureUrl: orgInfo.infoDisclosureUrl,
|
||
businessAddress: orgInfo.businessAddress,
|
||
legalRepresentative: orgInfo.legalRepresentative,
|
||
contactName: orgInfo.contactName,
|
||
contactPhone: orgInfo.contactPhone,
|
||
projectName: evalProjectDetailData.projectName,
|
||
projectAddress: evalProjectDetailData.customerAddress || "",
|
||
industryText,
|
||
projectLeader,
|
||
members,
|
||
};
|
||
try {
|
||
await fillDocxTemplate(
|
||
'https://gbs-cqaqpj.oss-cn-beijing.aliyuncs.com/jjb/6a913235e4b0b3767a78622d.docx',
|
||
data,
|
||
`安全评价检测检验机构从业告知书.docx`,
|
||
{ download: true },
|
||
);
|
||
message.success("导出成功");
|
||
} catch {
|
||
message.error("导出失败,请重试");
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadAttachments();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (projectId) {
|
||
props.evalSurveyPage({ projectId, size: 100 }).then((res) => {
|
||
if (res?.success) {
|
||
setSurveyPersonnel(res.data || []);
|
||
}
|
||
});
|
||
}
|
||
}, [projectId]);
|
||
|
||
const validateSurveyStep = () => {
|
||
if (surveyPersonnel.length < 2) {
|
||
message.warning("您的打卡信息未完善,请前往现场在APP中进行定位打卡");
|
||
return false;
|
||
}
|
||
const hasProjectLead = surveyPersonnel.some((p) =>
|
||
Array.isArray(p.role) ? p.role.includes("PROJECT_LEAD") : p.role === "PROJECT_LEAD"
|
||
);
|
||
if (!hasProjectLead) {
|
||
message.warning("您的打卡信息未完善,请前往现场在APP中进行定位打卡");
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const goPrev = () => {
|
||
if (currentIdx > 0) setActiveStage(STAGES[currentIdx - 1].key);
|
||
};
|
||
|
||
const goNext = () => {
|
||
if (currentIdx === STAGES.length - 1) return;
|
||
if (activeStage === "SITE_INSPECTION_FORM" && !validateSurveyStep()) return;
|
||
setActiveStage(STAGES[currentIdx + 1].key);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
const values = form.getFieldsValue();
|
||
// 检查是否有文件正在上传中
|
||
const hasUploading = STAGES.some((s) => {
|
||
const fileList = values[s.key] || [];
|
||
return fileList.some((f) => f.status === "uploading");
|
||
});
|
||
if (hasUploading) {
|
||
message.warning("文件上传中,请稍后再保存");
|
||
return;
|
||
}
|
||
if (!validateSurveyStep()) return;
|
||
const payload = [];
|
||
for (const s of STAGES) {
|
||
const fileList = values[s.key] || [];
|
||
const validFiles = fileList.filter((f) => f.status === "done" && f.url);
|
||
if (validFiles.length === 0) {
|
||
setActiveStage(s.key);
|
||
message.warning(`请先在"${s.label}"步骤上传附件`);
|
||
return;
|
||
}
|
||
}
|
||
for (const s of STAGES) {
|
||
const fileList = values[s.key] || [];
|
||
const validFiles = fileList.filter((f) => f.status === "done" && f.url);
|
||
if (validFiles.length === 0) continue;
|
||
payload.push({
|
||
projectId,
|
||
attachTypeCode: s.key,
|
||
fileUrl: JSON.stringify(fileList),
|
||
...(s.key === "RECTIFICATION_NOTICE"
|
||
? { checkbox: values.majorHazardCheckbox ? 1 : 0 }
|
||
: {}),
|
||
});
|
||
}
|
||
const res = await props.evalSurveySaveAttach(payload);
|
||
if (res?.success !== false) {
|
||
message.success("保存成功");
|
||
props.getDetail();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<Steps
|
||
current={currentIdx}
|
||
onChange={(idx) => setActiveStage(STAGES[idx].key)}
|
||
style={{ marginBottom: 16 }}
|
||
items={STAGES.map((s, idx) => {
|
||
const uploaded = (stepFiles?.[idx] || []).some(
|
||
(f) => f.status === "done" && f.url,
|
||
);
|
||
return {
|
||
title: s.label,
|
||
...(uploaded ? { status: "finish" } : { status: "wait" }),
|
||
};
|
||
})}
|
||
/>
|
||
|
||
<Form form={form} layout="vertical" disabled={readOnly} scrollToFirstError>
|
||
<div style={{ display: activeStage === "PRACTICE_NOTIFICATION" ? "block" : "none" }}>
|
||
<NoticePanel readOnly={readOnly} downloadTemplate={downloadTemplate} />
|
||
</div>
|
||
<div style={{ display: activeStage === "SITE_INSPECTION_FORM" ? "block" : "none" }}>
|
||
<InspectionPanel dataSource={surveyPersonnel} evalProjectDetailData={evalProjectDetailData} readOnly={readOnly} />
|
||
</div>
|
||
<div style={{ display: activeStage === "RECTIFICATION_NOTICE" ? "block" : "none" }}>
|
||
<RectificationNoticePanel readOnly={readOnly} />
|
||
</div>
|
||
<div style={{ display: activeStage === "ENTERPRISE_RECTIFICATION" ? "block" : "none" }}>
|
||
<EnterpriseRectificationPanel readOnly={readOnly} />
|
||
</div>
|
||
<div style={{ display: activeStage === "RECTIFICATION_REVIEW" ? "block" : "none" }}>
|
||
<RectificationReviewPanel readOnly={readOnly} />
|
||
</div>
|
||
</Form>
|
||
|
||
<LayoutFooterPortal slotId="pf-footer-slot">
|
||
<Flex justify="flex-end" gap={8}>
|
||
{!isFirst && <Button onClick={goPrev} disabled={evalSurveySaveAttachLoading}>上一步</Button>}
|
||
{isLast ? (
|
||
!readOnly && <Button type="primary" onClick={handleSave} loading={evalSurveySaveAttachLoading}>保存</Button>
|
||
) : (
|
||
<Button type="primary" onClick={goNext} disabled={evalSurveySaveAttachLoading}>下一步</Button>
|
||
)}
|
||
</Flex>
|
||
</LayoutFooterPortal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Connect(
|
||
[NS_SAFETY_EVAL_BUSINESS],
|
||
true,
|
||
)(SurveyContent);
|