大改造:施工进度系统+模板管理+物流收货+分包评分
- 新增5张数据库表:logistics_records, receiving_confirms, project_type_templates, project_phases, subcontractor_events - 修改3张表:projects(增加type_template_id/current_phase/phase_progress), purchase_orders(增加expected_delivery_date/receiving_status), subcontractors(增加score/initial_score) - 插入5套预设工程模板(配电安装/架空线路/电缆敷设/变电站/小型工程) - 后端新增:模板管理API、项目阶段管理API、收货确认API、分包商事件API - 后端修改:项目创建/预算签约支持模板选择,自动初始化阶段 - 前端新增:工程模板设计器(后台管理)、施工总览页、施工进度页(手机友好) - 前端修改:项目详情页重构为7个Tab,增加施工进度预览条和进入施工管理按钮 - 菜单更新:施工管理增加施工总览子菜单,后台管理增加工程模板管理
This commit is contained in:
@@ -65,6 +65,8 @@ app.use('/api/payment-execution', require('./routes/payment-execution'));
|
|||||||
app.use('/api/verifications-new', require('./routes/verifications-new'));
|
app.use('/api/verifications-new', require('./routes/verifications-new'));
|
||||||
app.use('/api/returns', require('./routes/returns'));
|
app.use('/api/returns', require('./routes/returns'));
|
||||||
app.use('/api/project-materials', require('./routes/project-materials'));
|
app.use('/api/project-materials', require('./routes/project-materials'));
|
||||||
|
app.use('/api/process-templates', require('./routes/process-templates'));
|
||||||
|
app.use('/api/receiving', require('./routes/receiving'));
|
||||||
|
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ router.put('/:id/sign', authenticate, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let project_id = projectResult.rows[0].project_id;
|
let project_id = projectResult.rows[0].project_id;
|
||||||
|
const { type_template_id } = req.body;
|
||||||
if (!project_id) {
|
if (!project_id) {
|
||||||
const projResult = await db.query(
|
const projResult = await db.query(
|
||||||
`INSERT INTO projects (name, customer_id, project_manager_id, status, created_at, updated_at)
|
`INSERT INTO projects (name, customer_id, project_manager_id, status, created_at, updated_at)
|
||||||
@@ -265,6 +266,23 @@ router.put('/:id/sign', authenticate, async (req, res) => {
|
|||||||
);
|
);
|
||||||
project_id = projResult.rows[0].id;
|
project_id = projResult.rows[0].id;
|
||||||
await db.query('UPDATE budget_projects SET project_id = $1 WHERE id = $2', [project_id, id]);
|
await db.query('UPDATE budget_projects SET project_id = $1 WHERE id = $2', [project_id, id]);
|
||||||
|
if (type_template_id) {
|
||||||
|
const tplResult = await db.query('SELECT phases FROM project_type_templates WHERE id = $1', [type_template_id]);
|
||||||
|
if (tplResult.rows.length > 0) {
|
||||||
|
const phases = tplResult.rows[0].phases;
|
||||||
|
if (Array.isArray(phases)) {
|
||||||
|
for (const phase of phases) {
|
||||||
|
const subItems = (phase.sub_items || []).map(item => ({ name: item, completed: false }));
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO project_phases (project_id, template_key, phase_name, phase_order, phase_type, depends_on, sub_items, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[project_id, phase.key || `phase_${phase.order}`, phase.name, phase.order, phase.type || 'serial', phase.depends || [], JSON.stringify(subItems), phase.order === 1 ? 'in_progress' : 'pending']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const firstPhase = phases.find(p => p.order === 1);
|
||||||
|
await db.query('UPDATE projects SET type_template_id = $1, current_phase = $2 WHERE id = $3', [type_template_id, firstPhase ? firstPhase.name : '', project_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, name, description, phases, is_system, created_at, updated_at FROM project_type_templates ORDER BY is_system DESC, created_at ASC'
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模板列表失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取模板列表失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT id, name, description, phases, is_system, created_at, updated_at FROM project_type_templates WHERE id = $1',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '模板不存在' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模板详情失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取模板详情失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, phases } = req.body;
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ success: false, message: '模板名称不能为空' });
|
||||||
|
}
|
||||||
|
if (!phases || !Array.isArray(phases) || phases.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '模板阶段不能为空' });
|
||||||
|
}
|
||||||
|
const result = await db.query(
|
||||||
|
'INSERT INTO project_type_templates (name, description, phases, is_system) VALUES ($1, $2, $3, false) RETURNING id',
|
||||||
|
[name, description || '', JSON.stringify(phases)]
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: '模板创建成功', data: { id: result.rows[0].id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建模板失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '创建模板失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const existing = await db.query('SELECT is_system FROM project_type_templates WHERE id = $1', [id]);
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '模板不存在' });
|
||||||
|
}
|
||||||
|
if (existing.rows[0].is_system) {
|
||||||
|
return res.status(403).json({ success: false, message: '系统预设模板不可修改' });
|
||||||
|
}
|
||||||
|
const { name, description, phases } = req.body;
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
if (name !== undefined) { updates.push(`name = $${paramIndex++}`); values.push(name); }
|
||||||
|
if (description !== undefined) { updates.push(`description = $${paramIndex++}`); values.push(description); }
|
||||||
|
if (phases !== undefined) { updates.push(`phases = $${paramIndex++}`); values.push(JSON.stringify(phases)); }
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '没有更新数据' });
|
||||||
|
}
|
||||||
|
updates.push('updated_at = NOW()');
|
||||||
|
values.push(id);
|
||||||
|
await db.query(`UPDATE project_type_templates SET ${updates.join(', ')} WHERE id = $${paramIndex}`, values);
|
||||||
|
res.json({ success: true, message: '模板更新成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新模板失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '更新模板失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const existing = await db.query('SELECT is_system FROM project_type_templates WHERE id = $1', [id]);
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '模板不存在' });
|
||||||
|
}
|
||||||
|
if (existing.rows[0].is_system) {
|
||||||
|
return res.status(403).json({ success: false, message: '系统预设模板不可删除' });
|
||||||
|
}
|
||||||
|
await db.query('DELETE FROM project_type_templates WHERE id = $1', [id]);
|
||||||
|
res.json({ success: true, message: '模板删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除模板失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '删除模板失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/copy', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name } = req.body;
|
||||||
|
const existing = await db.query('SELECT name, description, phases FROM project_type_templates WHERE id = $1', [id]);
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '模板不存在' });
|
||||||
|
}
|
||||||
|
const src = existing.rows[0];
|
||||||
|
const result = await db.query(
|
||||||
|
'INSERT INTO project_type_templates (name, description, phases, is_system) VALUES ($1, $2, $3, false) RETURNING id',
|
||||||
|
[name || `${src.name} (副本)`, src.description, src.phases]
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: '模板复制成功', data: { id: result.rows[0].id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制模板失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '复制模板失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+169
-5
@@ -362,22 +362,42 @@ router.get('/:id/construction-logs', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, code, customer_id, manager_id, contract_amount, start_date, end_date, description, status, location } = req.body;
|
const { name, code, customer_id, manager_id, contract_amount, start_date, end_date, description, status, location, type_template_id } = req.body;
|
||||||
|
|
||||||
const projectCode = code || 'PROJ' + new Date().toISOString().slice(0, 10).replace(/-/g, '') +
|
const projectCode = code || 'PROJ' + new Date().toISOString().slice(0, 10).replace(/-/g, '') +
|
||||||
String(Math.floor(Math.random() * 10000)).padStart(4, '0');
|
String(Math.floor(Math.random() * 10000)).padStart(4, '0');
|
||||||
|
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`INSERT INTO projects (name, code, customer_id, project_manager_id, contract_amount, start_date, end_date, description, status, location, created_at, updated_at)
|
`INSERT INTO projects (name, code, customer_id, project_manager_id, contract_amount, start_date, end_date, description, status, location, type_template_id, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[name, projectCode, customer_id || null, manager_id || null, contract_amount || 0, start_date || '', end_date || '', description || '', status || 'planning', location || '']
|
[name, projectCode, customer_id || null, manager_id || null, contract_amount || 0, start_date || '', end_date || '', description || '', status || 'planning', location || '', type_template_id || null]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const projectId = result.rows[0]?.id || result.rows?.[0]?.id;
|
||||||
|
|
||||||
|
if (type_template_id && projectId) {
|
||||||
|
const tplResult = await db.query('SELECT phases FROM project_type_templates WHERE id = $1', [type_template_id]);
|
||||||
|
if (tplResult.rows.length > 0) {
|
||||||
|
const phases = tplResult.rows[0].phases;
|
||||||
|
if (Array.isArray(phases)) {
|
||||||
|
for (const phase of phases) {
|
||||||
|
const subItems = (phase.sub_items || []).map(item => ({ name: item, completed: false }));
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO project_phases (project_id, template_key, phase_name, phase_order, phase_type, depends_on, sub_items, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[projectId, phase.key || `phase_${phase.order}`, phase.name, phase.order, phase.type || 'serial', phase.depends || [], JSON.stringify(subItems), phase.order === 1 ? 'in_progress' : 'pending']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const firstPhase = phases.find(p => p.order === 1);
|
||||||
|
await db.query('UPDATE projects SET current_phase = $1 WHERE id = $2', [firstPhase ? firstPhase.name : '', projectId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: '项目创建成功',
|
message: '项目创建成功',
|
||||||
data: { id: (result.rows[0]?.id || result.rows?.[0]?.id), code: projectCode }
|
data: { id: projectId, code: projectCode }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建项目失败:', error);
|
console.error('创建项目失败:', error);
|
||||||
@@ -624,3 +644,147 @@ router.get('/:id/cost-summary', async (req, res) => {
|
|||||||
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
router.get('/:id/phases', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取项目阶段失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取项目阶段失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/phases/init', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { template_id } = req.body;
|
||||||
|
const templateResult = await db.query('SELECT phases FROM project_type_templates WHERE id = $1', [template_id]);
|
||||||
|
if (templateResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '模板不存在' });
|
||||||
|
}
|
||||||
|
const phases = templateResult.rows[0].phases;
|
||||||
|
if (!Array.isArray(phases)) {
|
||||||
|
return res.status(400).json({ success: false, message: '模板阶段数据格式错误' });
|
||||||
|
}
|
||||||
|
const existing = await db.query('SELECT id FROM project_phases WHERE project_id = $1', [id]);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '该项目已有阶段数据' });
|
||||||
|
}
|
||||||
|
for (const phase of phases) {
|
||||||
|
const subItems = (phase.sub_items || []).map(item => ({ name: item, completed: false }));
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO project_phases (project_id, template_key, phase_name, phase_order, phase_type, depends_on, sub_items, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[id, phase.key || `phase_${phase.order}`, phase.name, phase.order, phase.type || 'serial', phase.depends || [], JSON.stringify(subItems), phase.order === 1 ? 'in_progress' : 'pending']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const firstPhase = phases.find(p => p.order === 1);
|
||||||
|
await db.query(
|
||||||
|
'UPDATE projects SET type_template_id = $1, current_phase = $2, phase_progress = 0, updated_at = CURRENT_TIMESTAMP WHERE id = $3',
|
||||||
|
[template_id, firstPhase ? firstPhase.name : '', id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: '阶段初始化成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化项目阶段失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '初始化项目阶段失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/phases/:phaseId/complete', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, phaseId } = req.params;
|
||||||
|
const { remark, photos, completed_by } = req.body;
|
||||||
|
const phaseResult = await db.query('SELECT * FROM project_phases WHERE id = $1 AND project_id = $2', [phaseId, id]);
|
||||||
|
if (phaseResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '阶段不存在' });
|
||||||
|
}
|
||||||
|
const phase = phaseResult.rows[0];
|
||||||
|
await db.query(
|
||||||
|
`UPDATE project_phases SET status = 'completed', completed_at = NOW(), completed_by = $1, remark = $2, photos = $3 WHERE id = $4`,
|
||||||
|
[completed_by || null, remark || phase.remark, photos || phase.photos, phaseId]
|
||||||
|
);
|
||||||
|
const allPhases = await db.query('SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order', [id]);
|
||||||
|
const completedCount = allPhases.rows.filter(p => p.status === 'completed').length;
|
||||||
|
const totalCount = allPhases.rows.length;
|
||||||
|
const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||||
|
let nextPhase = null;
|
||||||
|
for (const p of allPhases.rows) {
|
||||||
|
if (p.status === 'pending') {
|
||||||
|
const deps = p.depends_on || [];
|
||||||
|
const depsMet = deps.length === 0 || deps.every(depId => {
|
||||||
|
const depPhase = allPhases.rows.find(ap => ap.id === depId || ap.phase_order === depId);
|
||||||
|
return depPhase && depPhase.status === 'completed';
|
||||||
|
});
|
||||||
|
if (depsMet) {
|
||||||
|
nextPhase = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextPhase) {
|
||||||
|
await db.query("UPDATE project_phases SET status = 'in_progress', started_at = NOW() WHERE id = $1", [nextPhase.id]);
|
||||||
|
const newStatus = progress === 100 ? 'completed' : 'active';
|
||||||
|
await db.query(
|
||||||
|
'UPDATE projects SET current_phase = $1, phase_progress = $2, status = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4',
|
||||||
|
[nextPhase.phase_name, progress, newStatus, id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const newStatus = progress === 100 ? 'completed' : 'active';
|
||||||
|
await db.query(
|
||||||
|
'UPDATE projects SET current_phase = $1, phase_progress = $2, status = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4',
|
||||||
|
['', progress, newStatus, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '阶段完成', data: { progress, nextPhase: nextPhase ? nextPhase.phase_name : null } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('完成阶段失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '完成阶段失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/phases/:phaseId/reopen', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, phaseId } = req.params;
|
||||||
|
await db.query(
|
||||||
|
"UPDATE project_phases SET status = 'in_progress', completed_at = NULL, completed_by = NULL WHERE id = $1 AND project_id = $2",
|
||||||
|
[phaseId, id]
|
||||||
|
);
|
||||||
|
const allPhases = await db.query('SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order', [id]);
|
||||||
|
const completedCount = allPhases.rows.filter(p => p.status === 'completed').length;
|
||||||
|
const totalCount = allPhases.rows.length;
|
||||||
|
const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||||
|
const reopenedPhase = allPhases.rows.find(p => p.id === parseInt(phaseId));
|
||||||
|
await db.query(
|
||||||
|
'UPDATE projects SET current_phase = $1, phase_progress = $2, status = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4',
|
||||||
|
[reopenedPhase ? reopenedPhase.phase_name : '', progress, 'active', id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: '阶段已重新打开', data: { progress } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重新打开阶段失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '重新打开阶段失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/phases/:phaseId/sub-item', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, phaseId } = req.params;
|
||||||
|
const { sub_item_index, completed } = req.body;
|
||||||
|
const phaseResult = await db.query('SELECT sub_items FROM project_phases WHERE id = $1 AND project_id = $2', [phaseId, id]);
|
||||||
|
if (phaseResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '阶段不存在' });
|
||||||
|
}
|
||||||
|
const subItems = phaseResult.rows[0].sub_items || [];
|
||||||
|
if (sub_item_index >= 0 && sub_item_index < subItems.length) {
|
||||||
|
subItems[sub_item_index].completed = completed;
|
||||||
|
await db.query('UPDATE project_phases SET sub_items = $1 WHERE id = $2', [JSON.stringify(subItems), phaseId]);
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '子项状态更新成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新子项状态失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '更新子项状态失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { purchase_order_id, logistics_record_id, confirm_date, receiver_name, receiver_phone, received_location, is_complete, missing_items, damage_items, photo_urls, remark, confirmed_by } = req.body;
|
||||||
|
if (!purchase_order_id) {
|
||||||
|
return res.status(400).json({ success: false, message: '采购订单ID不能为空' });
|
||||||
|
}
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO receiving_confirms (purchase_order_id, logistics_record_id, confirm_date, receiver_name, receiver_phone, received_location, is_complete, missing_items, damage_items, photo_urls, remark, confirmed_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
|
||||||
|
[purchase_order_id, logistics_record_id || null, confirm_date || new Date().toISOString().slice(0, 10), receiver_name || '', receiver_phone || '', received_location || '', is_complete !== false, missing_items || '', damage_items || '', photo_urls || [], remark || '', confirmed_by || null]
|
||||||
|
);
|
||||||
|
if (is_complete) {
|
||||||
|
await db.query("UPDATE purchase_orders SET receiving_status = 'received' WHERE id = $1", [purchase_order_id]);
|
||||||
|
} else {
|
||||||
|
await db.query("UPDATE purchase_orders SET receiving_status = 'partial_received' WHERE id = $1", [purchase_order_id]);
|
||||||
|
}
|
||||||
|
if (logistics_record_id) {
|
||||||
|
await db.query("UPDATE logistics_records SET status = 'arrived', final_arrival_date = CURRENT_DATE WHERE id = $1", [logistics_record_id]);
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: '收货确认成功', data: { id: result.rows[0].id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('收货确认失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '收货确认失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:purchase_order_id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT * FROM receiving_confirms WHERE purchase_order_id = $1 ORDER BY created_at DESC',
|
||||||
|
[req.params.purchase_order_id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取收货记录失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取收货记录失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const fields = ['confirm_date', 'receiver_name', 'receiver_phone', 'received_location', 'is_complete', 'missing_items', 'damage_items', 'photo_urls', 'remark'];
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
for (const field of fields) {
|
||||||
|
if (req.body[field] !== undefined) {
|
||||||
|
updates.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push(req.body[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: '没有更新数据' });
|
||||||
|
}
|
||||||
|
values.push(id);
|
||||||
|
await db.query(`UPDATE receiving_confirms SET ${updates.join(', ')} WHERE id = $${paramIndex}`, values);
|
||||||
|
res.json({ success: true, message: '收货记录更新成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新收货记录失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '更新收货记录失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -515,3 +515,62 @@ router.delete('/payment-infos/:infoId', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
router.get('/:id/events', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT * FROM subcontractor_events WHERE subcontractor_id = $1 ORDER BY event_date DESC, created_at DESC',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取分包商事件失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '获取分包商事件失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/events', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { project_id, event_type, score_change, event_date, title, description, created_by } = req.body;
|
||||||
|
if (!event_type || !score_change || !event_date || !title) {
|
||||||
|
return res.status(400).json({ success: false, message: '缺少必填字段' });
|
||||||
|
}
|
||||||
|
if (!['positive', 'negative'].includes(event_type)) {
|
||||||
|
return res.status(400).json({ success: false, message: '事件类型必须是positive或negative' });
|
||||||
|
}
|
||||||
|
const actualChange = event_type === 'positive' ? Math.abs(score_change) : -Math.abs(score_change);
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO subcontractor_events (subcontractor_id, project_id, event_type, score_change, event_date, title, description, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||||
|
[id, project_id || null, event_type, actualChange, event_date, title, description || '', created_by || null]
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
'UPDATE subcontractors SET score = GREATEST(0, score + $1) WHERE id = $2',
|
||||||
|
[actualChange, id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: '事件添加成功', data: { id: result.rows[0].id } });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加分包商事件失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '添加分包商事件失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/events/:eventId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id, eventId } = req.params;
|
||||||
|
const eventResult = await db.query('SELECT score_change FROM subcontractor_events WHERE id = $1 AND subcontractor_id = $2', [eventId, id]);
|
||||||
|
if (eventResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: '事件不存在' });
|
||||||
|
}
|
||||||
|
const scoreChange = eventResult.rows[0].score_change;
|
||||||
|
await db.query('DELETE FROM subcontractor_events WHERE id = $1', [eventId]);
|
||||||
|
await db.query(
|
||||||
|
'UPDATE subcontractors SET score = GREATEST(0, LEAST(200, score - $1)) WHERE id = $2',
|
||||||
|
[scoreChange, id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, message: '事件删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除分包商事件失败:', error);
|
||||||
|
res.status(500).json({ success: false, message: '删除分包商事件失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,11 +50,14 @@ import BudgetProjectDetail from './pages/budget/BudgetProjectDetail'
|
|||||||
import ConstructionList from './pages/construction'
|
import ConstructionList from './pages/construction'
|
||||||
import ConstructionLog from './pages/construction/ConstructionLog'
|
import ConstructionLog from './pages/construction/ConstructionLog'
|
||||||
import ConstructionMilestones from './pages/construction/ConstructionMilestones'
|
import ConstructionMilestones from './pages/construction/ConstructionMilestones'
|
||||||
|
import ConstructionOverview from './pages/construction/ConstructionOverview'
|
||||||
|
import ConstructionProgress from './pages/construction/ConstructionProgress'
|
||||||
|
|
||||||
// 后台管理
|
// 后台管理
|
||||||
import AdminLayout from './layouts/AdminLayout'
|
import AdminLayout from './layouts/AdminLayout'
|
||||||
import BackupPage from './pages/admin/BackupPage'
|
import BackupPage from './pages/admin/BackupPage'
|
||||||
import ProcessManagement from './pages/admin/ProcessManagement'
|
import ProcessManagement from './pages/admin/ProcessManagement'
|
||||||
|
import ProcessTemplates from './pages/admin/ProcessTemplates'
|
||||||
import AboutPage from './pages/admin/AboutPage'
|
import AboutPage from './pages/admin/AboutPage'
|
||||||
|
|
||||||
// 布局组件
|
// 布局组件
|
||||||
@@ -137,9 +140,10 @@ function App() {
|
|||||||
<Route path="budget-projects" element={<BudgetProjectList />} />
|
<Route path="budget-projects" element={<BudgetProjectList />} />
|
||||||
<Route path="budget-projects/create" element={<BudgetProjectCreate />} />
|
<Route path="budget-projects/create" element={<BudgetProjectCreate />} />
|
||||||
<Route path="budget-projects/:id" element={<BudgetProjectDetail />} />
|
<Route path="budget-projects/:id" element={<BudgetProjectDetail />} />
|
||||||
<Route path="construction" element={<ConstructionList />} />
|
<Route path="construction" element={<ConstructionOverview />} />
|
||||||
<Route path="construction/:id/logs" element={<ConstructionLog />} />
|
<Route path="construction/:id/logs" element={<ConstructionLog />} />
|
||||||
<Route path="construction/:id/milestones" element={<ConstructionMilestones />} />
|
<Route path="construction/:id/milestones" element={<ConstructionMilestones />} />
|
||||||
|
<Route path="construction/progress/:id" element={<ConstructionProgress />} />
|
||||||
<Route path="approval" element={<ApprovalManagement />} />
|
<Route path="approval" element={<ApprovalManagement />} />
|
||||||
<Route path="execution" element={<ExecutionManagement />} />
|
<Route path="execution" element={<ExecutionManagement />} />
|
||||||
<Route path="advances" element={<AdvancesPage />} />
|
<Route path="advances" element={<AdvancesPage />} />
|
||||||
@@ -173,6 +177,7 @@ function App() {
|
|||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="roles" element={<RolesPage />} />
|
<Route path="roles" element={<RolesPage />} />
|
||||||
<Route path="process" element={<ProcessManagement />} />
|
<Route path="process" element={<ProcessManagement />} />
|
||||||
|
<Route path="process-templates" element={<ProcessTemplates />} />
|
||||||
<Route path="logs" element={<SystemLogsPage />} />
|
<Route path="logs" element={<SystemLogsPage />} />
|
||||||
<Route path="backup" element={<BackupPage />} />
|
<Route path="backup" element={<BackupPage />} />
|
||||||
<Route path="about" element={<AboutPage />} />
|
<Route path="about" element={<AboutPage />} />
|
||||||
|
|||||||
@@ -68,7 +68,13 @@ const menuItems = [
|
|||||||
{
|
{
|
||||||
key: '/construction',
|
key: '/construction',
|
||||||
icon: <ToolOutlined />,
|
icon: <ToolOutlined />,
|
||||||
label: '施工管理'
|
label: '施工管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/construction',
|
||||||
|
label: '施工总览'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'approval',
|
key: 'approval',
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
SettingOutlined
|
SettingOutlined,
|
||||||
|
AppstoreOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -34,6 +35,11 @@ const AdminLayout: React.FC = () => {
|
|||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
label: '流程管理'
|
label: '流程管理'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/admin/process-templates',
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
label: '工程模板管理'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/admin/logs',
|
key: '/admin/logs',
|
||||||
icon: <FileTextOutlined />,
|
icon: <FileTextOutlined />,
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Button, Table, Space, Modal, Form, Input, Select, InputNumber, Tag, Steps, message, Popconfirm, Drawer, List, Checkbox, Radio, Empty, Spin, Typography, Row, Col } from 'antd';
|
||||||
|
import { PlusOutlined, CopyOutlined, EditOutlined, DeleteOutlined, EyeOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||||
|
import apiClient from '../../utils/request';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
const { Option } = Select;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface PhaseItem {
|
||||||
|
order: number;
|
||||||
|
name: string;
|
||||||
|
key?: string;
|
||||||
|
type: 'serial' | 'parallel';
|
||||||
|
depends: number[];
|
||||||
|
sub_items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
phases: PhaseItem[];
|
||||||
|
is_system: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProcessTemplates: React.FC = () => {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||||
|
const [viewDrawerVisible, setViewDrawerVisible] = useState(false);
|
||||||
|
const [currentTemplate, setCurrentTemplate] = useState<Template | null>(null);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [phases, setPhases] = useState<PhaseItem[]>([]);
|
||||||
|
const [phaseDrawerVisible, setPhaseDrawerVisible] = useState(false);
|
||||||
|
const [editingPhaseIndex, setEditingPhaseIndex] = useState<number>(-1);
|
||||||
|
const [phaseForm] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => { fetchTemplates(); }, []);
|
||||||
|
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/process-templates');
|
||||||
|
if (res.data.success) setTemplates(res.data.data);
|
||||||
|
} catch (e) { message.error('获取模板列表失败'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setPhases([]);
|
||||||
|
setCurrentStep(0);
|
||||||
|
setCreateModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (t: Template) => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/process-templates/${t.id}/copy`, { name: `${t.name} (副本)` });
|
||||||
|
message.success('复制成功');
|
||||||
|
fetchTemplates();
|
||||||
|
} catch (e) { message.error('复制失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/process-templates/${id}`);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchTemplates();
|
||||||
|
} catch (e) { message.error('删除失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (t: Template) => {
|
||||||
|
setCurrentTemplate(t);
|
||||||
|
setViewDrawerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const name = form.getFieldValue('name');
|
||||||
|
const description = form.getFieldValue('description');
|
||||||
|
if (!name) { message.warning('请输入模板名称'); setCurrentStep(0); return; }
|
||||||
|
if (phases.length === 0) { message.warning('请至少添加一个阶段'); setCurrentStep(1); return; }
|
||||||
|
await apiClient.post('/process-templates', { name, description, phases });
|
||||||
|
message.success('模板创建成功');
|
||||||
|
setCreateModalVisible(false);
|
||||||
|
fetchTemplates();
|
||||||
|
} catch (e) { message.error('创建失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPhase = () => {
|
||||||
|
setEditingPhaseIndex(-1);
|
||||||
|
phaseForm.resetFields();
|
||||||
|
phaseForm.setFieldsValue({ name: '', type: 'serial', sub_items_text: '', depends: [] });
|
||||||
|
setPhaseDrawerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editPhase = (index: number) => {
|
||||||
|
setEditingPhaseIndex(index);
|
||||||
|
const p = phases[index];
|
||||||
|
phaseForm.setFieldsValue({
|
||||||
|
name: p.name,
|
||||||
|
type: p.type,
|
||||||
|
sub_items_text: (p.sub_items || []).join('\n'),
|
||||||
|
depends: p.depends.map(d => d),
|
||||||
|
});
|
||||||
|
setPhaseDrawerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePhase = () => {
|
||||||
|
const values = phaseForm.getFieldsValue();
|
||||||
|
const subItems = (values.sub_items_text || '').split('\n').map((s: string) => s.trim()).filter(Boolean);
|
||||||
|
const phaseData: PhaseItem = {
|
||||||
|
order: 0,
|
||||||
|
name: values.name,
|
||||||
|
type: values.type || 'serial',
|
||||||
|
depends: values.depends || [],
|
||||||
|
sub_items: subItems,
|
||||||
|
};
|
||||||
|
if (!phaseData.name) { message.warning('阶段名称不能为空'); return; }
|
||||||
|
const newPhases = [...phases];
|
||||||
|
if (editingPhaseIndex >= 0) {
|
||||||
|
phaseData.order = newPhases[editingPhaseIndex].order;
|
||||||
|
newPhases[editingPhaseIndex] = phaseData;
|
||||||
|
} else {
|
||||||
|
phaseData.order = newPhases.length + 1;
|
||||||
|
newPhases.push(phaseData);
|
||||||
|
}
|
||||||
|
setPhases(newPhases);
|
||||||
|
setPhaseDrawerVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhase = (index: number) => {
|
||||||
|
const newPhases = phases.filter((_, i) => i !== index).map((p, i) => ({ ...p, order: i + 1 }));
|
||||||
|
setPhases(newPhases);
|
||||||
|
};
|
||||||
|
|
||||||
|
const movePhase = (index: number, direction: 'up' | 'down') => {
|
||||||
|
const newPhases = [...phases];
|
||||||
|
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= newPhases.length) return;
|
||||||
|
[newPhases[index], newPhases[targetIndex]] = [newPhases[targetIndex], newPhases[index]];
|
||||||
|
newPhases.forEach((p, i) => p.order = i + 1);
|
||||||
|
setPhases(newPhases);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeTag = (type: string) => type === 'parallel' ? <Tag color="blue">并行</Tag> : <Tag color="green">串行</Tag>;
|
||||||
|
|
||||||
|
const getDependsNames = (depends: number[]) => {
|
||||||
|
return depends.map(d => {
|
||||||
|
const p = phases.find(ph => ph.order === d);
|
||||||
|
return p ? p.name : `阶段${d}`;
|
||||||
|
}).join('、') || '无';
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '模板名称', dataIndex: 'name', key: 'name', render: (text: string, r: Template) => <Space>{text}{r.is_system && <Tag color="gold">系统预设</Tag>}</Space> },
|
||||||
|
{ title: '阶段数', key: 'phases', render: (_: unknown, r: Template) => r.phases?.length || 0 },
|
||||||
|
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||||
|
{ title: '操作', key: 'action', render: (_: unknown, r: Template) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(r)}>查看</Button>
|
||||||
|
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(r)}>复制</Button>
|
||||||
|
{!r.is_system && <Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}><Button size="small" danger icon={<DeleteOutlined />}>删除</Button></Popconfirm>}
|
||||||
|
</Space>
|
||||||
|
)},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Card title="工程模板管理" extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建模板</Button>}>
|
||||||
|
<Table columns={columns} dataSource={templates} rowKey="id" loading={loading} pagination={false} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal title="新建工程模板" open={createModalVisible} onCancel={() => setCreateModalVisible(false)} width={800} footer={[
|
||||||
|
<Button key="cancel" onClick={() => setCreateModalVisible(false)}>取消</Button>,
|
||||||
|
currentStep > 0 && <Button key="prev" onClick={() => setCurrentStep(currentStep - 1)}>上一步</Button>,
|
||||||
|
currentStep < 2 && <Button key="next" type="primary" onClick={() => {
|
||||||
|
if (currentStep === 0 && !form.getFieldValue('name')) { message.warning('请输入模板名称'); return; }
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}}>下一步</Button>,
|
||||||
|
currentStep === 2 && <Button key="save" type="primary" onClick={handleSaveTemplate}>确认创建</Button>,
|
||||||
|
]}>
|
||||||
|
<Steps current={currentStep} size="small" style={{ marginBottom: 24 }}>
|
||||||
|
<Steps.Step title="基本信息" />
|
||||||
|
<Steps.Step title="设计阶段" />
|
||||||
|
<Steps.Step title="预览确认" />
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="例如:配电安装工程" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="模板描述">
|
||||||
|
<TextArea rows={3} placeholder="描述该模板适用的工程类型" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<div>
|
||||||
|
{phases.length === 0 ? <Empty description="暂无阶段,请点击下方添加" /> : (
|
||||||
|
<List bordered dataSource={phases} renderItem={(phase, index) => (
|
||||||
|
<List.Item actions={[
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => editPhase(index)}>编辑</Button>,
|
||||||
|
<Button size="small" icon={<ArrowUpOutlined />} onClick={() => movePhase(index, 'up')} disabled={index === 0} />,
|
||||||
|
<Button size="small" icon={<ArrowDownOutlined />} onClick={() => movePhase(index, 'down')} disabled={index === phases.length - 1} />,
|
||||||
|
<Popconfirm title="确定删除?" onConfirm={() => removePhase(index)}><Button size="small" danger icon={<DeleteOutlined />} /></Popconfirm>,
|
||||||
|
]}>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)} <Text type="secondary">依赖:{getDependsNames(phase.depends)}</Text></Space>}
|
||||||
|
description={phase.sub_items?.length > 0 ? `子项:${phase.sub_items.join('、')}` : '无子项'}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
<Button type="dashed" block icon={<PlusOutlined />} style={{ marginTop: 16 }} onClick={addPhase}>添加阶段</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<div>
|
||||||
|
<Title level={5}>{form.getFieldValue('name')}</Title>
|
||||||
|
<Paragraph type="secondary">{form.getFieldValue('description')}</Paragraph>
|
||||||
|
<List bordered dataSource={phases} renderItem={(phase) => (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)}</Space>}
|
||||||
|
description={<>
|
||||||
|
<Text type="secondary">依赖:{getDependsNames(phase.depends)}</Text><br />
|
||||||
|
{phase.sub_items?.length > 0 && <Text>子项:{phase.sub_items.join('、')}</Text>}
|
||||||
|
</>}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer title="阶段编辑" open={phaseDrawerVisible} onClose={() => setPhaseDrawerVisible(false)} width={400} extra={<Button type="primary" onClick={savePhase}>保存</Button>}>
|
||||||
|
<Form form={phaseForm} layout="vertical">
|
||||||
|
<Form.Item name="name" label="阶段名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="例如:物资采购" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="type" label="阶段类型">
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value="serial">串行(必须等依赖项完成)</Radio>
|
||||||
|
<Radio value="parallel">并行(可与相邻阶段同时进行)</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="depends" label="依赖关系(哪些阶段完成后才能开始)">
|
||||||
|
<Checkbox.Group>
|
||||||
|
{phases.filter((_, i) => i !== editingPhaseIndex).map(p => (
|
||||||
|
<Checkbox key={p.order} value={p.order} style={{ display: 'block' }}>{p.order}. {p.name}</Checkbox>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sub_items_text" label="子项列表(每行一个)">
|
||||||
|
<TextArea rows={6} placeholder={"电杆采购\n变压器采购\n电缆采购"} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer title={currentTemplate?.name} open={viewDrawerVisible} onClose={() => setViewDrawerVisible(false)} width={500}>
|
||||||
|
{currentTemplate && (
|
||||||
|
<div>
|
||||||
|
<Paragraph type="secondary">{currentTemplate.description}</Paragraph>
|
||||||
|
<List bordered dataSource={currentTemplate.phases || []} renderItem={(phase: PhaseItem) => (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={<Space>{phase.order}. {phase.name} {phase.type === 'parallel' ? <Tag color="blue">并行</Tag> : <Tag color="green">串行</Tag>}</Space>}
|
||||||
|
description={<>
|
||||||
|
<Text type="secondary">依赖:阶段{phase.depends?.join('、') || '无'}</Text><br />
|
||||||
|
{phase.sub_items?.length > 0 && <Text>子项:{phase.sub_items.join('、')}</Text>}
|
||||||
|
</>}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProcessTemplates;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Button, Progress, Tag, List, Spin, message, Empty, Space, Typography } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import apiClient from '../../utils/request';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface ProjectPhase {
|
||||||
|
id: number;
|
||||||
|
phase_name: string;
|
||||||
|
phase_order: number;
|
||||||
|
phase_type: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
current_phase: string;
|
||||||
|
phase_progress: number;
|
||||||
|
contract_amount: number;
|
||||||
|
phases: ProjectPhase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConstructionOverview: React.FC = () => {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => { fetchProjects(); }, []);
|
||||||
|
|
||||||
|
const fetchProjects = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/construction/my-projects');
|
||||||
|
if (res.data.success) {
|
||||||
|
const projectsData = res.data.data || [];
|
||||||
|
const enriched = await Promise.all(projectsData.map(async (p: Project) => {
|
||||||
|
try {
|
||||||
|
const phaseRes = await apiClient.get(`/projects/${p.id}/phases`);
|
||||||
|
return { ...p, phases: phaseRes.data.data || [] };
|
||||||
|
} catch { return { ...p, phases: [] }; }
|
||||||
|
}));
|
||||||
|
setProjects(enriched);
|
||||||
|
}
|
||||||
|
} catch (e) { message.error('获取项目列表失败'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const map: Record<string, string> = { active: 'green', planning: 'blue', completed: 'default', suspended: 'orange' };
|
||||||
|
return map[status] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const map: Record<string, string> = { active: '施工中', planning: '待开工', completed: '已完工', suspended: '暂停' };
|
||||||
|
return map[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeProjects = projects.filter(p => p.status !== 'completed');
|
||||||
|
const completedProjects = projects.filter(p => p.status === 'completed');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Card title={<Title level={4} style={{ margin: 0 }}>施工总览</Title>}>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{activeProjects.length === 0 && completedProjects.length === 0 ? (
|
||||||
|
<Empty description="暂无施工项目" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeProjects.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>施工中项目:{activeProjects.length}个</Text>
|
||||||
|
<List
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
dataSource={activeProjects}
|
||||||
|
renderItem={(project) => (
|
||||||
|
<List.Item
|
||||||
|
actions={[<Button type="primary" icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>进入</Button>]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={<Space><Text strong>{project.name}</Text><Tag color={getStatusColor(project.status)}>{getStatusText(project.status)}</Tag></Space>}
|
||||||
|
description={
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<Text type="secondary">当前阶段:{project.current_phase || '未设置'}</Text>
|
||||||
|
{project.phases.length > 0 && <Text type="secondary" style={{ marginLeft: 16 }}>({project.phases.filter(p => p.status === 'completed').length}/{project.phases.length})</Text>}
|
||||||
|
</div>
|
||||||
|
<Progress percent={project.phase_progress || 0} size="small" strokeColor="#1890ff" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{completedProjects.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text strong style={{ fontSize: 16, marginTop: 24, display: 'block' }}>已完工项目:{completedProjects.length}个</Text>
|
||||||
|
<List
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
dataSource={completedProjects}
|
||||||
|
renderItem={(project) => (
|
||||||
|
<List.Item
|
||||||
|
actions={[<Button icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>查看</Button>]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={<Space><Text>{project.name}</Text><Tag>已完工</Tag></Space>}
|
||||||
|
description={<Progress percent={100} size="small" />}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConstructionOverview;
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Button, Progress, Tag, Checkbox, Input, Upload, Space, Spin, message, List, Typography, Modal, Image, Divider } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, CameraOutlined, CheckCircleOutlined, ClockCircleOutlined, MinusCircleOutlined, UndoOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import apiClient from '../../utils/request';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface Phase {
|
||||||
|
id: number;
|
||||||
|
phase_name: string;
|
||||||
|
phase_order: number;
|
||||||
|
phase_type: string;
|
||||||
|
depends_on: number[];
|
||||||
|
status: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
remark: string | null;
|
||||||
|
photos: string[];
|
||||||
|
sub_items: { name: string; completed: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConstructionProgress: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [project, setProject] = useState<any>(null);
|
||||||
|
const [phases, setPhases] = useState<Phase[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [completingPhaseId, setCompletingPhaseId] = useState<number | null>(null);
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => { if (id) { fetchProject(); fetchPhases(); } }, [id]);
|
||||||
|
|
||||||
|
const fetchProject = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/projects/${id}`);
|
||||||
|
if (res.data.success) setProject(res.data.data);
|
||||||
|
} catch (e) { message.error('获取项目信息失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPhases = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/projects/${id}/phases`);
|
||||||
|
if (res.data.success) setPhases(res.data.data || []);
|
||||||
|
} catch (e) { message.error('获取阶段信息失败'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompletePhase = (phaseId: number) => {
|
||||||
|
setCompletingPhaseId(phaseId);
|
||||||
|
setRemark('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmComplete = async () => {
|
||||||
|
if (!completingPhaseId || !id) return;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.put(`/projects/${id}/phases/${completingPhaseId}/complete`, {
|
||||||
|
remark,
|
||||||
|
completed_by: 1,
|
||||||
|
});
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(`阶段完成!进度: ${res.data.data.progress}%`);
|
||||||
|
if (res.data.data.nextPhase) {
|
||||||
|
message.info(`已推进到: ${res.data.data.nextPhase}`);
|
||||||
|
}
|
||||||
|
fetchPhases();
|
||||||
|
fetchProject();
|
||||||
|
}
|
||||||
|
} catch (e) { message.error('操作失败'); }
|
||||||
|
finally { setCompletingPhaseId(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReopen = async (phaseId: number) => {
|
||||||
|
if (!id) return;
|
||||||
|
Modal.confirm({
|
||||||
|
title: '重新打开阶段',
|
||||||
|
content: '确定要重新打开此阶段吗?项目进度将回退。',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.put(`/projects/${id}/phases/${phaseId}/reopen`, {});
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success('阶段已重新打开');
|
||||||
|
fetchPhases();
|
||||||
|
fetchProject();
|
||||||
|
}
|
||||||
|
} catch (e) { message.error('操作失败'); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubItemToggle = async (phaseId: number, subItemIndex: number, completed: boolean) => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/projects/${id}/phases/${phaseId}/sub-item`, {
|
||||||
|
sub_item_index: subItemIndex,
|
||||||
|
completed,
|
||||||
|
});
|
||||||
|
fetchPhases();
|
||||||
|
} catch (e) { message.error('更新子项失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return <CheckCircleOutlined style={{ color: '#52c41a', fontSize: 20 }} />;
|
||||||
|
case 'in_progress': return <ClockCircleOutlined style={{ color: '#1890ff', fontSize: 20 }} />;
|
||||||
|
default: return <MinusCircleOutlined style={{ color: '#d9d9d9', fontSize: 20 }} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusTag = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return <Tag color="green">已完成</Tag>;
|
||||||
|
case 'in_progress': return <Tag color="blue">进行中</Tag>;
|
||||||
|
default: return <Tag>待开始</Tag>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPhase = phases.find(p => p.status === 'in_progress');
|
||||||
|
const completedPhases = phases.filter(p => p.status === 'completed').reverse();
|
||||||
|
const pendingPhases = phases.filter(p => p.status === 'pending');
|
||||||
|
const progress = project?.phase_progress || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '16px', maxWidth: 600, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/construction')}>返回总览</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>{project?.name || '加载中...'}</Title>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Progress percent={progress} strokeColor="#1890ff" />
|
||||||
|
<Space style={{ marginTop: 4 }}>
|
||||||
|
{project?.current_phase && <Tag color="blue">当前: {project.current_phase}</Tag>}
|
||||||
|
{getStatusTag(project?.status)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{currentPhase && (
|
||||||
|
<Card
|
||||||
|
title={<Space>{getStatusIcon('in_progress')} 📍 当前阶段:{currentPhase.phase_name}</Space>}
|
||||||
|
style={{ marginBottom: 16, borderColor: '#1890ff', borderWidth: 2 }}
|
||||||
|
>
|
||||||
|
{currentPhase.phase_type === 'parallel' && (
|
||||||
|
<Tag color="blue" style={{ marginBottom: 8 }}>并行阶段(可与其他阶段同时进行)</Tag>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPhase.sub_items && currentPhase.sub_items.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Text strong>完工标准:</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{currentPhase.sub_items.map((item, idx) => (
|
||||||
|
<div key={idx} style={{ marginBottom: 4 }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={item.completed}
|
||||||
|
onChange={(e) => handleSubItemToggle(currentPhase.id, idx, e.target.checked)}
|
||||||
|
>
|
||||||
|
<Text delete={item.completed} type={item.completed ? 'secondary' : undefined}>{item.name}</Text>
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Text strong>备注(可选):</Text>
|
||||||
|
<TextArea rows={2} value={remark} onChange={e => setRemark(e.target.value)} placeholder="填写完成备注..." style={{ marginTop: 4 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
onClick={() => handleCompletePhase(currentPhase.id)}
|
||||||
|
>
|
||||||
|
确认完成此阶段
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingPhases.length > 0 && (
|
||||||
|
<Card title="后续阶段" style={{ marginBottom: 16 }} size="small">
|
||||||
|
{pendingPhases.map(phase => (
|
||||||
|
<div key={phase.id} style={{ padding: '8px 0', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<Space>
|
||||||
|
{getStatusIcon('pending')}
|
||||||
|
<Text type="secondary">{phase.phase_order}. {phase.phase_name}</Text>
|
||||||
|
{phase.phase_type === 'parallel' && <Tag color="blue">并行</Tag>}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{completedPhases.length > 0 && (
|
||||||
|
<Card title="阶段历史" size="small">
|
||||||
|
{completedPhases.map(phase => (
|
||||||
|
<div key={phase.id} style={{ padding: '8px 0', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Space>
|
||||||
|
{getStatusIcon('completed')}
|
||||||
|
<Text>{phase.phase_order}. {phase.phase_name}</Text>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
{phase.completed_at && <Text type="secondary" style={{ fontSize: 12 }}>{new Date(phase.completed_at).toLocaleDateString()}</Text>}
|
||||||
|
<Button size="small" type="link" icon={<UndoOutlined />} onClick={() => handleReopen(phase.id)}>回退</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
{phase.remark && <Paragraph type="secondary" style={{ margin: '4px 0 0 28px', fontSize: 12 }}>{phase.remark}</Paragraph>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!currentPhase && pendingPhases.length === 0 && completedPhases.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<Text type="secondary">此项目尚未初始化施工阶段</Text>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Button type="primary" onClick={() => navigate(`/projects/${id}`)}>返回项目详情</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="确认完成阶段"
|
||||||
|
open={completingPhaseId !== null}
|
||||||
|
onOk={confirmComplete}
|
||||||
|
onCancel={() => setCompletingPhaseId(null)}
|
||||||
|
okText="确认完成"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Paragraph>确认此阶段已完成?系统将自动推进到下一阶段。</Paragraph>
|
||||||
|
{remark && <Paragraph type="secondary">备注:{remark}</Paragraph>}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConstructionProgress;
|
||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Tabs, Card, Descriptions, Button, Table, Tag, Progress, Space, message, Spin, Select, Menu, Dropdown, Modal, Form, Input, InputNumber, Switch, Upload, DatePicker } from 'antd'
|
import { Tabs, Card, Descriptions, Button, Table, Tag, Progress, Space, message, Spin, Select, Menu, Dropdown, Modal, Form, Input, InputNumber, Switch, Upload, DatePicker } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { DownOutlined, UploadOutlined } from '@ant-design/icons'
|
import { DownOutlined, UploadOutlined, ToolOutlined } from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@@ -945,13 +945,19 @@ const ProjectDetail: React.FC = () => {
|
|||||||
<h2 style={{ margin: 0 }}>{project.name}</h2>
|
<h2 style={{ margin: 0 }}>{project.name}</h2>
|
||||||
<Space style={{ marginTop: 8 }}>
|
<Space style={{ marginTop: 8 }}>
|
||||||
{getStatusTag(project.status)}
|
{getStatusTag(project.status)}
|
||||||
<span>进度: 60%</span>
|
{project.current_phase && <Tag color="blue">当前: {project.current_phase}</Tag>}
|
||||||
{/* 从付款节点中查找质保金节点,显示质保金金额 */}
|
{project.phase_progress > 0 && <span>进度: {project.phase_progress}%</span>}
|
||||||
<span>质保金: ¥{(milestones.find(m => m.milestone_name === '质保金')?.amount || 0).toLocaleString()}</span>
|
<span>质保金: ¥{(milestones.find(m => m.milestone_name === '质保金')?.amount || 0).toLocaleString()}</span>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<EditOutlined />}>编辑项目</Button>
|
<Space>
|
||||||
|
<Button icon={<ToolOutlined />} type="primary" onClick={() => navigate(`/construction/progress/${id}`)}>进入施工管理</Button>
|
||||||
|
<Button icon={<EditOutlined />} onClick={() => setBasicInfoEditModalVisible(true)}>编辑项目</Button>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
{project.phase_progress > 0 && (
|
||||||
|
<Progress percent={project.phase_progress} style={{ marginTop: 12 }} strokeColor="#1890ff" />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -962,13 +968,12 @@ const ProjectDetail: React.FC = () => {
|
|||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'basic', label: '基本信息' },
|
{ value: 'basic', label: '基本信息' },
|
||||||
{ value: 'contract', label: '合同详情' },
|
{ value: 'contract', label: '合同与收款' },
|
||||||
{ value: 'subcontract', label: '分包管理' },
|
{ value: 'subcontract', label: '分包管理' },
|
||||||
{ value: 'material', label: '材料管理' },
|
{ value: 'material', label: '材料管理' },
|
||||||
{ value: 'milestone', label: '施工节点' },
|
{ value: 'finance', label: '财务收支' },
|
||||||
{ value: 'log', label: '施工日志' },
|
{ value: 'warranty', label: '质保金' },
|
||||||
{ value: 'finance', label: '财务信息' },
|
{ value: 'log', label: '施工日志' }
|
||||||
{ value: 'warranty', label: '质保金' }
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
@@ -976,10 +981,9 @@ const ProjectDetail: React.FC = () => {
|
|||||||
{activeTab === 'contract' && <ContractTab />}
|
{activeTab === 'contract' && <ContractTab />}
|
||||||
{activeTab === 'subcontract' && <SubcontractTab />}
|
{activeTab === 'subcontract' && <SubcontractTab />}
|
||||||
{activeTab === 'material' && <MaterialTab />}
|
{activeTab === 'material' && <MaterialTab />}
|
||||||
{activeTab === 'milestone' && <MilestoneTab />}
|
|
||||||
{activeTab === 'log' && <LogTab />}
|
|
||||||
{activeTab === 'finance' && <FinanceTab />}
|
{activeTab === 'finance' && <FinanceTab />}
|
||||||
{activeTab === 'warranty' && <WarrantyTab />}
|
{activeTab === 'warranty' && <WarrantyTab />}
|
||||||
|
{activeTab === 'log' && <LogTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -995,7 +999,7 @@ const ProjectDetail: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'contract',
|
key: 'contract',
|
||||||
label: <span><FileTextOutlined /> 合同详情</span>,
|
label: <span><FileTextOutlined /> 合同与收款</span>,
|
||||||
children: <ContractTab />
|
children: <ContractTab />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1008,25 +1012,20 @@ const ProjectDetail: React.FC = () => {
|
|||||||
label: <span><DatabaseOutlined /> 材料管理</span>,
|
label: <span><DatabaseOutlined /> 材料管理</span>,
|
||||||
children: <MaterialTab />
|
children: <MaterialTab />
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'milestone',
|
|
||||||
label: <span><CheckCircleOutlined /> 施工节点</span>,
|
|
||||||
children: <MilestoneTab />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'log',
|
|
||||||
label: <span><FileSearchOutlined /> 施工日志</span>,
|
|
||||||
children: <LogTab />
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'finance',
|
key: 'finance',
|
||||||
label: <span><DollarOutlined /> 财务信息</span>,
|
label: <span><DollarOutlined /> 财务收支</span>,
|
||||||
children: <FinanceTab />
|
children: <FinanceTab />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'warranty',
|
key: 'warranty',
|
||||||
label: <span><SafetyOutlined /> 质保金</span>,
|
label: <span><SafetyOutlined /> 质保金</span>,
|
||||||
children: <WarrantyTab />
|
children: <WarrantyTab />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'log',
|
||||||
|
label: <span><FileSearchOutlined /> 施工日志</span>,
|
||||||
|
children: <LogTab />
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user