备份:PWA配置前的完整版本
包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
This commit is contained in:
@@ -40,6 +40,7 @@ app.use('/api/products', require('./routes/products'));
|
||||
app.use('/api/customers', require('./routes/customers'));
|
||||
app.use('/api/suppliers', require('./routes/suppliers'));
|
||||
app.use('/api/subcontractors', require('./routes/subcontractors'));
|
||||
app.use('/api/project-receipts', require('./routes/project-receipts'));
|
||||
app.use('/api/projects', require('./routes/projects'));
|
||||
app.use('/api/upload', require('./routes/upload'));
|
||||
app.use('/api/construction', require('./routes/construction'));
|
||||
@@ -69,6 +70,7 @@ app.use('/api/process-templates', require('./routes/process-templates'));
|
||||
app.use('/api/receiving', require('./routes/receiving'));
|
||||
app.use('/api/expense-categories', require('./routes/expense-categories'));
|
||||
app.use('/api/financial-records', require('./routes/financial-records'));
|
||||
app.use('/api/cash-management', require('./routes/cash-management'));
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
||||
|
||||
Binary file not shown.
@@ -114,7 +114,7 @@ router.put('/:id', async (req, res) => {
|
||||
[amount, reason, project_id, currency, advance_date, JSON.stringify(attachments || []), amount_cny, applicant, status, id]
|
||||
);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '更新成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '预支申请不存在' });
|
||||
@@ -131,7 +131,7 @@ router.delete('/:id', async (req, res) => {
|
||||
|
||||
const result = await db.query('DELETE FROM advances WHERE id = $1', [id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '预支申请不存在' });
|
||||
@@ -148,7 +148,7 @@ router.post('/:id/submit', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '提交成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '预支申请不存在' });
|
||||
@@ -165,7 +165,7 @@ router.post('/:id/withdraw', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['withdrawn', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '撤回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '预支申请不存在' });
|
||||
@@ -183,7 +183,7 @@ router.post('/:id/approve', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE advances SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '审批通过成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '预支申请不存在' });
|
||||
@@ -199,9 +199,9 @@ router.post('/:id/reject', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { rejectReason } = req.body;
|
||||
|
||||
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['pending_edit', id]);
|
||||
const result = await db.query('UPDATE advances SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '退回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '预支申请不存在' });
|
||||
|
||||
@@ -16,7 +16,7 @@ router.post('/login', async (req, res) => {
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
'SELECT id, username, name, email, phone, role, password_hash FROM users WHERE username = $1',
|
||||
'SELECT id, username, name, email, phone, role, avatar, passport, driver_license, password_hash FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
@@ -53,6 +53,9 @@ router.post('/login', async (req, res) => {
|
||||
phone: user.phone,
|
||||
role: user.role,
|
||||
department: '',
|
||||
avatar: user.avatar || null,
|
||||
passport: user.passport || null,
|
||||
driverLicense: user.driver_license || null,
|
||||
token: token
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function generateCode(prefix) {
|
||||
const now = new Date();
|
||||
const dateStr = now.getFullYear().toString() +
|
||||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||
now.getDate().toString().padStart(2, '0');
|
||||
const rand = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
return `${prefix}-${dateStr}-${rand}`;
|
||||
}
|
||||
|
||||
router.get('/summary', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { date_from, date_to } = req.query;
|
||||
let where = "WHERE status != 'voided'";
|
||||
const params = [];
|
||||
let idx = 1;
|
||||
|
||||
if (date_from) { params.push(date_from); where += ` AND record_date >= $${idx++}`; }
|
||||
if (date_to) { params.push(date_to); where += ` AND record_date <= $${idx++}`; }
|
||||
|
||||
const totalResult = await db.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN txn_type = 'income' THEN amount_cny ELSE 0 END), 0) as total_income,
|
||||
COALESCE(SUM(CASE WHEN txn_type = 'expense' THEN amount_cny ELSE 0 END), 0) as total_expense,
|
||||
COALESCE(SUM(CASE WHEN txn_type = 'income' THEN amount_cny ELSE -amount_cny END), 0) as net_amount
|
||||
FROM financial_records ${where}`,
|
||||
params
|
||||
);
|
||||
|
||||
const byCategory = await db.query(
|
||||
`SELECT category_level1, category_level2,
|
||||
COALESCE(SUM(amount_cny), 0) as total_amount,
|
||||
COUNT(*) as count
|
||||
FROM financial_records ${where}
|
||||
GROUP BY category_level1, category_level2
|
||||
ORDER BY category_level1, total_amount DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const recentResult = await db.query(
|
||||
`SELECT fr.*, p.name as project_name
|
||||
FROM financial_records fr
|
||||
LEFT JOIN projects p ON fr.project_id = p.id
|
||||
${where}
|
||||
ORDER BY fr.record_date DESC, fr.created_at DESC
|
||||
LIMIT 20`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totals: totalResult.rows[0],
|
||||
byCategory: byCategory.rows,
|
||||
recent: recentResult.rows
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取资金概览失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取资金概览失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/records', authenticate, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
txn_type, category_level1, category_level2,
|
||||
project_id, date_from, date_to,
|
||||
page = 1, pageSize = 20
|
||||
} = req.query;
|
||||
|
||||
let sql = `SELECT fr.*, p.name as project_name FROM financial_records fr LEFT JOIN projects p ON fr.project_id = p.id WHERE 1=1`;
|
||||
const params = [];
|
||||
let idx = 1;
|
||||
|
||||
if (txn_type) { params.push(txn_type); sql += ` AND fr.txn_type = $${idx++}`; }
|
||||
if (category_level1) { params.push(category_level1); sql += ` AND fr.category_level1 = $${idx++}`; }
|
||||
if (category_level2) { params.push(category_level2); sql += ` AND fr.category_level2 = $${idx++}`; }
|
||||
if (project_id) { params.push(project_id); sql += ` AND fr.project_id = $${idx++}`; }
|
||||
if (date_from) { params.push(date_from); sql += ` AND fr.record_date >= $${idx++}`; }
|
||||
if (date_to) { params.push(date_to); sql += ` AND fr.record_date <= $${idx++}`; }
|
||||
|
||||
const countResult = await db.query(`SELECT COUNT(*) as total FROM (${sql}) sub`, params);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
sql += ' ORDER BY fr.record_date DESC, fr.created_at DESC';
|
||||
const offset = (parseInt(page) - 1) * parseInt(pageSize);
|
||||
params.push(parseInt(pageSize));
|
||||
sql += ` LIMIT $${idx++}`;
|
||||
params.push(offset);
|
||||
sql += ` OFFSET $${idx++}`;
|
||||
|
||||
const result = await db.query(sql, params);
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
total,
|
||||
totalPages: Math.ceil(total / parseInt(pageSize))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('查询收支记录失败:', error);
|
||||
res.status(500).json({ success: false, message: '查询收支记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authenticate, async (req, res) => {
|
||||
const client = await db.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const {
|
||||
txn_type, category_level1, category_level2,
|
||||
project_id, amount, currency, exchange_rate,
|
||||
record_date, counterparty_name, counterparty_type, counterparty_id,
|
||||
description, voucher_url
|
||||
} = req.body;
|
||||
|
||||
const userId = req.user?.id || req.user?.userId;
|
||||
|
||||
if (!txn_type || !category_level1 || !category_level2 || !amount || !record_date) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ success: false, message: '缺少必填字段' });
|
||||
}
|
||||
|
||||
const amt = parseFloat(amount) || 0;
|
||||
const rate = parseFloat(exchange_rate) || 1;
|
||||
const amountCny = parseFloat((amt * rate).toFixed(2));
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const codeResult = await client.query(
|
||||
"SELECT COUNT(*) as cnt FROM financial_records WHERE record_code LIKE $1",
|
||||
[`FIN-${dateStr}%`]
|
||||
);
|
||||
const seq = String(parseInt(codeResult.rows[0].cnt) + 1).padStart(4, '0');
|
||||
const recordCode = `FIN-${dateStr}-${seq}`;
|
||||
|
||||
const frResult = await client.query(
|
||||
`INSERT INTO financial_records
|
||||
(record_code, txn_type, category_level1, category_level2, project_id, user_id, user_name,
|
||||
amount_original, currency, exchange_rate, amount_cny, record_date,
|
||||
counterparty_name, counterparty_type, counterparty_id, source, source_code, description, attachments, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'cash_management', $16, $17, $18, 'confirmed')
|
||||
RETURNING *`,
|
||||
[
|
||||
recordCode, txn_type, category_level1, category_level2,
|
||||
project_id || null, userId, req.user?.name || req.user?.username || '',
|
||||
amt, currency || 'CNY', rate, amountCny,
|
||||
record_date,
|
||||
counterparty_name || null, counterparty_type || null, counterparty_id || null,
|
||||
recordCode, description || null, voucher_url || null
|
||||
]
|
||||
);
|
||||
|
||||
if (txn_type === 'income' && project_id && ['contract_payment', 'customer_advance'].includes(category_level2)) {
|
||||
const msResult = await client.query(
|
||||
'SELECT id FROM project_milestones WHERE project_id = $1 ORDER BY id LIMIT 1',
|
||||
[project_id]
|
||||
);
|
||||
if (msResult.rows.length > 0) {
|
||||
const milestoneId = msResult.rows[0].id;
|
||||
const msData = await client.query('SELECT amount FROM project_milestones WHERE id = $1', [milestoneId]);
|
||||
if (msData.rows.length > 0 && parseFloat(msData.rows[0].amount) > 0) {
|
||||
const prResult = await client.query(
|
||||
`INSERT INTO project_receipts
|
||||
(project_id, receipt_type, milestone_id, amount, currency, exchange_rate, amount_cny,
|
||||
receipt_date, payer_name, description, voucher_url, financial_record_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
project_id,
|
||||
category_level2 === 'customer_advance' ? 'advance' : 'node',
|
||||
milestoneId, amt, currency || 'CNY', rate, amountCny,
|
||||
record_date, counterparty_name || '', description || '', voucher_url || '',
|
||||
frResult.rows[0].id, userId
|
||||
]
|
||||
);
|
||||
|
||||
const totalReceived = await client.query(
|
||||
`SELECT COALESCE(SUM(amount), 0) as total FROM project_receipts
|
||||
WHERE milestone_id = $1 AND receipt_type = 'node'`,
|
||||
[milestoneId]
|
||||
);
|
||||
const progress = Math.min(100, Math.round((parseFloat(totalReceived.rows[0].total) / parseFloat(msData.rows[0].amount)) * 100));
|
||||
await client.query(
|
||||
`UPDATE project_milestones SET completion_progress = $1::numeric,
|
||||
status = CASE WHEN $1::numeric >= 100 THEN 'completed' ELSE 'in_progress' END,
|
||||
actual_date = CASE WHEN $1::numeric >= 100 THEN $2 ELSE actual_date END
|
||||
WHERE id = $3`,
|
||||
[progress, record_date, milestoneId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
const fullResult = await db.query(
|
||||
`SELECT fr.*, p.name as project_name FROM financial_records fr LEFT JOIN projects p ON fr.project_id = p.id WHERE fr.id = $1`,
|
||||
[frResult.rows[0].id]
|
||||
);
|
||||
|
||||
res.json({ success: true, data: fullResult.rows[0] });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('新增收支记录失败:', error);
|
||||
res.status(500).json({ success: false, message: '新增收支记录失败' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, async (req, res) => {
|
||||
const client = await db.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { id } = req.params;
|
||||
|
||||
const recordResult = await client.query(
|
||||
`SELECT * FROM financial_records WHERE id = $1 AND source = 'cash_management'`,
|
||||
[id]
|
||||
);
|
||||
if (recordResult.rowCount === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ success: false, message: '记录不存在或无权删除' });
|
||||
}
|
||||
|
||||
const record = recordResult.rows[0];
|
||||
|
||||
if (record.txn_type === 'income' && record.project_id) {
|
||||
const prResult = await client.query(
|
||||
'SELECT id FROM project_receipts WHERE financial_record_id = $1',
|
||||
[id]
|
||||
);
|
||||
if (prResult.rows.length > 0) {
|
||||
const receipt = prResult.rows[0];
|
||||
await client.query('DELETE FROM project_receipts WHERE id = $1', [receipt.id]);
|
||||
|
||||
if (receipt.milestone_id) {
|
||||
const msData = await client.query('SELECT amount FROM project_milestones WHERE id = $1', [receipt.milestone_id]);
|
||||
if (msData.rows.length > 0 && parseFloat(msData.rows[0].amount) > 0) {
|
||||
const totalReceived = await client.query(
|
||||
`SELECT COALESCE(SUM(amount), 0) as total FROM project_receipts
|
||||
WHERE milestone_id = $1 AND receipt_type = 'node'`,
|
||||
[receipt.milestone_id]
|
||||
);
|
||||
const progress = Math.min(100, Math.round((parseFloat(totalReceived.rows[0].total) / parseFloat(msData.rows[0].amount)) * 100));
|
||||
await client.query(
|
||||
`UPDATE project_milestones SET completion_progress = $1::numeric,
|
||||
status = CASE WHEN $1::numeric >= 100 THEN 'completed' WHEN $1::numeric > 0 THEN 'in_progress' ELSE 'pending' END
|
||||
WHERE id = $2`,
|
||||
[progress, receipt.milestone_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('DELETE FROM financial_records WHERE id = $1', [id]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('删除收支记录失败:', error);
|
||||
res.status(500).json({ success: false, message: '删除收支记录失败' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,6 +4,7 @@ const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 获取施工项目列表
|
||||
router.get('/my-projects', async (req, res) => {
|
||||
try {
|
||||
const result = await db.query(`
|
||||
@@ -30,4 +31,298 @@ router.get('/my-projects', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 施工日志 ====================
|
||||
|
||||
// 获取项目施工日志
|
||||
router.get('/projects/:projectId/logs', async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const result = await db.query(
|
||||
'SELECT * FROM construction_logs WHERE project_id = $1 ORDER BY log_date DESC, created_at DESC',
|
||||
[projectId]
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error) {
|
||||
console.error('获取施工日志失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取施工日志失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 新增施工日志
|
||||
router.post('/projects/:projectId/logs', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { log_date, weather, work_content, next_plan, issues, photos } = req.body;
|
||||
const userId = req.user?.id || req.user?.userId;
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO construction_logs (project_id, log_date, weather, recorded_by, work_content, next_plan, issues, photos)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[projectId, log_date, weather || 'sunny', userId, work_content || '', next_plan || '', issues || '', photos || []]
|
||||
);
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('新增施工日志失败:', error);
|
||||
res.status(500).json({ success: false, message: '新增施工日志失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除施工日志
|
||||
router.delete('/logs/:logId', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { logId } = req.params;
|
||||
const result = await db.query('DELETE FROM construction_logs WHERE id = $1 RETURNING id', [logId]);
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '日志不存在' });
|
||||
}
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} catch (error) {
|
||||
console.error('删除施工日志失败:', error);
|
||||
res.status(500).json({ success: false, message: '删除施工日志失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 施工进度 ====================
|
||||
|
||||
// 获取项目施工进度(包含phases和项目信息)
|
||||
router.get('/projects/:projectId/progress', async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
|
||||
// 获取项目信息
|
||||
const projectResult = await db.query(
|
||||
`SELECT p.*, c.name as customer_name, u.name as manager_name
|
||||
FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN users u ON p.project_manager_id = u.id
|
||||
WHERE p.id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
if (projectResult.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '项目不存在' });
|
||||
}
|
||||
|
||||
// 获取阶段
|
||||
const phasesResult = await db.query(
|
||||
'SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order',
|
||||
[projectId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
project: projectResult.rows[0],
|
||||
phases: phasesResult.rows
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取施工进度失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取施工进度失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 完成阶段
|
||||
router.post('/projects/:projectId/phases/:phaseId/complete', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { projectId, phaseId } = req.params;
|
||||
const { remark, photos } = req.body;
|
||||
const userId = req.user?.id || req.user?.userId;
|
||||
|
||||
// 标记阶段完成
|
||||
await db.query(
|
||||
`UPDATE project_phases SET status = 'completed', completed_at = CURRENT_TIMESTAMP, completed_by = $1, remark = COALESCE(remark, '') || $2, photos = COALESCE(photos, '{}') || $3 WHERE id = $4 AND project_id = $5`,
|
||||
[userId, remark ? '\n' + remark : '', photos || [], phaseId, projectId]
|
||||
);
|
||||
|
||||
// 计算总体进度
|
||||
const progressResult = await db.query(
|
||||
'SELECT COUNT(*) as total, COUNT(CASE WHEN status = $1 THEN 1 END) as completed FROM project_phases WHERE project_id = $2',
|
||||
['completed', projectId]
|
||||
);
|
||||
const total = parseInt(progressResult.rows[0].total);
|
||||
const completed = parseInt(progressResult.rows[0].completed);
|
||||
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
// 找下一个待开始的阶段
|
||||
const nextPhaseResult = await db.query(
|
||||
`SELECT * FROM project_phases WHERE project_id = $1 AND status = 'pending' ORDER BY phase_order LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
if (nextPhaseResult.rows.length > 0) {
|
||||
await db.query(
|
||||
`UPDATE project_phases SET status = 'in_progress', started_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||
[nextPhaseResult.rows[0].id]
|
||||
);
|
||||
// 更新项目当前阶段
|
||||
await db.query(
|
||||
`UPDATE projects SET current_phase = $1, phase_progress = $2 WHERE id = $3`,
|
||||
[nextPhaseResult.rows[0].phase_name, progress, projectId]
|
||||
);
|
||||
} else {
|
||||
// 所有阶段完成
|
||||
await db.query(
|
||||
`UPDATE projects SET current_phase = '已完成', phase_progress = 100, status = 'completed' WHERE id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true, progress });
|
||||
} catch (error) {
|
||||
console.error('完成阶段失败:', error);
|
||||
res.status(500).json({ success: false, message: '完成阶段失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 重新打开阶段
|
||||
router.put('/phases/:phaseId/reopen', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { phaseId } = req.params;
|
||||
|
||||
await db.query(
|
||||
`UPDATE project_phases SET status = 'in_progress', completed_at = NULL, completed_by = NULL WHERE id = $1`,
|
||||
[phaseId]
|
||||
);
|
||||
|
||||
// 重新计算进度
|
||||
const phaseResult = await db.query('SELECT project_id FROM project_phases WHERE id = $1', [phaseId]);
|
||||
if (phaseResult.rows.length > 0) {
|
||||
const projectId = phaseResult.rows[0].project_id;
|
||||
const progressResult = await db.query(
|
||||
'SELECT COUNT(*) as total, COUNT(CASE WHEN status = $1 THEN 1 END) as completed FROM project_phases WHERE project_id = $2',
|
||||
['completed', projectId]
|
||||
);
|
||||
const total = parseInt(progressResult.rows[0].total);
|
||||
const completed = parseInt(progressResult.rows[0].completed);
|
||||
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
await db.query(
|
||||
`UPDATE projects SET phase_progress = $1, status = 'active' WHERE id = $2`,
|
||||
[progress, projectId]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('重新打开阶段失败:', error);
|
||||
res.status(500).json({ success: false, message: '重新打开阶段失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新子项状态(支持完成时间、照片、与资料管理共享)
|
||||
router.put('/phases/:phaseId/sub-item', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { phaseId } = req.params;
|
||||
const { itemIndex, completed, completed_at, photos } = req.body;
|
||||
const userId = req.user?.id || req.user?.userId;
|
||||
|
||||
const phaseResult = await db.query('SELECT sub_items, project_id FROM project_phases WHERE id = $1', [phaseId]);
|
||||
if (phaseResult.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '阶段不存在' });
|
||||
}
|
||||
|
||||
const subItems = phaseResult.rows[0].sub_items || [];
|
||||
const projectId = phaseResult.rows[0].project_id;
|
||||
|
||||
if (itemIndex >= 0 && itemIndex < subItems.length) {
|
||||
subItems[itemIndex].completed = completed;
|
||||
if (completed) {
|
||||
subItems[itemIndex].completed_at = completed_at || new Date().toISOString();
|
||||
if (photos && photos.length > 0) {
|
||||
subItems[itemIndex].photos = photos;
|
||||
// 同步写入 project_documents 实现与资料管理共享
|
||||
for (const url of photos) {
|
||||
const fileName = url.split('/').pop() || 'photo.jpg';
|
||||
await db.query(
|
||||
`INSERT INTO project_documents (project_id, doc_type, file_name, file_url, description, uploaded_by)
|
||||
VALUES ($1, 'image', $2, $3, $4, $5)`,
|
||||
[projectId, fileName, url, `阶段子项: ${subItems[itemIndex].name}`, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
subItems[itemIndex].completed_at = null;
|
||||
subItems[itemIndex].photos = [];
|
||||
}
|
||||
await db.query('UPDATE project_phases SET sub_items = $1 WHERE id = $2', [JSON.stringify(subItems), phaseId]);
|
||||
}
|
||||
|
||||
res.json({ success: true, subItems });
|
||||
} catch (error) {
|
||||
console.error('更新子项状态失败:', error);
|
||||
res.status(500).json({ success: false, message: '更新子项状态失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 资料管理 ====================
|
||||
|
||||
// 获取项目资料列表
|
||||
router.get('/projects/:projectId/documents', async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { doc_type } = req.query;
|
||||
|
||||
let query = 'SELECT d.*, u.name as uploader_name FROM project_documents d LEFT JOIN users u ON d.uploaded_by = u.id WHERE d.project_id = $1';
|
||||
const params = [projectId];
|
||||
|
||||
if (doc_type) {
|
||||
query += ' AND d.doc_type = $2';
|
||||
params.push(doc_type);
|
||||
}
|
||||
|
||||
query += ' ORDER BY d.created_at DESC';
|
||||
|
||||
const result = await db.query(query, params);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error) {
|
||||
console.error('获取项目资料失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取项目资料失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 上传项目资料
|
||||
router.post('/projects/:projectId/documents', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { doc_type, file_name, file_url, description } = req.body;
|
||||
const userId = req.user?.id || req.user?.userId;
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO project_documents (project_id, doc_type, file_name, file_url, description, uploaded_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[projectId, doc_type || 'document', file_name, file_url, description || '', userId]
|
||||
);
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('上传项目资料失败:', error);
|
||||
res.status(500).json({ success: false, message: '上传项目资料失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除项目资料
|
||||
router.delete('/documents/:docId', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { docId } = req.params;
|
||||
const userId = req.user?.id || req.user?.userId;
|
||||
const isAdmin = req.user?.role === 'admin';
|
||||
|
||||
// 管理员或上传者可删除
|
||||
const docResult = await db.query('SELECT * FROM project_documents WHERE id = $1', [docId]);
|
||||
if (docResult.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '资料不存在' });
|
||||
}
|
||||
|
||||
if (!isAdmin && docResult.rows[0].uploaded_by !== userId) {
|
||||
return res.status(403).json({ success: false, message: '无权删除' });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM project_documents WHERE id = $1', [docId]);
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} catch (error) {
|
||||
console.error('删除项目资料失败:', error);
|
||||
res.status(500).json({ success: false, message: '删除项目资料失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -85,7 +85,7 @@ router.post('/', async (req, res) => {
|
||||
|
||||
let status = action === 'execute' ? 'executed' : 'rejected';
|
||||
if (action === 'reject') {
|
||||
status = 'pending_edit';
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
const executeDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
@@ -236,8 +236,14 @@ router.post('/batch', authenticate, async (req, res) => {
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i];
|
||||
try {
|
||||
if (!r.txn_type || !r.category_level1 || !r.category_level2 || !r.amount_original || !r.record_date) {
|
||||
errors.push({ row: i + 1, message: '缺少必填字段' });
|
||||
const missingFields = [];
|
||||
if (!r.txn_type) missingFields.push('收支类型');
|
||||
if (!r.category_level1) missingFields.push('一级分类');
|
||||
if (!r.category_level2) missingFields.push('二级分类');
|
||||
if (!r.amount_original) missingFields.push('金额');
|
||||
if (!r.record_date) missingFields.push('日期');
|
||||
if (missingFields.length > 0) {
|
||||
errors.push({ row: i + 1, message: `缺少必填字段: ${missingFields.join(', ')}`, data: r });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -246,15 +252,34 @@ router.post('/batch', authenticate, async (req, res) => {
|
||||
|
||||
let projectId = r.project_id || null;
|
||||
if (!projectId && r.project_name) {
|
||||
// 先精确匹配
|
||||
const projResult = await db.query("SELECT id FROM projects WHERE name = $1", [r.project_name]);
|
||||
if (projResult.rows.length > 0) {
|
||||
projectId = projResult.rows[0].id;
|
||||
} else {
|
||||
// 模糊匹配:去除空格后比较
|
||||
const fuzzyResult = await db.query(
|
||||
"SELECT id, name FROM projects WHERE REPLACE(name, ' ', '') = REPLACE($1, ' ', '')",
|
||||
[r.project_name]
|
||||
);
|
||||
if (fuzzyResult.rows.length > 0) {
|
||||
projectId = fuzzyResult.rows[0].id;
|
||||
} else {
|
||||
// 包含匹配
|
||||
const likeResult = await db.query(
|
||||
"SELECT id, name FROM projects WHERE name LIKE '%' || $1 || '%' OR $1 LIKE '%' || name || '%'",
|
||||
[r.project_name]
|
||||
);
|
||||
if (likeResult.rows.length > 0) {
|
||||
projectId = likeResult.rows[0].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let userId = r.user_id || null;
|
||||
if (!userId && r.user_name) {
|
||||
const userResult = await db.query("SELECT id FROM users WHERE name = $1", [r.user_name]);
|
||||
const userResult = await db.query("SELECT id FROM users WHERE name = $1 OR username = $1", [r.user_name]);
|
||||
if (userResult.rows.length > 0) {
|
||||
userId = userResult.rows[0].id;
|
||||
}
|
||||
@@ -280,7 +305,7 @@ router.post('/batch', authenticate, async (req, res) => {
|
||||
);
|
||||
results.push(result.rows[0]);
|
||||
} catch (err) {
|
||||
errors.push({ row: i + 1, message: err.message });
|
||||
errors.push({ row: i + 1, message: err.message, data: { txn_type: r.txn_type, category_level1: r.category_level1, category_level2: r.category_level2, project_name: r.project_name, amount: r.amount_original } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,32 @@ router.get('/summary', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/in', async (req, res) => {
|
||||
try {
|
||||
const { project_id, product_id, quantity, unit_price, total_amount, operator, remark } = req.body;
|
||||
|
||||
const result = await db.query(`
|
||||
INSERT INTO inventory_records
|
||||
(record_type, project_id, product_id, quantity, unit_price, total_amount, record_date, operator, remark)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_DATE, $7, $8)
|
||||
RETURNING id
|
||||
`, ['in', project_id || null, product_id, quantity, unit_price || null, total_amount || null, operator || '系统', remark || null]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '入库成功',
|
||||
data: { id: result.rows[0]?.id }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('入库失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '入库失败',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/out', async (req, res) => {
|
||||
try {
|
||||
const { project_id, product_id, quantity, unit_price, total_amount, operator, remark } = req.body;
|
||||
|
||||
@@ -61,7 +61,7 @@ router.post('/', async (req, res) => {
|
||||
payee, bank_account, bank_name, amount, currency, reason, payment_date,
|
||||
request_code, status, applicant, detail_items, attachments,
|
||||
payee_type, payee_id, expense_type, expense_category, project_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17`,
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
[
|
||||
payee, finalBankAccount, finalBankName, finalAmount, currency || 'CNY',
|
||||
reason, payment_date, requestCode, 'pending', applicant,
|
||||
@@ -157,7 +157,7 @@ router.put('/:id', async (req, res) => {
|
||||
params
|
||||
);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '更新成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '付款申请不存在' });
|
||||
@@ -174,7 +174,7 @@ router.delete('/:id', async (req, res) => {
|
||||
|
||||
const result = await db.query('DELETE FROM payment_requests WHERE id = $1', [id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '付款申请不存在' });
|
||||
@@ -191,7 +191,7 @@ router.post('/:id/submit', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '提交成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '付款申请不存在' });
|
||||
@@ -208,7 +208,7 @@ router.post('/:id/withdraw', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['withdrawn', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '撤回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '付款申请不存在' });
|
||||
@@ -226,7 +226,7 @@ router.post('/:id/approve', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE payment_requests SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '审批通过成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '付款申请不存在' });
|
||||
@@ -242,9 +242,9 @@ router.post('/:id/reject', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { rejectReason } = req.body;
|
||||
|
||||
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['pending_edit', id]);
|
||||
const result = await db.query('UPDATE payment_requests SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '退回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '付款申请不存在' });
|
||||
|
||||
@@ -106,7 +106,7 @@ router.post('/:id/copy', async (req, res) => {
|
||||
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]
|
||||
[name || `${src.name} (副本)`, src.description, JSON.stringify(src.phases)]
|
||||
);
|
||||
res.json({ success: true, message: '模板复制成功', data: { id: result.rows[0].id } });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 获取项目收款记录
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.query;
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ success: false, message: '缺少项目ID' });
|
||||
}
|
||||
const result = await db.query(
|
||||
`SELECT r.*, m.milestone_name, m.percentage as milestone_percentage,
|
||||
u.name as creator_name
|
||||
FROM project_receipts r
|
||||
LEFT JOIN project_milestones m ON r.milestone_id = m.id
|
||||
LEFT JOIN users u ON r.created_by = u.id
|
||||
WHERE r.project_id = $1
|
||||
ORDER BY r.receipt_date DESC, r.created_at DESC`,
|
||||
[projectId]
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error) {
|
||||
console.error('获取收款记录失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取收款记录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取项目付款节点列表(用于收款时选择)
|
||||
router.get('/milestones', async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.query;
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ success: false, message: '缺少项目ID' });
|
||||
}
|
||||
const result = await db.query(
|
||||
`SELECT id, milestone_name, percentage, amount, status, completion_progress
|
||||
FROM project_milestones WHERE project_id = $1 ORDER BY id`,
|
||||
[projectId]
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error) {
|
||||
console.error('获取付款节点失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取付款节点失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 新增收款记录(同时写入 financial_records 统一记账)
|
||||
router.post('/', authenticate, async (req, res) => {
|
||||
const client = await db.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { project_id, receipt_type, milestone_id, amount, currency, exchange_rate, receipt_date, payer_name, description, voucher_url, counterparty_id } = req.body;
|
||||
const userId = req.user?.id || req.user?.userId;
|
||||
|
||||
if (!project_id) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ success: false, message: '缺少项目ID' });
|
||||
}
|
||||
|
||||
const amt = parseFloat(amount) || 0;
|
||||
const rate = parseFloat(exchange_rate) || 1;
|
||||
const amountCny = parseFloat((amt * rate).toFixed(2));
|
||||
|
||||
// 1. 写入 financial_records(统一记账)
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const codeResult = await client.query(
|
||||
"SELECT COUNT(*) as cnt FROM financial_records WHERE record_code LIKE $1",
|
||||
[`FIN-${dateStr}%`]
|
||||
);
|
||||
const seq = String(parseInt(codeResult.rows[0].cnt) + 1).padStart(4, '0');
|
||||
const recordCode = `FIN-${dateStr}-${seq}`;
|
||||
|
||||
// 确定分类
|
||||
let categoryLevel2 = 'contract_payment';
|
||||
if (receipt_type === 'advance') {
|
||||
categoryLevel2 = 'customer_advance';
|
||||
} else if (receipt_type === 'other') {
|
||||
categoryLevel2 = 'other_income';
|
||||
}
|
||||
|
||||
const frResult = await client.query(
|
||||
`INSERT INTO financial_records
|
||||
(record_code, txn_type, category_level1, category_level2, project_id, user_id, user_name,
|
||||
amount_original, currency, exchange_rate, amount_cny, record_date,
|
||||
counterparty_name, counterparty_type, counterparty_id, source, source_code, description, status)
|
||||
VALUES ($1, 'income', $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'customer', $13, 'receipt', $14, $15, 'confirmed')
|
||||
RETURNING id`,
|
||||
[
|
||||
recordCode, 'income', categoryLevel2, project_id,
|
||||
userId, req.user?.name || req.user?.username || '',
|
||||
amt, currency || 'CNY', rate, amountCny,
|
||||
receipt_date, payer_name || '',
|
||||
counterparty_id || null,
|
||||
recordCode, description || ''
|
||||
]
|
||||
);
|
||||
const financialRecordId = frResult.rows[0].id;
|
||||
|
||||
// 2. 写入 project_receipts
|
||||
const receiptResult = await client.query(
|
||||
`INSERT INTO project_receipts
|
||||
(project_id, receipt_type, milestone_id, amount, currency, exchange_rate, amount_cny,
|
||||
receipt_date, payer_name, description, voucher_url, financial_record_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[project_id, receipt_type || 'node', milestone_id || null, amt, currency || 'CNY', rate, amountCny,
|
||||
receipt_date, payer_name || '', description || '', voucher_url || '', financialRecordId, userId]
|
||||
);
|
||||
|
||||
// 3. 如果是节点收款,更新里程碑完成进度
|
||||
if (receipt_type === 'node' && milestone_id) {
|
||||
const msResult = await client.query('SELECT amount FROM project_milestones WHERE id = $1', [milestone_id]);
|
||||
if (msResult.rows.length > 0 && parseFloat(msResult.rows[0].amount) > 0) {
|
||||
const totalReceived = await client.query(
|
||||
`SELECT COALESCE(SUM(amount), 0) as total FROM project_receipts
|
||||
WHERE milestone_id = $1 AND receipt_type = 'node'`,
|
||||
[milestone_id]
|
||||
);
|
||||
const progress = Math.min(100, Math.round((parseFloat(totalReceived.rows[0].total) / parseFloat(msResult.rows[0].amount)) * 100));
|
||||
await client.query(
|
||||
`UPDATE project_milestones SET completion_progress = $1::numeric, status = CASE WHEN $1::numeric >= 100 THEN 'completed' ELSE 'in_progress' END, actual_date = CASE WHEN $1::numeric >= 100 THEN $2 ELSE actual_date END WHERE id = $3`,
|
||||
[progress, receipt_date, milestone_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// 返回带里程碑名称的完整记录
|
||||
const fullResult = await db.query(
|
||||
`SELECT r.*, m.milestone_name FROM project_receipts r LEFT JOIN project_milestones m ON r.milestone_id = m.id WHERE r.id = $1`,
|
||||
[receiptResult.rows[0].id]
|
||||
);
|
||||
|
||||
res.json({ success: true, data: fullResult.rows[0] });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('新增收款记录失败:', error);
|
||||
res.status(500).json({ success: false, message: '新增收款记录失败' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 删除收款记录(同时删除 financial_records)
|
||||
router.delete('/:receiptId', authenticate, async (req, res) => {
|
||||
const client = await db.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { receiptId } = req.params;
|
||||
|
||||
const receiptResult = await client.query(
|
||||
'SELECT * FROM project_receipts WHERE id = $1',
|
||||
[receiptId]
|
||||
);
|
||||
if (receiptResult.rowCount === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ success: false, message: '收款记录不存在' });
|
||||
}
|
||||
|
||||
const receipt = receiptResult.rows[0];
|
||||
const financialRecordId = receipt.financial_record_id;
|
||||
|
||||
// 先删除 project_receipts(引用方),再删除 financial_records(被引用方)
|
||||
await client.query('DELETE FROM project_receipts WHERE id = $1', [receiptId]);
|
||||
|
||||
if (financialRecordId) {
|
||||
await client.query('DELETE FROM financial_records WHERE id = $1', [financialRecordId]);
|
||||
}
|
||||
|
||||
// 如果是节点收款,重新计算里程碑进度
|
||||
if (receipt.receipt_type === 'node' && receipt.milestone_id) {
|
||||
const msResult = await client.query('SELECT amount FROM project_milestones WHERE id = $1', [receipt.milestone_id]);
|
||||
if (msResult.rows.length > 0 && parseFloat(msResult.rows[0].amount) > 0) {
|
||||
const totalReceived = await client.query(
|
||||
`SELECT COALESCE(SUM(amount), 0) as total FROM project_receipts
|
||||
WHERE milestone_id = $1 AND receipt_type = 'node'`,
|
||||
[receipt.milestone_id]
|
||||
);
|
||||
const progress = Math.min(100, Math.round((parseFloat(totalReceived.rows[0].total) / parseFloat(msResult.rows[0].amount)) * 100));
|
||||
await client.query(
|
||||
`UPDATE project_milestones SET completion_progress = $1::numeric, status = CASE WHEN $1::numeric >= 100 THEN 'completed' WHEN $1::numeric > 0 THEN 'in_progress' ELSE 'pending' END WHERE id = $2`,
|
||||
[progress, receipt.milestone_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('删除收款记录失败:', error);
|
||||
res.status(500).json({ success: false, message: '删除收款记录失败' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+105
-31
@@ -19,10 +19,17 @@ router.get('/', async (req, res) => {
|
||||
p.status,
|
||||
p.location,
|
||||
c.name as customer_name,
|
||||
u.name as manager_name
|
||||
u.name as manager_name,
|
||||
COALESCE(expense_summary.total_expense, 0) as total_expense
|
||||
FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN users u ON p.project_manager_id = u.id
|
||||
LEFT JOIN (
|
||||
SELECT project_id, SUM(amount_cny::numeric) as total_expense
|
||||
FROM financial_records
|
||||
WHERE txn_type = 'expense' AND status = 'confirmed'
|
||||
GROUP BY project_id
|
||||
) expense_summary ON p.id = expense_summary.project_id
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT 50
|
||||
`);
|
||||
@@ -472,6 +479,14 @@ router.put('/:id', async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
// 合同金额变更时,自动按比例更新付款节点金额
|
||||
if (contract_amount !== undefined && contract_amount !== null) {
|
||||
await db.query(
|
||||
`UPDATE project_milestones SET amount = ROUND($1 * percentage / 100, 2) WHERE project_id = $2`,
|
||||
[contract_amount, id]
|
||||
);
|
||||
}
|
||||
|
||||
if (start_date && end_date) {
|
||||
const start = new Date(start_date);
|
||||
const end = new Date(end_date);
|
||||
@@ -553,6 +568,12 @@ router.put('/:id/contract', async (req, res) => {
|
||||
[id, node.name, node.condition || '', node.percentage, node.amount, 'pending']
|
||||
);
|
||||
}
|
||||
} else if (contract_total) {
|
||||
// 没有传付款节点但合同金额变了,按比例更新现有里程碑金额
|
||||
await db.query(
|
||||
`UPDATE project_milestones SET amount = ROUND($1 * percentage / 100, 2) WHERE project_id = $2`,
|
||||
[contract_total, id]
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 处理单价项
|
||||
@@ -581,55 +602,108 @@ router.put('/:id/contract', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id/financial-details', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { txn_type, category_level1, category_level2, date_from, date_to, page = 1, pageSize = 20 } = req.query;
|
||||
|
||||
let sql = `SELECT fr.*, p.name as project_name FROM financial_records fr LEFT JOIN projects p ON fr.project_id = p.id WHERE fr.project_id = $1 AND fr.status != 'voided'`;
|
||||
const params = [id];
|
||||
let idx = 2;
|
||||
|
||||
if (txn_type) { params.push(txn_type); sql += ` AND fr.txn_type = $${idx++}`; }
|
||||
if (category_level1) { params.push(category_level1); sql += ` AND fr.category_level1 = $${idx++}`; }
|
||||
if (category_level2) { params.push(category_level2); sql += ` AND fr.category_level2 = $${idx++}`; }
|
||||
if (date_from) { params.push(date_from); sql += ` AND fr.record_date >= $${idx++}`; }
|
||||
if (date_to) { params.push(date_to); sql += ` AND fr.record_date <= $${idx++}`; }
|
||||
|
||||
const countResult = await db.query(`SELECT COUNT(*) as total FROM (${sql}) sub`, params);
|
||||
const total = parseInt(countResult.rows[0].total);
|
||||
|
||||
sql += ' ORDER BY fr.record_date DESC, fr.created_at DESC';
|
||||
const offset = (parseInt(page) - 1) * parseInt(pageSize);
|
||||
params.push(parseInt(pageSize));
|
||||
sql += ` LIMIT $${idx++}`;
|
||||
params.push(offset);
|
||||
sql += ` OFFSET $${idx++}`;
|
||||
|
||||
const result = await db.query(sql, params);
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
pagination: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取项目财务明细失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取项目财务明细失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id/cost-summary', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const purchaseResult = await db.query(`
|
||||
SELECT
|
||||
expense_category,
|
||||
SUM(total_amount) as total_amount
|
||||
FROM purchase_requests
|
||||
WHERE project_id = $1 AND status IN ('approved', 'executed')
|
||||
GROUP BY expense_category
|
||||
`, [id]);
|
||||
|
||||
const paymentResult = await db.query(`
|
||||
SELECT
|
||||
SUM(amount) as total_payment
|
||||
FROM payment_requests
|
||||
WHERE project_id = $1 AND status = 'approved' AND payment_type = 'company'
|
||||
`, [id]);
|
||||
|
||||
const projectResult = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
|
||||
|
||||
if (projectResult.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: '项目不存在' });
|
||||
}
|
||||
|
||||
const project = projectResult.rows[0];
|
||||
const purchaseByCategory = {};
|
||||
let totalPurchase = 0;
|
||||
|
||||
purchaseResult.rows.forEach(row => {
|
||||
purchaseByCategory[row.expense_category] = row.total_amount;
|
||||
totalPurchase += row.total_amount;
|
||||
const incomeResult = await db.query(`
|
||||
SELECT category_level2, COALESCE(SUM(amount_cny), 0) as total_amount
|
||||
FROM financial_records
|
||||
WHERE project_id = $1 AND txn_type = 'income' AND status != 'voided'
|
||||
GROUP BY category_level2
|
||||
`, [id]);
|
||||
|
||||
const expenseResult = await db.query(`
|
||||
SELECT category_level1, category_level2, COALESCE(SUM(amount_cny), 0) as total_amount
|
||||
FROM financial_records
|
||||
WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided'
|
||||
GROUP BY category_level1, category_level2
|
||||
`, [id]);
|
||||
|
||||
const totalIncomeRow = await db.query(`
|
||||
SELECT COALESCE(SUM(amount_cny), 0) as total FROM financial_records
|
||||
WHERE project_id = $1 AND txn_type = 'income' AND status != 'voided'
|
||||
`, [id]);
|
||||
|
||||
const totalExpenseRow = await db.query(`
|
||||
SELECT COALESCE(SUM(amount_cny), 0) as total FROM financial_records
|
||||
WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided'
|
||||
`, [id]);
|
||||
|
||||
const incomeByCategory = {};
|
||||
let totalIncome = parseFloat(totalIncomeRow.rows[0].total);
|
||||
incomeResult.rows.forEach(row => {
|
||||
incomeByCategory[row.category_level2] = parseFloat(row.total_amount);
|
||||
});
|
||||
|
||||
const totalPayment = paymentResult.rows[0]?.total_payment || 0;
|
||||
const expenseByCategory = {};
|
||||
const expenseByLevel1 = {};
|
||||
let totalExpense = parseFloat(totalExpenseRow.rows[0].total);
|
||||
expenseResult.rows.forEach(row => {
|
||||
expenseByCategory[row.category_level2] = parseFloat(row.total_amount);
|
||||
if (!expenseByLevel1[row.category_level1]) expenseByLevel1[row.category_level1] = 0;
|
||||
expenseByLevel1[row.category_level1] += parseFloat(row.total_amount);
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
project_name: project.name,
|
||||
contract_amount: project.contract_amount || 0,
|
||||
purchase_cost: {
|
||||
total: totalPurchase,
|
||||
by_category: purchaseByCategory
|
||||
income: {
|
||||
total: totalIncome,
|
||||
by_category: incomeByCategory
|
||||
},
|
||||
payment_cost: totalPayment,
|
||||
total_cost: totalPurchase + totalPayment,
|
||||
profit: (project.contract_amount || 0) - (totalPurchase + totalPayment)
|
||||
expense: {
|
||||
total: totalExpense,
|
||||
by_category: expenseByCategory,
|
||||
by_level1: expenseByLevel1
|
||||
},
|
||||
total_cost: totalExpense,
|
||||
profit: totalIncome - totalExpense
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -203,7 +203,7 @@ router.put('/:id', async (req, res) => {
|
||||
WHERE id = ?
|
||||
`, [supplier_id, supplier_country, contract_url, quotation_url, remark, id]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '采购订单不存在' });
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ router.post('/:id/cancel', async (req, res) => {
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '采购订单不存在' });
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ router.put('/:id/items/:itemId', async (req, res) => {
|
||||
WHERE id = ? AND purchase_order_id = ?
|
||||
`, [product_id, product_name, specification, unit, quantity, unit_price, total_price, itemId, id]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '商品明细不存在' });
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ router.delete('/:id/items/:itemId', async (req, res) => {
|
||||
[itemId, id]
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '商品明细不存在' });
|
||||
}
|
||||
|
||||
@@ -506,7 +506,7 @@ router.put('/:id/payment-plans/:planId', async (req, res) => {
|
||||
WHERE id = ? AND purchase_order_id = ?
|
||||
`, [stage, planned_date, planned_amount, planned_percentage, remark, planId, id]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '付款计划不存在' });
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ router.delete('/:id/payment-plans/:planId', async (req, res) => {
|
||||
[planId, id]
|
||||
);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '付款计划不存在' });
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ router.post('/', async (req, res) => {
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[requestCode, project_id, purchase_type || 'inventory', total_amount || 0, currency || 'CNY',
|
||||
'pending_edit', remark]);
|
||||
'pending', remark]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -219,7 +219,7 @@ router.post('/:id/submit', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await db.query(
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['pending', id]
|
||||
);
|
||||
|
||||
@@ -256,7 +256,7 @@ router.post('/:id/approve', async (req, res) => {
|
||||
|
||||
try {
|
||||
await db.query(
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['approved', id]
|
||||
);
|
||||
|
||||
@@ -297,8 +297,8 @@ router.post('/:id/reject', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await db.query(
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
|
||||
['pending_edit', id]
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['pending', id]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
@@ -321,7 +321,7 @@ router.post('/:id/withdraw', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await db.query(
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['withdrawn', id]
|
||||
);
|
||||
|
||||
@@ -345,7 +345,7 @@ router.post('/:id/execute', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
await db.query(
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = datetime(\'now\') WHERE id = $2',
|
||||
'UPDATE purchase_requests SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['executed', id]
|
||||
);
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ router.put('/:id', async (req, res) => {
|
||||
[amount, reason, project_id, currency, reimbursement_date, JSON.stringify(attachments || []), amount_cny, applicant, expense_type, JSON.stringify(detail_items || []), status, id]
|
||||
);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '更新成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '报销申请不存在' });
|
||||
@@ -154,7 +154,7 @@ router.delete('/:id', async (req, res) => {
|
||||
|
||||
const result = await db.query('DELETE FROM reimbursements WHERE id = $1', [id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '报销申请不存在' });
|
||||
@@ -171,7 +171,7 @@ router.post('/:id/submit', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '提交成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '报销申请不存在' });
|
||||
@@ -188,7 +188,7 @@ router.post('/:id/withdraw', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['withdrawn', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '撤回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '报销申请不存在' });
|
||||
@@ -206,7 +206,7 @@ router.post('/:id/approve', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE reimbursements SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '审批通过成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '报销申请不存在' });
|
||||
@@ -222,9 +222,9 @@ router.post('/:id/reject', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { rejectReason } = req.body;
|
||||
|
||||
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['pending_edit', id]);
|
||||
const result = await db.query('UPDATE reimbursements SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '退回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '报销申请不存在' });
|
||||
|
||||
@@ -248,7 +248,7 @@ router.post('/:id/reject', async (req, res) => {
|
||||
WHERE id = ?
|
||||
`, [reason || '无', id]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '退库单不存在' });
|
||||
}
|
||||
|
||||
|
||||
+64
-6
@@ -4,6 +4,34 @@ const db = require('../db');
|
||||
const { hashPassword, verifyPassword } = require('../utils/auth');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
const result = await db.query('SELECT id, username, name, email, phone, role, avatar, passport, driver_license, is_active, created_at, updated_at FROM users WHERE id = $1', [req.user.id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: '用户不存在' });
|
||||
}
|
||||
const u = result.rows[0];
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
phone: u.phone,
|
||||
role: u.role,
|
||||
avatar: u.avatar,
|
||||
passport: u.passport,
|
||||
driverLicense: u.driver_license,
|
||||
is_active: u.is_active
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
res.status(500).json({ success: false, message: '获取用户信息失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const usersResult = await db.query('SELECT id, username, name, email, phone, role, is_active, created_at, updated_at FROM users ORDER BY id');
|
||||
@@ -45,25 +73,55 @@ router.post('/', authenticate, requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/profile', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = parseInt(id);
|
||||
|
||||
if (req.user.id !== userId && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, message: '只能修改自己的个人信息' });
|
||||
}
|
||||
|
||||
const { name, email, phone, avatar, passport, driver_license } = req.body;
|
||||
|
||||
await db.query(
|
||||
'UPDATE users SET name = $1, email = $2, phone = $3, avatar = $4, passport = $5, driver_license = $6, updated_at = NOW() WHERE id = $7',
|
||||
[name, email || null, phone || null, avatar || null, passport || null, driver_license || null, userId]
|
||||
);
|
||||
|
||||
const updatedUserResult = await db.query('SELECT id, username, name, email, phone, role, avatar, passport, driver_license, is_active, created_at, updated_at FROM users WHERE id = $1', [userId]);
|
||||
const updatedUser = updatedUserResult.rows[0];
|
||||
|
||||
if (!updatedUser) {
|
||||
return res.status(404).json({ success: false, message: '用户不存在' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: updatedUser });
|
||||
} catch (error) {
|
||||
console.error('更新个人信息失败:', error);
|
||||
res.status(500).json({ success: false, message: '更新个人信息失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', authenticate, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, email, phone, role, password } = req.body;
|
||||
const { name, email, phone, role, password, avatar, passport, driver_license } = req.body;
|
||||
|
||||
if (password) {
|
||||
const passwordHash = hashPassword(password);
|
||||
await db.query(
|
||||
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, password_hash = $5, updated_at = NOW() WHERE id = $6',
|
||||
[name, email || null, phone || null, role, passwordHash, id]
|
||||
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, avatar = $5, passport = $6, driver_license = $7, password_hash = $8, updated_at = NOW() WHERE id = $9',
|
||||
[name, email || null, phone || null, role, avatar || null, passport || null, driver_license || null, passwordHash, id]
|
||||
);
|
||||
} else {
|
||||
await db.query(
|
||||
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, updated_at = NOW() WHERE id = $5',
|
||||
[name, email || null, phone || null, role, id]
|
||||
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, avatar = $5, passport = $6, driver_license = $7, updated_at = NOW() WHERE id = $8',
|
||||
[name, email || null, phone || null, role, avatar || null, passport || null, driver_license || null, id]
|
||||
);
|
||||
}
|
||||
|
||||
const updatedUserResult = await db.query('SELECT id, username, name, email, phone, role, is_active, created_at, updated_at FROM users WHERE id = $1', [id]);
|
||||
const updatedUserResult = await db.query('SELECT id, username, name, email, phone, role, avatar, passport, driver_license, is_active, created_at, updated_at FROM users WHERE id = $1', [id]);
|
||||
const updatedUser = updatedUserResult.rows[0];
|
||||
|
||||
if (!updatedUser) {
|
||||
|
||||
@@ -284,7 +284,7 @@ router.post('/:id/reject', async (req, res) => {
|
||||
WHERE id = ?
|
||||
`, [reason || '无', id]);
|
||||
|
||||
if (result.changes === 0) {
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: '验收单不存在' });
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ router.put('/:id', async (req, res) => {
|
||||
// 提交事务
|
||||
await db.query('COMMIT');
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '更新成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '核销申请不存在' });
|
||||
@@ -249,7 +249,7 @@ router.delete('/:id', async (req, res) => {
|
||||
// 提交事务
|
||||
await db.query('COMMIT');
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '删除成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '核销申请不存在' });
|
||||
@@ -271,7 +271,7 @@ router.post('/:id/submit', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '提交成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '核销申请不存在' });
|
||||
@@ -288,7 +288,7 @@ router.post('/:id/withdraw', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['withdrawn', id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '撤回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '核销申请不存在' });
|
||||
@@ -306,7 +306,7 @@ router.post('/:id/approve', async (req, res) => {
|
||||
|
||||
const result = await db.query('UPDATE verifications SET status = $1, approval_remark = $2 WHERE id = $3', ['approved', remark, id]);
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '审批通过成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '核销申请不存在' });
|
||||
@@ -332,7 +332,7 @@ router.post('/:id/reject', async (req, res) => {
|
||||
const advanceId = verification.rows[0]?.advance_id;
|
||||
|
||||
// 退回核销申请
|
||||
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['pending_edit', id]);
|
||||
const result = await db.query('UPDATE verifications SET status = $1 WHERE id = $2', ['pending', id]);
|
||||
|
||||
// 不在这里恢复预支单已核销金额,因为只有执行的核销才会增加已核销金额
|
||||
// if (advanceId && amount > 0) {
|
||||
@@ -345,7 +345,7 @@ router.post('/:id/reject', async (req, res) => {
|
||||
// 提交事务
|
||||
await db.query('COMMIT');
|
||||
|
||||
if (result.changes > 0) {
|
||||
if (result.rowCount > 0) {
|
||||
res.json({ success: true, message: '退回成功' });
|
||||
} else {
|
||||
res.status(404).json({ success: false, message: '核销申请不存在' });
|
||||
|
||||
@@ -73,6 +73,7 @@ import { useLanguageStore } from './store/languageStore'
|
||||
// 路由守卫组件
|
||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -83,7 +84,7 @@ const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
height: '100vh',
|
||||
fontSize: '24px'
|
||||
}}>
|
||||
加载中...
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -143,6 +144,7 @@ function App() {
|
||||
<Route path="budget-projects/create" element={<BudgetProjectCreate />} />
|
||||
<Route path="budget-projects/:id" element={<BudgetProjectDetail />} />
|
||||
<Route path="construction" element={<ConstructionOverview />} />
|
||||
<Route path="construction/:id" element={<ConstructionProgress />} />
|
||||
<Route path="construction/:id/logs" element={<ConstructionLog />} />
|
||||
<Route path="construction/:id/milestones" element={<ConstructionMilestones />} />
|
||||
<Route path="construction/progress/:id" element={<ConstructionProgress />} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Table, Card, Row, Col, Statistic, Empty, Tag } from 'antd'
|
||||
import { DollarOutlined } from '@ant-design/icons'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface LedgerItem {
|
||||
id: number
|
||||
@@ -56,21 +57,23 @@ const formatAmount = (amount: number | undefined, currency?: string) => {
|
||||
|
||||
const getStatusTag = (status: string | undefined) => {
|
||||
if (!status) return '-'
|
||||
const t = useLanguageStore.getState().t
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
in_progress: { color: 'processing', text: '进行中' },
|
||||
planning: { color: 'default', text: '规划中' },
|
||||
pending: { color: 'default', text: '待处理' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
paid: { color: 'green', text: '已支付' },
|
||||
requested: { color: 'blue', text: '已申请' },
|
||||
active: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: t('businessLedger.completed') },
|
||||
in_progress: { color: 'processing', text: t('businessLedger.inProgress') },
|
||||
planning: { color: 'default', text: t('businessLedger.planning') },
|
||||
pending: { color: 'default', text: t('businessLedger.pending') },
|
||||
approved: { color: 'success', text: t('businessLedger.approved') },
|
||||
paid: { color: 'green', text: t('businessLedger.paid') },
|
||||
requested: { color: 'blue', text: t('businessLedger.applied') },
|
||||
active: { color: 'processing', text: t('businessLedger.inProgress') },
|
||||
}
|
||||
const info = map[status] || { color: 'default', text: status }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
}
|
||||
|
||||
const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summary, items, loading }) => {
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const renderSummaryCards = () => {
|
||||
switch (partnerType) {
|
||||
case 'subcontractor':
|
||||
@@ -78,22 +81,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
|
||||
<Statistic title="合同总金额" value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.contractTotal')} value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Statistic title="已付总额" value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalPaid')} value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
|
||||
<Statistic title="未付总额" value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalUnpaid')} value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
|
||||
<Statistic title="项目数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.projectCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -103,22 +106,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
|
||||
<Statistic title="合同总金额" value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.contractTotal')} value={summary.total_contract_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Statistic title="已收总额" value={summary.total_received_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalReceived')} value={summary.total_received_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
|
||||
<Statistic title="应收总额" value={summary.total_receivable_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalReceivable')} value={summary.total_receivable_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
|
||||
<Statistic title="项目数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.projectCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -128,22 +131,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
|
||||
<Statistic title="采购总额" value={summary.total_order_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.purchaseTotal')} value={summary.total_order_amount || 0} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Statistic title="已付总额" value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalPaid')} value={summary.total_paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
|
||||
<Statistic title="未付总额" value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalUnpaid')} value={summary.total_unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
|
||||
<Statistic title="订单数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.orderCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -153,22 +156,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}>
|
||||
<Statistic title="一次运费总额" value={summary.total_primary_freight || 0} suffix=" CNY" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.freight1Total')} value={summary.total_primary_freight || 0} suffix=" CNY" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||
<Statistic title="已付总额" value={summary.paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalPaid')} value={summary.paid_amount || 0} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}>
|
||||
<Statistic title="未付总额" value={summary.unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.totalUnpaid')} value={summary.unpaid_amount || 0} prefix="¥" valueStyle={{ color: '#ff4d4f', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} lg={6}>
|
||||
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}>
|
||||
<Statistic title="物流单数量" value={summary.item_count || 0} suffix="个" valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
<Statistic title={t('businessLedger.logisticsCount')} value={summary.item_count || 0} suffix={t('common.unit')} valueStyle={{ color: '#faad14', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -178,36 +181,36 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
|
||||
|
||||
const getColumns = () => {
|
||||
const baseColumns: any[] = [
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span> },
|
||||
{ title: t('businessLedger.code'), dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: t('businessLedger.name'), dataIndex: 'name', key: 'name', render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span> },
|
||||
]
|
||||
|
||||
switch (partnerType) {
|
||||
case 'subcontractor':
|
||||
return [
|
||||
...baseColumns,
|
||||
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
{ title: t('businessLedger.contractAmount'), dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
|
||||
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
]
|
||||
case 'customer':
|
||||
return [
|
||||
...baseColumns,
|
||||
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
{ title: t('businessLedger.contractAmount'), dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
|
||||
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
]
|
||||
case 'supplier':
|
||||
return [
|
||||
...baseColumns,
|
||||
{ title: '采购金额', dataIndex: 'order_amount', key: 'order_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
{ title: t('businessLedger.purchaseAmount'), dataIndex: 'order_amount', key: 'order_amount', align: 'right' as const, render: (v: number) => formatAmount(v) },
|
||||
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
]
|
||||
case 'logistics':
|
||||
return [
|
||||
...baseColumns,
|
||||
{ title: '项目', dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' },
|
||||
{ title: '一次运费', dataIndex: 'primary_freight', key: 'primary_freight', align: 'right' as const, render: (v: number, r: LedgerItem) => formatAmount(v, r.primary_freight_currency) },
|
||||
{ title: '一次运费状态', dataIndex: 'primary_freight_status', key: 'primary_freight_status', align: 'center' as const, width: 100, render: getStatusTag },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
{ title: t('businessLedger.project'), dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' },
|
||||
{ title: t('businessLedger.freight1'), dataIndex: 'primary_freight', key: 'primary_freight', align: 'right' as const, render: (v: number, r: LedgerItem) => formatAmount(v, r.primary_freight_currency) },
|
||||
{ title: t('businessLedger.freight1Status'), dataIndex: 'primary_freight_status', key: 'primary_freight_status', align: 'center' as const, width: 100, render: getStatusTag },
|
||||
{ title: t('businessLedger.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
|
||||
]
|
||||
default:
|
||||
return baseColumns
|
||||
@@ -228,7 +231,7 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
|
||||
bordered
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无业务记录" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
<Empty description={t('businessLedger.noRecord')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -76,8 +76,8 @@ const ContactManager: React.FC<ContactManagerProps> = ({
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
{record.mobile && <div><PhoneOutlined style={{ marginRight: 4 }} />{record.mobile}</div>}
|
||||
{record.phone && <div style={{ fontSize: '12px', color: '#666' }}>电话: {record.phone}</div>}
|
||||
{record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>微信: {record.wechat}</div>}
|
||||
{record.phone && <div style={{ fontSize: '12px', color: '#666' }}>{t('component.phonePrefix')}{record.phone}</div>}
|
||||
{record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>{t('component.wechatPrefix')}{record.wechat}</div>}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
@@ -192,7 +192,7 @@ const ContactManager: React.FC<ContactManagerProps> = ({
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="whatsapp" label="WhatsApp">
|
||||
<Input placeholder="输入WhatsApp号码" />
|
||||
<Input placeholder={t('component.whatsappPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_primary" label={t('contact.primaryContact')} valuePropName="checked">
|
||||
<Switch />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { Component, ReactNode } from 'react'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
@@ -8,24 +9,44 @@ interface ErrorBoundaryProps {
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
currentLanguage: string
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
unsubscribe: (() => void) | null = null
|
||||
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
this.state = {
|
||||
hasError: false,
|
||||
currentLanguage: useLanguageStore.getState().currentLanguage
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = useLanguageStore.subscribe((state) => {
|
||||
if (state.currentLanguage !== this.state.currentLanguage) {
|
||||
this.setState({ currentLanguage: state.currentLanguage })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.()
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('组件渲染错误:', error)
|
||||
console.error('错误信息:', errorInfo.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = useLanguageStore.getState().t
|
||||
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
@@ -45,9 +66,9 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', color: '#ff4d4f', marginBottom: '16px' }}>⚠️</div>
|
||||
<h3 style={{ color: '#333', marginBottom: '12px' }}>页面加载出错</h3>
|
||||
<h3 style={{ color: '#333', marginBottom: '12px' }}>{t('errorBoundary.title')}</h3>
|
||||
<p style={{ color: '#666', marginBottom: '24px', maxWidth: '500px' }}>
|
||||
抱歉,页面渲染时发生了错误。请尝试刷新页面或联系管理员。
|
||||
{t('errorBoundary.description')}
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<div style={{
|
||||
@@ -61,10 +82,10 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
maxWidth: '600px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<div><strong>错误信息:</strong> {this.state.error.message}</div>
|
||||
<div><strong>{t('errorBoundary.errorInfo')}</strong> {this.state.error.message}</div>
|
||||
{this.state.error.stack && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<strong>错误堆栈:</strong>
|
||||
<strong>{t('errorBoundary.stackTrace')}</strong>
|
||||
<pre style={{ margin: '8px 0', whiteSpace: 'pre-wrap' }}>
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
@@ -85,7 +106,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
刷新页面
|
||||
{t('errorBoundary.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Upload, Modal, Image, Spin, Progress, message } from 'antd';
|
||||
import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
interface FileUploadProps {
|
||||
value?: string[];
|
||||
@@ -38,6 +39,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
const [previewImage, setPreviewImage] = useState('');
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
// 当 value 变化时,更新 fileList
|
||||
useEffect(() => {
|
||||
@@ -107,9 +109,25 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
// 从 localStorage 读取认证 token
|
||||
let authHeader = '';
|
||||
try {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
const parsed = JSON.parse(authStorage);
|
||||
const token = parsed?.state?.token;
|
||||
if (token) {
|
||||
authHeader = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
|
||||
const res = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
body: formData,
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
@@ -119,15 +137,15 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
onProgress({ percent: 100 });
|
||||
// 传递包含url属性的对象,这是Ant Design Upload组件在customRequest中期望的格式
|
||||
onSuccess({ url: data.data.url }, file);
|
||||
message.success('上传成功');
|
||||
message.success(t('fileUpload.uploadSuccess'));
|
||||
} else {
|
||||
onError(new Error(data.error));
|
||||
message.error(data.error || '上传失败');
|
||||
message.error(data.error || t('fileUpload.uploadFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传错误:', error);
|
||||
onError(error);
|
||||
message.error('上传失败');
|
||||
message.error(t('fileUpload.uploadFailed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -136,7 +154,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
const uploadButton = (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>上传</div>
|
||||
<div style={{ marginTop: 8 }}>{t('fileUpload.upload')}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -158,7 +176,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
{/* 图片预览弹窗 */}
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title="图片预览"
|
||||
title={t('fileUpload.preview')}
|
||||
footer={null}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
width="80%"
|
||||
@@ -175,7 +193,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
|
||||
{uploading && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Spin size="small" /> 上传中...
|
||||
<Spin size="small" /> {t('fileUpload.uploading')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Space, Typography } from 'antd'
|
||||
import { ThunderboltOutlined } from '@ant-design/icons'
|
||||
import { useLanguageStore } from '../../store/languageStore'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
@@ -10,6 +11,7 @@ interface CompanyLogoProps {
|
||||
}
|
||||
|
||||
const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medium' }) => {
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const sizeMap = {
|
||||
small: { fontSize: 14, iconSize: 20 },
|
||||
medium: { fontSize: 16, iconSize: 28 },
|
||||
@@ -41,7 +43,7 @@ const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medi
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
轻远电力老挝ERP
|
||||
{t('login.title')}
|
||||
</Title>
|
||||
<Text
|
||||
type="secondary"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Layout,
|
||||
@@ -49,169 +49,6 @@ import LanguageSelector from '../common/LanguageSelector'
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '工作台'
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <ProjectOutlined />,
|
||||
label: '项目管理'
|
||||
},
|
||||
{
|
||||
key: '/budget-projects',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '预算报价'
|
||||
},
|
||||
{
|
||||
key: '/construction',
|
||||
icon: <ToolOutlined />,
|
||||
label: '施工管理',
|
||||
children: [
|
||||
{
|
||||
key: '/construction',
|
||||
label: '施工总览'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
icon: <SolutionOutlined />,
|
||||
label: '审批管理',
|
||||
children: [
|
||||
{
|
||||
key: '/approval',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: '待审批'
|
||||
},
|
||||
{
|
||||
key: '/execution',
|
||||
icon: <DollarOutlined />,
|
||||
label: '待执行'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'finance-docs',
|
||||
icon: <FileDoneOutlined />,
|
||||
label: '财务申请',
|
||||
children: [
|
||||
{
|
||||
key: '/advances',
|
||||
icon: <WalletOutlined />,
|
||||
label: '预支申请'
|
||||
},
|
||||
{
|
||||
key: '/reimbursements',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '报销申请'
|
||||
},
|
||||
{
|
||||
key: '/payment-requests',
|
||||
icon: <MoneyCollectOutlined />,
|
||||
label: '付款申请'
|
||||
},
|
||||
{
|
||||
key: '/verification',
|
||||
icon: <AuditOutlined />,
|
||||
label: '核销申请'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'finance-group',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '财务管理',
|
||||
children: [
|
||||
{
|
||||
key: '/finance',
|
||||
label: '财务概览'
|
||||
},
|
||||
{
|
||||
key: '/exchange-rates',
|
||||
icon: <DollarOutlined />,
|
||||
label: '汇率管理'
|
||||
},
|
||||
{
|
||||
key: '/project-cost',
|
||||
icon: <DollarCircleOutlined />,
|
||||
label: '项目成本'
|
||||
},
|
||||
{
|
||||
key: '/advances/verification-status',
|
||||
icon: <AuditOutlined />,
|
||||
label: '预支核销状态'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/reports',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: '报表分析'
|
||||
},
|
||||
{
|
||||
key: 'procurement',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
label: '采购管理',
|
||||
children: [
|
||||
{
|
||||
key: '/products',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '商品管理'
|
||||
},
|
||||
{
|
||||
key: '/purchase-requests',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '采购申请'
|
||||
},
|
||||
{
|
||||
key: '/purchase-orders',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
label: '采购订单'
|
||||
},
|
||||
{
|
||||
key: '/payment-plans',
|
||||
icon: <MoneyCollectOutlined />,
|
||||
label: '付款计划'
|
||||
},
|
||||
{
|
||||
key: '/inventory',
|
||||
icon: <InboxOutlined />,
|
||||
label: '库存管理'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'partners',
|
||||
icon: <TeamOutlined />,
|
||||
label: '合作伙伴',
|
||||
children: [
|
||||
{
|
||||
key: '/suppliers',
|
||||
icon: <ShopOutlined />,
|
||||
label: '供应商管理'
|
||||
},
|
||||
{
|
||||
key: '/subcontractors',
|
||||
icon: <SolutionOutlined />,
|
||||
label: '分包商管理'
|
||||
},
|
||||
{
|
||||
key: '/customers',
|
||||
icon: <HomeOutlined />,
|
||||
label: '客户管理'
|
||||
},
|
||||
{
|
||||
key: '/logistics-companies',
|
||||
icon: <CarOutlined />,
|
||||
label: '物流管理'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -221,12 +58,175 @@ const MainLayout: React.FC = () => {
|
||||
const [settingsVisible, setSettingsVisible] = useState(false)
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([])
|
||||
const { user, logout } = useAuthStore()
|
||||
const { t } = useLanguageStore()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken()
|
||||
|
||||
const menuItems = useMemo(() => [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: t('menu.dashboard')
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <ProjectOutlined />,
|
||||
label: t('menu.projects')
|
||||
},
|
||||
{
|
||||
key: '/budget-projects',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: t('menu.budgetQuotation')
|
||||
},
|
||||
{
|
||||
key: '/construction',
|
||||
icon: <ToolOutlined />,
|
||||
label: t('menu.construction'),
|
||||
children: [
|
||||
{
|
||||
key: '/construction',
|
||||
label: t('menu.constructionOverview')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'approval',
|
||||
icon: <SolutionOutlined />,
|
||||
label: t('menu.approval'),
|
||||
children: [
|
||||
{
|
||||
key: '/approval',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: t('menu.pendingApproval')
|
||||
},
|
||||
{
|
||||
key: '/execution',
|
||||
icon: <DollarOutlined />,
|
||||
label: t('menu.pendingExecution')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'finance-docs',
|
||||
icon: <FileDoneOutlined />,
|
||||
label: t('menu.financeDocs'),
|
||||
children: [
|
||||
{
|
||||
key: '/advances',
|
||||
icon: <WalletOutlined />,
|
||||
label: t('menu.advanceApply')
|
||||
},
|
||||
{
|
||||
key: '/reimbursements',
|
||||
icon: <FileTextOutlined />,
|
||||
label: t('menu.reimburseApply')
|
||||
},
|
||||
{
|
||||
key: '/payment-requests',
|
||||
icon: <MoneyCollectOutlined />,
|
||||
label: t('menu.paymentApply')
|
||||
},
|
||||
{
|
||||
key: '/verification',
|
||||
icon: <AuditOutlined />,
|
||||
label: t('menu.verificationApply')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'finance-group',
|
||||
icon: <BarChartOutlined />,
|
||||
label: t('menu.financeManagement'),
|
||||
children: [
|
||||
{
|
||||
key: '/finance',
|
||||
label: t('menu.financeOverview')
|
||||
},
|
||||
{
|
||||
key: '/exchange-rates',
|
||||
icon: <DollarOutlined />,
|
||||
label: t('menu.exchangeRate')
|
||||
},
|
||||
{
|
||||
key: '/project-cost',
|
||||
icon: <DollarCircleOutlined />,
|
||||
label: t('menu.projectCost')
|
||||
},
|
||||
{
|
||||
key: '/advances/verification-status',
|
||||
icon: <AuditOutlined />,
|
||||
label: t('menu.advanceVerificationStatus')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '/reports',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: t('menu.reports')
|
||||
},
|
||||
{
|
||||
key: 'procurement',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
label: t('menu.procurement'),
|
||||
children: [
|
||||
{
|
||||
key: '/products',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: t('menu.productManagement')
|
||||
},
|
||||
{
|
||||
key: '/purchase-requests',
|
||||
icon: <FileTextOutlined />,
|
||||
label: t('menu.purchaseRequest')
|
||||
},
|
||||
{
|
||||
key: '/purchase-orders',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
label: t('menu.purchaseOrder')
|
||||
},
|
||||
{
|
||||
key: '/payment-plans',
|
||||
icon: <MoneyCollectOutlined />,
|
||||
label: t('menu.paymentPlan')
|
||||
},
|
||||
{
|
||||
key: '/inventory',
|
||||
icon: <InboxOutlined />,
|
||||
label: t('menu.inventory')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'partners',
|
||||
icon: <TeamOutlined />,
|
||||
label: t('menu.partners'),
|
||||
children: [
|
||||
{
|
||||
key: '/suppliers',
|
||||
icon: <ShopOutlined />,
|
||||
label: t('menu.supplierManagement')
|
||||
},
|
||||
{
|
||||
key: '/subcontractors',
|
||||
icon: <SolutionOutlined />,
|
||||
label: t('menu.subcontractorManagement')
|
||||
},
|
||||
{
|
||||
key: '/customers',
|
||||
icon: <HomeOutlined />,
|
||||
label: t('menu.customerManagement')
|
||||
},
|
||||
{
|
||||
key: '/logistics-companies',
|
||||
icon: <CarOutlined />,
|
||||
label: t('menu.logisticsManagement')
|
||||
}
|
||||
]
|
||||
}
|
||||
], [t, currentLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth <= 768
|
||||
@@ -241,23 +241,23 @@ const MainLayout: React.FC = () => {
|
||||
return () => window.removeEventListener('resize', checkMobile)
|
||||
}, [])
|
||||
|
||||
const userMenuItems = [
|
||||
const userMenuItems = useMemo(() => [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人信息'
|
||||
label: t('menu.profile')
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置'
|
||||
label: t('menu.settings')
|
||||
},
|
||||
...(user?.role === 'admin' ? [{
|
||||
type: 'divider' as const
|
||||
}, {
|
||||
key: '/admin',
|
||||
icon: <SettingOutlined />,
|
||||
label: '后台管理'
|
||||
label: t('menu.admin')
|
||||
}] : []),
|
||||
{
|
||||
type: 'divider' as const
|
||||
@@ -265,9 +265,9 @@ const MainLayout: React.FC = () => {
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录'
|
||||
label: t('menu.logout')
|
||||
}
|
||||
]
|
||||
], [t, currentLanguage, user?.role])
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
if (key === 'logout') {
|
||||
@@ -313,7 +313,9 @@ const MainLayout: React.FC = () => {
|
||||
path.startsWith('/inventory')) {
|
||||
return ['procurement']
|
||||
}
|
||||
if (path.startsWith('/project-cost')) {
|
||||
if (path.startsWith('/project-cost') ||
|
||||
path.startsWith('/finance') ||
|
||||
path.startsWith('/exchange-rates')) {
|
||||
return ['finance-group']
|
||||
}
|
||||
return []
|
||||
@@ -389,7 +391,7 @@ const MainLayout: React.FC = () => {
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ width: collapsed ? '100%' : 'auto' }}
|
||||
>
|
||||
{!collapsed && '收起菜单'}
|
||||
{!collapsed && t('menu.collapse')}
|
||||
</Button>
|
||||
</div>
|
||||
</Sider>
|
||||
@@ -446,7 +448,7 @@ const MainLayout: React.FC = () => {
|
||||
<Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
|
||||
{!isMobile && <Text>{user?.name || user?.username || '用户'}</Text>}
|
||||
{!isMobile && <Text>{user?.name || user?.username || t('user.userLabel')}</Text>}
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
@@ -462,12 +464,12 @@ const MainLayout: React.FC = () => {
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title="系统设置"
|
||||
title={t('menu.settings')}
|
||||
open={settingsVisible}
|
||||
onCancel={() => setSettingsVisible(false)}
|
||||
footer={null}
|
||||
>
|
||||
<p>系统设置功能开发中...</p>
|
||||
<p>{t('menu.settings')}...</p>
|
||||
</Modal>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -14,58 +14,60 @@ import {
|
||||
ImportOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/admin/users',
|
||||
icon: <UserOutlined />,
|
||||
label: '用户管理'
|
||||
label: t('menu.userManagement')
|
||||
},
|
||||
{
|
||||
key: '/admin/roles',
|
||||
icon: <SafetyOutlined />,
|
||||
label: '角色权限'
|
||||
label: t('menu.rolePermission')
|
||||
},
|
||||
{
|
||||
key: '/admin/process',
|
||||
icon: <SettingOutlined />,
|
||||
label: '流程管理'
|
||||
label: t('menu.processManagement')
|
||||
},
|
||||
{
|
||||
key: '/admin/process-templates',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '工程模板管理'
|
||||
label: t('menu.templateManagement')
|
||||
},
|
||||
{
|
||||
key: '/admin/expense-categories',
|
||||
icon: <AccountBookOutlined />,
|
||||
label: '财务分类管理'
|
||||
label: t('menu.expenseCategory')
|
||||
},
|
||||
{
|
||||
key: '/admin/excel-import',
|
||||
icon: <ImportOutlined />,
|
||||
label: 'Excel批量导入'
|
||||
label: t('menu.excelImport')
|
||||
},
|
||||
{
|
||||
key: '/admin/logs',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '系统日志'
|
||||
label: t('menu.systemLogs')
|
||||
},
|
||||
{
|
||||
key: '/admin/backup',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '数据备份'
|
||||
label: t('menu.dataBackup')
|
||||
},
|
||||
{
|
||||
key: '/admin/about',
|
||||
icon: <InfoCircleOutlined />,
|
||||
label: '关于系统'
|
||||
label: t('menu.aboutSystem')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -90,7 +92,7 @@ const AdminLayout: React.FC = () => {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16
|
||||
}}>
|
||||
系统后台管理
|
||||
{t('menu.admin')}
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
@@ -115,7 +117,7 @@ const AdminLayout: React.FC = () => {
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined /> 返回前台
|
||||
<ArrowLeftOutlined /> {t('menu.backToFront')}
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
+2503
-21
File diff suppressed because it is too large
Load Diff
+3510
-30
File diff suppressed because it is too large
Load Diff
+2506
-24
File diff suppressed because it is too large
Load Diff
+2502
-20
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import apiClient from '../utils/request'
|
||||
import BusinessLedgerTab from '../components/BusinessLedgerTab'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -77,6 +78,7 @@ interface BudgetProject {
|
||||
const CustomerDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const [customer, setCustomer] = useState<Customer | null>(null)
|
||||
const [budgetProjects, setBudgetProjects] = useState<BudgetProject[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -109,30 +111,30 @@ const CustomerDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
|
||||
if (!customer) return <Empty description="客户不存在" style={{ marginTop: 100 }} />
|
||||
if (!customer) return <Empty description={t('customer.notFound')} style={{ marginTop: 100 }} />
|
||||
|
||||
const budgetProjectColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name', render: (v: string, record: BudgetProject) => (
|
||||
{ title: t('customer.projectName'), dataIndex: 'name', key: 'name', render: (v: string, record: BudgetProject) => (
|
||||
<Text strong onClick={() => navigate(`/budget-projects/${record.id}`)} style={{ cursor: 'pointer', color: '#1890ff' }}>{v}</Text>
|
||||
) },
|
||||
{ title: '业务经理', dataIndex: 'manager_name', key: 'manager_name' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => {
|
||||
{ title: t('customer.businessManager'), dataIndex: 'manager_name', key: 'manager_name' },
|
||||
{ title: t('common.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => {
|
||||
const map: Record<string, { status: 'success' | 'processing' | 'error' | 'default'; text: string }> = {
|
||||
negotiating: { status: 'processing', text: '商谈中' },
|
||||
signed: { status: 'success', text: '已签约' },
|
||||
unsigned: { status: 'error', text: '未签约' }
|
||||
negotiating: { status: 'processing', text: t('customer.inNegotiation') },
|
||||
signed: { status: 'success', text: t('customer.signed') },
|
||||
unsigned: { status: 'error', text: t('customer.unsigned') }
|
||||
}
|
||||
const c = map[v] || { status: 'default', text: v }
|
||||
return <Badge status={c.status} text={c.text} />
|
||||
} },
|
||||
{ title: '报价版本数', dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (q: Quotation[]) => (q || []).length },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.split('T')[0] || '-' }
|
||||
{ title: t('customer.quotationCount'), dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (q: Quotation[]) => (q || []).length },
|
||||
{ title: t('customer.createdAt'), dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.split('T')[0] || '-' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} style={{ marginBottom: 16 }} type="text">
|
||||
返回列表
|
||||
{t('customer.returnToList')}
|
||||
</Button>
|
||||
|
||||
<Title level={4} style={{ marginBottom: 24 }}>
|
||||
@@ -143,42 +145,42 @@ const CustomerDetail: React.FC = () => {
|
||||
<Card style={{ borderRadius: 8 }}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
{/* TAB1: 基本信息 */}
|
||||
<Tabs.TabPane tab={<span><UserOutlined /> 基本信息</span>} key="basic">
|
||||
<Tabs.TabPane tab={<span><UserOutlined /> {t('customer.basicInfo')}</span>} key="basic">
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2 }} size="small">
|
||||
<Descriptions.Item label="编号">{customer.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{customer.address || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('customer.code')}>{customer.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('customer.address')}>{customer.address || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{customer.remark && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">备注:</Text>
|
||||
<Text type="secondary">{t('customer.remarkLabel')}</Text>
|
||||
<div style={{ padding: 12, background: '#f6ffed', borderRadius: 4, border: '1px solid #b7eb8f', marginTop: 8 }}>{customer.remark}</div>
|
||||
</div>
|
||||
)}
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB2: 联系人 */}
|
||||
<Tabs.TabPane tab={<span><PhoneOutlined /> 联系人</span>} key="contacts">
|
||||
<Tabs.TabPane tab={<span><PhoneOutlined /> {t('customer.contact')}</span>} key="contacts">
|
||||
<Row gutter={[16, 16]}>
|
||||
{(customer.contacts || []).map((contact, i) => (
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #52c41a' : '3px solid #d9d9d9', background: contact.is_primary ? '#f6ffed' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{contact.name || '未命名'}</Text>
|
||||
{contact.is_primary && <Tag color="green">主联系人</Tag>}
|
||||
<Text strong>{contact.name || t('common.unnamed')}</Text>
|
||||
{contact.is_primary && <Tag color="green">{t('customer.mainContactTag')}</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
{contact.position && <div>职位:{contact.position}</div>}
|
||||
{contact.phone && <div>电话:{contact.phone}</div>}
|
||||
{contact.position && <div>{t('customer.positionLabel')}{contact.position}</div>}
|
||||
{contact.phone && <div>{t('customer.phoneLabel')}{contact.phone}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(customer.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{(customer.contacts || []).length === 0 && <Empty description={t('customer.noContact')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB3: 业务台账 */}
|
||||
<Tabs.TabPane tab={<span><DollarOutlined /> 业务台账</span>} key="ledger">
|
||||
<Tabs.TabPane tab={<span><DollarOutlined /> {t('customer.ledger')}</span>} key="ledger">
|
||||
<BusinessLedgerTab
|
||||
partnerType="customer"
|
||||
summary={customer.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_received_amount: 0, total_receivable_amount: 0 }}
|
||||
@@ -187,11 +189,11 @@ const CustomerDetail: React.FC = () => {
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB4: 关联预算 */}
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> 关联预算</span>} key="budget">
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('customer.relatedBudget')}</span>} key="budget">
|
||||
{budgetProjects.length > 0 ? (
|
||||
<Table columns={budgetProjectColumns} dataSource={budgetProjects} rowKey="id" size="small" pagination={{ pageSize: 10 }} bordered />
|
||||
) : (
|
||||
<Empty description="暂无关联预算项目" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
<Empty description={t('customer.noBudget')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, HomeOutline
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import FileUpload from '../components/FileUpload'
|
||||
import useFormDraft from '../hooks/useFormDraft'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
@@ -37,6 +38,7 @@ interface Customer {
|
||||
|
||||
const CustomerPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
@@ -61,7 +63,7 @@ const CustomerPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
if (data.success) setCustomers(data.data || [])
|
||||
} catch (error) {
|
||||
message.error('获取客户列表失败')
|
||||
message.error(t('customer.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -87,32 +89,32 @@ const CustomerPage: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<Customer> = [
|
||||
{
|
||||
title: '名称', dataIndex: 'name', key: 'name',
|
||||
title: t('customer.name'), dataIndex: 'name', key: 'name',
|
||||
render: (text, record) => (
|
||||
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/customers/${record.id}`)}>{text}</Button>
|
||||
)
|
||||
},
|
||||
{ title: '地址', dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
|
||||
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{ title: t('customer.address'), dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
|
||||
{ title: t('customer.mainContact'), key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
|
||||
{
|
||||
title: '收款信息',
|
||||
title: t('customer.paymentInfo'),
|
||||
key: 'payment_info',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const primary = getPrimaryPaymentInfo(record.payment_infos || [])
|
||||
if (!primary) return <Tag>未设置</Tag>
|
||||
if (!primary) return <Tag>{t('common.notSet')}</Tag>
|
||||
return (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div><BankOutlined /> {primary.bank_name || '-'}</div>
|
||||
<div>户名: {primary.account_name || '-'}</div>
|
||||
<div>账号: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
|
||||
<div>{t('customer.accountNameLabel')} {primary.account_name || '-'}</div>
|
||||
<div>{t('customer.accountNumLabel')} {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
|
||||
{ title: '应收金额', dataIndex: 'total_receivable', key: 'total_receivable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
|
||||
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
|
||||
{ title: t('customer.contractAmount'), dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
|
||||
{ title: t('customer.receivableAmount'), dataIndex: 'total_receivable', key: 'total_receivable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
|
||||
{ title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
|
||||
@@ -170,17 +172,17 @@ const CustomerPage: React.FC = () => {
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success(editingCustomer ? '更新成功' : '创建成功')
|
||||
message.success(editingCustomer ? t('common.updateSuccess') : t('common.createSuccess'))
|
||||
clearDraft()
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingCustomer(null)
|
||||
fetchCustomers()
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
message.error(data.message || t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,14 +200,14 @@ const CustomerPage: React.FC = () => {
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除', content: '确定要删除此客户吗?', okText: '确定', cancelText: '取消',
|
||||
title: t('common.confirmDelete'), content: t('customer.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) { message.success('删除成功'); fetchCustomers() }
|
||||
else message.error(data.message || '删除失败')
|
||||
} catch (error) { message.error('删除失败') }
|
||||
if (data.success) { message.success(t('common.deleteSuccess')); fetchCustomers() }
|
||||
else message.error(data.message || t('common.deleteFailed'))
|
||||
} catch (error) { message.error(t('common.deleteFailed')) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -222,10 +224,10 @@ const CustomerPage: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的客户信息,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft()
|
||||
},
|
||||
@@ -245,74 +247,75 @@ const CustomerPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}><Card><Statistic title="客户总数" value={stats.total} prefix={<HomeOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="应收总金额" value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('customer.totalCount')} value={stats.total} prefix={<HomeOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('customer.totalContract')} value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('customer.totalReceivable')} value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input placeholder="搜索客户编号、名称或地址" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增客户</Button>
|
||||
<Input placeholder={t('customer.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('customer.newCustomer')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1100 }} />
|
||||
<Table columns={columns} dataSource={filteredCustomers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingCustomer ? '编辑客户' : '新增客户'}
|
||||
title={editingCustomer ? t('customer.editCustomer') : t('customer.newCustomer')}
|
||||
open={modalVisible}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft()
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingCustomer(null)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingCustomer(null)
|
||||
}
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
width={800}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="客户名称" />
|
||||
<Form.Item name="name" label={t('customer.name')} rules={[{ required: true, message: t('customer.nameRequired') }]}>
|
||||
<Input placeholder={t('customer.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="客户地址" />
|
||||
<Form.Item name="address" label={t('customer.address')}>
|
||||
<Input placeholder={t('customer.addressPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="备注信息" />
|
||||
<Form.Item name="remark" label={t('common.remark')}>
|
||||
<Input.TextArea rows={2} placeholder={t('customer.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<h4>联系人</h4>
|
||||
<h4>{t('customer.contact')}</h4>
|
||||
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
|
||||
{(fields, { add, remove }) => (
|
||||
<div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="姓名" />
|
||||
<Input placeholder={t('logistics.name')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="职位" />
|
||||
<Input placeholder={t('common.position')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="电话" />
|
||||
<Input placeholder={t('common.phone')} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
||||
@@ -321,33 +324,33 @@ const CustomerPage: React.FC = () => {
|
||||
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>主联系人</span>
|
||||
<span>{t('customer.mainContact')}</span>
|
||||
</div>
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>删除</Button>}
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ 添加联系人</Button>
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('customer.addContact')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>收款信息</h4>
|
||||
<h4 style={{ marginTop: 24 }}>{t('customer.paymentInfo')}</h4>
|
||||
<Form.List name="payment_infos" initialValue={[]}>
|
||||
{(fields, { add, remove }) => (
|
||||
<div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="收款户名" />
|
||||
<Form.Item {...restField} name={[name, 'account_name']} label={t('customer.accountName')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('customer.accountName')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="开户银行" />
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label={t('customer.bankName')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('customer.bankName')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="银行账号" />
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label={t('customer.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('customer.bankAccount')} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
||||
@@ -356,19 +359,19 @@ const CustomerPage: React.FC = () => {
|
||||
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>主要收款账户</span>
|
||||
<span>{t('customer.mainAccount')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label={t('customer.qrCode')} style={{ marginBottom: 0 }}>
|
||||
<FileUpload maxCount={1} accept="image/*" />
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>删除此收款信息</Button>
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('customer.deletePaymentInfo')}</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
|
||||
+ 添加收款信息
|
||||
{t('customer.addPaymentInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
|
||||
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import apiClient from '../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const RATE_PAIRS = [
|
||||
{ key: 'CNY_LAK', label: '中老汇率', from: 'CNY', to: 'LAK', fromLabel: '人民币', toLabel: '老挝基普' },
|
||||
{ key: 'CNY_USD', label: '中美汇率', from: 'CNY', to: 'USD', fromLabel: '人民币', toLabel: '美元' },
|
||||
{ key: 'CNY_THB', label: '中泰汇率', from: 'CNY', to: 'THB', fromLabel: '人民币', toLabel: '泰铢' },
|
||||
{ key: 'USD_LAK', label: '美老汇率', from: 'USD', to: 'LAK', fromLabel: '美元', toLabel: '老挝基普' },
|
||||
{ key: 'THB_LAK', label: '泰老汇率', from: 'THB', to: 'LAK', fromLabel: '泰铢', toLabel: '老挝基普' },
|
||||
const RATE_PAIR_KEYS = [
|
||||
{ key: 'CNY_LAK', i18nKey: 'CNYLAK', from: 'CNY', to: 'LAK', fromI18n: 'CNY', toI18n: 'LAK' },
|
||||
{ key: 'CNY_USD', i18nKey: 'CNYUSD', from: 'CNY', to: 'USD', fromI18n: 'CNY', toI18n: 'USD' },
|
||||
{ key: 'CNY_THB', i18nKey: 'CNYTHB', from: 'CNY', to: 'THB', fromI18n: 'CNY', toI18n: 'THB' },
|
||||
{ key: 'USD_LAK', i18nKey: 'USDLAK', from: 'USD', to: 'LAK', fromI18n: 'USD', toI18n: 'LAK' },
|
||||
{ key: 'THB_LAK', i18nKey: 'THBLAK', from: 'THB', to: 'LAK', fromI18n: 'THB', toI18n: 'LAK' },
|
||||
];
|
||||
|
||||
interface RateItem {
|
||||
@@ -30,6 +31,15 @@ interface HistoryRate {
|
||||
}
|
||||
|
||||
const ExchangeRatePage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
const RATE_PAIRS = useMemo(() => RATE_PAIR_KEYS.map(p => ({
|
||||
...p,
|
||||
label: t(`exchangeRate.${p.i18nKey}`),
|
||||
fromLabel: t(`exchangeRate.${p.fromI18n}`),
|
||||
toLabel: t(`exchangeRate.${p.toI18n}`),
|
||||
})), [t]);
|
||||
|
||||
const [rates, setRates] = useState<Record<string, RateItem>>({});
|
||||
const [initialRates, setInitialRates] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -71,7 +81,7 @@ const ExchangeRatePage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取汇率失败');
|
||||
message.error(t('exchangeRate.getRateFailed'));
|
||||
const defaultRates: Record<string, RateItem> = {};
|
||||
const defaultInitialRates: Record<string, number> = {};
|
||||
RATE_PAIRS.forEach(pair => {
|
||||
@@ -97,13 +107,11 @@ const ExchangeRatePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 左侧输入 - 右侧自动变为1,重新计算汇率
|
||||
const handleLeftChange = (key: string, value: number | null) => {
|
||||
if (value === null || value <= 0) return;
|
||||
const pair = RATE_PAIRS.find(p => p.key === key);
|
||||
if (!pair) return;
|
||||
|
||||
// 当左侧输入值时,右侧变为1,计算新的汇率
|
||||
const newRate = 1 / value;
|
||||
|
||||
setRates(prev => ({
|
||||
@@ -116,13 +124,11 @@ const ExchangeRatePage: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// 右侧输入 - 左侧自动变为1,重新计算汇率
|
||||
const handleRightChange = (key: string, value: number | null) => {
|
||||
if (value === null || value <= 0) return;
|
||||
const pair = RATE_PAIRS.find(p => p.key === key);
|
||||
if (!pair) return;
|
||||
|
||||
// 当右侧输入值时,左侧变为1,计算新的汇率
|
||||
const newRate = value;
|
||||
|
||||
setRates(prev => ({
|
||||
@@ -135,7 +141,6 @@ const ExchangeRatePage: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// 计算实际汇率显示
|
||||
const getActualRateDisplay = (key: string) => {
|
||||
const item = rates[key];
|
||||
if (!item) return '1 : 1.00';
|
||||
@@ -143,13 +148,11 @@ const ExchangeRatePage: React.FC = () => {
|
||||
const pair = RATE_PAIRS.find(p => p.key === key);
|
||||
const actualRate = item.actualRate;
|
||||
|
||||
// 根据汇率对选择合适的小数位数
|
||||
const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2;
|
||||
|
||||
return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`;
|
||||
};
|
||||
|
||||
// 确认保存
|
||||
const handleConfirm = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -160,7 +163,6 @@ const ExchangeRatePage: React.FC = () => {
|
||||
const actualRate = item.rightValue / item.leftValue;
|
||||
const initialRate = initialRates[pair.key];
|
||||
|
||||
// 只保存有变化的汇率
|
||||
if (Math.abs(actualRate - initialRate) < 0.0001) {
|
||||
return null;
|
||||
}
|
||||
@@ -175,17 +177,16 @@ const ExchangeRatePage: React.FC = () => {
|
||||
const validPromises = savePromises.filter(Boolean) as Promise<any>[];
|
||||
|
||||
if (validPromises.length === 0) {
|
||||
message.info('没有汇率发生变化');
|
||||
message.info(t('exchangeRate.noChange'));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(validPromises);
|
||||
|
||||
message.success('汇率保存成功');
|
||||
message.success(t('exchangeRate.saveSuccess'));
|
||||
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
|
||||
fetchHistory();
|
||||
// 更新初始汇率为当前汇率
|
||||
const newInitialRates: Record<string, number> = {};
|
||||
RATE_PAIRS.forEach(pair => {
|
||||
const item = rates[pair.key];
|
||||
@@ -195,16 +196,15 @@ const ExchangeRatePage: React.FC = () => {
|
||||
});
|
||||
setInitialRates(newInitialRates);
|
||||
} catch (error) {
|
||||
message.error('保存汇率失败');
|
||||
message.error(t('exchangeRate.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 历史汇率表格列
|
||||
const historyColumns = [
|
||||
{
|
||||
title: '汇率对',
|
||||
title: t('exchangeRate.ratePair'),
|
||||
dataIndex: 'from_currency',
|
||||
key: 'from_currency',
|
||||
render: (_: string, record: HistoryRate) => {
|
||||
@@ -214,7 +214,7 @@ const ExchangeRatePage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '汇率',
|
||||
title: t('exchangeRate.rate'),
|
||||
dataIndex: 'rate',
|
||||
key: 'rate',
|
||||
render: (rate: number, record: HistoryRate) => {
|
||||
@@ -224,19 +224,19 @@ const ExchangeRatePage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '生效日期',
|
||||
title: t('exchangeRate.effectiveDate'),
|
||||
dataIndex: 'effective_date',
|
||||
key: 'effective_date',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
title: '设置时间',
|
||||
title: t('exchangeRate.setTime'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
{
|
||||
title: '设置人',
|
||||
title: t('exchangeRate.setBy'),
|
||||
dataIndex: 'created_by_name',
|
||||
key: 'created_by_name',
|
||||
render: (name: string) => name || '-'
|
||||
@@ -250,11 +250,11 @@ const ExchangeRatePage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 8 }}>汇率管理</Title>
|
||||
<Title level={2} style={{ marginBottom: 8 }}>{t('exchangeRate.title')}</Title>
|
||||
<Space>
|
||||
<Text type="secondary">设置各币种汇率,输入任意一侧自动计算</Text>
|
||||
<Text type="secondary">{t('exchangeRate.description')}</Text>
|
||||
{lastUpdateTime && (
|
||||
<Tag color="blue">上次更新: {lastUpdateTime}</Tag>
|
||||
<Tag color="blue">{t('exchangeRate.lastUpdated')}{lastUpdateTime}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@ const ExchangeRatePage: React.FC = () => {
|
||||
e.target.select();
|
||||
}
|
||||
}}
|
||||
placeholder={`输入${pair.fromLabel}金额`}
|
||||
placeholder={t('exchangeRate.inputFrom', { from: pair.fromLabel })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff', fontWeight: 'bold' }}>=</div>
|
||||
@@ -319,14 +319,14 @@ const ExchangeRatePage: React.FC = () => {
|
||||
e.target.select();
|
||||
}
|
||||
}}
|
||||
placeholder={`输入${pair.toLabel}金额`}
|
||||
placeholder={t('exchangeRate.inputTo', { to: pair.toLabel })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
实际汇率: {getActualRateDisplay(pair.key)}
|
||||
{t('exchangeRate.actualRate')}{getActualRateDisplay(pair.key)}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -335,7 +335,6 @@ const ExchangeRatePage: React.FC = () => {
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -345,16 +344,15 @@ const ExchangeRatePage: React.FC = () => {
|
||||
loading={saving}
|
||||
style={{ minWidth: 200 }}
|
||||
>
|
||||
确认保存汇率
|
||||
{t('exchangeRate.confirmSave')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 历史汇率表 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
<span>历史汇率记录</span>
|
||||
<span>{t('exchangeRate.historyRate')}</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 24 }}
|
||||
@@ -370,7 +368,7 @@ const ExchangeRatePage: React.FC = () => {
|
||||
|
||||
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
|
||||
<Text type="warning">
|
||||
提示:输入任意一侧数值,另一侧会自动计算。实际汇率实时显示为 1左侧币种 = X右侧币种。点击"确认保存汇率"按钮保存当前设置到数据库。
|
||||
{t('exchangeRate.tipText')}
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,289 +1,116 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card,
|
||||
Row, Col, Statistic, DatePicker, InputNumber, Tabs
|
||||
Row, Col, DatePicker, InputNumber, Tabs, Statistic, Descriptions, Badge, Tooltip, Divider
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, SearchOutlined, InboxOutlined, ExportOutlined
|
||||
PlusOutlined, EditOutlined, EditFilled, DeleteOutlined, EyeOutlined,
|
||||
CheckOutlined, WarningOutlined, ArrowUpOutlined, ArrowDownOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface InventoryRecord {
|
||||
interface InventoryItem {
|
||||
id: number
|
||||
record_type: string
|
||||
project_id?: number | null
|
||||
project_name?: string
|
||||
purchase_request_id?: number | null
|
||||
product_id: number
|
||||
product_name?: string
|
||||
quantity: number
|
||||
unit_price?: number | null
|
||||
total_amount?: number | null
|
||||
record_date: string
|
||||
operator?: string | null
|
||||
remark?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface InventorySummary {
|
||||
product_id: number
|
||||
product_name: string
|
||||
unit?: string | null
|
||||
total_in: number
|
||||
total_out: number
|
||||
current_quantity: number
|
||||
product_code: string
|
||||
product_category: string
|
||||
product_specification: string
|
||||
product_unit: string
|
||||
stock_quantity: number
|
||||
safety_stock: number
|
||||
locked_quantity: number
|
||||
last_in_date: string
|
||||
last_out_date: string
|
||||
}
|
||||
|
||||
interface InventoryLog {
|
||||
id: number
|
||||
product_id: number
|
||||
product_name: string
|
||||
type: string
|
||||
quantity: number
|
||||
before_quantity: number
|
||||
after_quantity: number
|
||||
purchase_order_id: number
|
||||
order_code: string
|
||||
remark: string
|
||||
operator: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
category: string
|
||||
specification: string
|
||||
unit: string
|
||||
stock_quantity: number
|
||||
safety_stock: number
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
// ==================== 组件 ====================
|
||||
const InventoryPage: React.FC = () => {
|
||||
// 状态
|
||||
const [records, setRecords] = useState<InventoryRecord[]>([])
|
||||
const [summary, setSummary] = useState<InventorySummary[]>([])
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [inventory, setInventory] = useState<InventoryItem[]>([])
|
||||
const [logs, setLogs] = useState<InventoryLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
|
||||
// 筛选状态
|
||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null)
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
|
||||
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(null)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [stockFilter, setStockFilter] = useState<string>('all')
|
||||
const [activeTab, setActiveTab] = useState('inventory')
|
||||
|
||||
// 弹窗状态
|
||||
const [inModalVisible, setInModalVisible] = useState(false)
|
||||
const [outModalVisible, setOutModalVisible] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [inForm] = Form.useForm()
|
||||
const [outForm] = Form.useForm()
|
||||
|
||||
// Tab状态
|
||||
const [activeTab, setActiveTab] = useState('records')
|
||||
const navigate = useNavigate()
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
const getRecordTypeTag = (type: string) => {
|
||||
if (type === 'in') {
|
||||
return <Tag color="green" icon={<InboxOutlined />}>入库</Tag>
|
||||
} else {
|
||||
return <Tag color="orange" icon={<ExportOutlined />}>出库</Tag>
|
||||
}
|
||||
}
|
||||
|
||||
const recordColumns: ColumnsType<InventoryRecord> = [
|
||||
{
|
||||
title: '记录类型',
|
||||
dataIndex: 'record_type',
|
||||
key: 'record_type',
|
||||
width: 100,
|
||||
render: getRecordTypeTag
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'product_name',
|
||||
key: 'product_name'
|
||||
},
|
||||
{
|
||||
title: '项目',
|
||||
dataIndex: 'project_name',
|
||||
key: 'project_name'
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'unit_price',
|
||||
key: 'unit_price',
|
||||
width: 120,
|
||||
render: (price) => price ? price.toFixed(2) : '-'
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'total_amount',
|
||||
key: 'total_amount',
|
||||
width: 120,
|
||||
render: (amount) => amount ? amount.toFixed(2) : '-'
|
||||
},
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'record_date',
|
||||
key: 'record_date',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
key: 'operator',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
key: 'remark'
|
||||
}
|
||||
]
|
||||
|
||||
const summaryColumns: ColumnsType<InventorySummary> = [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'product_name',
|
||||
key: 'product_name'
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
key: 'unit',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '入库总量',
|
||||
dataIndex: 'total_in',
|
||||
key: 'total_in',
|
||||
width: 120,
|
||||
render: (val) => (val || 0).toFixed(2)
|
||||
},
|
||||
{
|
||||
title: '出库总量',
|
||||
dataIndex: 'total_out',
|
||||
key: 'total_out',
|
||||
width: 120,
|
||||
render: (val) => (val || 0).toFixed(2)
|
||||
},
|
||||
{
|
||||
title: '当前库存',
|
||||
dataIndex: 'current_quantity',
|
||||
key: 'current_quantity',
|
||||
width: 120,
|
||||
render: (val) => (
|
||||
<span style={{ fontWeight: 'bold', color: (val || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||
{(val || 0).toFixed(2)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'records',
|
||||
label: '库存记录',
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="选择商品筛选"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setSelectedProductId(value)}
|
||||
>
|
||||
{products.map(product => (
|
||||
<Select.Option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="选择项目筛选"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setSelectedProjectId(value)}
|
||||
>
|
||||
{projects.map(project => (
|
||||
<Select.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="选择记录类型"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setSelectedRecordType(value)}
|
||||
>
|
||||
<Select.Option value="in">入库</Select.Option>
|
||||
<Select.Option value="out">出库</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={6} style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOutModalVisible(true)}>
|
||||
出库
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={recordColumns}
|
||||
dataSource={records}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
label: '库存汇总',
|
||||
children: (
|
||||
<Table
|
||||
columns={summaryColumns}
|
||||
dataSource={summary}
|
||||
rowKey="product_id"
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
// ==================== 数据加载 ====================
|
||||
|
||||
const fetchRecords = async () => {
|
||||
const fetchInventory = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (selectedProductId) params.append('product_id', selectedProductId.toString())
|
||||
if (selectedProjectId) params.append('project_id', selectedProjectId.toString())
|
||||
if (selectedRecordType) params.append('record_type', selectedRecordType)
|
||||
if (selectedCategory) params.append('category', selectedCategory)
|
||||
if (stockFilter && stockFilter !== 'all') params.append('stock_filter', stockFilter)
|
||||
|
||||
const response = await fetch(`/api/inventory?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setRecords(data.data)
|
||||
setInventory(data.data)
|
||||
} else {
|
||||
message.error('获取库存记录失败')
|
||||
message.error(t('inventory.getListFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取库存记录失败:', error)
|
||||
message.error('获取库存记录失败')
|
||||
console.error('获取库存列表失败:', error)
|
||||
message.error(t('inventory.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSummary = async () => {
|
||||
const fetchLogs = async () => {
|
||||
setLogLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/inventory/summary')
|
||||
const response = await fetch('/api/inventory?limit=100')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setSummary(data.data)
|
||||
setLogs(data.data)
|
||||
} else {
|
||||
message.error(t('inventory.getLogFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取库存汇总失败:', error)
|
||||
console.error('获取库存流水失败:', error)
|
||||
message.error(t('inventory.getLogFailed'))
|
||||
} finally {
|
||||
setLogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,127 +122,455 @@ const InventoryPage: React.FC = () => {
|
||||
setProducts(data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取商品列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setProjects(data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error)
|
||||
console.error('获取产品列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts()
|
||||
fetchProjects()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'records') {
|
||||
fetchRecords()
|
||||
} else {
|
||||
fetchSummary()
|
||||
fetchInventory()
|
||||
}, [selectedCategory, stockFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [activeTab])
|
||||
|
||||
const handleStockIn = () => {
|
||||
inForm.resetFields()
|
||||
inForm.setFieldsValue({ type: 'in', date: dayjs() })
|
||||
setInModalVisible(true)
|
||||
}
|
||||
}, [activeTab, selectedProductId, selectedProjectId, selectedRecordType])
|
||||
|
||||
// ==================== 操作函数 ====================
|
||||
const handleStockOut = () => {
|
||||
outForm.resetFields()
|
||||
outForm.setFieldsValue({ type: 'out', date: dayjs() })
|
||||
setOutModalVisible(true)
|
||||
}
|
||||
|
||||
const handleOutModalOk = async () => {
|
||||
const handleSaveStockIn = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const values = await inForm.validateFields()
|
||||
const data = {
|
||||
...values,
|
||||
date: values.date.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const response = await fetch('/api/inventory/in', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
message.success(t('inventory.stockInSuccess'))
|
||||
setInModalVisible(false)
|
||||
fetchInventory()
|
||||
} else {
|
||||
message.error(result.error || t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('入库失败:', error)
|
||||
message.error(t('inventory.stockInFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveStockOut = async () => {
|
||||
try {
|
||||
const values = await outForm.validateFields()
|
||||
const data = {
|
||||
...values,
|
||||
date: values.date.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const response = await fetch('/api/inventory/out', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...values,
|
||||
operator: '系统管理员'
|
||||
})
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success('出库成功')
|
||||
if (result.success) {
|
||||
message.success(t('inventory.stockOutSuccess'))
|
||||
setOutModalVisible(false)
|
||||
form.resetFields()
|
||||
fetchRecords()
|
||||
fetchSummary()
|
||||
fetchInventory()
|
||||
} else {
|
||||
message.error('出库失败')
|
||||
message.error(result.error || t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('出库失败:', error)
|
||||
message.error('出库失败')
|
||||
message.error(t('inventory.stockOutFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const getStockStatus = (item: InventoryItem) => {
|
||||
if (item.stock_quantity <= 0) return 'out_of_stock'
|
||||
if (item.stock_quantity <= item.safety_stock) return 'low_stock'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
const columns: ColumnsType<InventoryItem> = [
|
||||
{
|
||||
title: t('inventory.productCode'),
|
||||
dataIndex: 'product_code',
|
||||
key: 'product_code',
|
||||
width: 110
|
||||
},
|
||||
{
|
||||
title: t('inventory.productName'),
|
||||
dataIndex: 'product_name',
|
||||
key: 'product_name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: t('inventory.spec'),
|
||||
dataIndex: 'product_specification',
|
||||
key: 'product_specification',
|
||||
width: 130,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: t('inventory.unit'),
|
||||
dataIndex: 'product_unit',
|
||||
key: 'product_unit',
|
||||
width: 60
|
||||
},
|
||||
{
|
||||
title: t('inventory.stock'),
|
||||
dataIndex: 'stock_quantity',
|
||||
key: 'stock_quantity',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: number, r: InventoryItem) => {
|
||||
const status = getStockStatus(r)
|
||||
const color = status === 'out_of_stock' ? '#ff4d4f' : (status === 'low_stock' ? '#faad14' : '#52c41a')
|
||||
return <span style={{ color, fontWeight: 500 }}>{v}</span>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('inventory.safetyStock'),
|
||||
dataIndex: 'safety_stock',
|
||||
key: 'safety_stock',
|
||||
width: 80,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
title: t('inventory.locked'),
|
||||
dataIndex: 'locked_quantity',
|
||||
key: 'locked_quantity',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
render: (v: number) => v || 0
|
||||
},
|
||||
{
|
||||
title: t('inventory.lastIn'),
|
||||
dataIndex: 'last_in_date',
|
||||
key: 'last_in_date',
|
||||
width: 100,
|
||||
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
|
||||
},
|
||||
{
|
||||
title: t('inventory.lastOut'),
|
||||
dataIndex: 'last_out_date',
|
||||
key: 'last_out_date',
|
||||
width: 100,
|
||||
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
|
||||
},
|
||||
{
|
||||
title: t('inventory.status'),
|
||||
key: 'status',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, r: InventoryItem) => {
|
||||
const status = getStockStatus(r)
|
||||
const map: Record<string, { icon: React.ReactNode; text: string; color: string }> = {
|
||||
normal: { icon: <CheckOutlined />, text: t('inventory.normal'), color: 'success' },
|
||||
low_stock: { icon: <WarningOutlined />, text: t('inventory.lowStock'), color: 'warning' },
|
||||
out_of_stock: { icon: <WarningOutlined />, text: t('inventory.outOfStock'), color: 'error' }
|
||||
}
|
||||
const info = map[status] || map.normal
|
||||
return <Tag color={info.color} icon={info.icon}>{info.text}</Tag>
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const logColumns: ColumnsType<InventoryLog> = [
|
||||
{
|
||||
title: t('inventory.time'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 160,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
{
|
||||
title: t('inventory.product'),
|
||||
dataIndex: 'product_name',
|
||||
key: 'product_name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: t('inventory.type'),
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'in' ? 'green' : 'red'}>
|
||||
{v === 'in' ? t('inventory.in') : t('inventory.out')}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('inventory.quantity'),
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
width: 80,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
title: t('inventory.before'),
|
||||
dataIndex: 'before_quantity',
|
||||
key: 'before_quantity',
|
||||
width: 80,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
title: t('inventory.after'),
|
||||
dataIndex: 'after_quantity',
|
||||
key: 'after_quantity',
|
||||
width: 80,
|
||||
align: 'right'
|
||||
},
|
||||
{
|
||||
title: t('inventory.orderCode'),
|
||||
dataIndex: 'order_code',
|
||||
key: 'order_code',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: t('inventory.remark'),
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: t('inventory.operator'),
|
||||
dataIndex: 'operator',
|
||||
key: 'operator',
|
||||
width: 80
|
||||
}
|
||||
]
|
||||
|
||||
const categories = React.useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
inventory.forEach(item => {
|
||||
if (item.product_category) cats.add(item.product_category)
|
||||
})
|
||||
return Array.from(cats)
|
||||
}, [inventory])
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>{t('inventory.title')}</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('inventory.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" icon={<ArrowDownOutlined />} onClick={handleStockIn}>
|
||||
{t('inventory.stockIn')}
|
||||
</Button>
|
||||
<Button icon={<ArrowUpOutlined />} onClick={handleStockOut}>
|
||||
{t('inventory.stockOut')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.TabPane tab={t('inventory.tabInventory')} key="inventory">
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder={t('inventory.categoryFilter')}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setSelectedCategory(v)}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<Select.Option key={cat} value={cat}>{cat}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder={t('inventory.stockFilter')}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={stockFilter}
|
||||
onChange={(v) => setStockFilter(v || 'all')}
|
||||
>
|
||||
<Select.Option value="all">{t('inventory.all')}</Select.Option>
|
||||
<Select.Option value="low_stock">{t('inventory.lowStock')}</Select.Option>
|
||||
<Select.Option value="out_of_stock">{t('inventory.outOfStock')}</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={inventory}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('inventory.tabLog')} key="logs">
|
||||
<Table
|
||||
columns={logColumns}
|
||||
dataSource={logs}
|
||||
rowKey="id"
|
||||
loading={logLoading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* 入库弹窗 */}
|
||||
<Modal
|
||||
title={t('inventory.stockInTitle')}
|
||||
open={inModalVisible}
|
||||
onOk={handleSaveStockIn}
|
||||
onCancel={() => {
|
||||
inForm.resetFields();
|
||||
setInModalVisible(false);
|
||||
}}
|
||||
destroyOnClose
|
||||
width={500}
|
||||
>
|
||||
<Form form={inForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="product_id"
|
||||
label={t('inventory.selectProduct')}
|
||||
rules={[{ required: true, message: t('inventory.selectProductRequired') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={t('inventory.selectProduct')}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) => (option?.children as unknown as string)?.includes(input)}
|
||||
>
|
||||
{products.map(p => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name} ({p.code})
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="quantity"
|
||||
label={t('inventory.quantity')}
|
||||
rules={[{ required: true, message: t('inventory.quantityRequired') }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder={t('inventory.quantityPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="date"
|
||||
label={t('inventory.date')}
|
||||
rules={[{ required: true, message: t('inventory.dateRequired') }]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="order_code" label={t('inventory.relatedOrder')}>
|
||||
<Input placeholder={t('inventory.relatedOrderPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label={t('inventory.remark')}>
|
||||
<Input.TextArea rows={3} placeholder={t('inventory.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 出库弹窗 */}
|
||||
<Modal
|
||||
title="商品出库"
|
||||
title={t('inventory.stockOutTitle')}
|
||||
open={outModalVisible}
|
||||
onOk={handleOutModalOk}
|
||||
onCancel={() => setOutModalVisible(false)}
|
||||
onOk={handleSaveStockOut}
|
||||
onCancel={() => {
|
||||
outForm.resetFields();
|
||||
setOutModalVisible(false);
|
||||
}}
|
||||
destroyOnClose
|
||||
width={500}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="project_id"
|
||||
label="关联项目"
|
||||
rules={[{ required: true, message: '请选择项目' }]}
|
||||
>
|
||||
<Select placeholder="请选择项目">
|
||||
{projects.map(project => (
|
||||
<Select.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form form={outForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="product_id"
|
||||
label="商品"
|
||||
rules={[{ required: true, message: '请选择商品' }]}
|
||||
label={t('inventory.selectProduct')}
|
||||
rules={[{ required: true, message: t('inventory.selectProductRequired') }]}
|
||||
>
|
||||
<Select placeholder="请选择商品">
|
||||
{products.map(product => (
|
||||
<Select.Option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
<Select
|
||||
placeholder={t('inventory.selectProduct')}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) => (option?.children as unknown as string)?.includes(input)}
|
||||
>
|
||||
{products.map(p => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name} ({p.code})
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="quantity"
|
||||
label="出库数量"
|
||||
rules={[{ required: true, message: '请输入出库数量' }]}
|
||||
label={t('inventory.quantity')}
|
||||
rules={[{ required: true, message: t('inventory.quantityRequired') }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入出库数量" />
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder={t('inventory.quantityPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="unit_price" label="单价">
|
||||
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入单价" />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="date"
|
||||
label={t('inventory.date')}
|
||||
rules={[{ required: true, message: t('inventory.dateRequired') }]}
|
||||
>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="total_amount" label="总金额">
|
||||
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入总金额" />
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="order_code" label={t('inventory.relatedOrder')}>
|
||||
<Input placeholder={t('inventory.relatedOrderPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="请输入备注" />
|
||||
<Form.Item name="remark" label={t('inventory.remark')}>
|
||||
<Input.TextArea rows={3} placeholder={t('inventory.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import BusinessLedgerTab from '../components/BusinessLedgerTab'
|
||||
import useFormDraft from '../hooks/useFormDraft'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface LogisticsCompany {
|
||||
id: number
|
||||
@@ -85,6 +86,7 @@ interface OrderRecord {
|
||||
}
|
||||
|
||||
const LogisticsCompaniesPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const [companies, setCompanies] = useState<LogisticsCompany[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -123,11 +125,11 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
if (data.success) {
|
||||
setCompanies(data.data)
|
||||
} else {
|
||||
message.error('获取物流公司列表失败')
|
||||
message.error(t('logistics.getListFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取物流公司列表失败:', error)
|
||||
message.error('获取物流公司列表失败')
|
||||
message.error(t('logistics.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -142,11 +144,11 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
setDetailModalVisible(true)
|
||||
setActiveDetailTab('basic')
|
||||
} else {
|
||||
message.error('获取物流公司详情失败')
|
||||
message.error(t('logistics.getDetailFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取物流公司详情失败:', error)
|
||||
message.error('获取物流公司详情失败')
|
||||
message.error(t('logistics.getDetailFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +164,10 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的物流公司信息,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft()
|
||||
},
|
||||
@@ -189,14 +191,14 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
const response = await fetch(`/api/logistics-companies/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('删除成功')
|
||||
message.success(t('common.deleteSuccess'))
|
||||
fetchCompanies()
|
||||
} else {
|
||||
message.error(data.message || '删除失败')
|
||||
message.error(data.message || t('common.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
message.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,12 +232,12 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
message.success(editingCompany ? '更新成功' : '创建成功')
|
||||
message.success(editingCompany ? t('common.updateSuccess') : t('common.createSuccess'))
|
||||
clearDraft()
|
||||
setModalVisible(false)
|
||||
fetchCompanies()
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
message.error(t('common.saveFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
@@ -270,11 +272,11 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(editingContact ? '联系人更新成功' : '联系人添加成功')
|
||||
message.success(editingContact ? t('logistics.contactUpdateSuccess') : t('logistics.contactAddSuccess'))
|
||||
setContactModalVisible(false)
|
||||
fetchCompanyDetail(currentCompany!.id)
|
||||
} else {
|
||||
message.error('操作失败')
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存联系人失败:', error)
|
||||
@@ -288,10 +290,10 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('联系人删除成功')
|
||||
message.success(t('logistics.contactDeleteSuccess'))
|
||||
fetchCompanyDetail(currentCompany!.id)
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
message.error(t('common.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除联系人失败:', error)
|
||||
@@ -326,11 +328,11 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(editingPayment ? '收款信息更新成功' : '收款信息添加成功')
|
||||
message.success(editingPayment ? t('logistics.paymentInfoUpdateSuccess') : t('logistics.paymentInfoAddSuccess'))
|
||||
setPaymentModalVisible(false)
|
||||
fetchCompanyDetail(currentCompany!.id)
|
||||
} else {
|
||||
message.error('操作失败')
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存收款信息失败:', error)
|
||||
@@ -344,10 +346,10 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('收款信息删除成功')
|
||||
message.success(t('logistics.paymentInfoDeleteSuccess'))
|
||||
fetchCompanyDetail(currentCompany!.id)
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
message.error(t('common.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除收款信息失败:', error)
|
||||
@@ -356,9 +358,9 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
|
||||
const getFreightStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待付款' },
|
||||
requested: { color: 'blue', text: '已申请' },
|
||||
paid: { color: 'green', text: '已支付' }
|
||||
pending: { color: 'default', text: t('logistics.pendingPayment') },
|
||||
requested: { color: 'blue', text: t('logistics.applied') },
|
||||
paid: { color: 'green', text: t('logistics.paid') }
|
||||
}
|
||||
const info = statusMap[status] || { color: 'default', text: status }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
@@ -366,7 +368,7 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<LogisticsCompany> = [
|
||||
{
|
||||
title: '公司名称',
|
||||
title: t('logistics.companyName'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 180,
|
||||
@@ -375,13 +377,13 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
title: t('logistics.phone'),
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '报价描述',
|
||||
title: t('logistics.quoteDescription'),
|
||||
dataIndex: 'quotation_description',
|
||||
key: 'quotation_description',
|
||||
width: 200,
|
||||
@@ -389,14 +391,14 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
title: t('logistics.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 100,
|
||||
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('logistics.action'),
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
@@ -404,7 +406,7 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchCompanyDetail(record.id)} />
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
<Popconfirm title="确定要删除吗?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Popconfirm title={t('logistics.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -413,18 +415,18 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
]
|
||||
|
||||
const contactColumns: ColumnsType<Contact> = [
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
||||
{ title: '职位', dataIndex: 'position', key: 'position', width: 80 },
|
||||
{ title: '电话', dataIndex: 'phone', key: 'phone', width: 120 },
|
||||
{ title: '主联系人', dataIndex: 'is_primary', key: 'is_primary', width: 80, render: (v: boolean | number) => v ? <Tag color="blue">主联系人</Tag> : null },
|
||||
{ title: t('logistics.name'), dataIndex: 'name', key: 'name', width: 100 },
|
||||
{ title: t('logistics.position'), dataIndex: 'position', key: 'position', width: 80 },
|
||||
{ title: t('logistics.phoneLabel'), dataIndex: 'phone', key: 'phone', width: 120 },
|
||||
{ title: t('logistics.mainContact'), dataIndex: 'is_primary', key: 'is_primary', width: 80, render: (v: boolean | number) => v ? <Tag color="blue">{t('logistics.mainContact')}</Tag> : null },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('logistics.action'),
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditContact(record)} />
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteContact(record.id)}>
|
||||
<Popconfirm title={t('logistics.confirmDeleteShort')} onConfirm={() => handleDeleteContact(record.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -433,18 +435,18 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
]
|
||||
|
||||
const paymentColumns: ColumnsType<PaymentInfo> = [
|
||||
{ title: '收款户名', dataIndex: 'account_name', key: 'account_name', width: 120 },
|
||||
{ title: '银行账号', dataIndex: 'account_number', key: 'account_number', width: 150 },
|
||||
{ title: '开户银行', dataIndex: 'bank_name', key: 'bank_name', width: 120 },
|
||||
{ title: '默认', dataIndex: 'is_default', key: 'is_default', width: 60, render: (v: boolean | number) => v ? <Tag color="green">默认</Tag> : null },
|
||||
{ title: t('logistics.accountName'), dataIndex: 'account_name', key: 'account_name', width: 120 },
|
||||
{ title: t('logistics.bankAccount'), dataIndex: 'account_number', key: 'account_number', width: 150 },
|
||||
{ title: t('logistics.bankName'), dataIndex: 'bank_name', key: 'bank_name', width: 120 },
|
||||
{ title: t('logistics.defaultLabel'), dataIndex: 'is_default', key: 'is_default', width: 60, render: (v: boolean | number) => v ? <Tag color="green">{t('logistics.defaultLabel')}</Tag> : null },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('logistics.action'),
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditPayment(record)} />
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeletePayment(record.id)}>
|
||||
<Popconfirm title={t('logistics.confirmDeleteShort')} onConfirm={() => handleDeletePayment(record.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -453,92 +455,95 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
]
|
||||
|
||||
const orderColumns: ColumnsType<OrderRecord> = [
|
||||
{ title: '物流单号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: '采购订单', dataIndex: 'order_code', key: 'order_code', width: 120 },
|
||||
{ title: '发货日期', dataIndex: 'ship_date', key: 'ship_date', width: 100 },
|
||||
{ title: '一次运费', dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.primary_freight_currency || 'CNY'} ${v?.toFixed(2) || '0.00'}` },
|
||||
{ title: '一次运费状态', dataIndex: 'primary_freight_status', key: 'primary_freight_status', width: 100, render: getFreightStatusTag },
|
||||
{ title: '二次运费', dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.secondary_freight_currency || 'LAK'} ${v?.toFixed(2) || '0.00'}` },
|
||||
{ title: '二次运费状态', dataIndex: 'secondary_freight_status', key: 'secondary_freight_status', width: 100, render: getFreightStatusTag },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag>{s}</Tag> }
|
||||
{ title: t('logistics.trackingNumber'), dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: t('logistics.purchaseOrder'), dataIndex: 'order_code', key: 'order_code', width: 120 },
|
||||
{ title: t('logistics.deliveryDate'), dataIndex: 'ship_date', key: 'ship_date', width: 100 },
|
||||
{ title: t('logistics.freight1'), dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.primary_freight_currency || 'CNY'} ${v?.toFixed(2) || '0.00'}` },
|
||||
{ title: t('logistics.freight1Status'), dataIndex: 'primary_freight_status', key: 'primary_freight_status', width: 100, render: getFreightStatusTag },
|
||||
{ title: t('logistics.freight2'), dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number, r: OrderRecord) => `${r.secondary_freight_currency || 'LAK'} ${v?.toFixed(2) || '0.00'}` },
|
||||
{ title: t('logistics.freight2Status'), dataIndex: 'secondary_freight_status', key: 'secondary_freight_status', width: 100, render: getFreightStatusTag },
|
||||
{ title: t('logistics.status'), dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag>{s}</Tag> }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>物流管理</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理物流合作伙伴(统一合作伙伴界面规范)</p>
|
||||
<h2 style={{ marginBottom: 8 }}>{t('logistics.title')}</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('logistics.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建物流公司</Button>}>
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('logistics.newCompany')}</Button>}>
|
||||
<Table columns={columns} dataSource={companies} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="small" scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
{/* 编辑/新建弹窗 */}
|
||||
<Modal
|
||||
title={editingCompany ? '编辑物流公司' : '新建物流公司'}
|
||||
title={editingCompany ? t('logistics.editCompany') : t('logistics.newCompany')}
|
||||
open={modalVisible}
|
||||
onOk={handleSave}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft()
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
}
|
||||
}}
|
||||
destroyOnClose
|
||||
width={700}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
|
||||
<Form.Item name="name" label="公司名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入公司名称" />
|
||||
<Form.Item name="name" label={t('logistics.companyName')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('common.inputPlaceholder') + t('logistics.companyName')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址">
|
||||
<Input placeholder="请输入地址" />
|
||||
<Form.Item name="address" label={t('logistics.address')}>
|
||||
<Input placeholder={t('logistics.addressPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="quotation_description" label="报价描述">
|
||||
<Input.TextArea rows={3} placeholder="请输入报价描述(如:中国-老挝陆运报价、时效等)" />
|
||||
<Form.Item name="quotation_description" label={t('logistics.quoteDescription')}>
|
||||
<Input.TextArea rows={3} placeholder={t('logistics.quotePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="请输入备注" />
|
||||
<Form.Item name="remark" label={t('logistics.remark')}>
|
||||
<Input.TextArea rows={2} placeholder={t('logistics.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
{!editingCompany && (
|
||||
<Form.List name="contacts">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 500 }}>联系人</span>
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => add()}>添加联系人</Button>
|
||||
<span style={{ fontWeight: 500 }}>{t('logistics.contact')}</span>
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => add()}>{t('logistics.addContact')}</Button>
|
||||
</div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Row key={key} gutter={8} style={{ marginBottom: 8 }}>
|
||||
<Col span={6}>
|
||||
<Form.Item {...restField} name={[name, 'name']} rules={[{ required: true, message: '必填' }]}>
|
||||
<Input placeholder="姓名" size="small" />
|
||||
<Form.Item {...restField} name={[name, 'name']} rules={[{ required: true, message: t('common.required') }]}>
|
||||
<Input placeholder={t('logistics.name')} size="small" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Form.Item {...restField} name={[name, 'phone']}>
|
||||
<Input placeholder="电话" size="small" />
|
||||
<Input placeholder={t('logistics.phoneLabel')} size="small" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Form.Item {...restField} name={[name, 'position']}>
|
||||
<Input placeholder="职位" size="small" />
|
||||
<Input placeholder={t('logistics.position')} size="small" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked">
|
||||
<Checkbox>主联系人</Checkbox>
|
||||
<Checkbox>{t('logistics.mainContact')}</Checkbox>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={3}>
|
||||
@@ -555,7 +560,7 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
|
||||
{/* 详情弹窗 - 多TAB */}
|
||||
<Modal
|
||||
title={`物流公司详情 - ${currentCompany?.name || ''}`}
|
||||
title={t('logistics.detailTitle', { name: currentCompany?.name || '' })}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={null}
|
||||
@@ -564,32 +569,32 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
{currentCompany && (
|
||||
<Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}>
|
||||
{/* TAB1: 基本信息 */}
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> 基本信息</span>} key="basic">
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('logistics.basicInfo')}</span>} key="basic">
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="公司名称">{currentCompany.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">{currentCompany.phone || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{currentCompany.email || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{currentCompany.created_at}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址" span={2}>{currentCompany.address || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="报价描述" span={2}>{currentCompany.quotation_description || '-'}</Descriptions.Item>
|
||||
{currentCompany.remark && <Descriptions.Item label="备注" span={2}>{currentCompany.remark}</Descriptions.Item>}
|
||||
<Descriptions.Item label={t('logistics.companyName')}>{currentCompany.name}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('logistics.phone')}>{currentCompany.phone || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('logistics.email')}>{currentCompany.email || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('logistics.createdAt')}>{currentCompany.created_at}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('logistics.address')} span={2}>{currentCompany.address || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('logistics.quoteDescription')} span={2}>{currentCompany.quotation_description || '-'}</Descriptions.Item>
|
||||
{currentCompany.remark && <Descriptions.Item label={t('logistics.remark')} span={2}>{currentCompany.remark}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB2: 联系人 */}
|
||||
<Tabs.TabPane tab={<span><PhoneOutlined /> 联系人</span>} key="contacts">
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddContact} style={{ marginBottom: 16 }}>添加联系人</Button>
|
||||
<Tabs.TabPane tab={<span><PhoneOutlined /> {t('logistics.contact')}</span>} key="contacts">
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddContact} style={{ marginBottom: 16 }}>{t('logistics.addContact')}</Button>
|
||||
<Table columns={contactColumns} dataSource={currentCompany.contacts || []} rowKey="id" pagination={false} size="small" />
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB3: 收款信息 */}
|
||||
<Tabs.TabPane tab={<span><BankOutlined /> 收款信息</span>} key="payment">
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}>添加收款信息</Button>
|
||||
<Tabs.TabPane tab={<span><BankOutlined /> {t('logistics.paymentTab')}</span>} key="payment">
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}>{t('logistics.addPaymentInfo')}</Button>
|
||||
<Table columns={paymentColumns} dataSource={currentCompany.payment_infos || []} rowKey="id" pagination={false} size="small" />
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB4: 业务台账 */}
|
||||
<Tabs.TabPane tab={<span><DollarOutlined /> 业务台账</span>} key="orders">
|
||||
<Tabs.TabPane tab={<span><DollarOutlined /> {t('logistics.ledger')}</span>} key="orders">
|
||||
<BusinessLedgerTab
|
||||
partnerType="logistics"
|
||||
summary={currentCompany.ledger?.summary || { item_count: 0, total_primary_freight: 0, total_secondary_freight: 0, total_freight: 0, paid_primary_freight: 0, paid_secondary_freight: 0, paid_amount: 0, unpaid_amount: 0 }}
|
||||
@@ -602,36 +607,40 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
|
||||
{/* 联系人编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingContact ? '编辑联系人' : '添加联系人'}
|
||||
title={editingContact ? t('logistics.editContact') : t('logistics.addContactTitle')}
|
||||
open={contactModalVisible}
|
||||
onOk={handleSaveContact}
|
||||
onCancel={() => setContactModalVisible(false)}
|
||||
onCancel={() => {
|
||||
contactForm.resetFields();
|
||||
setContactModalVisible(false);
|
||||
}}
|
||||
destroyOnClose
|
||||
width={500}
|
||||
>
|
||||
<Form form={contactForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
<Form.Item name="name" label={t('logistics.name')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('common.inputPlaceholder') + t('logistics.name')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="position" label="职位">
|
||||
<Input placeholder="请输入职位" />
|
||||
<Form.Item name="position" label={t('logistics.position')}>
|
||||
<Input placeholder={t('common.inputPlaceholder') + t('logistics.position')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input placeholder="请输入电话" />
|
||||
<Form.Item name="phone" label={t('logistics.phoneLabel')}>
|
||||
<Input placeholder={t('common.inputPlaceholder') + t('logistics.phoneLabel')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="is_primary" label="主联系人">
|
||||
<Select placeholder="是否为主联系人">
|
||||
<Select.Option value={true}>是</Select.Option>
|
||||
<Select.Option value={false}>否</Select.Option>
|
||||
<Form.Item name="is_primary" label={t('logistics.mainContact')}>
|
||||
<Select placeholder={t('logistics.isMainContact')}>
|
||||
<Select.Option value={true}>{t('common.is')}</Select.Option>
|
||||
<Select.Option value={false}>{t('common.no')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -639,35 +648,39 @@ const LogisticsCompaniesPage: React.FC = () => {
|
||||
|
||||
{/* 收款信息编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingPayment ? '编辑收款信息' : '添加收款信息'}
|
||||
title={editingPayment ? t('logistics.editPaymentInfo') : t('logistics.addPaymentInfoTitle')}
|
||||
open={paymentModalVisible}
|
||||
onOk={handleSavePayment}
|
||||
onCancel={() => setPaymentModalVisible(false)}
|
||||
onCancel={() => {
|
||||
paymentForm.resetFields();
|
||||
setPaymentModalVisible(false);
|
||||
}}
|
||||
destroyOnClose
|
||||
width={500}
|
||||
>
|
||||
<Form form={paymentForm} layout="vertical">
|
||||
<Form.Item name="account_name" label="收款户名" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入收款户名" />
|
||||
<Form.Item name="account_name" label={t('logistics.accountName')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('common.inputPlaceholder') + t('logistics.accountName')} />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="account_number" label="银行账号" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入银行账号" />
|
||||
<Form.Item name="account_number" label={t('logistics.bankAccount')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('common.inputPlaceholder') + t('logistics.bankAccount')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入开户银行" />
|
||||
<Form.Item name="bank_name" label={t('logistics.bankName')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('common.inputPlaceholder') + t('logistics.bankName')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="qr_code" label="收款码">
|
||||
<Input placeholder="请输入收款码图片URL" />
|
||||
<Form.Item name="qr_code" label={t('logistics.qrCode')}>
|
||||
<Input placeholder={t('logistics.qrCodeRequired')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_default" label="默认账户">
|
||||
<Select placeholder="是否为默认账户">
|
||||
<Select.Option value={true}>是</Select.Option>
|
||||
<Select.Option value={false}>否</Select.Option>
|
||||
<Form.Item name="is_default" label={t('logistics.defaultAccount')}>
|
||||
<Select placeholder={t('logistics.isDefaultAccount')}>
|
||||
<Select.Option value={true}>{t('common.is')}</Select.Option>
|
||||
<Select.Option value={false}>{t('common.no')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface PaymentPlan {
|
||||
@@ -37,6 +38,7 @@ interface PurchaseOrder {
|
||||
|
||||
// ==================== 组件 ====================
|
||||
const PaymentPlansPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
// 状态
|
||||
const [paymentPlans, setPaymentPlans] = useState<PaymentPlan[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -63,11 +65,11 @@ const PaymentPlansPage: React.FC = () => {
|
||||
if (data.success) {
|
||||
setPaymentPlans(data.data)
|
||||
} else {
|
||||
message.error('获取付款计划列表失败')
|
||||
message.error(t('paymentPlan.getListFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取付款计划列表失败:', error)
|
||||
message.error('获取付款计划列表失败')
|
||||
message.error(t('paymentPlan.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -93,11 +95,11 @@ const PaymentPlansPage: React.FC = () => {
|
||||
setViewingPlan(data.data)
|
||||
setDetailModalVisible(true)
|
||||
} else {
|
||||
message.error('获取付款计划详情失败')
|
||||
message.error(t('paymentPlan.getDetailFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取付款计划详情失败:', error)
|
||||
message.error('获取付款计划详情失败')
|
||||
message.error(t('paymentPlan.getDetailFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +121,7 @@ const PaymentPlansPage: React.FC = () => {
|
||||
currency: 'CNY',
|
||||
payment_type: 'partial',
|
||||
status: 'pending',
|
||||
created_by: '系统管理员'
|
||||
created_by: t('common.systemAdmin')
|
||||
})
|
||||
setModalVisible(true)
|
||||
}
|
||||
@@ -150,15 +152,15 @@ const PaymentPlansPage: React.FC = () => {
|
||||
payment_type: fullRecord.payment_type || 'partial',
|
||||
status: fullRecord.status || 'pending',
|
||||
description: fullRecord.description,
|
||||
created_by: fullRecord.created_by || '系统管理员'
|
||||
created_by: fullRecord.created_by || t('common.systemAdmin')
|
||||
});
|
||||
}, 100);
|
||||
} else {
|
||||
message.error('获取付款计划详情失败')
|
||||
message.error(t('paymentPlan.getDetailFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取付款计划详情失败:', error)
|
||||
message.error('获取付款计划详情失败')
|
||||
message.error(t('paymentPlan.getDetailFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,15 +193,15 @@ const PaymentPlansPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(editingPlan ? '保存成功' : '创建成功')
|
||||
message.success(editingPlan ? t('common.saveSuccess') : t('common.createSuccess'))
|
||||
setModalVisible(false)
|
||||
fetchPaymentPlans()
|
||||
} else {
|
||||
message.error(editingPlan ? '保存失败' : '创建失败')
|
||||
message.error(editingPlan ? t('common.saveFailed') : t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
message.error('保存失败')
|
||||
message.error(t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,10 +209,10 @@ const PaymentPlansPage: React.FC = () => {
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'blue', text: '待处理' },
|
||||
approved: { color: 'green', text: '已审批' },
|
||||
executed: { color: 'purple', text: '已执行' },
|
||||
cancelled: { color: 'red', text: '已取消' }
|
||||
pending: { color: 'blue', text: t('paymentPlan.pending') },
|
||||
approved: { color: 'green', text: t('paymentPlan.approved') },
|
||||
executed: { color: 'purple', text: t('paymentPlan.executed') },
|
||||
cancelled: { color: 'red', text: t('paymentPlan.cancelled') }
|
||||
}
|
||||
const info = statusMap[status] || { color: 'default', text: status }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
@@ -218,8 +220,8 @@ const PaymentPlansPage: React.FC = () => {
|
||||
|
||||
const getPaymentTypeTag = (type: string) => {
|
||||
const typeMap: Record<string, { color: string; text: string }> = {
|
||||
partial: { color: 'blue', text: '部分付款' },
|
||||
full: { color: 'green', text: '全额付款' }
|
||||
partial: { color: 'blue', text: t('paymentPlan.partialPayment') },
|
||||
full: { color: 'green', text: t('paymentPlan.fullPayment') }
|
||||
}
|
||||
const info = typeMap[type] || { color: 'default', text: type }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
@@ -227,14 +229,14 @@ const PaymentPlansPage: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<PaymentPlan> = [
|
||||
{
|
||||
title: '计划编号',
|
||||
title: t('paymentPlan.planCode'),
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 150,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '采购订单',
|
||||
title: t('paymentPlan.purchaseOrder'),
|
||||
dataIndex: 'purchase_order_id',
|
||||
key: 'purchase_order_id',
|
||||
width: 140,
|
||||
@@ -244,14 +246,14 @@ const PaymentPlansPage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '付款日期',
|
||||
title: t('paymentPlan.paymentDate'),
|
||||
dataIndex: 'payment_date',
|
||||
key: 'payment_date',
|
||||
width: 110,
|
||||
render: (date) => dayjs(date).format('MM-DD')
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
title: t('paymentPlan.amount'),
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 120,
|
||||
@@ -263,7 +265,7 @@ const PaymentPlansPage: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '付款类型',
|
||||
title: t('paymentPlan.paymentType'),
|
||||
dataIndex: 'payment_type',
|
||||
key: 'payment_type',
|
||||
width: 100,
|
||||
@@ -271,7 +273,7 @@ const PaymentPlansPage: React.FC = () => {
|
||||
render: getPaymentTypeTag
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
title: t('paymentPlan.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
@@ -279,13 +281,13 @@ const PaymentPlansPage: React.FC = () => {
|
||||
render: getStatusTag
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
title: t('paymentPlan.creator'),
|
||||
dataIndex: 'created_by',
|
||||
key: 'created_by',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('paymentPlan.action'),
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
@@ -297,7 +299,7 @@ const PaymentPlansPage: React.FC = () => {
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => fetchPlanDetail(record.id)}
|
||||
>
|
||||
详情
|
||||
{t('common.detail')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -305,7 +307,7 @@ const PaymentPlansPage: React.FC = () => {
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
@@ -315,11 +317,11 @@ const PaymentPlansPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>付款计划</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理采购订单的付款计划</p>
|
||||
<h2 style={{ marginBottom: 8 }}>{t('paymentPlan.title')}</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('paymentPlan.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建付款计划</Button>}>
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('paymentPlan.newPlan')}</Button>}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={paymentPlans}
|
||||
@@ -333,19 +335,22 @@ const PaymentPlansPage: React.FC = () => {
|
||||
|
||||
{/* 编辑/新建弹窗 */}
|
||||
<Modal
|
||||
title={editingPlan ? '编辑付款计划' : '新建付款计划'}
|
||||
title={editingPlan ? t('paymentPlan.editPlan') : t('paymentPlan.newPlan')}
|
||||
open={modalVisible}
|
||||
onCancel={() => {
|
||||
setModalVisible(false)
|
||||
setEditingPlan(null)
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
setEditingPlan(null);
|
||||
}}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => {
|
||||
setModalVisible(false)
|
||||
setEditingPlan(null)
|
||||
}}>取消</Button>,
|
||||
<Button key="save" type="primary" onClick={handleSave}>保存</Button>
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
setEditingPlan(null);
|
||||
}}>{t('common.cancel')}</Button>,
|
||||
<Button key="save" type="primary" onClick={handleSave}>{t('common.save')}</Button>
|
||||
]}
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
@@ -353,10 +358,10 @@ const PaymentPlansPage: React.FC = () => {
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="purchase_order_id"
|
||||
label="关联采购订单"
|
||||
rules={[{ required: true, message: '请选择采购订单' }]}
|
||||
label={t('paymentPlan.purchaseOrder')}
|
||||
rules={[{ required: true, message: t('paymentPlan.selectOrder') }]}
|
||||
>
|
||||
<Select placeholder="请选择采购订单" allowClear>
|
||||
<Select placeholder={t('paymentPlan.selectOrder')} allowClear>
|
||||
{purchaseOrders.map(order => (
|
||||
<Select.Option key={order.id} value={order.id}>
|
||||
{order.code} - {order.supplier_name} ({order.currency} {order.total_amount})
|
||||
@@ -371,8 +376,8 @@ const PaymentPlansPage: React.FC = () => {
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="payment_date"
|
||||
label="付款日期"
|
||||
rules={[{ required: true, message: '请选择付款日期' }]}
|
||||
label={t('paymentPlan.paymentDate')}
|
||||
rules={[{ required: true, message: t('paymentPlan.selectDate') }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
@@ -380,10 +385,10 @@ const PaymentPlansPage: React.FC = () => {
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="付款金额"
|
||||
rules={[{ required: true, message: '请输入付款金额' }]}
|
||||
label={t('paymentPlan.amount')}
|
||||
rules={[{ required: true, message: t('paymentPlan.inputAmount') }]}
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="付款金额" />
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder={t('paymentPlan.amountPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -392,26 +397,26 @@ const PaymentPlansPage: React.FC = () => {
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
label={t('common.currency')}
|
||||
rules={[{ required: true, message: t('paymentPlan.selectCurrency') }]}
|
||||
>
|
||||
<Select placeholder="请选择币种">
|
||||
<Select.Option value="CNY">人民币</Select.Option>
|
||||
<Select.Option value="USD">美元</Select.Option>
|
||||
<Select.Option value="LAK">老挝基普</Select.Option>
|
||||
<Select.Option value="THB">泰铢</Select.Option>
|
||||
<Select placeholder={t('paymentPlan.selectCurrency')}>
|
||||
<Select.Option value="CNY">{t('paymentPlan.currencyCNY')}</Select.Option>
|
||||
<Select.Option value="USD">{t('paymentPlan.currencyUSD')}</Select.Option>
|
||||
<Select.Option value="LAK">{t('paymentPlan.currencyLAK')}</Select.Option>
|
||||
<Select.Option value="THB">{t('paymentPlan.currencyTHB')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="payment_type"
|
||||
label="付款类型"
|
||||
rules={[{ required: true, message: '请选择付款类型' }]}
|
||||
label={t('paymentPlan.paymentType')}
|
||||
rules={[{ required: true, message: t('paymentPlan.selectType') }]}
|
||||
>
|
||||
<Select placeholder="请选择付款类型">
|
||||
<Select.Option value="partial">部分付款</Select.Option>
|
||||
<Select.Option value="full">全额付款</Select.Option>
|
||||
<Select placeholder={t('paymentPlan.selectType')}>
|
||||
<Select.Option value="partial">{t('paymentPlan.partialPayment')}</Select.Option>
|
||||
<Select.Option value="full">{t('paymentPlan.fullPayment')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -421,40 +426,40 @@ const PaymentPlansPage: React.FC = () => {
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: '请选择状态' }]}
|
||||
label={t('paymentPlan.status')}
|
||||
rules={[{ required: true, message: t('paymentPlan.selectStatus') }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Select.Option value="pending">待处理</Select.Option>
|
||||
<Select.Option value="approved">已审批</Select.Option>
|
||||
<Select.Option value="executed">已执行</Select.Option>
|
||||
<Select.Option value="cancelled">已取消</Select.Option>
|
||||
<Select placeholder={t('paymentPlan.selectStatus')}>
|
||||
<Select.Option value="pending">{t('paymentPlan.pending')}</Select.Option>
|
||||
<Select.Option value="approved">{t('paymentPlan.approved')}</Select.Option>
|
||||
<Select.Option value="executed">{t('paymentPlan.executed')}</Select.Option>
|
||||
<Select.Option value="cancelled">{t('paymentPlan.cancelled')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="created_by"
|
||||
label="创建人"
|
||||
rules={[{ required: true, message: '请输入创建人' }]}
|
||||
label={t('paymentPlan.creator')}
|
||||
rules={[{ required: true, message: t('paymentPlan.inputCreator') }]}
|
||||
>
|
||||
<Input placeholder="请输入创建人" />
|
||||
<Input placeholder={t('paymentPlan.inputCreator')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
label={t('common.remark')}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="请输入付款计划描述" />
|
||||
<Input.TextArea rows={3} placeholder={t('paymentPlan.descPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title="付款计划详情"
|
||||
title={t('paymentPlan.detailTitle')}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={null}
|
||||
@@ -463,20 +468,20 @@ const PaymentPlansPage: React.FC = () => {
|
||||
{viewingPlan && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="计划编号">{viewingPlan.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(viewingPlan.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="采购订单">
|
||||
<Descriptions.Item label={t('paymentPlan.detailCode')}>{viewingPlan.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentPlan.status')}>{getStatusTag(viewingPlan.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentPlan.purchaseOrder')}>
|
||||
{(() => {
|
||||
const order = purchaseOrders.find(o => o.id == viewingPlan.purchase_order_id)
|
||||
return order ? order.code : viewingPlan.purchase_order_id
|
||||
})()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="付款类型">{getPaymentTypeTag(viewingPlan.payment_type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="付款日期">{viewingPlan.payment_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{viewingPlan.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额" span={2}>{viewingPlan.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" span={2}>{viewingPlan.description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人" span={2}>{viewingPlan.created_by}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentPlan.paymentType')}>{getPaymentTypeTag(viewingPlan.payment_type)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentPlan.paymentDate')}>{viewingPlan.payment_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.currency')}>{viewingPlan.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentPlan.amount')} span={2}>{viewingPlan.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.remark')} span={2}>{viewingPlan.description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentPlan.creator')} span={2}>{viewingPlan.created_by}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import FileUpload from '../components/FileUpload';
|
||||
import useFormDraft from '../hooks/useFormDraft';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
@@ -59,6 +60,7 @@ interface PayeeEntity {
|
||||
}
|
||||
|
||||
const PaymentRequestsPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const { user } = useAuthStore();
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [completedRequests, setCompletedRequests] = useState<any[]>([]);
|
||||
@@ -116,7 +118,7 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取付款申请列表失败:', error);
|
||||
message.error('获取付款申请列表失败');
|
||||
message.error(t('paymentRequest.getListFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -217,19 +219,21 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
application_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
applicant: user?.name || user?.username || t('common.currentUser'),
|
||||
attachments: [],
|
||||
payee_type: 'other',
|
||||
expense_type: 'company'
|
||||
});
|
||||
setWatchedAmount(null);
|
||||
setWatchedCurrency('CNY');
|
||||
setModalVisible(true);
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的付款申请,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft();
|
||||
},
|
||||
@@ -239,7 +243,7 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
application_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
applicant: user?.name || user?.username || t('common.currentUser'),
|
||||
attachments: [],
|
||||
payee_type: 'other',
|
||||
expense_type: 'company'
|
||||
@@ -257,6 +261,8 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
application_date: record.application_date ? dayjs(record.application_date) : (record.payment_date ? dayjs(record.payment_date) : null),
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setWatchedAmount(record.amount || null);
|
||||
setWatchedCurrency(record.currency || 'CNY');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -267,15 +273,15 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条付款申请吗?',
|
||||
title: t('common.deleteConfirm'),
|
||||
content: t('paymentRequest.deleteConfirmMsg') || t('common.confirmDeleteMsg'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/payment-requests/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
message.success(t('paymentRequest.deleteSuccess'));
|
||||
fetchRequests();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
message.error(t('paymentRequest.deleteFailed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -283,15 +289,15 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
title: t('paymentRequest.withdrawConfirm'),
|
||||
content: t('paymentRequest.withdrawConfirmMsg'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/payment-requests/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
message.success(t('paymentRequest.withdrawSuccess'));
|
||||
fetchRequests();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
message.error(t('paymentRequest.withdrawFailed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -342,32 +348,56 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '更新成功' : '创建成功');
|
||||
message.success(editingId ? t('common.updateSuccess') : t('common.createSuccess'));
|
||||
clearDraft();
|
||||
setModalVisible(false);
|
||||
fetchRequests();
|
||||
} else {
|
||||
message.error(result.error || '操作失败');
|
||||
message.error(result.error || t('common.operationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
message.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const convertToCNY = (amount: number, curr: string): number => {
|
||||
if (curr === "CNY") return amount;
|
||||
// 先尝试 XXX_CNY 格式
|
||||
const rateKey = curr + "_CNY";
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount * rate;
|
||||
if (exchangeRates[rateKey]) {
|
||||
return amount * exchangeRates[rateKey];
|
||||
}
|
||||
// 尝试 CNY_XXX 格式的倒数
|
||||
const reverseKey = "CNY_" + curr;
|
||||
if (exchangeRates[reverseKey]) {
|
||||
return amount / exchangeRates[reverseKey];
|
||||
}
|
||||
// 尝试通过 USD 中转: XXX -> USD -> CNY
|
||||
const xxxUsdKey = curr + "_USD";
|
||||
const usdCnyKey = "USD_CNY";
|
||||
const cnyUsdKey = "CNY_USD";
|
||||
if (exchangeRates[xxxUsdKey]) {
|
||||
const usdAmount = amount * exchangeRates[xxxUsdKey];
|
||||
if (exchangeRates[usdCnyKey]) return usdAmount * exchangeRates[usdCnyKey];
|
||||
if (exchangeRates[cnyUsdKey]) return usdAmount / exchangeRates[cnyUsdKey];
|
||||
}
|
||||
// 通过 LAK 中转
|
||||
const xxxLakKey = curr + "_LAK";
|
||||
const cnyLakKey = "CNY_LAK";
|
||||
if (exchangeRates[xxxLakKey] && exchangeRates[cnyLakKey]) {
|
||||
const lakAmount = amount * exchangeRates[xxxLakKey];
|
||||
return lakAmount / exchangeRates[cnyLakKey];
|
||||
}
|
||||
return amount;
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
paid: { color: 'blue', text: '已付款' },
|
||||
pending: { color: 'processing', text: t('paymentRequest.pendingApproval') },
|
||||
approved: { color: 'success', text: t('paymentRequest.approved') },
|
||||
rejected: { color: 'error', text: t('paymentRequest.rejected') },
|
||||
withdrawn: { color: 'default', text: t('paymentRequest.withdrawn') },
|
||||
paid: { color: 'blue', text: t('paymentRequest.paid') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -393,48 +423,48 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '收款单位', dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
{ title: t('paymentRequest.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: t('paymentRequest.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: t('paymentRequest.payee'), dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true },
|
||||
{ title: t('paymentRequest.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '申请日期', dataIndex: 'application_date', key: 'application_date', width: 100, render: (v: string, r: any) => v || r.payment_date },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'request_code', key: 'request_code', width: 120 },
|
||||
{ title: t('paymentRequest.applicationDate'), dataIndex: 'application_date', key: 'application_date', width: 100, render: (v: string, r: any) => v || r.payment_date },
|
||||
{ title: t('paymentRequest.status'), dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: t('paymentRequest.code'), dataIndex: 'request_code', key: 'request_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
title: t('paymentRequest.action'), key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>{t('common.detail')}</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('paymentRequest.edit')}</Button>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>{t('paymentRequest.withdraw')}</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('paymentRequest.reEdit')}</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>{t('paymentRequest.delete')}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 监听表单值变化
|
||||
// 监听表单值变化 - 使用 useState + onValuesChange 替代 Form.useWatch 以确保稳定触发
|
||||
const [watchedAmount, setWatchedAmount] = useState<number | null>(null);
|
||||
const [watchedCurrency, setWatchedCurrency] = useState<string>('CNY');
|
||||
const payeeType = Form.useWatch('payee_type', form);
|
||||
const payeeSelect = Form.useWatch('payee_select', form);
|
||||
const expenseType = Form.useWatch('expense_type', form);
|
||||
const amount = Form.useWatch('amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
|
||||
const amountCNY = React.useMemo(() => {
|
||||
return amount && currency ? convertToCNY(amount, currency) : 0;
|
||||
}, [amount, currency, exchangeRates]);
|
||||
return watchedAmount && watchedCurrency ? convertToCNY(watchedAmount, watchedCurrency) : 0;
|
||||
}, [watchedAmount, watchedCurrency, exchangeRates]);
|
||||
|
||||
// 当选择收款单位时,自动填充收款信息
|
||||
useEffect(() => {
|
||||
@@ -454,45 +484,47 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>付款申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理对外付款申请</p>
|
||||
<h2 style={{ marginBottom: 8 }}>{t('paymentRequest.title')}</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('paymentRequest.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建付款申请</Button>}>
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('paymentRequest.newRequest')}</Button>}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.TabPane tab="活跃申请" key="active">
|
||||
<Tabs.TabPane tab={t('paymentRequest.activeApplications')} key="active">
|
||||
<Table dataSource={requests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="已完结" key="completed">
|
||||
<Tabs.TabPane tab={t('paymentRequest.completed')} key="completed">
|
||||
<Table dataSource={completedRequests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<Modal title={editingId ? '编辑付款申请' : '新建付款申请'} open={modalVisible} onOk={handleSubmit} onCancel={() => {
|
||||
<Modal title={editingId ? t('paymentRequest.editPayment') : t('paymentRequest.newPayment')} open={modalVisible} onOk={handleSubmit} onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft();
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
}
|
||||
}} maskClosable={false} width={900}>
|
||||
}} maskClosable={false} width={900} destroyOnClose>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Form.Item name="applicant" label={t('paymentRequest.applicant')}>
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 第2项:支出类型和支出分类 */}
|
||||
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出类型">
|
||||
<Form.Item name="expense_type" label={t('paymentRequest.expenseType')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('paymentRequest.selectExpenseType')}>
|
||||
{EXPENSE_TYPES.map(type => (
|
||||
<Option key={type.value} value={type.value}>{type.label}</Option>
|
||||
))}
|
||||
@@ -501,8 +533,8 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
|
||||
{/* 项目支出 - 选择项目 */}
|
||||
{expenseType === 'project' && (
|
||||
<Form.Item name="project_id" label="关联项目" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||||
<Form.Item name="project_id" label={t('paymentRequest.relatedProject')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('paymentRequest.selectProject')} showSearch optionFilterProp="children">
|
||||
{projects.map(proj => (
|
||||
<Option key={proj.id} value={proj.id}>{proj.name}</Option>
|
||||
))}
|
||||
@@ -511,8 +543,8 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 支出分类 */}
|
||||
<Form.Item name="expense_category" label="支出分类" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出分类">
|
||||
<Form.Item name="expense_category" label={t('paymentRequest.expenseCategory')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('paymentRequest.selectCategory')}>
|
||||
{(expenseType === 'project' ? PROJECT_EXPENSE_CATEGORIES : COMPANY_EXPENSE_CATEGORIES).map(cat => (
|
||||
<Option key={cat.value} value={cat.value}>{cat.label}</Option>
|
||||
))}
|
||||
@@ -520,13 +552,13 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
{/* 申请日期(原付款日期,不显示) */}
|
||||
<Form.Item name="application_date" label="申请日期" rules={[{ required: true }]} style={{ display: 'none' }}>
|
||||
<Form.Item name="application_date" label={t('paymentRequest.applicationDate')} rules={[{ required: true }]} style={{ display: 'none' }}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 收款单位 - 二级选择 */}
|
||||
<Form.Item name="payee_type" label="收款单位类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择收款单位类型">
|
||||
<Form.Item name="payee_type" label={t('paymentRequest.payeeType')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('paymentRequest.selectPayeeType')}>
|
||||
{PAYEE_TYPES.map(type => (
|
||||
<Option key={type.value} value={type.value}>{type.label}</Option>
|
||||
))}
|
||||
@@ -534,8 +566,8 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
{payeeType === 'subcontractor' && (
|
||||
<Form.Item name="payee_select" label="选择分包商" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择分包商" showSearch optionFilterProp="children">
|
||||
<Form.Item name="payee_select" label={t('paymentRequest.selectSubcontractor')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('paymentRequest.selectSubcontractor')} showSearch optionFilterProp="children">
|
||||
{subcontractors.map(sub => (
|
||||
<Option key={sub.id} value={sub.id}>{sub.name}</Option>
|
||||
))}
|
||||
@@ -544,8 +576,8 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{payeeType === 'supplier' && (
|
||||
<Form.Item name="payee_select" label="选择供应商" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择供应商" showSearch optionFilterProp="children">
|
||||
<Form.Item name="payee_select" label={t('paymentRequest.selectSupplier')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('paymentRequest.selectSupplier')} showSearch optionFilterProp="children">
|
||||
{suppliers.map(sup => (
|
||||
<Option key={sup.id} value={sup.id}>{sup.name}</Option>
|
||||
))}
|
||||
@@ -554,8 +586,8 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{payeeType === 'customer' && (
|
||||
<Form.Item name="payee_select" label="选择客户" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择客户" showSearch optionFilterProp="children">
|
||||
<Form.Item name="payee_select" label={t('paymentRequest.selectCustomer')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('paymentRequest.selectCustomer')} showSearch optionFilterProp="children">
|
||||
{customers.map(cust => (
|
||||
<Option key={cust.id} value={cust.id}>{cust.name}</Option>
|
||||
))}
|
||||
@@ -564,102 +596,102 @@ const PaymentRequestsPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{payeeType === 'other' && (
|
||||
<Form.Item name="payee_input" label="收款单位" rules={[{ required: true }]}>
|
||||
<Input placeholder="手动输入收款单位名称" />
|
||||
<Form.Item name="payee_input" label={t('paymentRequest.payee')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('paymentRequest.payeeNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 收款户名 - 新增字段 */}
|
||||
<Form.Item name="account_name" label="收款户名">
|
||||
<Input placeholder="收款户名(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
<Form.Item name="account_name" label={t('paymentRequest.accountName')}>
|
||||
<Input placeholder={t('paymentRequest.accountNamePlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="bank_account" label="银行账号">
|
||||
<Input placeholder="收款银行账号(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
<Form.Item name="bank_account" label={t('paymentRequest.bankAccount')}>
|
||||
<Input placeholder={t('paymentRequest.bankAccountPlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="bank_name" label="开户银行">
|
||||
<Input placeholder="开户银行名称(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
<Form.Item name="bank_name" label={t('paymentRequest.bankName')}>
|
||||
<Input placeholder={t('paymentRequest.bankNamePlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 收款码 - 新增字段 */}
|
||||
<Form.Item name="qr_code" label="收款码">
|
||||
<Form.Item name="qr_code" label={t('paymentRequest.qrCode')}>
|
||||
<FileUpload maxCount={1} accept="image/*" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 200 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
<Form.Item name="currency" label={t('common.currency')} rules={[{ required: true }]}>
|
||||
<Select style={{ width: 200 }} onChange={(value: string) => setWatchedCurrency(value)}>
|
||||
<Option value="CNY">{t('paymentRequest.currencyCNY')}</Option>
|
||||
<Option value="USD">{t('paymentRequest.currencyUSD')}</Option>
|
||||
<Option value="LAK">{t('paymentRequest.currencyLAK')}</Option>
|
||||
<Option value="THB">{t('paymentRequest.currencyTHB')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* 金额 - 直接输入 */}
|
||||
<Form.Item name="amount" label="付款金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="输入付款金额" />
|
||||
{amount && currency !== 'CNY' && amountCNY > 0 && (
|
||||
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
||||
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
<Form.Item name="amount" label={t('paymentRequest.paymentAmount')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder={t('paymentRequest.paymentAmountPlaceholder')} onChange={(value) => setWatchedAmount(value)} />
|
||||
</Form.Item>
|
||||
{watchedAmount && watchedCurrency !== 'CNY' && amountCNY > 0 && (
|
||||
<div style={{ marginTop: -20, marginBottom: 24, color: '#888', fontSize: 13 }}>
|
||||
{t('paymentRequest.equivalentCNY')}{amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form.Item name="reason" label={t('paymentRequest.paymentReason')} rules={[{ required: true }]}>
|
||||
<TextArea rows={2} placeholder={t('paymentRequest.paymentReasonPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reason" label="付款事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={2} placeholder="付款原因" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>凭证附件</Divider>
|
||||
<Form.Item name="attachments" label="上传凭证附件">
|
||||
<Divider>{t('paymentRequest.proofAttachment')}</Divider>
|
||||
<Form.Item name="attachments" label={t('paymentRequest.uploadProof')}>
|
||||
<FileUpload maxCount={9} accept="image/*" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="付款申请详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
|
||||
<Modal title={t('paymentRequest.detailTitle')} open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={900}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.request_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{selectedRecord.application_date || selectedRecord.payment_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款单位类型">{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款单位">{selectedRecord.payee}</Descriptions.Item>
|
||||
<Descriptions.Item label="收款户名">{selectedRecord.account_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{selectedRecord.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">{selectedRecord.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{selectedRecord.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
<Descriptions.Item label={t('paymentRequest.applicationCode')}>{selectedRecord.request_code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.applicationDate')}>{selectedRecord.application_date || selectedRecord.payment_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.payeeType')}>{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.payee')}>{selectedRecord.payee}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.accountName')}>{selectedRecord.account_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.bankAccount')}>{selectedRecord.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.bankName')}>{selectedRecord.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.expenseType')}>
|
||||
{selectedRecord.expense_type === 'company' ? t('paymentRequest.companyExpense') : t('paymentRequest.projectExpense')}
|
||||
</Descriptions.Item>
|
||||
{selectedRecord.expense_type === 'project' && (
|
||||
<Descriptions.Item label="关联项目">
|
||||
<Descriptions.Item label={t('paymentRequest.relatedProject')}>
|
||||
{projects.find(p => p.id === selectedRecord.project_id)?.name || '-'}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="支出分类">
|
||||
<Descriptions.Item label={t('paymentRequest.expenseCategory')}>
|
||||
{getExpenseCategoryLabel(selectedRecord.expense_type, selectedRecord.expense_category)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
<Descriptions.Item label={t('common.amount')}>
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="付款事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('paymentRequest.paymentReason')} span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{selectedRecord.qr_code && (
|
||||
<>
|
||||
<Divider>收款码</Divider>
|
||||
<Divider>{t('paymentRequest.qrCode')}</Divider>
|
||||
<Image src={selectedRecord.qr_code} width={200} style={{ borderRadius: 4 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<Divider>{t('paymentRequest.proofAttachment')}</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
|
||||
@@ -2,11 +2,13 @@ import React, { useCallback } from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Select, DatePicker, InputNumber, message, Row, Col, Statistic } from 'antd';
|
||||
import { PlusOutlined, SearchOutlined, ShoppingOutlined } from '@ant-design/icons';
|
||||
import useFormDraft from '../hooks/useFormDraft';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const ProcurementPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
@@ -22,15 +24,15 @@ const ProcurementPage: React.FC = () => {
|
||||
}, [saveDraft])
|
||||
|
||||
const columns = [
|
||||
{ title: '采购单号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: '采购日期', dataIndex: 'date', key: 'date', width: 120 },
|
||||
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' },
|
||||
{ title: '物料名称', dataIndex: 'material', key: 'material' },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80 },
|
||||
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100, render: (v: number) => `¥${v}` },
|
||||
{ title: '总金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number) => `¥${v?.toLocaleString()}` },
|
||||
{ title: t('procurement.orderCode'), dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: t('procurement.purchaseDate'), dataIndex: 'date', key: 'date', width: 120 },
|
||||
{ title: t('procurement.supplier'), dataIndex: 'supplier', key: 'supplier' },
|
||||
{ title: t('procurement.materialName'), dataIndex: 'material', key: 'material' },
|
||||
{ title: t('procurement.quantity'), dataIndex: 'quantity', key: 'quantity', width: 80 },
|
||||
{ title: t('procurement.unitPrice'), dataIndex: 'unitPrice', key: 'unitPrice', width: 100, render: (v: number) => `¥${v}` },
|
||||
{ title: t('procurement.totalAmount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number) => `¥${v?.toLocaleString()}` },
|
||||
{
|
||||
title: '状态',
|
||||
title: t('procurement.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
@@ -42,22 +44,22 @@ const ProcurementPage: React.FC = () => {
|
||||
rejected: 'error'
|
||||
};
|
||||
const texts: Record<string, string> = {
|
||||
pending: '待审批',
|
||||
approved: '已批准',
|
||||
received: '已入库',
|
||||
rejected: '已拒绝'
|
||||
pending: t('procurement.pendingApproval'),
|
||||
approved: t('procurement.approved'),
|
||||
received: t('procurement.stocked'),
|
||||
rejected: t('procurement.rejected')
|
||||
};
|
||||
return <Tag color={colors[v]}>{texts[v]}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('procurement.action'),
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" type="link">查看</Button>
|
||||
<Button size="small" type="link">审批</Button>
|
||||
<Button size="small" type="link">{t('procurement.view')}</Button>
|
||||
<Button size="small" type="link">{t('procurement.approve')}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -70,7 +72,7 @@ const ProcurementPage: React.FC = () => {
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
message.success('采购申请已提交');
|
||||
message.success(t('procurement.submitSuccess'));
|
||||
clearDraft()
|
||||
setModalVisible(false);
|
||||
};
|
||||
@@ -79,22 +81,22 @@ const ProcurementPage: React.FC = () => {
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>采购管理</Title>
|
||||
<Paragraph type="secondary">管理采购订单和物料入库</Paragraph>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>{t('procurement.title')}</Title>
|
||||
<Paragraph type="secondary">{t('procurement.description')}</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<RangePicker placeholder={['开始日期', '结束日期']} />
|
||||
<Input.Search placeholder="搜索采购单号" style={{ width: 200 }} />
|
||||
<RangePicker placeholder={[t('procurement.startDate'), t('procurement.endDate')]} />
|
||||
<Input.Search placeholder={t('procurement.searchOrder')} style={{ width: 200 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setModalVisible(true)
|
||||
// 检查是否有草稿,提示用户是否恢复
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的采购信息,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft()
|
||||
},
|
||||
@@ -106,7 +108,7 @@ const ProcurementPage: React.FC = () => {
|
||||
}
|
||||
}, 0)
|
||||
}}>
|
||||
新建采购
|
||||
{t('procurement.newProcurement')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -114,22 +116,22 @@ const ProcurementPage: React.FC = () => {
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="待审批" value={5} prefix={<ShoppingOutlined />} />
|
||||
<Statistic title={t('procurement.pendingApproval')} value={5} prefix={<ShoppingOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="已批准" value={12} valueStyle={{ color: '#1890ff' }} />
|
||||
<Statistic title={t('procurement.approved')} value={12} valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="已入库" value={28} valueStyle={{ color: '#52c41a' }} />
|
||||
<Statistic title={t('procurement.stocked')} value={28} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="本月采购额" value={156000} prefix="¥" />
|
||||
<Statistic title={t('procurement.monthPurchase')} value={156000} prefix="¥" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -139,53 +141,56 @@ const ProcurementPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新建采购申请"
|
||||
title={t('procurement.newApplication')}
|
||||
open={modalVisible}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft()
|
||||
form.resetFields()
|
||||
setModalVisible(false)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
form.resetFields()
|
||||
setModalVisible(false)
|
||||
}
|
||||
}}
|
||||
onOk={handleSubmit}
|
||||
destroyOnClose
|
||||
width={600}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
|
||||
<Form.Item label="供应商" name="supplier" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择供应商" options={[
|
||||
<Form.Item label={t('procurement.supplier')} name="supplier" rules={[{ required: true }]}>
|
||||
<Select placeholder={t('procurement.selectSupplier')} options={[
|
||||
{ value: 'supplier1', label: '老挝电力设备公司' },
|
||||
{ value: 'supplier2', label: '万象建材供应商' },
|
||||
{ value: 'supplier3', label: '沙湾五金店' }
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label="物料名称" name="material" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入物料名称" />
|
||||
<Form.Item label={t('procurement.materialName')} name="material" rules={[{ required: true }]}>
|
||||
<Input placeholder={t('procurement.inputMaterialName')} />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="数量" name="quantity" rules={[{ required: true }]}>
|
||||
<Form.Item label={t('procurement.quantity')} name="quantity" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="单价" name="unitPrice" rules={[{ required: true }]}>
|
||||
<Form.Item label={t('procurement.unitPrice')} name="unitPrice" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} prefix="¥" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="备注" name="remark">
|
||||
<Input.TextArea rows={3} placeholder="请输入备注说明" />
|
||||
<Form.Item label={t('procurement.remark')} name="remark">
|
||||
<Input.TextArea rows={3} placeholder={t('procurement.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
+281
-942
File diff suppressed because it is too large
Load Diff
+175
-172
@@ -1,14 +1,15 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal } from 'antd';
|
||||
import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal, Image, Popconfirm } from 'antd';
|
||||
import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined, EyeOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import useFormDraft from '../hooks/useFormDraft';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
import apiClient from '../utils/request';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { user, setUser } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [form] = Form.useForm();
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -16,96 +17,79 @@ const ProfilePage: React.FC = () => {
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(user?.avatar);
|
||||
const [passportUrl, setPassportUrl] = useState<string | undefined>(user?.passport);
|
||||
const [driverLicenseUrl, setDriverLicenseUrl] = useState<string | undefined>(user?.driverLicense);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// 表单草稿保护
|
||||
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
|
||||
form,
|
||||
storageKey: 'profile_edit',
|
||||
onRestore: (data) => {
|
||||
if (data.avatarUrl !== undefined) setAvatarUrl(data.avatarUrl)
|
||||
if (data.passportUrl !== undefined) setPassportUrl(data.passportUrl)
|
||||
if (data.driverLicenseUrl !== undefined) setDriverLicenseUrl(data.driverLicenseUrl)
|
||||
},
|
||||
})
|
||||
|
||||
const handleFormChange = useCallback(() => {
|
||||
saveDraft({ avatarUrl, passportUrl, driverLicenseUrl })
|
||||
}, [saveDraft, avatarUrl, passportUrl, driverLicenseUrl])
|
||||
|
||||
// 页面级 beforeunload 保护
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (form.isFieldsTouched()) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [form])
|
||||
|
||||
// 页面加载时检查草稿
|
||||
useEffect(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未保存的个人信息修改,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '放弃草稿',
|
||||
onOk: () => {
|
||||
restoreDraft()
|
||||
},
|
||||
onCancel: () => {
|
||||
clearDraft()
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.setFieldsValue({
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
email: user.email
|
||||
});
|
||||
form.setFieldsValue({ name: user.name, phone: user.phone, email: user.email });
|
||||
setAvatarUrl(user.avatar);
|
||||
setPassportUrl(user.passport);
|
||||
setDriverLicenseUrl(user.driverLicense);
|
||||
}
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/users/me');
|
||||
if (res.data.success) {
|
||||
const d = res.data.data;
|
||||
form.setFieldsValue({ name: d.name, phone: d.phone, email: d.email });
|
||||
setAvatarUrl(d.avatar);
|
||||
setPassportUrl(d.passport);
|
||||
setDriverLicenseUrl(d.driverLicense);
|
||||
if (user) {
|
||||
setUser({ ...user, name: d.name, email: d.email, phone: d.phone, avatar: d.avatar, passport: d.passport, driverLicense: d.driverLicense });
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
fetchProfile();
|
||||
}, [user, form]);
|
||||
|
||||
const uploadToCos = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await apiClient.post('/upload/single', formData);
|
||||
if (res.data.success) return res.data.data.url;
|
||||
message.error(t('user.uploadFailed'));
|
||||
return null;
|
||||
} catch (e) {
|
||||
message.error(t('user.uploadFailed'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const response = await apiClient.put(`/users/${user?.id}`, {
|
||||
const response = await apiClient.put(`/users/${user?.id}/profile`, {
|
||||
...values,
|
||||
avatar: avatarUrl,
|
||||
passport: passportUrl,
|
||||
driverLicense: driverLicenseUrl
|
||||
driver_license: driverLicenseUrl
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (data.success) {
|
||||
message.success('个人信息已更新');
|
||||
// 更新authStore中的用户信息
|
||||
message.success(t('user.profileUpdated'));
|
||||
if (user) {
|
||||
const updatedUser = {
|
||||
...user,
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
phone: values.phone,
|
||||
avatar: avatarUrl
|
||||
};
|
||||
setUser(updatedUser);
|
||||
setUser({ ...user, name: values.name, email: values.email, phone: values.phone, avatar: avatarUrl, passport: passportUrl, driverLicense: driverLicenseUrl });
|
||||
}
|
||||
} else {
|
||||
message.error(data.message || '更新失败');
|
||||
message.error(data.message || t('user.updateFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
message.error('更新失败,请重试');
|
||||
message.error(t('user.updateRetry'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -114,200 +98,219 @@ const ProfilePage: React.FC = () => {
|
||||
const handlePasswordSubmit = async () => {
|
||||
try {
|
||||
const values = await passwordForm.validateFields();
|
||||
|
||||
const response = await apiClient.put(`/users/${user?.id}/password`, {
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (data.success) {
|
||||
message.success('密码已更新');
|
||||
message.success(t('user.passwordUpdated'));
|
||||
setPasswordModalVisible(false);
|
||||
passwordForm.resetFields();
|
||||
} else {
|
||||
message.error(data.message || '密码更新失败');
|
||||
message.error(data.message || t('user.passwordUpdateFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
message.error('密码更新失败,请重试');
|
||||
message.error(t('user.passwordUpdateRetry'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (info: any) => {
|
||||
if (info.file.status === 'done') {
|
||||
setAvatarUrl(URL.createObjectURL(info.file.originFileObj));
|
||||
message.success('头像上传成功');
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error('头像上传失败');
|
||||
}
|
||||
const handleAvatarUpload = async (options: any) => {
|
||||
setUploading('avatar');
|
||||
const url = await uploadToCos(options.file);
|
||||
if (url) setAvatarUrl(url);
|
||||
setUploading(null);
|
||||
};
|
||||
|
||||
const handlePassportChange = (info: any) => {
|
||||
if (info.file.status === 'done') {
|
||||
setPassportUrl(URL.createObjectURL(info.file.originFileObj));
|
||||
message.success('护照上传成功');
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error('护照上传失败');
|
||||
}
|
||||
const handlePassportUpload = async (options: any) => {
|
||||
setUploading('passport');
|
||||
const url = await uploadToCos(options.file);
|
||||
if (url) setPassportUrl(url);
|
||||
setUploading(null);
|
||||
};
|
||||
|
||||
const handleDriverLicenseChange = (info: any) => {
|
||||
if (info.file.status === 'done') {
|
||||
setDriverLicenseUrl(URL.createObjectURL(info.file.originFileObj));
|
||||
message.success('驾照上传成功');
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error('驾照上传失败');
|
||||
}
|
||||
const handleDriverLicenseUpload = async (options: any) => {
|
||||
setUploading('driverLicense');
|
||||
const url = await uploadToCos(options.file);
|
||||
if (url) setDriverLicenseUrl(url);
|
||||
setUploading(null);
|
||||
};
|
||||
|
||||
const showPreview = (title: string, url: string) => {
|
||||
setPreviewTitle(title);
|
||||
setPreviewUrl(url);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
const colSpan = isMobile ? 24 : 12;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3}>个人信息</Title>
|
||||
<Paragraph type="secondary">管理个人账号信息</Paragraph>
|
||||
<Title level={3}>{t('user.profile')}</Title>
|
||||
<Paragraph type="secondary">{t('user.managingProfile')}</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Space direction="vertical" style={{ alignItems: 'center' }}>
|
||||
<Upload
|
||||
name="avatar"
|
||||
listType="picture-circle"
|
||||
customRequest={handleAvatarUpload}
|
||||
showUploadList={false}
|
||||
onChange={handleAvatarChange}
|
||||
maxCount={1}
|
||||
accept="image/*"
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Avatar size={128} src={avatarUrl} />
|
||||
<Avatar size={128} src={avatarUrl} style={{ cursor: 'pointer' }} />
|
||||
) : (
|
||||
<Avatar size={128} icon={<UserOutlined />} />
|
||||
<Avatar size={128} icon={<UserOutlined />} style={{ cursor: 'pointer' }} />
|
||||
)}
|
||||
</Upload>
|
||||
<Typography.Text>点击更换头像</Typography.Text>
|
||||
<Typography.Text strong>{user?.name || user?.username}</Typography.Text>
|
||||
<Typography.Text type="secondary">{user?.role === 'admin' ? '管理员' : user?.role === 'manager' ? '经理' : '普通用户'}</Typography.Text>
|
||||
<Text>{t('user.clickToChangeAvatar')}</Text>
|
||||
<Text strong>{user?.name || user?.username}</Text>
|
||||
<Text type="secondary">{user?.role === 'admin' ? t('user.admin') : user?.role === 'manager' ? t('user.manager') : t('user.employee')}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
<Col span={colSpan}>
|
||||
<Form.Item label={t('user.name')} name="name" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
|
||||
<Input placeholder={t('common.inputPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="手机号" name="phone" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
<Input placeholder="请输入手机号" prefix={<PhoneOutlined />} />
|
||||
<Col span={colSpan}>
|
||||
<Form.Item label={t('user.phone')} name="phone" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
|
||||
<Input placeholder={t('common.inputPlaceholder')} prefix={<PhoneOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="邮箱" name="email" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '请输入正确的邮箱地址' }]}>
|
||||
<Input placeholder="请输入邮箱" prefix={<MailOutlined />} />
|
||||
<Col span={colSpan}>
|
||||
<Form.Item label={t('user.email')} name="email" rules={[{ required: true, message: t('common.inputPlaceholder') }, { type: 'email', message: 'Invalid email' }]}>
|
||||
<Input placeholder="email@example.com" prefix={<MailOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="用户名" disabled>
|
||||
<Input value={user?.username} placeholder="用户名" />
|
||||
<Col span={colSpan}>
|
||||
<Form.Item label={t('user.username')} disabled>
|
||||
<Input value={user?.username} placeholder={t('user.username')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={4}>证件上传</Title>
|
||||
<div style={{ marginBottom: 16, marginTop: 8 }}>
|
||||
<Title level={4}>{t('user.idDocument')}</Title>
|
||||
<Text type="secondary">{t('user.idDocTip')}</Text>
|
||||
</div>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="护照">
|
||||
<Upload
|
||||
name="passport"
|
||||
listType="picture"
|
||||
showUploadList={false}
|
||||
onChange={handlePassportChange}
|
||||
maxCount={1}
|
||||
>
|
||||
<Col span={colSpan}>
|
||||
<Card
|
||||
style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9' }}
|
||||
size="small"
|
||||
title={t('user.passport')}
|
||||
extra={passportUrl ? (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => showPreview(t('user.passport'), passportUrl)}>{t('common.view')}</Button>
|
||||
<Upload customRequest={handlePassportUpload} showUploadList={false} accept="image/*">
|
||||
<Button size="small" icon={<ReloadOutlined />} loading={uploading === 'passport'}>{t('user.replace')}</Button>
|
||||
</Upload>
|
||||
<Popconfirm title={t('user.deletePassportConfirm')} okText={t('common.confirm')} cancelText={t('common.cancel')} onConfirm={() => setPassportUrl(undefined)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{passportUrl ? (
|
||||
<img src={passportUrl} alt="护照" style={{ maxWidth: '100%', maxHeight: 200 }} />
|
||||
<div style={{ cursor: 'pointer', textAlign: 'center' }} onClick={() => showPreview(t('user.passport'), passportUrl)}>
|
||||
<img src={passportUrl} alt={t('user.passport')} style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 4, objectFit: 'contain' }} />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ alignItems: 'center' }}>
|
||||
<Upload customRequest={handlePassportUpload} showUploadList={false} accept="image/*">
|
||||
<div style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9', borderRadius: 4, cursor: 'pointer' }}>
|
||||
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
|
||||
<Typography.Text>点击上传护照</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadPassport')}</Text></div>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</Card>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="驾照">
|
||||
<Upload
|
||||
name="driverLicense"
|
||||
listType="picture"
|
||||
showUploadList={false}
|
||||
onChange={handleDriverLicenseChange}
|
||||
maxCount={1}
|
||||
>
|
||||
<Col span={colSpan}>
|
||||
<Card
|
||||
style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9' }}
|
||||
size="small"
|
||||
title={t('user.driverLicense')}
|
||||
extra={driverLicenseUrl ? (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => showPreview(t('user.driverLicense'), driverLicenseUrl)}>{t('common.view')}</Button>
|
||||
<Upload customRequest={handleDriverLicenseUpload} showUploadList={false} accept="image/*">
|
||||
<Button size="small" icon={<ReloadOutlined />} loading={uploading === 'driverLicense'}>{t('user.replace')}</Button>
|
||||
</Upload>
|
||||
<Popconfirm title={t('user.deleteDriverLicenseConfirm')} okText={t('common.confirm')} cancelText={t('common.cancel')} onConfirm={() => setDriverLicenseUrl(undefined)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : null}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{driverLicenseUrl ? (
|
||||
<img src={driverLicenseUrl} alt="驾照" style={{ maxWidth: '100%', maxHeight: 200 }} />
|
||||
<div style={{ cursor: 'pointer', textAlign: 'center' }} onClick={() => showPreview(t('user.driverLicense'), driverLicenseUrl)}>
|
||||
<img src={driverLicenseUrl} alt={t('user.driverLicense')} style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 4, objectFit: 'contain' }} />
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ alignItems: 'center' }}>
|
||||
<Upload customRequest={handleDriverLicenseUpload} showUploadList={false} accept="image/*">
|
||||
<div style={{ textAlign: 'center', padding: 24, border: '1px dashed #d9d9d9', borderRadius: 4, cursor: 'pointer' }}>
|
||||
<UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
|
||||
<Typography.Text>点击上传驾照</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadDriverLicense')}</Text></div>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</Card>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
保存修改
|
||||
</Button>
|
||||
<Button style={{ marginLeft: 16 }} onClick={() => setPasswordModalVisible(true)}>
|
||||
修改密码
|
||||
</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24, gap: 8 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>{t('user.saveProfile')}</Button>
|
||||
<Button onClick={() => setPasswordModalVisible(true)}>{t('user.changePassword')}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="修改密码"
|
||||
title={t('user.changePassword')}
|
||||
open={passwordModalVisible}
|
||||
onCancel={() => setPasswordModalVisible(false)}
|
||||
onOk={handlePasswordSubmit}
|
||||
width={400}
|
||||
>
|
||||
<Form form={passwordForm} layout="vertical">
|
||||
<Form.Item label="当前密码" name="currentPassword" rules={[{ required: true, message: '请输入当前密码' }]}>
|
||||
<Input.Password placeholder="请输入当前密码" prefix={<LockOutlined />} />
|
||||
<Form.Item label={t('user.currentPassword')} name="currentPassword" rules={[{ required: true, message: t('user.currentPasswordPlaceholder') }]}>
|
||||
<Input.Password placeholder={t('user.currentPasswordPlaceholder')} prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度至少为6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码" prefix={<LockOutlined />} />
|
||||
<Form.Item label={t('user.newPassword')} name="newPassword" rules={[{ required: true, message: t('user.newPasswordPlaceholder') }, { min: 6, message: t('user.passwordMinLen') }]}>
|
||||
<Input.Password placeholder={t('user.newPasswordPlaceholder')} prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item label="确认新密码" name="confirmPassword" rules={[{ required: true, message: '请确认新密码' }, ({ getFieldValue }) => ({
|
||||
<Form.Item label={t('user.confirmPassword')} name="confirmPassword" rules={[{ required: true, message: t('user.confirmPasswordPlaceholder') }, ({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
if (!value || getFieldValue('newPassword') === value) return Promise.resolve();
|
||||
return Promise.reject(new Error(t('user.passwordMismatch')));
|
||||
}
|
||||
})]}>
|
||||
<Input.Password placeholder="请确认新密码" prefix={<LockOutlined />} />
|
||||
<Input.Password placeholder={t('user.confirmPasswordPlaceholder')} prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={previewTitle}
|
||||
open={previewVisible}
|
||||
footer={null}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
width={isMobile ? '95%' : 600}
|
||||
centered
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img src={previewUrl} alt={previewTitle} style={{ maxWidth: '100%', maxHeight: '70vh', objectFit: 'contain' }} />
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,249 +1,407 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Table, Button, Card, Row, Col, Statistic, Select, message, Spin, Progress
|
||||
Card, Row, Col, Statistic, Select, message, Spin, Progress, Typography, Tabs, Table, Modal, Tag, DatePicker, Space
|
||||
} from 'antd'
|
||||
import { BarChartOutlined, DollarOutlined, ShoppingOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { RiseOutlined, FallOutlined, UnorderedListOutlined, BarChartOutlined } from '@ant-design/icons'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
import apiClient from '../utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface Project {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
contract_amount: number
|
||||
}
|
||||
const { Title } = Typography
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
interface CostSummary {
|
||||
project_name: string
|
||||
contract_amount: number
|
||||
purchase_cost: {
|
||||
total: number
|
||||
by_category: Record<string, number>
|
||||
}
|
||||
payment_cost: number
|
||||
total_cost: number
|
||||
profit: number
|
||||
}
|
||||
|
||||
// ==================== 组件 ====================
|
||||
const ProjectCostPage: React.FC = () => {
|
||||
// 状态
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const { t } = useLanguageStore()
|
||||
|
||||
const LEVEL2_LABELS = useMemo(() => ({
|
||||
contract_payment: t('finance.projectRevenue'),
|
||||
deposit_refund: t('finance.warrantyReturn'),
|
||||
shareholder_investment: t('finance.shareholderInvestment'),
|
||||
other_income: t('finance.otherIncome'),
|
||||
customer_advance: t('cash.customerAdvance'),
|
||||
bank_loan: t('cash.bankLoan'),
|
||||
other_loan: t('cash.otherLoan'),
|
||||
dividend_income: t('cash.dividendIncome'),
|
||||
interest_income: t('cash.interestIncome'),
|
||||
asset_disposal: t('cash.assetDisposal'),
|
||||
tax_refund: t('cash.taxRefund'),
|
||||
government_subsidy: t('cash.governmentSubsidy'),
|
||||
material: t('finance.materialPurchase'),
|
||||
equipment: t('finance.equipmentPurchase'),
|
||||
subcontract: t('finance.constructionSubcontract'),
|
||||
construction_subcontract: t('finance.constructionSubcontract'),
|
||||
labor: t('finance.laborWage'),
|
||||
travel: t('finance.travelTransport'),
|
||||
accommodation: t('finance.accommodationFood'),
|
||||
freight: t('finance.transportLogistics'),
|
||||
transport_logistics: t('finance.transportLogistics'),
|
||||
design: t('finance.surveyDesign'),
|
||||
survey_design: t('finance.surveyDesign'),
|
||||
tools: t('finance.smallTools'),
|
||||
client_relations: t('finance.customerEDLRelation'),
|
||||
customer_edl: t('finance.customerEDLRelation'),
|
||||
other_project: t('finance.otherProjectExpense'),
|
||||
salary: t('finance.salaryWelfare'),
|
||||
rent: t('finance.rentProperty'),
|
||||
office: t('finance.officeExpense'),
|
||||
commute: t('finance.commute'),
|
||||
vehicle_maintenance: t('finance.vehicleMaintenance'),
|
||||
assets: t('finance.fixedAsset'),
|
||||
marketing: t('finance.marketing'),
|
||||
entertainment: t('finance.entertainment'),
|
||||
welfare: t('finance.employeeBenefit'),
|
||||
logistics: t('finance.expressLogistics'),
|
||||
other_company: t('finance.otherCompanyExpense'),
|
||||
loan_repayment: t('cash.loanRepayment'),
|
||||
interest_expense: t('cash.interestExpense'),
|
||||
dividend_payment: t('cash.dividendPayment'),
|
||||
tax_payment: t('cash.taxPayment'),
|
||||
deposit_payment: t('cash.depositPayment'),
|
||||
owner_expense: t('cash.ownerExpense'),
|
||||
other_finance: t('cash.otherFinance'),
|
||||
}), [t])
|
||||
|
||||
const LEVEL1_LABELS = useMemo(() => ({
|
||||
income: t('finance.incomeCategory'),
|
||||
project: t('finance.projectExpense'),
|
||||
company: t('finance.companyExpense'),
|
||||
finance: t('cash.financeExpense'),
|
||||
}), [t])
|
||||
|
||||
const COUNTERPARTY_LABELS = useMemo(() => ({
|
||||
supplier: t('finance.counterpartySupplier'),
|
||||
subcontractor: t('finance.counterpartySubcontractor'),
|
||||
customer: t('finance.counterpartyCustomer'),
|
||||
employee: t('finance.counterpartyEmployee'),
|
||||
logistics: t('finance.counterpartyLogistics'),
|
||||
shareholder: t('finance.counterpartyShareholder'),
|
||||
bank: t('cash.counterpartyBank'),
|
||||
other: t('finance.counterpartyOther'),
|
||||
}), [t])
|
||||
|
||||
const SOURCE_LABELS = useMemo(() => ({
|
||||
manual: t('finance.manual'),
|
||||
cash_management: t('cash.sourceLabel'),
|
||||
receipt: t('cash.receiptSource'),
|
||||
advance: t('finance.advance'),
|
||||
reimbursement: t('finance.reimbursement'),
|
||||
material: t('finance.material'),
|
||||
primary_freight: t('finance.freight'),
|
||||
secondary_freight: t('finance.freight'),
|
||||
}), [t])
|
||||
|
||||
const [projects, setProjects] = useState<any[]>([])
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null)
|
||||
const [costSummary, setCostSummary] = useState<CostSummary | null>(null)
|
||||
const [costSummary, setCostSummary] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
|
||||
// ==================== 数据加载 ====================
|
||||
const [details, setDetails] = useState<any[]>([])
|
||||
const [detailPagination, setDetailPagination] = useState({ page: 1, pageSize: 20, total: 0 })
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detailFilter, setDetailFilter] = useState<any>({})
|
||||
const [detailDateRange, setDetailDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects')
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setProjects(data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCostSummary = async (projectId: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/cost-summary`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setCostSummary(data.data)
|
||||
} else {
|
||||
message.error('获取成本统计失败')
|
||||
setCostSummary(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取成本统计失败:', error)
|
||||
message.error('获取成本统计失败')
|
||||
setCostSummary(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
const [modalTitle, setModalTitle] = useState('')
|
||||
const [modalRecords, setModalRecords] = useState<any[]>([])
|
||||
const [modalPagination, setModalPagination] = useState({ page: 1, pageSize: 20, total: 0 })
|
||||
const [modalLoading, setModalLoading] = useState(false)
|
||||
const [modalFilter, setModalFilter] = useState<any>({})
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
|
||||
if (res.data.success) setProjects(res.data.data || res.data.projects || [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectId) {
|
||||
fetchCostSummary(selectedProjectId)
|
||||
setLoading(true)
|
||||
apiClient.get(`/projects/${selectedProjectId}/cost-summary`).then(res => {
|
||||
if (res.data.success) setCostSummary(res.data.data)
|
||||
else { message.error(t('projectCost.getDataFailed')); setCostSummary(null) }
|
||||
}).catch(() => { message.error(t('projectCost.getDataFailed')); setCostSummary(null) })
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
setCostSummary(null)
|
||||
}
|
||||
}, [selectedProjectId])
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
const fetchDetails = async (page = 1, filters?: any) => {
|
||||
if (!selectedProjectId) return
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const params: any = { page, pageSize: detailPagination.pageSize, ...filters }
|
||||
if (detailDateRange && detailDateRange[0]) {
|
||||
params.date_from = detailDateRange[0].format('YYYY-MM-DD')
|
||||
params.date_to = detailDateRange[1]?.format('YYYY-MM-DD')
|
||||
}
|
||||
const res = await apiClient.get(`/projects/${selectedProjectId}/financial-details`, { params })
|
||||
if (res.data.success) {
|
||||
setDetails(res.data.data)
|
||||
setDetailPagination(res.data.pagination)
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
setDetailLoading(false)
|
||||
}
|
||||
|
||||
const fetchModalRecords = async (page = 1) => {
|
||||
if (!selectedProjectId) return
|
||||
setModalLoading(true)
|
||||
try {
|
||||
const params: any = { page, pageSize: modalPagination.pageSize, ...modalFilter }
|
||||
const res = await apiClient.get(`/projects/${selectedProjectId}/financial-details`, { params })
|
||||
if (res.data.success) {
|
||||
setModalRecords(res.data.data)
|
||||
setModalPagination(res.data.pagination)
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
setModalLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'details' && selectedProjectId) {
|
||||
fetchDetails(1, detailFilter)
|
||||
}
|
||||
}, [activeTab, selectedProjectId, detailDateRange])
|
||||
|
||||
const handleCategoryClick = (txnType: string, categoryLevel2: string) => {
|
||||
const label = LEVEL2_LABELS[categoryLevel2] || categoryLevel2
|
||||
setModalTitle(`${label} - ${txnType === 'income' ? t('finance.income') : t('finance.expense')}${t('projectCost.detail')}`)
|
||||
const filter = { txn_type: txnType, category_level2: categoryLevel2 }
|
||||
setModalFilter(filter)
|
||||
setModalPagination(prev => ({ ...prev, page: 1, total: 0 }))
|
||||
setModalVisible(true)
|
||||
setModalLoading(true)
|
||||
apiClient.get(`/projects/${selectedProjectId}/financial-details`, { params: { page: 1, pageSize: 20, ...filter } })
|
||||
.then(res => {
|
||||
if (res.data.success) {
|
||||
setModalRecords(res.data.data)
|
||||
setModalPagination(res.data.pagination)
|
||||
}
|
||||
})
|
||||
.catch(() => message.error(t('projectCost.getDataFailed')))
|
||||
.finally(() => setModalLoading(false))
|
||||
}
|
||||
|
||||
const profitRate = costSummary && costSummary.income?.total > 0
|
||||
? ((costSummary.profit / costSummary.income.total) * 100).toFixed(1) : '0.0'
|
||||
const costRate = costSummary && costSummary.contract_amount > 0
|
||||
? ((costSummary.total_cost / costSummary.contract_amount) * 100).toFixed(1) : '0.0'
|
||||
|
||||
const detailColumns = [
|
||||
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
|
||||
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? t('finance.income') : t('finance.expense')}</Tag> },
|
||||
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
|
||||
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
|
||||
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const },
|
||||
{ title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
|
||||
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
|
||||
{ title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_LABELS[v] || v}</Tag> },
|
||||
]
|
||||
|
||||
const modalColumns = [
|
||||
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
|
||||
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 110, render: (v: number, r: any) => `${v?.toLocaleString()} ${r.currency}` },
|
||||
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
|
||||
{ title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
|
||||
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
|
||||
{ title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_LABELS[v] || v}</Tag> },
|
||||
]
|
||||
|
||||
const renderOverview = () => {
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: 40 }}><Spin size="large" /></div>
|
||||
if (!costSummary) return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>{t('projectCost.selectProject')}</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title={t('projectCost.contractAmount')} value={costSummary.contract_amount} precision={2} prefix="¥" valueStyle={{ color: '#1890ff', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title={t('projectCost.totalIncome')} value={costSummary.income?.total} precision={2} prefix="¥" valueStyle={{ color: '#52c41a', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title={t('projectCost.totalExpense')} value={costSummary.total_cost} precision={2} prefix="¥" valueStyle={{ color: '#fa541c', fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title={t('projectCost.profit')} value={costSummary.profit} precision={2} prefix="¥" valueStyle={{ color: costSummary.profit >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 20 }} />
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>{t('projectCost.profitRate')}: {profitRate}%</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title={t('projectCost.costProgress')} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span>{t('projectCost.costRatio')}</span>
|
||||
<span>{costRate}%</span>
|
||||
</div>
|
||||
<Progress percent={parseFloat(costRate)} strokeColor={parseFloat(costRate) > 80 ? '#ff4d4f' : parseFloat(costRate) > 60 ? '#faad14' : '#52c41a'} />
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title={<span><RiseOutlined style={{ color: '#52c41a', marginRight: 8 }} />{t('projectCost.incomeBreakdown')}</span>} size="small">
|
||||
{costSummary.income && Object.keys(costSummary.income.by_category).length > 0 ? (
|
||||
Object.entries(costSummary.income.by_category).map(([category, amount]: [string, any]) => (
|
||||
<div key={category} style={{ marginBottom: 12, cursor: 'pointer' }} onClick={() => handleCategoryClick('income', category)}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#1890ff', textDecoration: 'underline' }}>{LEVEL2_LABELS[category] || category}</span>
|
||||
<span>¥{(amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<Progress percent={costSummary.income.total > 0 ? (amount / costSummary.income.total) * 100 : 0} strokeColor="#52c41a" size="small" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: '#999' }}>{t('projectCost.noData')}</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title={<span><FallOutlined style={{ color: '#fa541c', marginRight: 8 }} />{t('projectCost.expenseBreakdown')}</span>} size="small">
|
||||
{costSummary.expense && Object.keys(costSummary.expense.by_category).length > 0 ? (
|
||||
Object.entries(costSummary.expense.by_category).map(([category, amount]: [string, any]) => (
|
||||
<div key={category} style={{ marginBottom: 12, cursor: 'pointer' }} onClick={() => handleCategoryClick('expense', category)}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#1890ff', textDecoration: 'underline' }}>{LEVEL2_LABELS[category] || category}</span>
|
||||
<span>¥{(amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||
</div>
|
||||
<Progress percent={costSummary.expense.total > 0 ? (amount / costSummary.expense.total) * 100 : 0} strokeColor="#fa541c" size="small" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: '#999' }}>{t('projectCost.noData')}</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{costSummary.expense && Object.keys(costSummary.expense.by_level1 || {}).length > 0 && (
|
||||
<Card title={t('projectCost.expenseByLevel1')} size="small" style={{ marginTop: 16 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{Object.entries(costSummary.expense.by_level1).map(([level1, amount]: [string, any]) => (
|
||||
<Col xs={8} sm={8} key={level1}>
|
||||
<Statistic title={LEVEL1_LABELS[level1] || level1} value={amount} precision={2} prefix="¥" valueStyle={{ fontSize: 16 }} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDetails = () => {
|
||||
if (!selectedProjectId) return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>{t('projectCost.selectProject')}</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space size="small" wrap>
|
||||
<RangePicker size="small" onChange={(dates) => setDetailDateRange(dates as any)} />
|
||||
<Select size="small" allowClear placeholder={t('finance.incomeType')} style={{ width: 100 }}
|
||||
onChange={(v) => { const f = { ...detailFilter, txn_type: v }; if (!v) delete f.txn_type; setDetailFilter(f); fetchDetails(1, f); }}
|
||||
options={[{ value: 'income', label: t('finance.income') }, { value: 'expense', label: t('finance.expense') }]}
|
||||
/>
|
||||
<Select size="small" allowClear placeholder={t('finance.level1Category')} style={{ width: 120 }}
|
||||
onChange={(v) => { const f = { ...detailFilter, category_level1: v }; if (!v) delete f.category_level1; setDetailFilter(f); fetchDetails(1, f); }}
|
||||
options={[
|
||||
{ value: 'income', label: t('finance.incomeCategory') },
|
||||
{ value: 'project', label: t('finance.projectExpense') },
|
||||
{ value: 'company', label: t('finance.companyExpense') },
|
||||
{ value: 'finance', label: t('cash.financeExpense') },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small" styles={{ body: { padding: 0 } }}>
|
||||
<Spin spinning={detailLoading}>
|
||||
<Table
|
||||
dataSource={details}
|
||||
columns={detailColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: detailPagination.page,
|
||||
pageSize: detailPagination.pageSize,
|
||||
total: detailPagination.total,
|
||||
onChange: (page) => fetchDetails(page, detailFilter),
|
||||
showTotal: (total) => t('finance.totalRecords', { total })
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: <span><BarChartOutlined /> {t('projectCost.overviewTab')}</span>,
|
||||
children: renderOverview(),
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
label: <span><UnorderedListOutlined /> {t('projectCost.detailsTab')}</span>,
|
||||
children: renderDetails(),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Title level={3} style={{ marginBottom: 24 }}>{t('projectCost.title')}</Title>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="请选择项目查看成本统计"
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('projectCost.selectProject')}
|
||||
style={{ width: '100%', maxWidth: 500 }}
|
||||
showSearch optionFilterProp="label"
|
||||
value={selectedProjectId}
|
||||
onChange={(value) => setSelectedProjectId(value)}
|
||||
onChange={setSelectedProjectId}
|
||||
options={projects.map(p => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} type="card" />
|
||||
|
||||
<Modal
|
||||
title={modalTitle}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
destroyOnClose
|
||||
>
|
||||
{projects.map(project => (
|
||||
<Select.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : costSummary ? (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="合同金额"
|
||||
value={costSummary.contract_amount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
<Spin spinning={modalLoading}>
|
||||
<Table
|
||||
dataSource={modalRecords}
|
||||
columns={modalColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
pagination={{
|
||||
current: modalPagination.page,
|
||||
pageSize: modalPagination.pageSize,
|
||||
total: modalPagination.total,
|
||||
onChange: (page) => fetchModalRecords(page),
|
||||
showTotal: (total) => t('finance.totalRecords', { total })
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="采购成本"
|
||||
value={costSummary.purchase_cost.total}
|
||||
precision={2}
|
||||
prefix={<ShoppingOutlined />}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="付款支出"
|
||||
value={costSummary.payment_cost}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined />}
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="利润"
|
||||
value={costSummary.profit}
|
||||
precision={2}
|
||||
prefix={<BarChartOutlined />}
|
||||
valueStyle={{ color: costSummary.profit >= 0 ? '#52c41a' : '#ff4d4f' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="总成本构成" style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>合同金额</span>
|
||||
<span>{(costSummary.contract_amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={100}
|
||||
status="active"
|
||||
strokeColor="#1890ff"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>采购成本</span>
|
||||
<span>{(costSummary.purchase_cost.total || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.contract_amount > 0 ? Math.min((costSummary.purchase_cost.total / costSummary.contract_amount) * 100, 100) : 0}
|
||||
status="normal"
|
||||
strokeColor="#faad14"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>付款支出</span>
|
||||
<span>{(costSummary.payment_cost || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.contract_amount > 0 ? Math.min((costSummary.payment_cost / costSummary.contract_amount) * 100, 100) : 0}
|
||||
status="normal"
|
||||
strokeColor="#fa8c16"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>总成本</span>
|
||||
<span>{costSummary.total_cost.toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.contract_amount > 0 ? Math.min((costSummary.total_cost / costSummary.contract_amount) * 100, 100) : 0}
|
||||
status="normal"
|
||||
strokeColor={costSummary.profit >= 0 ? '#52c41a' : '#ff4d4f'}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="采购成本分类">
|
||||
{Object.keys(costSummary.purchase_cost.by_category).length > 0 ? (
|
||||
<div>
|
||||
{Object.entries(costSummary.purchase_cost.by_category).map(([category, amount]) => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
material: '材料',
|
||||
equipment: '设备',
|
||||
pole: '电杆',
|
||||
other: '其他'
|
||||
}
|
||||
return (
|
||||
<div key={category} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span>{categoryMap[category] || category}</span>
|
||||
<span>{(amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={costSummary.purchase_cost.total > 0 ? (amount / costSummary.purchase_cost.total) * 100 : 0}
|
||||
status="normal"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: '#999' }}>
|
||||
暂无采购成本数据
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
|
||||
请选择项目查看成本统计
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface PurchaseOrder {
|
||||
id: number
|
||||
@@ -94,6 +95,7 @@ interface Supplier { id: number; name: string; country: string }
|
||||
interface Product { id: number; name: string; specification: string; unit: string }
|
||||
|
||||
const PurchaseOrdersPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [orders, setOrders] = useState<PurchaseOrder[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
@@ -128,11 +130,11 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
if (data.success) {
|
||||
setOrders(data.data)
|
||||
} else {
|
||||
message.error('获取采购订单列表失败')
|
||||
message.error(t('purchaseOrder.getListFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取采购订单列表失败:', error)
|
||||
message.error('获取采购订单列表失败')
|
||||
message.error(t('purchaseOrder.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -177,11 +179,11 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
setDetailModalVisible(true)
|
||||
setActiveDetailTab('basic')
|
||||
} else {
|
||||
message.error('获取订单详情失败')
|
||||
message.error(t('purchaseOrder.getDetailFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单详情失败:', error)
|
||||
message.error('获取订单详情失败')
|
||||
message.error(t('purchaseOrder.getDetailFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,14 +206,14 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('订单确认成功')
|
||||
message.success(t('purchaseOrder.orderConfirmSuccess'))
|
||||
fetchOrders()
|
||||
} else {
|
||||
message.error(data.message || '订单确认失败')
|
||||
message.error(data.message || t('purchaseOrder.orderConfirmFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('订单确认失败:', error)
|
||||
message.error('订单确认失败')
|
||||
message.error(t('purchaseOrder.orderConfirmFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,14 +222,14 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
const response = await fetch(`/api/purchase-orders/${id}/cancel`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('订单已取消')
|
||||
message.success(t('purchaseOrder.orderCancelSuccess'))
|
||||
fetchOrders()
|
||||
} else {
|
||||
message.error('取消订单失败')
|
||||
message.error(t('purchaseOrder.orderCancelFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消订单失败:', error)
|
||||
message.error('取消订单失败')
|
||||
message.error(t('purchaseOrder.orderCancelFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,14 +238,14 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
const response = await fetch(`/api/purchase-orders/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('订单删除成功')
|
||||
message.success(t('purchaseOrder.orderDeleteSuccess'))
|
||||
fetchOrders()
|
||||
} else {
|
||||
message.error('删除订单失败')
|
||||
message.error(t('purchaseOrder.orderDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除订单失败:', error)
|
||||
message.error('删除订单失败')
|
||||
message.error(t('purchaseOrder.orderDeleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,11 +277,11 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(editingItem ? '商品更新成功' : '商品添加成功')
|
||||
message.success(editingItem ? t('purchaseOrder.productUpdateSuccess') : t('purchaseOrder.productAddSuccess'))
|
||||
setItemModalVisible(false)
|
||||
fetchOrderDetail(currentOrder!.id)
|
||||
} else {
|
||||
message.error('操作失败')
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存商品失败:', error)
|
||||
@@ -293,10 +295,10 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('商品删除成功')
|
||||
message.success(t('purchaseOrder.productDeleteSuccess'))
|
||||
fetchOrderDetail(currentOrder!.id)
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
message.error(t('common.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除商品失败:', error)
|
||||
@@ -338,11 +340,11 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(editingPayment ? '付款计划更新成功' : '付款计划添加成功')
|
||||
message.success(editingPayment ? t('purchaseOrder.paymentPlanUpdateSuccess') : t('purchaseOrder.paymentPlanAddSuccess'))
|
||||
setPaymentModalVisible(false)
|
||||
fetchOrderDetail(currentOrder!.id)
|
||||
} else {
|
||||
message.error('操作失败')
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存付款计划失败:', error)
|
||||
@@ -356,10 +358,10 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success('付款计划删除成功')
|
||||
message.success(t('purchaseOrder.paymentPlanDeleteSuccess'))
|
||||
fetchOrderDetail(currentOrder!.id)
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
message.error(t('common.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除付款计划失败:', error)
|
||||
@@ -368,14 +370,14 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
draft: { color: 'default', text: '草稿' },
|
||||
confirmed: { color: 'blue', text: '已确认' },
|
||||
partial_paid: { color: 'orange', text: '部分付款' },
|
||||
paid: { color: 'green', text: '已付清' },
|
||||
shipping: { color: 'cyan', text: '物流中' },
|
||||
verified: { color: 'purple', text: '已验收' },
|
||||
closed: { color: 'success', text: '已关闭' },
|
||||
cancelled: { color: 'error', text: '已取消' }
|
||||
draft: { color: 'default', text: t('purchaseOrder.draft') },
|
||||
confirmed: { color: 'blue', text: t('purchaseOrder.confirmed') },
|
||||
partial_paid: { color: 'orange', text: t('purchaseOrder.partialPayment') },
|
||||
paid: { color: 'green', text: t('purchaseOrder.paidOff') },
|
||||
shipping: { color: 'cyan', text: t('purchaseOrder.inTransit') },
|
||||
verified: { color: 'purple', text: t('purchaseOrder.accepted') },
|
||||
closed: { color: 'success', text: t('purchaseOrder.closed') },
|
||||
cancelled: { color: 'error', text: t('purchaseOrder.cancelled') }
|
||||
}
|
||||
const info = statusMap[status] || { color: 'default', text: status }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
@@ -390,7 +392,7 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<PurchaseOrder> = [
|
||||
{
|
||||
title: '订单号',
|
||||
title: t('purchaseOrder.orderCode'),
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 150,
|
||||
@@ -399,21 +401,21 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
title: t('purchaseOrder.supplier'),
|
||||
dataIndex: 'supplier_name',
|
||||
key: 'supplier_name',
|
||||
width: 140,
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: '项目',
|
||||
title: t('purchaseOrder.relatedProject'),
|
||||
dataIndex: 'project_name',
|
||||
key: 'project_name',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
title: t('purchaseOrder.amount'),
|
||||
dataIndex: 'total_amount',
|
||||
key: 'total_amount',
|
||||
width: 140,
|
||||
@@ -428,7 +430,7 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '已付',
|
||||
title: t('purchaseOrder.paid'),
|
||||
dataIndex: 'paid_amount',
|
||||
key: 'paid_amount',
|
||||
width: 120,
|
||||
@@ -440,7 +442,7 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
title: t('purchaseOrder.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
@@ -448,14 +450,14 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
render: getStatusTag
|
||||
},
|
||||
{
|
||||
title: '创建日期',
|
||||
title: t('purchaseOrder.createDate'),
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 100,
|
||||
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('purchaseOrder.action'),
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
@@ -464,14 +466,14 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchOrderDetail(record.id)} />
|
||||
{record.status === 'draft' && (
|
||||
<>
|
||||
<Button size="small" type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} onClick={() => handleConfirmOrder(record.id)}>确认</Button>
|
||||
<Popconfirm title="确定要取消吗?" onConfirm={() => handleCancelOrder(record.id)}>
|
||||
<Button size="small" type="text" danger>取消</Button>
|
||||
<Button size="small" type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} onClick={() => handleConfirmOrder(record.id)}>{t('purchaseOrder.confirm')}</Button>
|
||||
<Popconfirm title={t('purchaseOrder.confirmCancel')} onConfirm={() => handleCancelOrder(record.id)}>
|
||||
<Button size="small" type="text" danger>{t('purchaseOrder.cancel')}</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'cancelled' && (
|
||||
<Popconfirm title="确定要删除吗?" onConfirm={() => handleDeleteOrder(record.id)}>
|
||||
<Popconfirm title={t('purchaseOrder.confirmDelete')} onConfirm={() => handleDeleteOrder(record.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
@@ -481,20 +483,20 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
]
|
||||
|
||||
const itemColumns: ColumnsType<OrderItem> = [
|
||||
{ title: '商品名称', dataIndex: 'product_name', key: 'product_name', width: 150 },
|
||||
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 100 },
|
||||
{ title: '单位', dataIndex: 'unit', key: 'unit', width: 60 },
|
||||
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80, align: 'right' },
|
||||
{ title: '单价', dataIndex: 'unit_price', key: 'unit_price', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '小计', dataIndex: 'total_price', key: 'total_price', width: 120, align: 'right', render: (v: number) => <strong>{v?.toFixed(2)}</strong> },
|
||||
{ title: t('purchaseOrder.productName'), dataIndex: 'product_name', key: 'product_name', width: 150 },
|
||||
{ title: t('purchaseOrder.spec'), dataIndex: 'specification', key: 'specification', width: 100 },
|
||||
{ title: t('purchaseOrder.unit'), dataIndex: 'unit', key: 'unit', width: 60 },
|
||||
{ title: t('purchaseOrder.quantity'), dataIndex: 'quantity', key: 'quantity', width: 80, align: 'right' },
|
||||
{ title: t('purchaseOrder.unitPrice'), dataIndex: 'unit_price', key: 'unit_price', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: t('purchaseOrder.subtotal'), dataIndex: 'total_price', key: 'total_price', width: 120, align: 'right', render: (v: number) => <strong>{v?.toFixed(2)}</strong> },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('purchaseOrder.action'),
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => currentOrder?.status === 'draft' && (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditItem(record)} />
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteItem(record.id)}>
|
||||
<Popconfirm title={t('purchaseOrder.confirmDeleteShort')} onConfirm={() => handleDeleteItem(record.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -503,28 +505,28 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
]
|
||||
|
||||
const paymentColumns: ColumnsType<PaymentPlan> = [
|
||||
{ title: '阶段', dataIndex: 'stage', key: 'stage', width: 80 },
|
||||
{ title: '计划日期', dataIndex: 'planned_date', key: 'planned_date', width: 100 },
|
||||
{ title: '计划金额', dataIndex: 'planned_amount', key: 'planned_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: '比例%', dataIndex: 'planned_percentage', key: 'planned_percentage', width: 80, align: 'right' },
|
||||
{ title: '实际金额', dataIndex: 'actual_amount', key: 'actual_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => {
|
||||
{ title: t('purchaseOrder.phase'), dataIndex: 'stage', key: 'stage', width: 80 },
|
||||
{ title: t('purchaseOrder.plannedDate'), dataIndex: 'planned_date', key: 'planned_date', width: 100 },
|
||||
{ title: t('purchaseOrder.plannedAmount'), dataIndex: 'planned_amount', key: 'planned_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) },
|
||||
{ title: t('purchaseOrder.ratioPercent'), dataIndex: 'planned_percentage', key: 'planned_percentage', width: 80, align: 'right' },
|
||||
{ title: t('purchaseOrder.actualAmount'), dataIndex: 'actual_amount', key: 'actual_amount', width: 120, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
|
||||
{ title: t('purchaseOrder.status'), dataIndex: 'status', key: 'status', width: 80, render: (s: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待付款' },
|
||||
requested: { color: 'blue', text: '已申请' },
|
||||
paid: { color: 'green', text: '已支付' }
|
||||
pending: { color: 'default', text: t('purchaseOrder.pendingPayment') },
|
||||
requested: { color: 'blue', text: t('purchaseOrder.applied') },
|
||||
paid: { color: 'green', text: t('purchaseOrder.paidStatus') }
|
||||
}
|
||||
const info = map[s] || { color: 'default', text: s }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
}},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('purchaseOrder.action'),
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => record.status === 'pending' && (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditPayment(record)} />
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeletePayment(record.id)}>
|
||||
<Popconfirm title={t('purchaseOrder.confirmDeleteShort')} onConfirm={() => handleDeletePayment(record.id)}>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -533,46 +535,46 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
]
|
||||
|
||||
const logisticsColumns: ColumnsType<LogisticsRecord> = [
|
||||
{ title: '物流单号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: '发货地', dataIndex: 'ship_from', key: 'ship_from', width: 80, render: (v: string) => v === 'China' ? '中国' : '老挝' },
|
||||
{ title: '物流公司', dataIndex: 'logistics_company_name', key: 'logistics_company_name', width: 120 },
|
||||
{ title: '发货日期', dataIndex: 'ship_date', key: 'ship_date', width: 100 },
|
||||
{ title: '一次运费', dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
|
||||
{ title: '二次运费', dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
|
||||
{ title: t('purchaseOrder.trackingNumber'), dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: t('purchaseOrder.origin'), dataIndex: 'ship_from', key: 'ship_from', width: 80, render: (v: string) => v === 'China' ? t('purchaseOrder.china') : t('purchaseOrder.laos') },
|
||||
{ title: t('purchaseOrder.logisticsCompany'), dataIndex: 'logistics_company_name', key: 'logistics_company_name', width: 120 },
|
||||
{ title: t('purchaseOrder.deliveryDate'), dataIndex: 'ship_date', key: 'ship_date', width: 100 },
|
||||
{ title: t('purchaseOrder.freight1'), dataIndex: 'primary_freight', key: 'primary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
|
||||
{ title: t('purchaseOrder.freight2'), dataIndex: 'secondary_freight', key: 'secondary_freight', width: 100, align: 'right', render: (v: number) => v?.toFixed(2) || '-' },
|
||||
{ title: t('purchaseOrder.status'), dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
|
||||
]
|
||||
|
||||
const verificationColumns: ColumnsType<VerificationRecord> = [
|
||||
{ title: '验收单号', dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: '验收日期', dataIndex: 'verification_date', key: 'verification_date', width: 100 },
|
||||
{ title: '验收人', dataIndex: 'verifier', key: 'verifier', width: 80 },
|
||||
{ title: '验收数量', dataIndex: 'total_verified', key: 'total_verified', width: 100, align: 'right' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
|
||||
{ title: t('purchaseOrder.acceptanceCode'), dataIndex: 'code', key: 'code', width: 120 },
|
||||
{ title: t('purchaseOrder.acceptanceDate'), dataIndex: 'verification_date', key: 'verification_date', width: 100 },
|
||||
{ title: t('purchaseOrder.acceptor'), dataIndex: 'verifier', key: 'verifier', width: 80 },
|
||||
{ title: t('purchaseOrder.acceptedQty'), dataIndex: 'total_verified', key: 'total_verified', width: 100, align: 'right' },
|
||||
{ title: t('purchaseOrder.status'), dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>采购订单</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理采购订单(多TAB:基本信息、商品明细、付款信息、物流信息、验收记录)</p>
|
||||
<h2 style={{ marginBottom: 8 }}>{t('purchaseOrder.title')}</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('purchaseOrder.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Select placeholder="选择项目筛选" allowClear style={{ width: '100%' }} onChange={(v) => setSelectedProjectId(v)}>
|
||||
<Select placeholder={t('purchaseOrder.selectProject')} allowClear style={{ width: '100%' }} onChange={(v) => setSelectedProjectId(v)}>
|
||||
{projects.map(p => <Select.Option key={p.id} value={p.id}>{p.name}</Select.Option>)}
|
||||
</Select>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Select placeholder="选择状态筛选" allowClear style={{ width: '100%' }} onChange={(v) => setSelectedStatus(v)}>
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="confirmed">已确认</Select.Option>
|
||||
<Select.Option value="partial_paid">部分付款</Select.Option>
|
||||
<Select.Option value="paid">已付清</Select.Option>
|
||||
<Select.Option value="shipping">物流中</Select.Option>
|
||||
<Select.Option value="verified">已验收</Select.Option>
|
||||
<Select.Option value="cancelled">已取消</Select.Option>
|
||||
<Select placeholder={t('purchaseOrder.selectStatus')} allowClear style={{ width: '100%' }} onChange={(v) => setSelectedStatus(v)}>
|
||||
<Select.Option value="draft">{t('purchaseOrder.draft')}</Select.Option>
|
||||
<Select.Option value="confirmed">{t('purchaseOrder.confirmed')}</Select.Option>
|
||||
<Select.Option value="partial_paid">{t('purchaseOrder.partialPayment')}</Select.Option>
|
||||
<Select.Option value="paid">{t('purchaseOrder.paidOff')}</Select.Option>
|
||||
<Select.Option value="shipping">{t('purchaseOrder.inTransit')}</Select.Option>
|
||||
<Select.Option value="verified">{t('purchaseOrder.accepted')}</Select.Option>
|
||||
<Select.Option value="cancelled">{t('purchaseOrder.cancelled')}</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -581,7 +583,7 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
|
||||
{/* 订单详情弹窗 - 多TAB */}
|
||||
<Modal
|
||||
title={`采购订单详情 - ${currentOrder?.code || ''}`}
|
||||
title={t('purchaseOrder.detailTitle').replace('{code}', currentOrder?.code || '')}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={null}
|
||||
@@ -590,59 +592,59 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
{currentOrder && (
|
||||
<Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}>
|
||||
{/* TAB1: 基本信息 */}
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> 基本信息</span>} key="basic">
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('purchaseOrder.basicInfo')}</span>} key="basic">
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="订单号">{currentOrder.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(currentOrder.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="供应商">{currentOrder.supplier_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="供应商国家">{currentOrder.supplier_country === 'China' ? '中国' : (currentOrder.supplier_country || '老挝')}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目">{currentOrder.project_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{currentOrder.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="预计金额">
|
||||
<Descriptions.Item label={t('purchaseOrder.orderCode')}>{currentOrder.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseOrder.status')}>{getStatusTag(currentOrder.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseOrder.supplier')}>{currentOrder.supplier_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseOrder.supplierCountry')}>{currentOrder.supplier_country === 'China' ? t('purchaseOrder.china') : (currentOrder.supplier_country || t('purchaseOrder.laos'))}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseOrder.relatedProject')}>{currentOrder.project_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.currency')}>{currentOrder.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseOrder.estimatedAmount')}>
|
||||
<span style={{ color: '#999' }}>{currentOrder.estimated_amount?.toFixed(2) || '0.00'}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订单金额">
|
||||
<Descriptions.Item label={t('purchaseOrder.orderAmount')}>
|
||||
<span style={{ fontWeight: 'bold', color: getAmountColor(currentOrder.status) }}>
|
||||
{currentOrder.total_amount?.toFixed(2) || '0.00'}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已付金额">
|
||||
<Descriptions.Item label={t('purchaseOrder.paidAmount')}>
|
||||
<span style={{ color: '#52c41a' }}>{currentOrder.paid_amount?.toFixed(2) || '0.00'}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{currentOrder.created_at}</Descriptions.Item>
|
||||
{currentOrder.remark && <Descriptions.Item label="备注" span={2}>{currentOrder.remark}</Descriptions.Item>}
|
||||
<Descriptions.Item label={t('purchaseOrder.createdAt')}>{currentOrder.created_at}</Descriptions.Item>
|
||||
{currentOrder.remark && <Descriptions.Item label={t('purchaseOrder.remark')} span={2}>{currentOrder.remark}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB2: 商品明细 */}
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> 商品明细</span>} key="items">
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('purchaseOrder.productDetails')}</span>} key="items">
|
||||
{currentOrder.status === 'draft' && (
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddItem} style={{ marginBottom: 16 }}>添加商品</Button>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddItem} style={{ marginBottom: 16 }}>{t('purchaseOrder.addProduct')}</Button>
|
||||
)}
|
||||
<Table columns={itemColumns} dataSource={currentOrder.items || []} rowKey="id" pagination={false} size="small" />
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<strong>商品总计:{currentOrder.currency} {(currentOrder.items || []).reduce((sum, item) => sum + (item.total_price || 0), 0).toFixed(2)}</strong>
|
||||
<strong>{t('purchaseOrder.productTotal')}{currentOrder.currency} {(currentOrder.items || []).reduce((sum, item) => sum + (item.total_price || 0), 0).toFixed(2)}</strong>
|
||||
</div>
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB3: 付款信息 */}
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> 付款信息</span>} key="payment">
|
||||
<Tabs.TabPane tab={<span><FileTextOutlined /> {t('purchaseOrder.paymentInfo')}</span>} key="payment">
|
||||
{currentOrder.status !== 'draft' && currentOrder.status !== 'cancelled' && (
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}>添加付款计划</Button>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}>{t('purchaseOrder.addPaymentPlan')}</Button>
|
||||
)}
|
||||
<Table columns={paymentColumns} dataSource={currentOrder.payment_plans || []} rowKey="id" pagination={false} size="small" />
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB4: 物流信息 */}
|
||||
<Tabs.TabPane tab={<span><CarOutlined /> 物流信息</span>} key="logistics">
|
||||
<Tabs.TabPane tab={<span><CarOutlined /> {t('purchaseOrder.logisticsInfo')}</span>} key="logistics">
|
||||
<Table columns={logisticsColumns} dataSource={currentOrder.logistics || []} rowKey="id" pagination={false} size="small" />
|
||||
{(currentOrder.logistics || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}>暂无物流信息</div>}
|
||||
{(currentOrder.logistics || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}>{t('purchaseOrder.noLogistics')}</div>}
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB5: 验收记录 */}
|
||||
<Tabs.TabPane tab={<span><SafetyCertificateOutlined /> 验收记录</span>} key="verification">
|
||||
<Tabs.TabPane tab={<span><SafetyCertificateOutlined /> {t('purchaseOrder.acceptanceRecord')}</span>} key="verification">
|
||||
<Table columns={verificationColumns} dataSource={currentOrder.verifications || []} rowKey="id" pagination={false} size="small" />
|
||||
{(currentOrder.verifications || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}>暂无验收记录</div>}
|
||||
{(currentOrder.verifications || []).length === 0 && <div style={{ textAlign: 'center', color: '#999', padding: 20 }}>{t('purchaseOrder.noAcceptance')}</div>}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
)}
|
||||
@@ -650,17 +652,21 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
|
||||
{/* 商品明细编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingItem ? '编辑商品' : '添加商品'}
|
||||
title={editingItem ? t('purchaseOrder.editProduct') : t('purchaseOrder.addProductTitle')}
|
||||
open={itemModalVisible}
|
||||
onOk={handleSaveItem}
|
||||
onCancel={() => setItemModalVisible(false)}
|
||||
onCancel={() => {
|
||||
itemForm.resetFields();
|
||||
setItemModalVisible(false);
|
||||
}}
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<Form form={itemForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="product_id" label="选择商品">
|
||||
<Select placeholder="选择商品" allowClear showSearch optionFilterProp="children" onChange={(v) => {
|
||||
<Form.Item name="product_id" label={t('purchaseOrder.selectProduct')}>
|
||||
<Select placeholder={t('purchaseOrder.selectProduct')} allowClear showSearch optionFilterProp="children" onChange={(v) => {
|
||||
const product = products.find(p => p.id === v)
|
||||
if (product) {
|
||||
itemForm.setFieldsValue({
|
||||
@@ -675,29 +681,29 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="product_name" label="商品名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="商品名称" />
|
||||
<Form.Item name="product_name" label={t('purchaseOrder.productNameInput')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('purchaseOrder.productNameInput')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="specification" label="规格">
|
||||
<Input placeholder="规格" />
|
||||
<Form.Item name="specification" label={t('purchaseOrder.specInput')}>
|
||||
<Input placeholder={t('purchaseOrder.specInput')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item name="unit" label="单位">
|
||||
<Input placeholder="单位" />
|
||||
<Form.Item name="unit" label={t('purchaseOrder.unitInput')}>
|
||||
<Input placeholder={t('purchaseOrder.unitInput')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="quantity" label="数量" rules={[{ required: true }]}>
|
||||
<Form.Item name="quantity" label={t('purchaseOrder.quantityInput')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="unit_price" label="单价" rules={[{ required: true }]}>
|
||||
<Form.Item name="unit_price" label={t('purchaseOrder.unitPriceInput')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -707,43 +713,47 @@ const PurchaseOrdersPage: React.FC = () => {
|
||||
|
||||
{/* 付款计划编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingPayment ? '编辑付款计划' : '添加付款计划'}
|
||||
title={editingPayment ? t('purchaseOrder.editPaymentPlan') : t('purchaseOrder.addPaymentPlanTitle')}
|
||||
open={paymentModalVisible}
|
||||
onOk={handleSavePayment}
|
||||
onCancel={() => setPaymentModalVisible(false)}
|
||||
onCancel={() => {
|
||||
paymentForm.resetFields();
|
||||
setPaymentModalVisible(false);
|
||||
}}
|
||||
destroyOnClose
|
||||
width={500}
|
||||
>
|
||||
<Form form={paymentForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="stage" label="阶段" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择阶段">
|
||||
<Select.Option value="预付款">预付款</Select.Option>
|
||||
<Select.Option value="发货款">发货款</Select.Option>
|
||||
<Select.Option value="验收款">验收款</Select.Option>
|
||||
<Select.Option value="尾款">尾款</Select.Option>
|
||||
<Form.Item name="stage" label={t('purchaseOrder.phase')} rules={[{ required: true }]}>
|
||||
<Select placeholder={t('purchaseOrder.selectPhase')}>
|
||||
<Select.Option value="预付款">{t('purchaseOrder.advancePayment')}</Select.Option>
|
||||
<Select.Option value="发货款">{t('purchaseOrder.deliveryPayment')}</Select.Option>
|
||||
<Select.Option value="验收款">{t('purchaseOrder.acceptancePayment')}</Select.Option>
|
||||
<Select.Option value="尾款">{t('purchaseOrder.finalPayment')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="planned_date" label="计划日期">
|
||||
<Form.Item name="planned_date" label={t('purchaseOrder.plannedDate')}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="planned_amount" label="计划金额" rules={[{ required: true }]}>
|
||||
<Form.Item name="planned_amount" label={t('purchaseOrder.plannedAmount')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="planned_percentage" label="比例(%)">
|
||||
<Form.Item name="planned_percentage" label={t('purchaseOrder.ratioPercent')}>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Form.Item name="remark" label={t('purchaseOrder.remark')}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import dayjs from 'dayjs'
|
||||
import useFormDraft from '../hooks/useFormDraft'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface PurchaseRequest {
|
||||
id: number
|
||||
@@ -50,6 +51,7 @@ interface Project {
|
||||
}
|
||||
|
||||
const PurchaseRequestsPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [purchaseRequests, setPurchaseRequests] = useState<PurchaseRequest[]>([])
|
||||
const [completedRequests, setCompletedRequests] = useState<PurchaseRequest[]>([])
|
||||
const [activeTab, setActiveTab] = useState('active')
|
||||
@@ -113,11 +115,11 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
setPurchaseRequests(active)
|
||||
setCompletedRequests(completed)
|
||||
} else {
|
||||
message.error('获取采购申请列表失败')
|
||||
message.error(t('purchaseRequest.getListFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取采购申请列表失败:', error)
|
||||
message.error('获取采购申请列表失败')
|
||||
message.error(t('purchaseRequest.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -143,11 +145,11 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
setViewingRequest(data.data)
|
||||
setDetailModalVisible(true)
|
||||
} else {
|
||||
message.error('获取采购申请详情失败')
|
||||
message.error(t('purchaseRequest.getDetailFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取采购申请详情失败:', error)
|
||||
message.error('获取采购申请详情失败')
|
||||
message.error(t('purchaseRequest.getDetailFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +198,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
expected_date: dayjs().add(7, 'day'),
|
||||
currency: 'CNY',
|
||||
expense_category: 'material',
|
||||
applicant: '系统管理员',
|
||||
applicant: t('common.systemAdmin'),
|
||||
total_amount: 0,
|
||||
attachments: []
|
||||
})
|
||||
@@ -207,10 +209,10 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的采购申请,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft()
|
||||
},
|
||||
@@ -223,7 +225,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
expected_date: dayjs().add(7, 'day'),
|
||||
currency: 'CNY',
|
||||
expense_category: 'material',
|
||||
applicant: '系统管理员',
|
||||
applicant: t('common.systemAdmin'),
|
||||
total_amount: 0,
|
||||
attachments: []
|
||||
})
|
||||
@@ -281,11 +283,11 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
})
|
||||
}, 100)
|
||||
} else {
|
||||
message.error('获取采购申请详情失败')
|
||||
message.error(t('purchaseRequest.getDetailFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取采购申请详情失败:', error)
|
||||
message.error('获取采购申请详情失败')
|
||||
message.error(t('purchaseRequest.getDetailFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,14 +297,14 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success('删除成功')
|
||||
message.success(t('purchaseRequest.deleteSuccess'))
|
||||
fetchPurchaseRequests()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
message.error(t('purchaseRequest.deleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
message.error(t('purchaseRequest.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +321,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
...values,
|
||||
request_date: values.request_date.format('YYYY-MM-DD'),
|
||||
expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null,
|
||||
applicant: '系统管理员',
|
||||
applicant: t('common.systemAdmin'),
|
||||
status: saveStatus,
|
||||
attachments: attachmentsUrl
|
||||
}
|
||||
@@ -342,7 +344,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(editingRequest ? '保存成功' : '创建成功')
|
||||
message.success(editingRequest ? t('purchaseRequest.saveSuccess') : t('purchaseRequest.createSuccess'))
|
||||
clearDraft()
|
||||
if (!editingRequest) {
|
||||
setEditingRequest(data.data)
|
||||
@@ -351,7 +353,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
fetchPurchaseRequests()
|
||||
setModalVisible(false)
|
||||
} else {
|
||||
message.error(editingRequest ? '保存失败' : '创建失败')
|
||||
message.error(editingRequest ? t('common.saveFailed') : t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
@@ -370,7 +372,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
...values,
|
||||
request_date: values.request_date.format('YYYY-MM-DD'),
|
||||
expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null,
|
||||
applicant: '系统管理员',
|
||||
applicant: t('common.systemAdmin'),
|
||||
status: 'pending_edit',
|
||||
attachments: attachmentsUrl
|
||||
}
|
||||
@@ -406,20 +408,20 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
const submitData = await submitResponse.json()
|
||||
|
||||
if (submitData.success) {
|
||||
message.success(editingRequest ? '提交成功' : '创建并提交成功')
|
||||
message.success(editingRequest ? t('common.submit') : t('purchaseRequest.submitSuccess'))
|
||||
clearDraft()
|
||||
setModalVisible(false)
|
||||
setSelectedStatus(null)
|
||||
fetchPurchaseRequests()
|
||||
} else {
|
||||
message.error('提交失败')
|
||||
message.error(t('purchaseRequest.submitFailed'))
|
||||
}
|
||||
} else {
|
||||
message.error(editingRequest ? '保存失败' : '创建失败')
|
||||
message.error(editingRequest ? t('common.saveFailed') : t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('提交失败')
|
||||
message.error(t('purchaseRequest.submitFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,15 +431,15 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success('撤回成功')
|
||||
message.success(t('purchaseRequest.withdrawSuccess'))
|
||||
setSelectedStatus(null)
|
||||
fetchPurchaseRequests()
|
||||
} else {
|
||||
message.error('撤回失败')
|
||||
message.error(t('purchaseRequest.withdrawFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('撤回失败:', error)
|
||||
message.error('撤回失败')
|
||||
message.error(t('purchaseRequest.withdrawFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,15 +449,15 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success(data.message || '审批通过成功')
|
||||
message.success(data.message || t('purchaseRequest.approveSuccess'))
|
||||
setSelectedStatus(null)
|
||||
fetchPurchaseRequests()
|
||||
} else {
|
||||
message.error('审批通过失败')
|
||||
message.error(t('purchaseRequest.approveFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审批通过失败:', error)
|
||||
message.error('审批通过失败')
|
||||
message.error(t('purchaseRequest.approveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,25 +467,25 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
message.success('驳回成功')
|
||||
message.success(t('purchaseRequest.rejectSuccess'))
|
||||
setSelectedStatus(null)
|
||||
fetchPurchaseRequests()
|
||||
} else {
|
||||
message.error('驳回失败')
|
||||
message.error(t('purchaseRequest.rejectFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('驳回失败:', error)
|
||||
message.error('驳回失败')
|
||||
message.error(t('purchaseRequest.rejectFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending_edit: { color: 'default', text: '待编辑' },
|
||||
pending: { color: 'blue', text: '待审批' },
|
||||
approved: { color: 'green', text: '已审批' },
|
||||
executed: { color: 'purple', text: '已执行' },
|
||||
withdrawn: { color: 'orange', text: '已撤回' }
|
||||
pending_edit: { color: 'default', text: t('purchaseRequest.pendingEdit') },
|
||||
pending: { color: 'blue', text: t('purchaseRequest.pendingApproval') },
|
||||
approved: { color: 'green', text: t('purchaseRequest.approved') },
|
||||
executed: { color: 'purple', text: t('purchaseRequest.executed') },
|
||||
withdrawn: { color: 'orange', text: t('purchaseRequest.withdrawn') }
|
||||
}
|
||||
const info = statusMap[status] || { color: 'default', text: status }
|
||||
return <Tag color={info.color}>{info.text}</Tag>
|
||||
@@ -491,7 +493,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<PurchaseRequest> = [
|
||||
{
|
||||
title: '事由',
|
||||
title: t('purchaseRequest.subject'),
|
||||
dataIndex: 'brief_description',
|
||||
key: 'brief_description',
|
||||
width: 180,
|
||||
@@ -501,7 +503,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '项目',
|
||||
title: t('purchaseRequest.project'),
|
||||
dataIndex: 'project_name',
|
||||
key: 'project_name',
|
||||
width: 120,
|
||||
@@ -509,22 +511,22 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
title: t('purchaseRequest.category'),
|
||||
dataIndex: 'expense_category',
|
||||
key: 'expense_category',
|
||||
width: 80,
|
||||
render: (category) => {
|
||||
const categoryMap: Record<string, string> = {
|
||||
material: '材料',
|
||||
equipment: '设备',
|
||||
pole: '电杆',
|
||||
other: '其他'
|
||||
material: t('purchaseRequest.material'),
|
||||
equipment: t('purchaseRequest.equipment'),
|
||||
pole: t('purchaseRequest.pole'),
|
||||
other: t('purchaseRequest.other')
|
||||
}
|
||||
return <Tag size="small">{categoryMap[category] || category}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '预计金额',
|
||||
title: t('purchaseRequest.estimatedAmount'),
|
||||
dataIndex: 'total_amount',
|
||||
key: 'total_amount',
|
||||
width: 130,
|
||||
@@ -536,14 +538,14 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '需求日期',
|
||||
title: t('purchaseRequest.demandDate'),
|
||||
dataIndex: 'expected_date',
|
||||
key: 'expected_date',
|
||||
width: 100,
|
||||
render: (date) => date ? dayjs(date).format('MM-DD') : '-'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
title: t('purchaseRequest.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
@@ -551,20 +553,20 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
render: getStatusTag
|
||||
},
|
||||
{
|
||||
title: '申请日期',
|
||||
title: t('purchaseRequest.applicationDate'),
|
||||
dataIndex: 'request_date',
|
||||
key: 'request_date',
|
||||
width: 100,
|
||||
render: (date) => date ? dayjs(date).format('MM-DD') : '-'
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
title: t('purchaseRequest.applicant'),
|
||||
dataIndex: 'applicant',
|
||||
key: 'applicant',
|
||||
width: 90
|
||||
},
|
||||
{
|
||||
title: '编号',
|
||||
title: t('purchaseRequest.code'),
|
||||
dataIndex: 'request_code',
|
||||
key: 'request_code',
|
||||
width: 150,
|
||||
@@ -572,7 +574,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
render: (v: string) => <span style={{ fontSize: 12, color: '#999' }}>{v || '-'}</span>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('purchaseRequest.action'),
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
@@ -594,7 +596,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
onClick={() => handleApprove(record.id)}
|
||||
style={{ color: '#52c41a' }}
|
||||
>
|
||||
通过
|
||||
{t('purchaseRequest.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -603,7 +605,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
onClick={() => handleReject(record.id)}
|
||||
danger
|
||||
>
|
||||
驳回
|
||||
{t('purchaseRequest.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -611,7 +613,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
icon={<UndoOutlined />}
|
||||
onClick={() => handleWithdraw(record.id)}
|
||||
>
|
||||
撤回
|
||||
{t('purchaseRequest.withdraw')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -624,10 +626,10 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
{t('purchaseRequest.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除吗?"
|
||||
title={t('purchaseRequest.confirmDelete')}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
|
||||
@@ -642,17 +644,17 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>采购申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理公司采购申请(简化版:仅填写需求描述和预计金额)</p>
|
||||
<h2 style={{ marginBottom: 8 }}>{t('purchaseRequest.title')}</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('purchaseRequest.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建采购申请</Button>}>
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('purchaseRequest.newRequest')}</Button>}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.TabPane tab="活跃申请" key="active">
|
||||
<Tabs.TabPane tab={t('purchaseRequest.activeApplications')} key="active">
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="选择项目筛选"
|
||||
placeholder={t('purchaseRequest.selectProjectFilter')}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setSelectedProjectId(value)}
|
||||
@@ -666,14 +668,14 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="选择状态筛选"
|
||||
placeholder={t('purchaseRequest.selectStatusFilter')}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setSelectedStatus(value)}
|
||||
>
|
||||
<Select.Option value="pending">待审批</Select.Option>
|
||||
<Select.Option value="withdrawn">已撤回</Select.Option>
|
||||
<Select.Option value="pending_edit">待编辑</Select.Option>
|
||||
<Select.Option value="pending">{t('purchaseRequest.pendingApproval')}</Select.Option>
|
||||
<Select.Option value="withdrawn">{t('purchaseRequest.withdrawn')}</Select.Option>
|
||||
<Select.Option value="pending_edit">{t('purchaseRequest.pendingEdit')}</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -687,11 +689,11 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="已完成" key="completed">
|
||||
<Tabs.TabPane tab={t('purchaseRequest.completed')} key="completed">
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Select
|
||||
placeholder="选择项目筛选"
|
||||
placeholder={t('purchaseRequest.selectProjectFilter')}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => setSelectedProjectId(value)}
|
||||
@@ -719,21 +721,23 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
|
||||
{/* 编辑/新建弹窗 - 简化版 */}
|
||||
<Modal
|
||||
title={editingRequest ? '编辑采购申请' : '新建采购申请'}
|
||||
title={editingRequest ? t('purchaseRequest.editRequest') : t('purchaseRequest.newRequest')}
|
||||
open={modalVisible}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft({ attachments, purchaseType, currency })
|
||||
form.resetFields()
|
||||
setModalVisible(false)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
form.resetFields()
|
||||
setModalVisible(false)
|
||||
}
|
||||
}}
|
||||
@@ -741,39 +745,42 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
<Button key="cancel" onClick={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft({ attachments, purchaseType, currency })
|
||||
form.resetFields()
|
||||
setModalVisible(false)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
form.resetFields()
|
||||
setModalVisible(false)
|
||||
}
|
||||
}}>取消</Button>,
|
||||
<Button key="save" onClick={handleSave}>保存</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleFormSubmit}>提交审批</Button>
|
||||
}}>{t('common.cancel')}</Button>,
|
||||
<Button key="save" onClick={handleSave}>{t('common.save')}</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleFormSubmit}>{t('purchaseRequest.submitApproval')}</Button>
|
||||
]}
|
||||
width={700}
|
||||
maskClosable={false}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="purchase_type"
|
||||
label="采购类型"
|
||||
rules={[{ required: true, message: '请选择采购类型' }]}
|
||||
label={t('purchaseRequest.purchaseType')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.purchaseTypeRequired') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择采购类型"
|
||||
placeholder={t('purchaseRequest.selectPurchaseType')}
|
||||
onChange={(value) => setPurchaseType(value as 'inventory' | 'project')}
|
||||
>
|
||||
<Select.Option value="inventory">库存采购</Select.Option>
|
||||
<Select.Option value="project">项目采购</Select.Option>
|
||||
<Select.Option value="inventory">{t('purchaseRequest.stockPurchase')}</Select.Option>
|
||||
<Select.Option value="project">{t('purchaseRequest.projectPurchase')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -781,10 +788,10 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
{purchaseType === 'project' && (
|
||||
<Form.Item
|
||||
name="project_id"
|
||||
label="关联项目"
|
||||
rules={[{ required: true, message: '项目采购必须关联项目' }]}
|
||||
label={t('purchaseRequest.relatedProject')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.projectRequired') }]}
|
||||
>
|
||||
<Select placeholder="请选择项目" allowClear>
|
||||
<Select placeholder={t('purchaseRequest.selectProject')} allowClear>
|
||||
{projects.map(project => (
|
||||
<Select.Option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
@@ -800,18 +807,18 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="applicant"
|
||||
label="申请人"
|
||||
initialValue="系统管理员"
|
||||
rules={[{ required: true, message: '请输入申请人' }]}
|
||||
label={t('purchaseRequest.applicantLabel')}
|
||||
initialValue={t('common.systemAdmin')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.applicantRequired') }]}
|
||||
>
|
||||
<Input placeholder="请输入申请人" disabled />
|
||||
<Input placeholder={t('purchaseRequest.applicantPlaceholder')} disabled />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="request_date"
|
||||
label="申请日期"
|
||||
rules={[{ required: true, message: '请选择申请日期' }]}
|
||||
label={t('purchaseRequest.applicationDateLabel')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.dateRequired') }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
@@ -820,15 +827,15 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
|
||||
<Form.Item
|
||||
name="brief_description"
|
||||
label="事由描述"
|
||||
label={t('purchaseRequest.subjectDescription')}
|
||||
rules={[
|
||||
{ required: true, message: '请输入事由描述' },
|
||||
{ max: 100, message: '事由描述不能超过100个字符' }
|
||||
{ required: true, message: t('purchaseRequest.subjectRequired') },
|
||||
{ max: 100, message: t('purchaseRequest.subjectMaxLength') }
|
||||
]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="请简要描述采购需求(如:采购XX项目所需电缆、电杆等材料)"
|
||||
placeholder={t('purchaseRequest.subjectPlaceholder')}
|
||||
maxLength={100}
|
||||
showCount
|
||||
/>
|
||||
@@ -838,26 +845,26 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="expense_category"
|
||||
label="支出分类"
|
||||
rules={[{ required: true, message: '请选择支出分类' }]}
|
||||
label={t('purchaseRequest.expenseCategory')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.categoryRequired') }]}
|
||||
>
|
||||
<Select placeholder="请选择支出分类">
|
||||
<Select.Option value="material">材料</Select.Option>
|
||||
<Select.Option value="equipment">设备</Select.Option>
|
||||
<Select.Option value="pole">电杆</Select.Option>
|
||||
<Select.Option value="other">其他</Select.Option>
|
||||
<Select placeholder={t('purchaseRequest.selectCategory')}>
|
||||
<Select.Option value="material">{t('purchaseRequest.material')}</Select.Option>
|
||||
<Select.Option value="equipment">{t('purchaseRequest.equipment')}</Select.Option>
|
||||
<Select.Option value="pole">{t('purchaseRequest.pole')}</Select.Option>
|
||||
<Select.Option value="other">{t('purchaseRequest.other')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="total_amount"
|
||||
label="预计金额"
|
||||
rules={[{ required: true, message: '请输入预计金额' }]}
|
||||
label={t('purchaseRequest.estimatedAmountLabel')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.amountRequired') }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder="预计金额"
|
||||
placeholder={t('purchaseRequest.estimatedAmountPlaceholder')}
|
||||
min={0}
|
||||
precision={2}
|
||||
/>
|
||||
@@ -866,14 +873,14 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
label={t('purchaseRequest.currency')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.currencyRequired') }]}
|
||||
>
|
||||
<Select placeholder="请选择币种" onChange={(value) => setCurrency(value)}>
|
||||
<Select.Option value="CNY">人民币</Select.Option>
|
||||
<Select.Option value="USD">美元</Select.Option>
|
||||
<Select.Option value="LAK">老挝基普</Select.Option>
|
||||
<Select.Option value="THB">泰铢</Select.Option>
|
||||
<Select placeholder={t('purchaseRequest.selectCurrency')} onChange={(value) => setCurrency(value)}>
|
||||
<Select.Option value="CNY">{t('purchaseRequest.currencyCNY')}</Select.Option>
|
||||
<Select.Option value="USD">{t('purchaseRequest.currencyUSD')}</Select.Option>
|
||||
<Select.Option value="LAK">{t('purchaseRequest.currencyLAK')}</Select.Option>
|
||||
<Select.Option value="THB">{t('purchaseRequest.currencyTHB')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -883,19 +890,19 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="expected_date"
|
||||
label="需求日期"
|
||||
rules={[{ required: true, message: '请选择需求日期' }]}
|
||||
label={t('purchaseRequest.demandDateLabel')}
|
||||
rules={[{ required: true, message: t('purchaseRequest.dateRequired') }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="期望到货日期" />
|
||||
<DatePicker style={{ width: '100%' }} placeholder={t('purchaseRequest.demandDatePlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="请输入备注(选填)" />
|
||||
<Form.Item name="remark" label={t('purchaseRequest.remarkLabel')}>
|
||||
<Input.TextArea rows={2} placeholder={t('purchaseRequest.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="attachments" label="附件">
|
||||
<Form.Item name="attachments" label={t('purchaseRequest.attachment')}>
|
||||
<Upload
|
||||
name="file"
|
||||
listType="text"
|
||||
@@ -915,9 +922,19 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
formData.append('file', file as File)
|
||||
|
||||
try {
|
||||
let authHeader = '';
|
||||
try {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
const parsed = JSON.parse(authStorage);
|
||||
const token = parsed?.state?.token;
|
||||
if (token) authHeader = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
const response = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
body: formData,
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
@@ -943,7 +960,7 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
window.open(file.url, '_blank')
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
<Button icon={<UploadOutlined />}>{t('purchaseRequest.selectFile')}</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -951,46 +968,46 @@ const PurchaseRequestsPage: React.FC = () => {
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title="采购申请详情"
|
||||
title={t('purchaseRequest.detailTitle')}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}>关闭</Button>]}
|
||||
footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}>{t('common.close')}</Button>]}
|
||||
width={700}
|
||||
>
|
||||
{viewingRequest && (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="申请编号" span={1}>{viewingRequest.request_code || viewingRequest.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态" span={1}>{getStatusTag(viewingRequest.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="采购类型" span={1}>
|
||||
{viewingRequest.purchase_type === 'project' ? '项目采购' : '库存采购'}
|
||||
<Descriptions.Item label={t('purchaseRequest.applicationCode')} span={1}>{viewingRequest.request_code || viewingRequest.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseRequest.status')} span={1}>{getStatusTag(viewingRequest.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseRequest.purchaseType')} span={1}>
|
||||
{viewingRequest.purchase_type === 'project' ? t('purchaseRequest.projectPurchase') : t('purchaseRequest.stockPurchase')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="项目" span={1}>{viewingRequest.project_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人" span={1}>{viewingRequest.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期" span={1}>{viewingRequest.request_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由描述" span={2}>{viewingRequest.brief_description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出分类" span={1}>
|
||||
<Descriptions.Item label={t('purchaseRequest.project')} span={1}>{viewingRequest.project_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseRequest.applicant')} span={1}>{viewingRequest.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseRequest.applicationDate')} span={1}>{viewingRequest.request_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseRequest.subjectDescription')} span={2}>{viewingRequest.brief_description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseRequest.expenseCategory')} span={1}>
|
||||
{{
|
||||
material: '材料',
|
||||
equipment: '设备',
|
||||
pole: '电杆',
|
||||
other: '其他'
|
||||
material: t('purchaseRequest.material'),
|
||||
equipment: t('purchaseRequest.equipment'),
|
||||
pole: t('purchaseRequest.pole'),
|
||||
other: t('purchaseRequest.other')
|
||||
}[viewingRequest.expense_category] || viewingRequest.expense_category}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预计金额" span={1}>
|
||||
<Descriptions.Item label={t('purchaseRequest.estimatedAmount')} span={1}>
|
||||
<strong style={{ fontSize: 16, color: '#999' }}>
|
||||
{viewingRequest.currency} {viewingRequest.total_amount?.toFixed(2) || '0.00'}
|
||||
</strong>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="需求日期" span={1}>
|
||||
<Descriptions.Item label={t('purchaseRequest.demandDate')} span={1}>
|
||||
{viewingRequest.expected_date || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间" span={1}>
|
||||
<Descriptions.Item label={t('purchaseRequest.createdAt')} span={1}>
|
||||
{viewingRequest.created_at}
|
||||
</Descriptions.Item>
|
||||
{viewingRequest.remark && (
|
||||
<Descriptions.Item label="备注" span={2}>{viewingRequest.remark}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('purchaseRequest.remarkLabel')} span={2}>{viewingRequest.remark}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
@@ -1,91 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Checkbox, message, Tree } from 'antd';
|
||||
import { PlusOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const RolesPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading] = React.useState(false);
|
||||
const [modalVisible, setModalVisible] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const permissionTree = [
|
||||
{
|
||||
title: '项目管理',
|
||||
title: t('roles.permProject'),
|
||||
key: 'project',
|
||||
children: [
|
||||
{ title: '查看项目', key: 'project:view' },
|
||||
{ title: '创建项目', key: 'project:create' },
|
||||
{ title: '编辑项目', key: 'project:edit' },
|
||||
{ title: '删除项目', key: 'project:delete' },
|
||||
{ title: t('roles.permViewProject'), key: 'project:view' },
|
||||
{ title: t('roles.permCreateProject'), key: 'project:create' },
|
||||
{ title: t('roles.permEditProject'), key: 'project:edit' },
|
||||
{ title: t('roles.permDeleteProject'), key: 'project:delete' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '财务管理',
|
||||
title: t('roles.permFinance'),
|
||||
key: 'finance',
|
||||
children: [
|
||||
{ title: '查看财务', key: 'finance:view' },
|
||||
{ title: '预支审批', key: 'finance:advance' },
|
||||
{ title: '报销审批', key: 'finance:reimburse' },
|
||||
{ title: '付款审批', key: 'finance:payment' },
|
||||
{ title: t('roles.permViewFinance'), key: 'finance:view' },
|
||||
{ title: t('roles.permApproveAdvance'), key: 'finance:advance' },
|
||||
{ title: t('roles.permApproveReimburse'), key: 'finance:reimburse' },
|
||||
{ title: t('roles.permApprovePayment'), key: 'finance:payment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '采购管理',
|
||||
title: t('roles.permProcurement'),
|
||||
key: 'procurement',
|
||||
children: [
|
||||
{ title: '查看采购', key: 'procurement:view' },
|
||||
{ title: '创建采购', key: 'procurement:create' },
|
||||
{ title: '审批采购', key: 'procurement:approve' },
|
||||
{ title: t('roles.permViewProcurement'), key: 'procurement:view' },
|
||||
{ title: t('roles.permCreateProcurement'), key: 'procurement:create' },
|
||||
{ title: t('roles.permApproveProcurement'), key: 'procurement:approve' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
title: t('roles.permSystem'),
|
||||
key: 'system',
|
||||
children: [
|
||||
{ title: '用户管理', key: 'system:users' },
|
||||
{ title: '角色管理', key: 'system:roles' },
|
||||
{ title: '系统配置', key: 'system:config' },
|
||||
{ title: t('roles.permUserManagement'), key: 'system:users' },
|
||||
{ title: t('roles.permRoleManagement'), key: 'system:roles' },
|
||||
{ title: t('roles.permSystemConfig'), key: 'system:config' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '角色ID', dataIndex: 'id', key: 'id', width: 100 },
|
||||
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '角色描述', dataIndex: 'description', key: 'description' },
|
||||
{ title: t('roles.roleId'), dataIndex: 'id', key: 'id', width: 100 },
|
||||
{ title: t('roles.roleName'), dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: t('roles.roleDesc'), dataIndex: 'description', key: 'description' },
|
||||
{
|
||||
title: '权限数量',
|
||||
title: t('roles.permCount'),
|
||||
dataIndex: 'permissionCount',
|
||||
key: 'permissionCount',
|
||||
width: 100,
|
||||
render: (v: number) => <Tag color="blue">{v} 项</Tag>
|
||||
render: (v: number) => <Tag color="blue">{v} {t('common.item')}</Tag>
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 150 },
|
||||
{ title: '创建人', dataIndex: 'creator', key: 'creator', width: 120 },
|
||||
{ title: t('roles.createdAt'), dataIndex: 'createdAt', key: 'createdAt', width: 150 },
|
||||
{ title: t('roles.creator'), dataIndex: 'creator', key: 'creator', width: 120 },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('roles.action'),
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" type="link">查看权限</Button>
|
||||
<Button size="small" type="link">编辑</Button>
|
||||
<Button size="small" type="link" danger>删除</Button>
|
||||
<Button size="small" type="link">{t('roles.viewPerm')}</Button>
|
||||
<Button size="small" type="link">{t('roles.edit')}</Button>
|
||||
<Button size="small" type="link" danger>{t('roles.delete')}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ key: '1', id: 'R001', name: '超级管理员', description: '拥有系统所有权限', permissionCount: 50, createdAt: '2026-01-01', creator: '系统' },
|
||||
{ key: '2', id: 'R002', name: '项目经理', description: '项目管理、施工管理权限', permissionCount: 25, createdAt: '2026-01-15', creator: 'admin' },
|
||||
{ key: '3', id: 'R003', name: '财务经理', description: '财务管理、审批权限', permissionCount: 18, createdAt: '2026-02-01', creator: 'admin' },
|
||||
{ key: '4', id: 'R004', name: '普通员工', description: '查看和申请权限', permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' },
|
||||
{ key: '1', id: 'R001', name: t('roles.superAdmin'), description: t('roles.superAdminDesc'), permissionCount: 50, createdAt: '2026-01-01', creator: t('roles.admin') },
|
||||
{ key: '2', id: 'R002', name: t('user.manager'), description: t('roles.adminDesc'), permissionCount: 25, createdAt: '2026-01-15', creator: 'admin' },
|
||||
{ key: '3', id: 'R003', name: t('roles.financeManager'), description: t('roles.financeManagerDesc'), permissionCount: 18, createdAt: '2026-02-01', creator: 'admin' },
|
||||
{ key: '4', id: 'R004', name: t('roles.employee'), description: t('roles.employeeDesc'), permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' },
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
message.success('角色已创建');
|
||||
message.success(t('roles.roleCreated'));
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
@@ -93,13 +95,13 @@ const RolesPage: React.FC = () => {
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>角色权限</Title>
|
||||
<Paragraph type="secondary">管理系统角色和权限分配</Paragraph>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>{t('roles.title')}</Title>
|
||||
<Paragraph type="secondary">{t('roles.description')}</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Input.Search placeholder="搜索角色" style={{ width: 200 }} />
|
||||
<Input.Search placeholder={t('roles.searchRole')} style={{ width: 200 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
新增角色
|
||||
{t('roles.newRole')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -109,20 +111,20 @@ const RolesPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新增角色"
|
||||
title={t('roles.newRole')}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSubmit}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="角色名称" name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入角色名称" prefix={<SafetyOutlined />} />
|
||||
<Form.Item label={t('roles.roleName')} name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder={t('roles.roleNameRequired')} prefix={<SafetyOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色描述" name="description">
|
||||
<Input placeholder="请输入角色描述" />
|
||||
<Form.Item label={t('roles.roleDesc')} name="description">
|
||||
<Input placeholder={t('roles.roleDescRequired')} />
|
||||
</Form.Item>
|
||||
<Form.Item label="权限配置" name="permissions">
|
||||
<Form.Item label={t('roles.permConfig')} name="permissions">
|
||||
<Tree
|
||||
checkable
|
||||
defaultExpandedKeys={['project', 'finance']}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FileTextOutlined, DollarOutlined, BankOutlined
|
||||
} from '@ant-design/icons'
|
||||
import BusinessLedgerTab from '../components/BusinessLedgerTab'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -68,6 +69,7 @@ interface Subcontractor {
|
||||
const SubcontractorDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const [subcontractor, setSubcontractor] = useState<Subcontractor | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('basic')
|
||||
@@ -89,12 +91,12 @@ const SubcontractorDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
|
||||
if (!subcontractor) return <Empty description="分包商不存在" style={{ marginTop: 100 }} />
|
||||
if (!subcontractor) return <Empty description={t('subcontractor.notFound')} style={{ marginTop: 100 }} />
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/subcontractors')} style={{ marginBottom: 16 }} type="text">
|
||||
返回列表
|
||||
{t('subcontractor.returnToList')}
|
||||
</Button>
|
||||
|
||||
<Title level={4} style={{ marginBottom: 24 }}>
|
||||
@@ -105,65 +107,65 @@ const SubcontractorDetail: React.FC = () => {
|
||||
<Card style={{ borderRadius: 8 }}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
{/* TAB1: 基本信息 */}
|
||||
<Tabs.TabPane tab={<span><UserOutlined /> 基本信息</span>} key="basic">
|
||||
<Tabs.TabPane tab={<span><UserOutlined /> {t('subcontractor.basicInfo')}</span>} key="basic">
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
|
||||
<Descriptions.Item label="编号">{subcontractor.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="承包范围">{subcontractor.scope || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家"><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label={t('subcontractor.code')}>{subcontractor.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('subcontractor.scope')}>{subcontractor.scope || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('subcontractor.country')}><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item>
|
||||
</Descriptions>
|
||||
{subcontractor.features && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">特点:</Text>
|
||||
<Text type="secondary">{t('subcontractor.featureLabel')}</Text>
|
||||
<div style={{ padding: 12, background: '#f9f0ff', borderRadius: 4, border: '1px solid #d3adf7', marginTop: 8 }}>{subcontractor.features}</div>
|
||||
</div>
|
||||
)}
|
||||
{subcontractor.remark && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">备注:</Text>
|
||||
<Text type="secondary">{t('subcontractor.remarkLabel')}</Text>
|
||||
<div style={{ padding: 12, background: '#fafafa', borderRadius: 4, marginTop: 8 }}>{subcontractor.remark}</div>
|
||||
</div>
|
||||
)}
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB2: 联系人 */}
|
||||
<Tabs.TabPane tab={<span><PhoneOutlined /> 联系人</span>} key="contacts">
|
||||
<Tabs.TabPane tab={<span><PhoneOutlined /> {t('subcontractor.contact')}</span>} key="contacts">
|
||||
<Row gutter={[16, 16]}>
|
||||
{(subcontractor.contacts || []).map((contact, i) => (
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #722ed1' : '3px solid #d9d9d9', background: contact.is_primary ? '#f9f0ff' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{contact.name || '未命名'}</Text>
|
||||
{contact.is_primary && <Tag color="purple">主联系人</Tag>}
|
||||
<Text strong>{contact.name || t('common.unnamed')}</Text>
|
||||
{contact.is_primary && <Tag color="purple">{t('subcontractor.mainContactTag')}</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
{contact.position && <div>职位:{contact.position}</div>}
|
||||
{contact.phone && <div>电话:{contact.phone}</div>}
|
||||
{contact.position && <div>{t('subcontractor.positionLabel')}{contact.position}</div>}
|
||||
{contact.phone && <div>{t('subcontractor.phoneLabel')}{contact.phone}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(subcontractor.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{(subcontractor.contacts || []).length === 0 && <Empty description={t('subcontractor.noContact')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB3: 收款信息 */}
|
||||
<Tabs.TabPane tab={<span><BankOutlined /> 收款信息</span>} key="payment">
|
||||
<Tabs.TabPane tab={<span><BankOutlined /> {t('subcontractor.paymentInfo')}</span>} key="payment">
|
||||
<Row gutter={[16, 16]}>
|
||||
{(subcontractor.payment_infos || []).map((payment, i) => (
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: payment.is_primary ? '3px solid #722ed1' : '3px solid #d9d9d9', background: payment.is_primary ? '#f9f0ff' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{payment.account_name}</Text>
|
||||
{payment.is_primary && <Tag color="purple">默认账户</Tag>}
|
||||
{payment.is_primary && <Tag color="purple">{t('subcontractor.defaultAccount')}</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
<div>银行:{payment.bank_name}</div>
|
||||
<div>账号:{payment.bank_account}</div>
|
||||
<div>{t('subcontractor.bankLabel')}{payment.bank_name}</div>
|
||||
<div>{t('subcontractor.accountLabel')}{payment.bank_account}</div>
|
||||
{payment.qr_code && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">二维码:</Text>
|
||||
<Text type="secondary">{t('subcontractor.qrCodeLabel')}</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<img src={payment.qr_code} alt="二维码" style={{ maxWidth: '100%', maxHeight: 120, border: '1px solid #d9d9d9', borderRadius: 4 }} />
|
||||
<img src={payment.qr_code} alt={t('subcontractor.qrCode')} style={{ maxWidth: '100%', maxHeight: 120, border: '1px solid #d9d9d9', borderRadius: 4 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -172,11 +174,11 @@ const SubcontractorDetail: React.FC = () => {
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(subcontractor.payment_infos || []).length === 0 && <Empty description="暂无收款信息" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{(subcontractor.payment_infos || []).length === 0 && <Empty description={t('subcontractor.noPayment')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</Tabs.TabPane>
|
||||
|
||||
{/* TAB4: 业务台账 */}
|
||||
<Tabs.TabPane tab={<span><DollarOutlined /> 业务台账</span>} key="ledger">
|
||||
<Tabs.TabPane tab={<span><DollarOutlined /> {t('subcontractor.ledger')}</span>} key="ledger">
|
||||
<BusinessLedgerTab
|
||||
partnerType="subcontractor"
|
||||
summary={subcontractor.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_paid_amount: 0, total_unpaid_amount: 0 }}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, SolutionOut
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import FileUpload from '../components/FileUpload'
|
||||
import useFormDraft from '../hooks/useFormDraft'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
@@ -39,6 +40,7 @@ interface Subcontractor {
|
||||
|
||||
const SubcontractorPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const [subcontractors, setSubcontractors] = useState<Subcontractor[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
@@ -63,7 +65,7 @@ const SubcontractorPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
if (data.success) setSubcontractors(data.data || [])
|
||||
} catch (error) {
|
||||
message.error('获取分包商列表失败')
|
||||
message.error(t('subcontractor.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -89,16 +91,16 @@ const SubcontractorPage: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<Subcontractor> = [
|
||||
{
|
||||
title: '名称', dataIndex: 'name', key: 'name',
|
||||
title: t('subcontractor.name'), dataIndex: 'name', key: 'name',
|
||||
render: (text, record) => (
|
||||
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/subcontractors/${record.id}`)}>{text}</Button>
|
||||
)
|
||||
},
|
||||
{ title: '承包范围', dataIndex: 'scope', key: 'scope', width: 120 },
|
||||
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> },
|
||||
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
|
||||
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
|
||||
{ title: '操作', key: 'actions', width: 100, render: (_, record) => (
|
||||
{ title: t('subcontractor.scope'), dataIndex: 'scope', key: 'scope', width: 120 },
|
||||
{ title: t('subcontractor.country'), dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> },
|
||||
{ title: t('subcontractor.contractAmount'), dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` },
|
||||
{ title: t('subcontractor.payableAmount'), dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (v) => <span style={{ color: v > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(v || 0).toLocaleString()}</span> },
|
||||
{ title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} size="small" />
|
||||
@@ -156,17 +158,17 @@ const SubcontractorPage: React.FC = () => {
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success(editingSubcontractor ? '更新成功' : '创建成功')
|
||||
message.success(editingSubcontractor ? t('common.updateSuccess') : t('common.createSuccess'))
|
||||
clearDraft()
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSubcontractor(null)
|
||||
fetchSubcontractors()
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
message.error(data.message || t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,14 +188,14 @@ const SubcontractorPage: React.FC = () => {
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除', content: '确定要删除此分包商吗?', okText: '确定', cancelText: '取消',
|
||||
title: t('common.confirmDelete'), content: t('subcontractor.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/subcontractors/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) { message.success('删除成功'); fetchSubcontractors() }
|
||||
else message.error(data.message || '删除失败')
|
||||
} catch (error) { message.error('删除失败') }
|
||||
if (data.success) { message.success(t('common.deleteSuccess')); fetchSubcontractors() }
|
||||
else message.error(data.message || t('common.deleteFailed'))
|
||||
} catch (error) { message.error(t('common.deleteFailed')) }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -211,10 +213,10 @@ const SubcontractorPage: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的分包商信息,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft()
|
||||
},
|
||||
@@ -235,89 +237,90 @@ const SubcontractorPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}><Card><Statistic title="分包商总数" value={stats.total} prefix={<SolutionOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="合同总金额" value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('subcontractor.totalCount')} value={stats.total} prefix={<SolutionOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('subcontractor.totalContract')} value={stats.totalContract} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('subcontractor.totalPayable')} value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input placeholder="搜索分包商编号、名称或承包范围" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增分包商</Button>
|
||||
<Input placeholder={t('subcontractor.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('subcontractor.newSubcontractor')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1100 }} />
|
||||
<Table columns={columns} dataSource={filteredSubcontractors} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingSubcontractor ? '编辑分包商' : '新增分包商'}
|
||||
title={editingSubcontractor ? t('subcontractor.editSubcontractor') : t('subcontractor.newSubcontractor')}
|
||||
open={modalVisible}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft()
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSubcontractor(null)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSubcontractor(null)
|
||||
}
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
width={800}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="分包商名称" />
|
||||
<Form.Item name="name" label={t('subcontractor.name')} rules={[{ required: true, message: t('subcontractor.nameRequired') }]}>
|
||||
<Input placeholder={t('subcontractor.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="scope" label="承包范围">
|
||||
<Input placeholder="手填:如电力安装、土建工程" />
|
||||
<Form.Item name="scope" label={t('subcontractor.scope')}>
|
||||
<Input placeholder={t('subcontractor.scopePlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="country" label="国家" initialValue="Laos">
|
||||
<Form.Item name="country" label={t('subcontractor.country')} initialValue="Laos">
|
||||
<Select>
|
||||
<Select.Option value="China">中国</Select.Option>
|
||||
<Select.Option value="Laos">老挝</Select.Option>
|
||||
<Select.Option value="China">{t('subcontractor.china')}</Select.Option>
|
||||
<Select.Option value="Laos">{t('subcontractor.laos')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="features" label="特点">
|
||||
<Input.TextArea rows={2} placeholder="手填:如专业团队、设备齐全、价格合理等" />
|
||||
<Form.Item name="features" label={t('subcontractor.feature')}>
|
||||
<Input.TextArea rows={2} placeholder={t('subcontractor.featurePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="备注信息" />
|
||||
<Form.Item name="remark" label={t('common.remark')}>
|
||||
<Input.TextArea rows={2} placeholder={t('subcontractor.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<h4>联系人</h4>
|
||||
<h4>{t('subcontractor.contact')}</h4>
|
||||
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
|
||||
{(fields, { add, remove }) => (
|
||||
<div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="姓名" />
|
||||
<Input placeholder={t('logistics.name')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="职位" />
|
||||
<Input placeholder={t('common.position')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="电话" />
|
||||
<Input placeholder={t('common.phone')} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
||||
@@ -326,33 +329,33 @@ const SubcontractorPage: React.FC = () => {
|
||||
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>主联系人</span>
|
||||
<span>{t('subcontractor.mainContact')}</span>
|
||||
</div>
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>删除</Button>}
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ 添加联系人</Button>
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('subcontractor.addContact')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>收款信息</h4>
|
||||
<h4 style={{ marginTop: 24 }}>{t('subcontractor.paymentInfo')}</h4>
|
||||
<Form.List name="payment_infos" initialValue={[]}>
|
||||
{(fields, { add, remove }) => (
|
||||
<div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="收款户名" />
|
||||
<Form.Item {...restField} name={[name, 'account_name']} label={t('subcontractor.accountName')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('subcontractor.accountName')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="开户银行" />
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label={t('subcontractor.bankName')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('subcontractor.bankName')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="银行账号" />
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label={t('subcontractor.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('subcontractor.bankAccount')} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
||||
@@ -361,19 +364,19 @@ const SubcontractorPage: React.FC = () => {
|
||||
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>主要收款账户</span>
|
||||
<span>{t('subcontractor.mainAccount')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label={t('subcontractor.qrCode')} style={{ marginBottom: 0 }}>
|
||||
<FileUpload maxCount={1} accept="image/*" />
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>删除此收款信息</Button>
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('subcontractor.deletePaymentInfo')}</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
|
||||
+ 添加收款信息
|
||||
{t('subcontractor.addPaymentInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ArrowLeftOutlined, ShopOutlined, UserOutlined, PhoneOutlined, BankOutlined, DollarOutlined
|
||||
} from '@ant-design/icons'
|
||||
import BusinessLedgerTab from '../components/BusinessLedgerTab'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -66,6 +67,7 @@ interface Supplier {
|
||||
const SupplierDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const [supplier, setSupplier] = useState<Supplier | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('basic')
|
||||
@@ -87,22 +89,22 @@ const SupplierDetail: React.FC = () => {
|
||||
}
|
||||
|
||||
if (loading) return <Spin style={{ display: 'flex', justifyContent: 'center', padding: 50 }} />
|
||||
if (!supplier) return <Empty description="供应商不存在" style={{ marginTop: 100 }} />
|
||||
if (!supplier) return <Empty description={t('supplier.notFound')} style={{ marginTop: 100 }} />
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'basic',
|
||||
label: <span><UserOutlined /> 基本信息</span>,
|
||||
label: <span><UserOutlined /> {t('supplier.basicInfo')}</span>,
|
||||
children: (
|
||||
<>
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
|
||||
<Descriptions.Item label="编号">{supplier.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="供应类别">{supplier.supply_category || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="国家"><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label={t('supplier.code')}>{supplier.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('supplier.supplyCategory')}>{supplier.supply_category || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('supplier.country')}><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item>
|
||||
</Descriptions>
|
||||
{supplier.remark && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">备注:</Text>
|
||||
<Text type="secondary">{t('supplier.remarkLabel')}</Text>
|
||||
<div style={{ padding: 12, background: '#e6f7ff', borderRadius: 4, border: '1px solid #91d5ff', marginTop: 8 }}>{supplier.remark}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -111,7 +113,7 @@ const SupplierDetail: React.FC = () => {
|
||||
},
|
||||
{
|
||||
key: 'contacts',
|
||||
label: <span><PhoneOutlined /> 联系人</span>,
|
||||
label: <span><PhoneOutlined /> {t('supplier.contact')}</span>,
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -119,24 +121,24 @@ const SupplierDetail: React.FC = () => {
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: contact.is_primary ? '3px solid #1890ff' : '3px solid #d9d9d9', background: contact.is_primary ? '#f0f5ff' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{contact.name || '未命名'}</Text>
|
||||
{contact.is_primary && <Tag color="blue">主联系人</Tag>}
|
||||
<Text strong>{contact.name || t('common.unnamed')}</Text>
|
||||
{contact.is_primary && <Tag color="blue">{t('supplier.mainContactTag')}</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
{contact.position && <div>职位:{contact.position}</div>}
|
||||
{contact.phone && <div>电话:{contact.phone}</div>}
|
||||
{contact.position && <div>{t('supplier.positionLabel')}{contact.position}</div>}
|
||||
{contact.phone && <div>{t('supplier.phoneLabel')}{contact.phone}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(supplier.contacts || []).length === 0 && <Empty description="暂无联系人" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{(supplier.contacts || []).length === 0 && <Empty description={t('supplier.noContact')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'payment',
|
||||
label: <span><BankOutlined /> 收款信息</span>,
|
||||
label: <span><BankOutlined /> {t('supplier.paymentInfo')}</span>,
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -144,17 +146,17 @@ const SupplierDetail: React.FC = () => {
|
||||
<Col key={i} xs={24} sm={12} lg={8}>
|
||||
<Card size="small" style={{ borderLeft: payment.is_primary ? '3px solid #1890ff' : '3px solid #d9d9d9', background: payment.is_primary ? '#f0f5ff' : '#fff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Text strong>{payment.bank_name || '未命名'}</Text>
|
||||
{payment.is_primary && <Tag color="blue">主要收款账户</Tag>}
|
||||
<Text strong>{payment.bank_name || t('common.unnamed')}</Text>
|
||||
{payment.is_primary && <Tag color="blue">{t('supplier.mainAccountTag')}</Tag>}
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 13 }}>
|
||||
{payment.account_name && <div>户名:{payment.account_name}</div>}
|
||||
{payment.bank_account && <div>账号:{payment.bank_account}</div>}
|
||||
{payment.account_name && <div>{t('supplier.accountNameLabel')}{payment.account_name}</div>}
|
||||
{payment.bank_account && <div>{t('supplier.accountNumLabel')}{payment.bank_account}</div>}
|
||||
{payment.qr_code && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">收款码:</Text>
|
||||
<Text type="secondary">{t('supplier.qrCodeLabel')}</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<img src={payment.qr_code} alt="收款码" style={{ maxWidth: '100px', maxHeight: '100px' }} />
|
||||
<img src={payment.qr_code} alt={t('supplier.qrCode')} style={{ maxWidth: '100px', maxHeight: '100px' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -163,13 +165,13 @@ const SupplierDetail: React.FC = () => {
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{(supplier.payment_infos || []).length === 0 && <Empty description="暂无收款信息" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
{(supplier.payment_infos || []).length === 0 && <Empty description={t('supplier.noPayment')} image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'ledger',
|
||||
label: <span><DollarOutlined /> 业务台账</span>,
|
||||
label: <span><DollarOutlined /> {t('supplier.ledger')}</span>,
|
||||
children: (
|
||||
<BusinessLedgerTab
|
||||
partnerType="supplier"
|
||||
@@ -183,7 +185,7 @@ const SupplierDetail: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/suppliers')} style={{ marginBottom: 16 }} type="text">
|
||||
返回列表
|
||||
{t('supplier.returnToList')}
|
||||
</Button>
|
||||
|
||||
<Title level={4} style={{ marginBottom: 24 }}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ShopOutline
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import FileUpload from '../components/FileUpload'
|
||||
import useFormDraft from '../hooks/useFormDraft'
|
||||
import { useLanguageStore } from '../store/languageStore'
|
||||
|
||||
interface Contact {
|
||||
name: string
|
||||
@@ -39,6 +40,7 @@ interface Supplier {
|
||||
const SupplierPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [modalVisible, setModalVisible] = useState(false)
|
||||
@@ -66,7 +68,7 @@ const SupplierPage: React.FC = () => {
|
||||
const data = await response.json()
|
||||
if (data.success) setSuppliers(data.data || [])
|
||||
} catch (error) {
|
||||
message.error('获取供应商列表失败')
|
||||
message.error(t('supplier.getListFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -92,7 +94,7 @@ const SupplierPage: React.FC = () => {
|
||||
|
||||
const columns: ColumnsType<Supplier> = [
|
||||
{
|
||||
title: '名称',
|
||||
title: t('supplier.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
@@ -101,12 +103,12 @@ const SupplierPage: React.FC = () => {
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{ title: '供应类别', dataIndex: 'supply_category', key: 'supply_category', width: 120 },
|
||||
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (country) => <Tag>{country || '-'}</Tag> },
|
||||
{ title: '采购金额', dataIndex: 'total_purchase_amount', key: 'total_purchase_amount', width: 100, render: (amount) => `¥${(amount || 0).toLocaleString()}` },
|
||||
{ title: '应付金额', dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (amount) => <span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(amount || 0).toLocaleString()}</span> },
|
||||
{ title: t('supplier.supplyCategory'), dataIndex: 'supply_category', key: 'supply_category', width: 120 },
|
||||
{ title: t('supplier.country'), dataIndex: 'country', key: 'country', width: 80, render: (country) => <Tag>{country || '-'}</Tag> },
|
||||
{ title: t('supplier.purchaseAmount'), dataIndex: 'total_purchase_amount', key: 'total_purchase_amount', width: 100, render: (amount) => `¥${(amount || 0).toLocaleString()}` },
|
||||
{ title: t('supplier.payableAmount'), dataIndex: 'total_payable', key: 'total_payable', width: 100, render: (amount) => <span style={{ color: amount > 0 ? '#ff4d4f' : '#52c41a' }}>¥{(amount || 0).toLocaleString()}</span> },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('common.action'),
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
@@ -169,7 +171,7 @@ const SupplierPage: React.FC = () => {
|
||||
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
message.success(editingSupplier ? '更新成功' : '创建成功')
|
||||
message.success(editingSupplier ? t('common.updateSuccess') : t('common.createSuccess'))
|
||||
clearDraft()
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
@@ -181,10 +183,10 @@ const SupplierPage: React.FC = () => {
|
||||
navigate(returnTo, { state: { supplierCreated: true } })
|
||||
}
|
||||
} else {
|
||||
message.error(data.message || '操作失败')
|
||||
message.error(data.message || t('common.operationFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
message.error(t('common.operationFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,18 +205,18 @@ const SupplierPage: React.FC = () => {
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除此供应商吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
title: t('common.confirmDelete'),
|
||||
content: t('supplier.confirmDeleteMsg'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
if (data.success) { message.success('删除成功'); fetchSuppliers() }
|
||||
else message.error(data.message || '删除失败')
|
||||
if (data.success) { message.success(t('common.deleteSuccess')); fetchSuppliers() }
|
||||
else message.error(data.message || t('common.deleteFailed'))
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
message.error(t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -233,10 +235,10 @@ const SupplierPage: React.FC = () => {
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的供应商信息,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft()
|
||||
},
|
||||
@@ -264,86 +266,87 @@ const SupplierPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}><Card><Statistic title="供应商总数" value={stats.total} prefix={<ShopOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="采购总金额" value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title="应付总金额" value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('supplier.totalCount')} value={stats.total} prefix={<ShopOutlined />} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('supplier.totalPurchase')} value={stats.totalPurchase} prefix="¥" valueStyle={{ color: '#1890ff' }} /></Card></Col>
|
||||
<Col span={8}><Card><Statistic title={t('supplier.totalPayable')} value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input placeholder="搜索供应商编号、名称或供应类别" prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增供应商</Button>
|
||||
<Input placeholder={t('supplier.searchPlaceholder')} prefix={<SearchOutlined />} value={searchText} onChange={(e) => setSearchText(e.target.value)} allowClear style={{ width: 350 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('supplier.newSupplier')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }} scroll={{ x: 1100 }} />
|
||||
<Table columns={columns} dataSource={filteredSuppliers} rowKey="id" loading={loading} pagination={{ pageSize: 10, showTotal: (total) => t('common.totalCount', { total }) }} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingSupplier ? '编辑供应商' : '新增供应商'}
|
||||
title={editingSupplier ? t('supplier.editSupplier') : t('supplier.newSupplier')}
|
||||
open={modalVisible}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft()
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSupplier(null)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
form.resetFields();
|
||||
setModalVisible(false)
|
||||
form.resetFields()
|
||||
setEditingSupplier(null)
|
||||
}
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnClose
|
||||
width={800}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="供应商名称" />
|
||||
<Form.Item name="name" label={t('supplier.name')} rules={[{ required: true, message: t('supplier.nameRequired') }]}>
|
||||
<Input placeholder={t('supplier.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="supply_category" label="供应类别">
|
||||
<Input placeholder="手填:如电力设备、建筑材料" />
|
||||
<Form.Item name="supply_category" label={t('supplier.supplyCategory')}>
|
||||
<Input placeholder={t('supplier.categoryPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="country" label="国家" initialValue="Laos">
|
||||
<Form.Item name="country" label={t('supplier.country')} initialValue="Laos">
|
||||
<Select>
|
||||
<Select.Option value="China">中国</Select.Option>
|
||||
<Select.Option value="Laos">老挝</Select.Option>
|
||||
<Select.Option value="China">{t('supplier.china')}</Select.Option>
|
||||
<Select.Option value="Laos">{t('supplier.laos')}</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="备注信息" />
|
||||
<Form.Item name="remark" label={t('common.remark')}>
|
||||
<Input.TextArea rows={2} placeholder={t('supplier.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<h4>联系人</h4>
|
||||
<h4>{t('supplier.contact')}</h4>
|
||||
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
|
||||
{(fields, { add, remove }) => (
|
||||
<div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="姓名" />
|
||||
<Input placeholder={t('logistics.name')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="职位" />
|
||||
<Input placeholder={t('common.position')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="电话" />
|
||||
<Input placeholder={t('common.phone')} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
||||
@@ -352,33 +355,33 @@ const SupplierPage: React.FC = () => {
|
||||
onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>主联系人</span>
|
||||
<span>{t('supplier.mainContact')}</span>
|
||||
</div>
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>删除</Button>}
|
||||
{fields.length > 1 && <Button type="link" danger onClick={() => remove(name)}>{t('common.delete')}</Button>}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ 添加联系人</Button>
|
||||
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('supplier.addContact')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<h4 style={{ marginTop: 24 }}>收款信息</h4>
|
||||
<h4 style={{ marginTop: 24 }}>{t('supplier.paymentInfo')}</h4>
|
||||
<Form.List name="payment_infos" initialValue={[]}>
|
||||
{(fields, { add, remove }) => (
|
||||
<div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="收款户名" />
|
||||
<Form.Item {...restField} name={[name, 'account_name']} label={t('supplier.accountName')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('supplier.accountName')} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="开户银行" />
|
||||
<Form.Item {...restField} name={[name, 'bank_name']} label={t('supplier.bankName')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('supplier.bankName')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="银行账号" />
|
||||
<Form.Item {...restField} name={[name, 'bank_account']} label={t('supplier.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder={t('supplier.bankAccount')} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
|
||||
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}>
|
||||
@@ -387,10 +390,10 @@ const SupplierPage: React.FC = () => {
|
||||
onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<span>主要收款账户</span>
|
||||
<span>{t('supplier.mainAccount')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label="收款码" style={{ marginBottom: 0 }}>
|
||||
<Form.Item {...restField} name={[name, 'qr_code']} label={t('supplier.qrCode')} style={{ marginBottom: 0 }}>
|
||||
<FileUpload
|
||||
maxCount={1}
|
||||
accept="image/*"
|
||||
@@ -405,12 +408,12 @@ const SupplierPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>删除此收款信息</Button>
|
||||
<Button type="link" danger onClick={() => remove(name)} style={{ marginTop: 8 }}>{t('supplier.deletePaymentInfo')}</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
|
||||
+ 添加收款信息
|
||||
{t('supplier.addPaymentInfo')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Select, DatePicker, Input } from 'antd';
|
||||
import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const SystemLogsPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading] = React.useState(false);
|
||||
|
||||
const columns = [
|
||||
{ title: '日志ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 180 },
|
||||
{ title: t('systemLogs.logId'), dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: t('systemLogs.time'), dataIndex: 'timestamp', key: 'timestamp', width: 180 },
|
||||
{
|
||||
title: '级别',
|
||||
title: t('systemLogs.level'),
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 100,
|
||||
@@ -21,47 +23,47 @@ const SystemLogsPage: React.FC = () => {
|
||||
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
|
||||
}
|
||||
},
|
||||
{ title: '模块', dataIndex: 'module', key: 'module', width: 120 },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action' },
|
||||
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{ title: '详情', dataIndex: 'detail', key: 'detail', ellipsis: true },
|
||||
{ title: t('systemLogs.module'), dataIndex: 'module', key: 'module', width: 120 },
|
||||
{ title: t('systemLogs.operator'), dataIndex: 'operator', key: 'operator', width: 120 },
|
||||
{ title: t('systemLogs.operation'), dataIndex: 'action', key: 'action' },
|
||||
{ title: t('systemLogs.ipAddress'), dataIndex: 'ip', key: 'ip', width: 130 },
|
||||
{ title: t('systemLogs.detail'), dataIndex: 'detail', key: 'detail', ellipsis: true },
|
||||
];
|
||||
|
||||
const data = [
|
||||
{ key: '1', id: 1001, timestamp: '2026-03-18 17:15:30', level: 'info', module: '用户管理', operator: 'admin', action: '用户登录', ip: '192.168.1.100', detail: '用户 admin 成功登录系统' },
|
||||
{ key: '2', id: 1002, timestamp: '2026-03-18 17:14:25', level: 'info', module: '项目管理', operator: 'manager', action: '创建项目', ip: '192.168.1.101', detail: '创建新项目: 博纳斯线路改造' },
|
||||
{ key: '3', id: 1003, timestamp: '2026-03-18 17:13:10', level: 'warning', module: '财务管理', operator: 'admin', action: '审批预支', ip: '192.168.1.100', detail: '预支申请单 ADV20260318001 审批通过' },
|
||||
{ key: '4', id: 1004, timestamp: '2026-03-18 17:12:05', level: 'success', module: '系统', operator: 'system', action: '数据备份', ip: '127.0.0.1', detail: '自动备份完成,耗时 45 秒' },
|
||||
{ key: '1', id: 1001, timestamp: '2026-03-18 17:15:30', level: 'info', module: t('systemLogs.moduleUser'), operator: 'admin', action: t('systemLogs.login'), ip: '192.168.1.100', detail: '用户 admin 成功登录系统' },
|
||||
{ key: '2', id: 1002, timestamp: '2026-03-18 17:14:25', level: 'info', module: t('systemLogs.moduleProject'), operator: 'manager', action: t('systemLogs.createProject'), ip: '192.168.1.101', detail: '创建新项目: 博纳斯线路改造' },
|
||||
{ key: '3', id: 1003, timestamp: '2026-03-18 17:13:10', level: 'warning', module: t('systemLogs.moduleFinance'), operator: 'admin', action: t('systemLogs.approveAdvance'), ip: '192.168.1.100', detail: '预支申请单 ADV20260318001 审批通过' },
|
||||
{ key: '4', id: 1004, timestamp: '2026-03-18 17:12:05', level: 'success', module: t('systemLogs.moduleSystem'), operator: 'system', action: t('systemLogs.dataBackup'), ip: '127.0.0.1', detail: '自动备份完成,耗时 45 秒' },
|
||||
{ key: '5', id: 1005, timestamp: '2026-03-18 17:10:00', level: 'error', module: 'API', operator: 'anonymous', action: '接口访问', ip: '10.0.0.55', detail: '无效的 API Token 访问尝试' },
|
||||
{ key: '6', id: 1006, timestamp: '2026-03-18 17:09:30', level: 'info', module: '采购管理', operator: 'pm1', action: '创建采购', ip: '192.168.1.102', detail: '创建采购申请: PO20260318002' },
|
||||
{ key: '7', id: 1007, timestamp: '2026-03-18 17:08:15', level: 'info', module: '用户管理', operator: 'admin', action: '修改角色', ip: '192.168.1.100', detail: '修改用户 zhang 的角色为项目经理' },
|
||||
{ key: '6', id: 1006, timestamp: '2026-03-18 17:09:30', level: 'info', module: t('menu.procurement'), operator: 'pm1', action: '创建采购', ip: '192.168.1.102', detail: '创建采购申请: PO20260318002' },
|
||||
{ key: '7', id: 1007, timestamp: '2026-03-18 17:08:15', level: 'info', module: t('systemLogs.moduleUser'), operator: 'admin', action: '修改角色', ip: '192.168.1.100', detail: '修改用户 zhang 的角色为项目经理' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>系统日志</Title>
|
||||
<Paragraph type="secondary">查看系统操作记录和审计日志</Paragraph>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>{t('systemLogs.title')}</Title>
|
||||
<Paragraph type="secondary">{t('systemLogs.description')}</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Select placeholder="日志级别" style={{ width: 120 }} allowClear options={[
|
||||
<Select placeholder={t('systemLogs.logLevel')} style={{ width: 120 }} allowClear options={[
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'warning', label: 'Warning' },
|
||||
{ value: 'error', label: 'Error' },
|
||||
{ value: 'success', label: 'Success' }
|
||||
]} />
|
||||
<Select placeholder="模块" style={{ width: 150 }} allowClear options={[
|
||||
{ value: 'user', label: '用户管理' },
|
||||
{ value: 'project', label: '项目管理' },
|
||||
{ value: 'finance', label: '财务管理' },
|
||||
{ value: 'system', label: '系统' }
|
||||
<Select placeholder={t('systemLogs.module')} style={{ width: 150 }} allowClear options={[
|
||||
{ value: 'user', label: t('systemLogs.moduleUser') },
|
||||
{ value: 'project', label: t('systemLogs.moduleProject') },
|
||||
{ value: 'finance', label: t('systemLogs.moduleFinance') },
|
||||
{ value: 'system', label: t('systemLogs.moduleSystem') }
|
||||
]} />
|
||||
<RangePicker placeholder={['开始日期', '结束日期']} />
|
||||
<Input.Search placeholder="搜索日志内容" style={{ width: 200 }} />
|
||||
<Button icon={<DownloadOutlined />}>导出</Button>
|
||||
<Button icon={<DeleteOutlined />} danger>清理</Button>
|
||||
<Input.Search placeholder={t('systemLogs.searchPlaceholder')} style={{ width: 200 }} />
|
||||
<Button icon={<DownloadOutlined />}>{t('systemLogs.export')}</Button>
|
||||
<Button icon={<DeleteOutlined />} danger>{t('systemLogs.clear')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import apiClient from '../utils/request';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
const UserManagement: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 从API获取用户数据
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -15,11 +16,11 @@ const UserManagement: React.FC = () => {
|
||||
if (response.data.success) {
|
||||
setUsers(response.data.data || []);
|
||||
} else {
|
||||
throw new Error('API返回失败: ' + (response.data.message || '未知错误'));
|
||||
throw new Error(t('userManagement.apiFailed') + (response.data.message || t('userManagement.unknownError')));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
setError(error instanceof Error ? error.message : '未知错误');
|
||||
setError(error instanceof Error ? error.message : t('userManagement.unknownError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -31,23 +32,23 @@ const UserManagement: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h1>用户管理</h1>
|
||||
<p>这是一个测试页面,用于检查API调用是否正常。</p>
|
||||
<h1>{t('userManagement.title')}</h1>
|
||||
<p>{t('userManagement.testPage')}</p>
|
||||
<button onClick={fetchUsers} disabled={loading}>
|
||||
{loading ? '加载中...' : '刷新用户列表'}
|
||||
{loading ? t('common.loading') : t('userManagement.refreshList')}
|
||||
</button>
|
||||
{error && (
|
||||
<div style={{ marginTop: 10, color: 'red' }}>
|
||||
错误: {error}
|
||||
{t('userManagement.errorPrefix')}{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<h2>API返回的数据:</h2>
|
||||
<h2>{t('userManagement.apiResult')}</h2>
|
||||
<pre>{JSON.stringify(users, null, 2)}</pre>
|
||||
</div>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<h2>加载状态:</h2>
|
||||
<p>{loading ? '加载中...' : '加载完成'}</p>
|
||||
<h2>{t('userManagement.loadStatus')}</h2>
|
||||
<p>{loading ? t('common.loading') : t('userManagement.loadComplete')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import apiClient from '../utils/request';
|
||||
import { Card, Button, Table, Tag, Space, Modal, Form, Input, Select, message, Row, Col, Avatar, Popconfirm } from 'antd';
|
||||
import { PlusOutlined, UserOutlined, LockOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, KeyOutlined } from '@ant-design/icons';
|
||||
import { useLanguageStore } from '../store/languageStore';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
@@ -16,6 +17,7 @@ interface User {
|
||||
}
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||
@@ -27,10 +29,10 @@ const UsersPage: React.FC = () => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
|
||||
const roles = [
|
||||
{ value: 'admin', label: '系统管理员' },
|
||||
{ value: 'finance', label: '财务专员' },
|
||||
{ value: 'manager', label: '项目经理' },
|
||||
{ value: 'employee', label: '普通员工' }
|
||||
{ value: 'admin', label: t('user.admin') },
|
||||
{ value: 'finance', label: t('user.finance') },
|
||||
{ value: 'manager', label: t('user.manager') },
|
||||
{ value: 'employee', label: t('user.employee') }
|
||||
];
|
||||
|
||||
const fetchUsers = async () => {
|
||||
@@ -40,14 +42,14 @@ const UsersPage: React.FC = () => {
|
||||
if (response.data.success) {
|
||||
setUsers(response.data.data || []);
|
||||
} else {
|
||||
message.error('获取用户列表失败: ' + (response.data.message || '未知错误'));
|
||||
message.error(t('users.getListFailedLog') + (response.data.message || t('users.unknownError')));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
if (error.response?.status === 403) {
|
||||
message.error('权限不足,仅管理员可访问');
|
||||
message.error(t('users.notAdmin'));
|
||||
} else {
|
||||
message.error('获取用户列表失败');
|
||||
message.error(t('users.getListFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -63,12 +65,12 @@ const UsersPage: React.FC = () => {
|
||||
const values = await addForm.validateFields();
|
||||
const response = await apiClient.post('/users', values);
|
||||
if (response.data.success) {
|
||||
message.success('用户已添加');
|
||||
message.success(t('users.addSuccess'));
|
||||
setAddModalVisible(false);
|
||||
addForm.resetFields();
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.data.message || '添加失败');
|
||||
message.error(response.data.message || t('common.failed'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.message) {
|
||||
@@ -76,7 +78,7 @@ const UsersPage: React.FC = () => {
|
||||
} else if (error.errorFields) {
|
||||
return;
|
||||
} else {
|
||||
message.error('添加失败,请重试');
|
||||
message.error(t('common.failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -98,13 +100,13 @@ const UsersPage: React.FC = () => {
|
||||
if (currentUser) {
|
||||
const response = await apiClient.put(`/users/${currentUser.id}`, values);
|
||||
if (response.data.success) {
|
||||
message.success('用户已更新');
|
||||
message.success(t('common.updateSuccess'));
|
||||
setEditModalVisible(false);
|
||||
editForm.resetFields();
|
||||
setCurrentUser(null);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.data.message || '更新失败');
|
||||
message.error(response.data.message || t('common.failed'));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -113,7 +115,7 @@ const UsersPage: React.FC = () => {
|
||||
} else if (error.errorFields) {
|
||||
return;
|
||||
} else {
|
||||
message.error('更新失败,请重试');
|
||||
message.error(t('common.failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -136,12 +138,12 @@ const UsersPage: React.FC = () => {
|
||||
password: values.newPassword
|
||||
});
|
||||
if (response.data.success) {
|
||||
message.success('密码已重置');
|
||||
message.success(t('common.success'));
|
||||
setResetPwdModalVisible(false);
|
||||
resetPwdForm.resetFields();
|
||||
setCurrentUser(null);
|
||||
} else {
|
||||
message.error(response.data.message || '重置失败');
|
||||
message.error(response.data.message || t('common.failed'));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -150,7 +152,7 @@ const UsersPage: React.FC = () => {
|
||||
} else if (error.errorFields) {
|
||||
return;
|
||||
} else {
|
||||
message.error('重置失败,请重试');
|
||||
message.error(t('common.failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -159,64 +161,64 @@ const UsersPage: React.FC = () => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/users/${user.id}`);
|
||||
if (response.data.success) {
|
||||
message.success('用户已删除');
|
||||
message.success(t('common.deleteSuccess'));
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(response.data.message || '删除失败');
|
||||
message.error(response.data.message || t('common.deleteFailed'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message);
|
||||
} else {
|
||||
message.error('删除失败,请重试');
|
||||
message.error(t('common.deleteFailed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{ title: t('users.id'), dataIndex: 'id', key: 'id', width: 60 },
|
||||
{
|
||||
title: '头像',
|
||||
title: t('users.avatar'),
|
||||
key: 'avatar',
|
||||
width: 60,
|
||||
render: () => <Avatar icon={<UserOutlined />} />
|
||||
},
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180, render: (v: string) => v || '-' },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: t('users.username'), dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: t('users.name'), dataIndex: 'name', key: 'name', width: 120 },
|
||||
{ title: t('users.email'), dataIndex: 'email', key: 'email', width: 180, render: (v: string) => v || '-' },
|
||||
{ title: t('users.phone'), dataIndex: 'phone', key: 'phone', width: 130, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '角色',
|
||||
title: t('users.role'),
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const colors: Record<string, string> = { 'admin': 'red', 'manager': 'blue', 'finance': 'orange', 'employee': 'green' };
|
||||
const roleMap = roles.find(role => role.value === v);
|
||||
return <Tag color={colors[v] || 'green'}>{roleMap?.label || v || '用户'}</Tag>;
|
||||
return <Tag color={colors[v] || 'green'}>{roleMap?.label || v || t('users.user')}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('users.action'),
|
||||
key: 'action',
|
||||
width: 240,
|
||||
render: (_: any, record: User) => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
{t('users.edit')}
|
||||
</Button>
|
||||
<Button size="small" type="link" icon={<KeyOutlined />} onClick={() => handleResetPassword(record)}>
|
||||
重置密码
|
||||
{t('users.resetPassword')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description={`确定要删除用户 ${record.username} 吗?`}
|
||||
title={t('users.confirmDelete')}
|
||||
description={t('users.confirmDeleteMsg', { name: record.username })}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button size="small" type="link" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -227,12 +229,12 @@ const UsersPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2>用户管理</h2>
|
||||
<h2>{t('users.title')}</h2>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>
|
||||
新增用户
|
||||
{t('users.newUser')}
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>刷新</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>{t('common.refresh')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +249,7 @@ const UsersPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新增用户"
|
||||
title={t('users.newUser')}
|
||||
open={addModalVisible}
|
||||
onCancel={() => { setAddModalVisible(false); addForm.resetFields(); }}
|
||||
onOk={handleAdd}
|
||||
@@ -256,37 +258,37 @@ const UsersPage: React.FC = () => {
|
||||
<Form form={addForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input placeholder="请输入用户名" prefix={<UserOutlined />} />
|
||||
<Form.Item label={t('users.username')} name="username" rules={[{ required: true, message: t('users.usernamePlaceholder') }]}>
|
||||
<Input placeholder={t('users.usernamePlaceholder')} prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
<Form.Item label={t('users.name')} name="name" rules={[{ required: true, message: t('users.namePlaceholder') }]}>
|
||||
<Input placeholder={t('users.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="邮箱" name="email">
|
||||
<Form.Item label={t('users.email')} name="email">
|
||||
<Input placeholder="email@example.com" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="手机号" name="phone">
|
||||
<Form.Item label={t('users.phone')} name="phone">
|
||||
<Input placeholder="+856 20 xxxx xxxx" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]} initialValue="employee">
|
||||
<Select placeholder="选择角色" options={roles} />
|
||||
<Form.Item label={t('users.role')} name="role" rules={[{ required: true, message: t('users.selectRolePlaceholder') }]} initialValue="employee">
|
||||
<Select placeholder={t('users.selectRole')} options={roles} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="初始密码" name="password" rules={[{ required: true, message: '请输入初始密码' }, { min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入初始密码" prefix={<LockOutlined />} />
|
||||
<Form.Item label={t('users.initialPassword')} name="password" rules={[{ required: true, message: t('users.initialPasswordPlaceholder') }, { min: 6, message: t('users.passwordMinLen') }]}>
|
||||
<Input.Password placeholder={t('users.initialPasswordPlaceholder')} prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -294,7 +296,7 @@ const UsersPage: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑用户"
|
||||
title={t('users.editUser')}
|
||||
open={editModalVisible}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
@@ -307,32 +309,32 @@ const UsersPage: React.FC = () => {
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="用户名">
|
||||
<Form.Item label={t('users.username')}>
|
||||
<Input value={currentUser?.username} disabled prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input placeholder="请输入姓名" />
|
||||
<Form.Item label={t('users.name')} name="name" rules={[{ required: true, message: t('users.namePlaceholder') }]}>
|
||||
<Input placeholder={t('users.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="邮箱" name="email">
|
||||
<Form.Item label={t('users.email')} name="email">
|
||||
<Input placeholder="email@example.com" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="手机号" name="phone">
|
||||
<Form.Item label={t('users.phone')} name="phone">
|
||||
<Input placeholder="+856 20 xxxx xxxx" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select placeholder="选择角色" options={roles} />
|
||||
<Form.Item label={t('users.role')} name="role" rules={[{ required: true, message: t('users.selectRolePlaceholder') }]}>
|
||||
<Select placeholder={t('users.selectRole')} options={roles} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -340,7 +342,7 @@ const UsersPage: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`重置密码 - ${currentUser?.username || ''}`}
|
||||
title={`${t('users.resetPassword')} - ${currentUser?.username || ''}`}
|
||||
open={resetPwdModalVisible}
|
||||
onCancel={() => {
|
||||
setResetPwdModalVisible(false);
|
||||
@@ -351,21 +353,21 @@ const UsersPage: React.FC = () => {
|
||||
width={400}
|
||||
>
|
||||
<Form form={resetPwdForm} layout="vertical">
|
||||
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码" prefix={<LockOutlined />} />
|
||||
<Form.Item label={t('user.newPassword')} name="newPassword" rules={[{ required: true, message: t('users.newPasswordPlaceholder') }, { min: 6, message: t('users.passwordMinLen') }]}>
|
||||
<Input.Password placeholder={t('users.newPasswordPlaceholder')} prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item label="确认密码" name="confirmPassword" rules={[
|
||||
{ required: true, message: '请确认密码' },
|
||||
<Form.Item label={t('users.confirmPassword')} name="confirmPassword" rules={[
|
||||
{ required: true, message: t('users.confirmPasswordPlaceholder') },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
return Promise.reject(new Error(t('users.passwordMismatch')));
|
||||
},
|
||||
}),
|
||||
]}>
|
||||
<Input.Password placeholder="请再次输入新密码" prefix={<LockOutlined />} />
|
||||
<Input.Password placeholder={t('users.reEnterPassword')} prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,74 +7,77 @@ import {
|
||||
CheckCircleOutlined,
|
||||
InfoCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>关于系统</Title>
|
||||
<Paragraph type="secondary">系统信息与版本</Paragraph>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('about.title')}</Title>
|
||||
<Paragraph type="secondary">{t('about.description')}</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card title={<><InfoCircleOutlined /> 系统信息</>}>
|
||||
<Card title={<><InfoCircleOutlined /> {t('about.systemInfo')}</>}>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="系统名称">轻远电力老挝ERP</Descriptions.Item>
|
||||
<Descriptions.Item label="系统版本">V1.0.0</Descriptions.Item>
|
||||
<Descriptions.Item label="开发团队">轻远电力信息技术部</Descriptions.Item>
|
||||
<Descriptions.Item label="上线日期">2026年3月</Descriptions.Item>
|
||||
<Descriptions.Item label="技术架构">
|
||||
<Descriptions.Item label={t('about.systemName')}>{t('about.systemNameValue')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.version')}>{t('about.versionValue')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.devTeam')}>{t('about.devTeamValue')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.onlineDate')}>{t('about.onlineDateValue')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.techArchitecture')}>
|
||||
<Tag color="blue">React 18</Tag>
|
||||
<Tag color="green">Ant Design 5</Tag>
|
||||
<Tag color="purple">Node.js</Tag>
|
||||
<Tag color="orange">PostgreSQL</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="部署环境">
|
||||
<Tag color="cyan">腾讯云服务器</Tag>
|
||||
<Descriptions.Item label={t('about.deployEnv')}>
|
||||
<Tag color="cyan">{t('about.deployEnvValue')}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="前端框架">Vite + React + TypeScript</Descriptions.Item>
|
||||
<Descriptions.Item label="后端框架">Express.js + PostgreSQL</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.frontend')}>{t('about.frontendValue')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.backend')}>{t('about.backendValue')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title={<><CheckCircleOutlined /> 功能模块</>} style={{ marginTop: 24 }}>
|
||||
<Card title={<><CheckCircleOutlined /> {t('about.modules')}</>} style={{ marginTop: 24 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>项目管理</Title>
|
||||
<Title level={5}>{t('menu.projects')}</Title>
|
||||
<Text type="secondary">项目创建、进度跟踪、合同管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>财务管理</Title>
|
||||
<Title level={5}>{t('menu.financeManagement')}</Title>
|
||||
<Text type="secondary">预支、报销、付款申请、核销</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>采购管理</Title>
|
||||
<Title level={5}>{t('menu.procurement')}</Title>
|
||||
<Text type="secondary">商品管理、供应商管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>合作伙伴</Title>
|
||||
<Title level={5}>{t('menu.partners')}</Title>
|
||||
<Text type="secondary">供应商、分包商、客户管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>施工管理</Title>
|
||||
<Title level={5}>{t('menu.construction')}</Title>
|
||||
<Text type="secondary">施工日志、里程碑管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Title level={5}>预算报价</Title>
|
||||
<Title level={5}>{t('menu.budgetQuotation')}</Title>
|
||||
<Text type="secondary">项目预算、报价管理</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -83,45 +86,45 @@ const AboutPage: React.FC = () => {
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card title={<><CloudServerOutlined /> 服务器状态</>}>
|
||||
<Card title={<><CloudServerOutlined /> {t('about.serverStatus')}</>}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary">CPU使用率</Text>
|
||||
<Text type="secondary">{t('about.cpuUsage')}</Text>
|
||||
<Progress percent={45} status="active" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary">内存使用</Text>
|
||||
<Text type="secondary">{t('about.memoryUsage')}</Text>
|
||||
<Progress percent={60} strokeColor="#52c41a" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text type="secondary">磁盘空间</Text>
|
||||
<Text type="secondary">{t('about.diskSpace')}</Text>
|
||||
<Progress percent={35} strokeColor="#1890ff" />
|
||||
</div>
|
||||
<Divider />
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="服务器IP">43.161.248.209</Descriptions.Item>
|
||||
<Descriptions.Item label="操作系统">OpenCloudOS 9</Descriptions.Item>
|
||||
<Descriptions.Item label="Node版本">v22.22.1</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.serverIp')}>43.161.248.209</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.os')}>{t('about.osValue')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.nodeVersion')}>v22.22.1</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title={<><DatabaseOutlined /> 数据库状态</>} style={{ marginTop: 24 }}>
|
||||
<Card title={<><DatabaseOutlined /> {t('about.databaseStatus')}</>} style={{ marginTop: 24 }}>
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a' }} />
|
||||
<Title level={4} style={{ margin: '16px 0 8px' }}>运行正常</Title>
|
||||
<Title level={4} style={{ margin: '16px 0 8px' }}>{t('about.running')}</Title>
|
||||
<Text type="secondary">PostgreSQL 15</Text>
|
||||
</div>
|
||||
<Divider />
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="数据库名">company_finance_db</Descriptions.Item>
|
||||
<Descriptions.Item label="连接状态">正常</Descriptions.Item>
|
||||
<Descriptions.Item label="最近备份">2026-03-19 00:00</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.dbName')}>company_finance_db</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.connectionStatus')}>{t('about.normal')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('about.lastBackup')}>2026-03-19 00:00</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}>
|
||||
<Text>© 2026 轻远电力老挝ERP系统 - 版本 V1.0.0</Text>
|
||||
<Text>{t('about.footer')}</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,27 +2,29 @@ import React from 'react';
|
||||
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, DatePicker, message, Row, Col, Progress } from 'antd';
|
||||
import { DownloadOutlined, UploadOutlined, DeleteOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
const BackupPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [backuping, setBackuping] = React.useState(false);
|
||||
|
||||
const columns = [
|
||||
{ title: '备份名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '备份时间', dataIndex: 'time', key: 'time' },
|
||||
{ title: '文件大小', dataIndex: 'size', key: 'size' },
|
||||
{ title: '备份类型', dataIndex: 'type', key: 'type', render: (v: string) => <Tag color={v === 'auto' ? 'blue' : 'green'}>{v === 'auto' ? '自动' : '手动'}</Tag> },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'success' ? 'success' : 'error'}>{v === 'success' ? '成功' : '失败'}</Tag> },
|
||||
{ title: t('backup.backupName'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('backup.backupTime'), dataIndex: 'time', key: 'time' },
|
||||
{ title: t('backup.fileSize'), dataIndex: 'size', key: 'size' },
|
||||
{ title: t('backup.backupType'), dataIndex: 'type', key: 'type', render: (v: string) => <Tag color={v === 'auto' ? 'blue' : 'green'}>{v === 'auto' ? t('backup.auto') : t('backup.manual')}</Tag> },
|
||||
{ title: t('backup.status'), dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'success' ? 'success' : 'error'}>{v === 'success' ? t('backup.success') : t('backup.failed')}</Tag> },
|
||||
{
|
||||
title: '操作',
|
||||
title: t('backup.action'),
|
||||
key: 'action',
|
||||
render: () => (
|
||||
<Space>
|
||||
<Button size="small" type="link" icon={<DownloadOutlined />}>下载</Button>
|
||||
<Button size="small" type="link" icon={<UploadOutlined />}>恢复</Button>
|
||||
<Button size="small" danger type="link" icon={<DeleteOutlined />}>删除</Button>
|
||||
<Button size="small" type="link" icon={<DownloadOutlined />}>{t('backup.download')}</Button>
|
||||
<Button size="small" type="link" icon={<UploadOutlined />}>{t('backup.restore')}</Button>
|
||||
<Button size="small" danger type="link" icon={<DeleteOutlined />}>{t('backup.delete')}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +39,7 @@ const BackupPage: React.FC = () => {
|
||||
const handleBackup = () => {
|
||||
setBackuping(true);
|
||||
setTimeout(() => {
|
||||
message.success('备份创建成功');
|
||||
message.success(t('backup.backupCreated'));
|
||||
setBackuping(false);
|
||||
}, 2000);
|
||||
};
|
||||
@@ -45,32 +47,32 @@ const BackupPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>数据备份</Title>
|
||||
<Paragraph type="secondary">管理系统数据备份与恢复</Paragraph>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('backup.title')}</Title>
|
||||
<Paragraph type="secondary">{t('backup.description')}</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">总备份数</Text>
|
||||
<Text type="secondary">{t('backup.totalBackups')}</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>3</Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">总大小</Text>
|
||||
<Text type="secondary">{t('backup.totalSize')}</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>44.5 MB</Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">最近备份</Text>
|
||||
<Text type="secondary">{t('backup.lastBackup')}</Text>
|
||||
<Title level={4} style={{ margin: '8px 0 0' }}>2026-03-19 00:00</Title>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Text type="secondary">存储空间</Text>
|
||||
<Text type="secondary">{t('backup.storageSpace')}</Text>
|
||||
<Progress percent={30} size="small" style={{ marginTop: 8 }} />
|
||||
<Text type="secondary">300 MB / 1 GB</Text>
|
||||
</Card>
|
||||
@@ -78,12 +80,12 @@ const BackupPage: React.FC = () => {
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
title="备份列表"
|
||||
title={t('backup.backupList')}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ClockCircleOutlined />}>自动备份设置</Button>
|
||||
<Button icon={<ClockCircleOutlined />}>{t('backup.autoBackupSetting')}</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} loading={backuping} onClick={handleBackup}>
|
||||
立即备份
|
||||
{t('backup.immediateBackup')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutl
|
||||
import * as XLSX from 'xlsx';
|
||||
import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -34,6 +35,7 @@ interface ParsedRow {
|
||||
}
|
||||
|
||||
const ExcelImport: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [parsedData, setParsedData] = useState<ParsedRow[]>([]);
|
||||
const [categories, setCategories] = useState<any>({});
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -42,7 +44,10 @@ const ExcelImport: React.FC = () => {
|
||||
useEffect(() => {
|
||||
apiClient.get('/expense-categories/grouped').then(res => {
|
||||
if (res.data.success) setCategories(res.data.data);
|
||||
}).catch(() => {});
|
||||
}).catch((err) => {
|
||||
console.error('加载支出分类失败:', err);
|
||||
message.error('加载支出分类失败,请刷新页面重试');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getCategoryMap = () => {
|
||||
@@ -63,16 +68,29 @@ const ExcelImport: React.FC = () => {
|
||||
const json = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||
|
||||
if (json.length < 2) {
|
||||
message.error('Excel文件没有数据行');
|
||||
message.error(t('excelImport.noData'));
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryMap = getCategoryMap();
|
||||
if (Object.keys(categoryMap).length === 0) {
|
||||
message.error('支出分类未加载,请刷新页面后重试');
|
||||
return;
|
||||
}
|
||||
console.log('Excel导入 - 分类映射表:', categoryMap);
|
||||
console.log('Excel导入 - 原始数据行数:', json.length - 1);
|
||||
const rows: ParsedRow[] = [];
|
||||
|
||||
for (let i = 1; i < json.length; i++) {
|
||||
const r: any[] = json[i];
|
||||
if (!r || r.length === 0 || !r[0]) continue;
|
||||
if (!r || r.length === 0) continue;
|
||||
// 跳过完全空行(所有列都为空)
|
||||
const hasData = r.some((cell: any) => cell !== null && cell !== undefined && cell !== '');
|
||||
if (!hasData) continue;
|
||||
|
||||
if (i <= 5) {
|
||||
console.log(`Excel导入 - 第${i+1}行原始数据:`, JSON.stringify(r));
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const dateVal = r[0];
|
||||
@@ -98,17 +116,27 @@ const ExcelImport: React.FC = () => {
|
||||
const description = String(r[12] || '').trim();
|
||||
const voucherNo = String(r[13] || '').trim();
|
||||
|
||||
const txnType = TXN_TYPE_MAP[txnTypeLabel] || '';
|
||||
const level1 = LEVEL1_MAP[level1Label] || '';
|
||||
const level2 = categoryMap[level2Label] || '';
|
||||
const txnType = TXN_TYPE_MAP[txnTypeLabel] || TXN_TYPE_MAP[txnTypeLabel.replace(/\s/g, '')] || '';
|
||||
const level1 = LEVEL1_MAP[level1Label] || LEVEL1_MAP[level1Label.replace(/\s/g, '')] || '';
|
||||
let level2 = categoryMap[level2Label] || categoryMap[level2Label.replace(/\s/g, '')] || '';
|
||||
|
||||
if (!recordDate) errors.push('日期为空');
|
||||
if (!txnType) errors.push(`收支类型无效: ${txnTypeLabel}`);
|
||||
if (!level1) errors.push(`一级分类无效: ${level1Label}`);
|
||||
if (!level2) errors.push(`二级分类无效: ${level2Label}`);
|
||||
if (amountOriginal <= 0) errors.push('金额必须大于0');
|
||||
// 如果精确匹配失败,尝试模糊匹配(包含关系)
|
||||
if (!level2 && level2Label) {
|
||||
for (const [label, value] of Object.entries(categoryMap)) {
|
||||
if (label.includes(level2Label) || level2Label.includes(label)) {
|
||||
level2 = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordDate) errors.push(t('excelImport.invalidDate'));
|
||||
if (!txnType) errors.push(`无效的收支类型: "${txnTypeLabel}" (应为: 收入/支出)`);
|
||||
if (!level1) errors.push(`无效的一级分类: "${level1Label}" (应为: 收入/项目支出/公司支出)`);
|
||||
if (!level2) errors.push(`无效的二级分类: "${level2Label}" (请参考模板中的分类下拉列表)`);
|
||||
if (amountOriginal <= 0) errors.push(t('excelImport.amountPositive'));
|
||||
if ((level1 === 'project' || (level1 === 'income' && level2 !== 'shareholder_investment')) && !projectName) {
|
||||
errors.push('项目支出/项目收入必须填写项目名称');
|
||||
errors.push(t('excelImport.projectRequired'));
|
||||
}
|
||||
|
||||
rows.push({
|
||||
@@ -133,9 +161,9 @@ const ExcelImport: React.FC = () => {
|
||||
|
||||
setParsedData(rows);
|
||||
setImportResult(null);
|
||||
message.success(`解析完成,共 ${rows.length} 条记录`);
|
||||
message.success(t('excelImport.parseComplete', { count: rows.length }));
|
||||
} catch (err: any) {
|
||||
message.error('解析Excel失败: ' + err.message);
|
||||
message.error(t('excelImport.parseFailed') + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
@@ -145,7 +173,7 @@ const ExcelImport: React.FC = () => {
|
||||
const handleImport = async () => {
|
||||
const validRows = parsedData.filter(r => r.errors.length === 0);
|
||||
if (validRows.length === 0) {
|
||||
message.error('没有有效的数据可导入');
|
||||
message.error(t('excelImport.noValidData'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,10 +200,10 @@ const ExcelImport: React.FC = () => {
|
||||
const res = await apiClient.post('/financial-records/batch', { records });
|
||||
if (res.data.success) {
|
||||
setImportResult(res.data.data);
|
||||
message.success(`导入完成:成功 ${res.data.data.imported} 条,失败 ${res.data.data.failed} 条`);
|
||||
message.success(t('excelImport.importComplete', { success: res.data.data.imported, fail: res.data.data.failed }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('导入失败: ' + (e.response?.data?.message || e.message));
|
||||
message.error(t('excelImport.importFailed') + (e.response?.data?.message || e.message));
|
||||
}
|
||||
setImporting(false);
|
||||
};
|
||||
@@ -184,22 +212,22 @@ const ExcelImport: React.FC = () => {
|
||||
const validCount = parsedData.filter(r => r.errors.length === 0).length;
|
||||
|
||||
const columns = [
|
||||
{ title: '行号', dataIndex: 'row', width: 50 },
|
||||
{ title: '日期', dataIndex: 'record_date', width: 100 },
|
||||
{ title: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> },
|
||||
{ title: '一级', dataIndex: 'category_level1', width: 80, render: (v: string) => {
|
||||
const m: Record<string, string> = { income: '收入', project: '项目', company: '公司' };
|
||||
{ title: t('excelImport.rowNum'), dataIndex: 'row', width: 50 },
|
||||
{ title: t('excelImport.date'), dataIndex: 'record_date', width: 100 },
|
||||
{ title: t('excelImport.incomeExpense'), dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? t('excelImport.income') : t('excelImport.expense')}</Tag> },
|
||||
{ title: t('excelImport.level1'), dataIndex: 'category_level1', width: 80, render: (v: string) => {
|
||||
const m: Record<string, string> = { income: t('excelImport.income'), project: t('excelImport.projectLabel'), company: t('excelImport.companyLabel') };
|
||||
return m[v] || v;
|
||||
}},
|
||||
{ title: '二级', dataIndex: 'category_level2', width: 100 },
|
||||
{ title: '项目', dataIndex: 'project_name', width: 120, ellipsis: true },
|
||||
{ title: '金额', dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() },
|
||||
{ title: '币种', dataIndex: 'currency', width: 50 },
|
||||
{ title: '汇率', dataIndex: 'exchange_rate', width: 60 },
|
||||
{ title: '等效人民币', dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() },
|
||||
{ title: '描述', dataIndex: 'description', width: 150, ellipsis: true },
|
||||
{ title: t('excelImport.level2'), dataIndex: 'category_level2', width: 100 },
|
||||
{ title: t('excelImport.projectLabel'), dataIndex: 'project_name', width: 120, ellipsis: true },
|
||||
{ title: t('excelImport.amount'), dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() },
|
||||
{ title: t('excelImport.currency'), dataIndex: 'currency', width: 50 },
|
||||
{ title: t('excelImport.rate'), dataIndex: 'exchange_rate', width: 60 },
|
||||
{ title: t('excelImport.equivalentCNY'), dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() },
|
||||
{ title: t('excelImport.desc'), dataIndex: 'description', width: 150, ellipsis: true },
|
||||
{
|
||||
title: '校验', width: 80,
|
||||
title: t('excelImport.validation'), width: 80,
|
||||
render: (_: unknown, r: ParsedRow) => r.errors.length === 0
|
||||
? <CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
: <CloseCircleOutlined style={{ color: '#ff4d4f' }} title={r.errors.join('; ')} />
|
||||
@@ -209,18 +237,18 @@ const ExcelImport: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Title level={4}>Excel 批量导入财务记录</Title>
|
||||
<Title level={4}>{t('excelImport.title')}</Title>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="Excel 模板格式要求"
|
||||
message={t('excelImport.templateRequirements')}
|
||||
description={
|
||||
<div>
|
||||
<p>列顺序:日期 | 收支类型 | 一级分类 | 二级分类 | 项目名称 | 金额 | 币种 | 汇率 | 等效人民币 | 对方名称 | 对方类型 | 人员姓名 | 描述 | 凭证编号</p>
|
||||
<p>收支类型:收入 / 支出 | 一级分类:收入 / 项目支出 / 公司支出 | 币种:CNY / USD / LAK / THB</p>
|
||||
<p>二级分类必须使用系统中已有的分类名称(如:材料采购、工资薪酬等)</p>
|
||||
<p>{t('excelImport.columnOrder')}</p>
|
||||
<p>{t('excelImport.formatRequirements')}</p>
|
||||
<p>{t('excelImport.categoryRequirements')}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -230,20 +258,20 @@ const ExcelImport: React.FC = () => {
|
||||
showUploadList={false}
|
||||
beforeUpload={handleFileUpload}
|
||||
>
|
||||
<Button icon={<FileExcelOutlined />} type="primary">选择 Excel 文件</Button>
|
||||
<Button icon={<FileExcelOutlined />} type="primary">{t('excelImport.selectFile')}</Button>
|
||||
</Upload>
|
||||
|
||||
<a href="/templates/财务记账导入模板.xlsx" download="财务记账导入模板.xlsx" style={{ marginLeft: 12 }}>
|
||||
<Button icon={<DownloadOutlined />}>下载模板文件</Button>
|
||||
<a href="/templates/财务记账导入模板.xlsx" download={t('excelImport.templateFileName')} style={{ marginLeft: 12 }}>
|
||||
<Button icon={<DownloadOutlined />}>{t('excelImport.downloadTemplate')}</Button>
|
||||
</a>
|
||||
|
||||
{parsedData.length > 0 && (
|
||||
<>
|
||||
<div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<span>共 {parsedData.length} 条</span>
|
||||
<Tag color="green">有效 {validCount}</Tag>
|
||||
{errorCount > 0 && <Tag color="red">有误 {errorCount}</Tag>}
|
||||
<span>{t('excelImport.countPrefix')} {parsedData.length} {t('excelImport.countSuffix')}</span>
|
||||
<Tag color="green">{t('excelImport.validPrefix')} {validCount}</Tag>
|
||||
{errorCount > 0 && <Tag color="red">{t('excelImport.errorPrefix')} {errorCount}</Tag>}
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -251,7 +279,7 @@ const ExcelImport: React.FC = () => {
|
||||
loading={importing}
|
||||
disabled={validCount === 0}
|
||||
>
|
||||
导入 {validCount} 条有效记录
|
||||
{t('excelImport.importPrefix')} {validCount} {t('excelImport.importSuffix')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -270,10 +298,10 @@ const ExcelImport: React.FC = () => {
|
||||
type={importResult.failed > 0 ? 'warning' : 'success'}
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
message={`导入完成:成功 ${importResult.imported} 条,失败 ${importResult.failed} 条`}
|
||||
message={t('excelImport.importComplete', { success: importResult.imported, fail: importResult.failed })}
|
||||
description={
|
||||
importResult.errors?.length > 0
|
||||
? importResult.errors.map((e: any, i: number) => <div key={i}>第 {e.row} 行:{e.message}</div>)
|
||||
? importResult.errors.map((e: any, i: number) => <div key={i}>{t('excelImport.rowPrefix')}{e.row}{t('excelImport.rowSuffix')}{e.message}</div>)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, Select, Switch, Tag, message, Space, Card, Typography } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import apiClient from '../../utils/request';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -12,6 +13,7 @@ const LEVEL1_OPTIONS = [
|
||||
];
|
||||
|
||||
const ExpenseCategories: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -26,7 +28,7 @@ const ExpenseCategories: React.FC = () => {
|
||||
setCategories(res.data.data);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('获取分类失败');
|
||||
message.error(t('expenseCategory.getFailed'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -50,10 +52,10 @@ const ExpenseCategories: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
if (editingId) {
|
||||
await apiClient.put(`/expense-categories/${editingId}`, values);
|
||||
message.success('更新成功');
|
||||
message.success(t('common.updateSuccess'));
|
||||
} else {
|
||||
await apiClient.post('/expense-categories', values);
|
||||
message.success('创建成功');
|
||||
message.success(t('common.createSuccess'));
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchCategories();
|
||||
@@ -65,10 +67,10 @@ const ExpenseCategories: React.FC = () => {
|
||||
const handleToggleActive = async (id: number, isActive: boolean) => {
|
||||
try {
|
||||
await apiClient.put(`/expense-categories/${id}`, { is_active: isActive });
|
||||
message.success(isActive ? '已启用' : '已禁用');
|
||||
message.success(isActive ? t('expenseCategory.enabled') : t('expenseCategory.disabled'));
|
||||
fetchCategories();
|
||||
} catch (e) {
|
||||
message.error('操作失败');
|
||||
message.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,22 +80,22 @@ const ExpenseCategories: React.FC = () => {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '一级分类', dataIndex: 'category_level1', width: 120, render: getLevel1Tag },
|
||||
{ title: '二级编码', dataIndex: 'category_level2', width: 160 },
|
||||
{ title: '显示名称', dataIndex: 'label', width: 140 },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sort_order', width: 70 },
|
||||
{ title: t('expenseCategory.id'), dataIndex: 'id', width: 60 },
|
||||
{ title: t('expenseCategory.level1'), dataIndex: 'category_level1', width: 120, render: getLevel1Tag },
|
||||
{ title: t('expenseCategory.level2Code'), dataIndex: 'category_level2', width: 160 },
|
||||
{ title: t('expenseCategory.displayName'), dataIndex: 'label', width: 140 },
|
||||
{ title: t('expenseCategory.desc'), dataIndex: 'description', ellipsis: true },
|
||||
{ title: t('expenseCategory.order'), dataIndex: 'sort_order', width: 70 },
|
||||
{
|
||||
title: '状态', dataIndex: 'is_active', width: 90,
|
||||
title: t('expenseCategory.status'), dataIndex: 'is_active', width: 90,
|
||||
render: (v: boolean, r: any) => (
|
||||
<Switch size="small" checked={v} onChange={(checked) => handleToggleActive(r.id, checked)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作', width: 100,
|
||||
title: t('expenseCategory.action'), width: 100,
|
||||
render: (_: unknown, r: any) => (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>{t('common.edit')}</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
@@ -102,10 +104,10 @@ const ExpenseCategories: React.FC = () => {
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>财务分类管理</Title>
|
||||
<Title level={4} style={{ margin: 0 }}>{t('expenseCategory.title')}</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchCategories}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增分类</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchCategories}>{t('expenseCategory.refresh')}</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('expenseCategory.add')}</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
@@ -119,26 +121,26 @@ const ExpenseCategories: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑分类' : '新增分类'}
|
||||
title={editingId ? t('expenseCategory.editCategory') : t('expenseCategory.addCategory')}
|
||||
open={modalVisible}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}>
|
||||
<Form.Item name="category_level1" label={t('expenseCategory.level1')} rules={[{ required: true, message: t('expenseCategory.selectLevel1') }]}>
|
||||
<Select options={LEVEL1_OPTIONS.map(o => ({ value: o.value, label: o.label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category_level2" label="二级编码" rules={[{ required: true, message: '请输入' }]}>
|
||||
<Input placeholder="如 material, salary 等" />
|
||||
<Form.Item name="category_level2" label={t('expenseCategory.level2Code')} rules={[{ required: true, message: t('expenseCategory.inputLevel2') }]}>
|
||||
<Input placeholder={t('expenseCategory.codePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="label" label="显示名称" rules={[{ required: true, message: '请输入' }]}>
|
||||
<Input placeholder="如 材料采购" />
|
||||
<Form.Item name="label" label={t('expenseCategory.displayName')} rules={[{ required: true, message: t('expenseCategory.inputLevel2') }]}>
|
||||
<Input placeholder={t('expenseCategory.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Form.Item name="description" label={t('expenseCategory.desc')}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<Form.Item name="sort_order" label={t('expenseCategory.order')} initialValue={0}>
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Typography, Table, Button, Space, Modal, Form, Select, Input, message, Tag, Steps, Divider, Switch, Badge } from 'antd';
|
||||
import { EditOutlined, PlusOutlined, SettingOutlined, CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -15,32 +16,30 @@ interface ProcessNode {
|
||||
}
|
||||
|
||||
const ProcessManagement: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingNode, setEditingNode] = useState<ProcessNode | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 流程节点数据
|
||||
const [nodes, setNodes] = useState<ProcessNode[]>([
|
||||
{ id: '1', name: '发起申请', role: 'applicant', roleName: '申请人(任意角色)', order: 1, enabled: true },
|
||||
{ id: '2', name: '审批', role: 'admin', roleName: '管理员', order: 2, enabled: true },
|
||||
{ id: '3', name: '执行付款', role: 'admin', roleName: '管理员', order: 3, enabled: true },
|
||||
{ id: '1', name: t('processManagement.submitApplication'), role: 'applicant', roleName: t('processManagement.roleApplicant'), order: 1, enabled: true },
|
||||
{ id: '2', name: t('processManagement.approvalNode'), role: 'admin', roleName: t('processManagement.roleAdmin'), order: 2, enabled: true },
|
||||
{ id: '3', name: t('processManagement.executePayment'), role: 'admin', roleName: t('processManagement.roleAdmin'), order: 3, enabled: true },
|
||||
]);
|
||||
|
||||
// 角色选项
|
||||
const roleOptions = [
|
||||
{ value: 'applicant', label: '申请人(任意角色)' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'finance', label: '财务专员' },
|
||||
{ value: 'manager', label: '项目经理' },
|
||||
{ value: 'applicant', label: t('processManagement.roleApplicant') },
|
||||
{ value: 'admin', label: t('processManagement.roleAdmin') },
|
||||
{ value: 'finance', label: t('processManagement.roleFinance') },
|
||||
{ value: 'manager', label: t('processManagement.roleManager') },
|
||||
];
|
||||
|
||||
// 流程类型
|
||||
const processTypes = [
|
||||
{ key: 'advance', name: '预支申请', description: '员工预支款项申请流程' },
|
||||
{ key: 'reimbursement', name: '报销申请', description: '费用报销申请流程' },
|
||||
{ key: 'payment', name: '付款申请', description: '供应商付款申请流程' },
|
||||
{ key: 'verification', name: '核销申请', description: '单据核销申请流程' },
|
||||
{ key: 'advance', name: t('processManagement.advanceProcess'), description: t('processManagement.advanceProcessDesc') },
|
||||
{ key: 'reimbursement', name: t('processManagement.reimburseProcess'), description: t('processManagement.reimburseProcessDesc') },
|
||||
{ key: 'payment', name: t('processManagement.paymentProcess'), description: t('processManagement.paymentProcessDesc') },
|
||||
{ key: 'verification', name: t('processManagement.verificationProcess'), description: t('processManagement.verificationProcessDesc') },
|
||||
];
|
||||
|
||||
const handleEdit = (node: ProcessNode) => {
|
||||
@@ -62,14 +61,14 @@ const ProcessManagement: React.FC = () => {
|
||||
return n;
|
||||
});
|
||||
setNodes(updatedNodes);
|
||||
message.success('节点配置已保存');
|
||||
message.success(t('processManagement.nodeSaved'));
|
||||
}
|
||||
setModalVisible(false);
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusTag = (enabled: boolean) => {
|
||||
return enabled ? <Tag color="success">已启用</Tag> : <Tag color="default">已禁用</Tag>;
|
||||
return enabled ? <Tag color="success">{t('processManagement.enabled')}</Tag> : <Tag color="default">{t('processManagement.disabled')}</Tag>;
|
||||
};
|
||||
|
||||
const getStepStatus = (order: number) => {
|
||||
@@ -80,15 +79,15 @@ const ProcessManagement: React.FC = () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '顺序',
|
||||
title: t('processManagement.sequence'),
|
||||
dataIndex: 'order',
|
||||
key: 'order',
|
||||
width: 80,
|
||||
render: (v: number) => <Badge count={v} style={{ backgroundColor: '#1890ff' }} />
|
||||
},
|
||||
{ title: '节点名称', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: t('processManagement.nodeName'), dataIndex: 'name', key: 'name', width: 150 },
|
||||
{
|
||||
title: '执行角色',
|
||||
title: t('processManagement.executeRole'),
|
||||
dataIndex: 'roleName',
|
||||
key: 'roleName',
|
||||
render: (v: string, r: ProcessNode) => (
|
||||
@@ -100,20 +99,20 @@ const ProcessManagement: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
title: t('processManagement.status'),
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 100,
|
||||
render: (v: boolean) => getStatusTag(v)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('processManagement.action'),
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: any, record: ProcessNode) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
|
||||
编辑
|
||||
{t('processManagement.edit')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
@@ -123,14 +122,13 @@ const ProcessManagement: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>流程管理</Title>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('processManagement.title')}</Title>
|
||||
<Paragraph type="secondary">
|
||||
配置财务申请的审批流程节点,支持自定义执行角色
|
||||
{t('processManagement.description')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 流程图示 */}
|
||||
<Card title="当前流程图" style={{ marginBottom: 24 }}>
|
||||
<Card title={t('processManagement.flowchart')} style={{ marginBottom: 24 }}>
|
||||
<Steps current={1} style={{ marginTop: 16 }}>
|
||||
{nodes.filter(n => n.enabled).map((node, index) => (
|
||||
<Steps.Step
|
||||
@@ -148,13 +146,12 @@ const ProcessManagement: React.FC = () => {
|
||||
</Steps>
|
||||
<Divider />
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
<Text strong>说明:</Text>
|
||||
当前流程为「申请人 → 管理员审批 → 管理员执行」,后续可在下方修改执行角色为财务专员。
|
||||
<Text strong>{t('processManagement.tipTitle')}</Text>
|
||||
{t('processManagement.tipContent')}
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
{/* 节点配置表 */}
|
||||
<Card title="节点配置">
|
||||
<Card title={t('processManagement.nodeConfig')}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={nodes}
|
||||
@@ -164,17 +161,16 @@ const ProcessManagement: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 流程类型说明 */}
|
||||
<Card title="适用流程" style={{ marginTop: 24 }}>
|
||||
<Card title={t('processManagement.applicableProcess')} style={{ marginTop: 24 }}>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '流程类型', dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: '说明', dataIndex: 'description', key: 'description' },
|
||||
{ title: t('processManagement.processType'), dataIndex: 'name', key: 'name', width: 150 },
|
||||
{ title: t('processManagement.desc'), dataIndex: 'description', key: 'description' },
|
||||
{
|
||||
title: '状态',
|
||||
title: t('processManagement.status'),
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: () => <Tag color="success">已启用</Tag>
|
||||
render: () => <Tag color="success">{t('processManagement.enabled')}</Tag>
|
||||
}
|
||||
]}
|
||||
dataSource={processTypes}
|
||||
@@ -184,24 +180,23 @@ const ProcessManagement: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 编辑节点弹窗 */}
|
||||
<Modal
|
||||
title={`编辑节点:${editingNode?.name}`}
|
||||
title={t('processManagement.editNode', { name: editingNode?.name || '' })}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={handleSave}
|
||||
width={500}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="节点名称">
|
||||
<Form.Item label={t('processManagement.nodeName')}>
|
||||
<Input value={editingNode?.name} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="role"
|
||||
label="执行角色"
|
||||
rules={[{ required: true, message: '请选择执行角色' }]}
|
||||
label={t('processManagement.executeRole')}
|
||||
rules={[{ required: true, message: t('processManagement.selectRolePlaceholder') }]}
|
||||
>
|
||||
<Select placeholder="选择执行角色">
|
||||
<Select placeholder={t('processManagement.selectRole')}>
|
||||
{roleOptions.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
@@ -210,7 +205,7 @@ const ProcessManagement: React.FC = () => {
|
||||
</Form>
|
||||
<div style={{ padding: 12, background: '#fffbe6', borderRadius: 6, marginTop: 16 }}>
|
||||
<Text type="warning">
|
||||
⚠️ 修改执行角色会影响所有使用此流程的申请。建议在公司有财务专员后再将执行节点改为财务角色。
|
||||
{t('processManagement.warning')}
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -26,6 +27,7 @@ interface Template {
|
||||
}
|
||||
|
||||
const ProcessTemplates: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
@@ -46,7 +48,7 @@ const ProcessTemplates: React.FC = () => {
|
||||
try {
|
||||
const res = await apiClient.get('/process-templates');
|
||||
if (res.data.success) setTemplates(res.data.data);
|
||||
} catch (e) { message.error('获取模板列表失败'); }
|
||||
} catch (e) { message.error(t('processTemplate.getListFailed')); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -58,32 +60,32 @@ const ProcessTemplates: React.FC = () => {
|
||||
setCreateModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (t: Template) => {
|
||||
form.setFieldsValue({ name: t.name, description: t.description });
|
||||
setPhases(t.phases || []);
|
||||
setCurrentStep(1);
|
||||
setEditingTemplateId(t.id);
|
||||
const handleEdit = (template: Template) => {
|
||||
form.setFieldsValue({ name: template.name, description: template.description });
|
||||
setPhases(template.phases || []);
|
||||
setCurrentStep(0);
|
||||
setEditingTemplateId(template.id);
|
||||
setCreateModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCopy = async (t: Template) => {
|
||||
const handleCopy = async (template: Template) => {
|
||||
try {
|
||||
await apiClient.post(`/process-templates/${t.id}/copy`, { name: `${t.name} (副本)` });
|
||||
message.success('复制成功');
|
||||
await apiClient.post(`/process-templates/${template.id}/copy`, { name: `${template.name} (副本)` });
|
||||
message.success(t('processTemplate.copySuccess'));
|
||||
fetchTemplates();
|
||||
} catch (e) { message.error('复制失败'); }
|
||||
} catch (e) { message.error(t('processTemplate.copyFailed')); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await apiClient.delete(`/process-templates/${id}`);
|
||||
message.success('删除成功');
|
||||
message.success(t('processTemplate.deleteSuccess'));
|
||||
fetchTemplates();
|
||||
} catch (e) { message.error('删除失败'); }
|
||||
} catch (e) { message.error(t('processTemplate.deleteFailed')); }
|
||||
};
|
||||
|
||||
const handleView = (t: Template) => {
|
||||
setCurrentTemplate(t);
|
||||
const handleView = (template: Template) => {
|
||||
setCurrentTemplate(template);
|
||||
setViewDrawerVisible(true);
|
||||
};
|
||||
|
||||
@@ -91,19 +93,19 @@ const ProcessTemplates: React.FC = () => {
|
||||
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; }
|
||||
if (!name) { message.warning(t('processTemplate.nameRequired')); setCurrentStep(0); return; }
|
||||
if (phases.length === 0) { message.warning(t('processTemplate.phaseRequired')); setCurrentStep(1); return; }
|
||||
if (editingTemplateId) {
|
||||
await apiClient.put(`/process-templates/${editingTemplateId}`, { name, description, phases });
|
||||
message.success('模板更新成功');
|
||||
message.success(t('processTemplate.updateSuccess'));
|
||||
} else {
|
||||
await apiClient.post('/process-templates', { name, description, phases });
|
||||
message.success('模板创建成功');
|
||||
message.success(t('processTemplate.createSuccess'));
|
||||
}
|
||||
setCreateModalVisible(false);
|
||||
setEditingTemplateId(null);
|
||||
fetchTemplates();
|
||||
} catch (e) { message.error('保存失败'); }
|
||||
} catch (e) { message.error(t('common.saveFailed')); }
|
||||
};
|
||||
|
||||
const addPhase = () => {
|
||||
@@ -135,7 +137,7 @@ const ProcessTemplates: React.FC = () => {
|
||||
depends: values.depends || [],
|
||||
sub_items: subItems,
|
||||
};
|
||||
if (!phaseData.name) { message.warning('阶段名称不能为空'); return; }
|
||||
if (!phaseData.name) { message.warning(t('processTemplate.phaseNameEmpty')); return; }
|
||||
const newPhases = [...phases];
|
||||
if (editingPhaseIndex >= 0) {
|
||||
phaseData.order = newPhases[editingPhaseIndex].order;
|
||||
@@ -162,7 +164,7 @@ const ProcessTemplates: React.FC = () => {
|
||||
setPhases(newPhases);
|
||||
};
|
||||
|
||||
const getTypeTag = (type: string) => type === 'parallel' ? <Tag color="blue">并行</Tag> : <Tag color="green">串行</Tag>;
|
||||
const getTypeTag = (type: string) => type === 'parallel' ? <Tag color="blue">{t('processTemplate.parallelLabel')}</Tag> : <Tag color="green">{t('processTemplate.serialLabel')}</Tag>;
|
||||
|
||||
const getDependsNames = (depends: number[]) => {
|
||||
return depends.map(d => {
|
||||
@@ -172,69 +174,69 @@ const ProcessTemplates: React.FC = () => {
|
||||
};
|
||||
|
||||
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) => (
|
||||
{ title: t('processTemplate.templateName'), dataIndex: 'name', key: 'name', render: (text: string, r: Template) => <Space>{text}{r.is_system && <Tag color="gold">{t('processTemplate.systemPreset')}</Tag>}</Space> },
|
||||
{ title: t('processTemplate.phaseCount'), key: 'phases', render: (_: unknown, r: Template) => r.phases?.length || 0 },
|
||||
{ title: t('processTemplate.desc'), dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: t('processTemplate.action'), key: 'action', render: (_: unknown, r: Template) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(r)}>查看</Button>
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(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>}
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(r)}>{t('common.view')}</Button>
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(r)}>{t('common.edit')}</Button>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(r)}>{t('processTemplate.copy')}</Button>
|
||||
{!r.is_system && <Popconfirm title={t('processTemplate.confirmDelete')} onConfirm={() => handleDelete(r.id)}><Button size="small" danger icon={<DeleteOutlined />}>{t('processTemplate.delete')}</Button></Popconfirm>}
|
||||
</Space>
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card title="工程模板管理" extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建模板</Button>}>
|
||||
<Card title={t('processTemplate.title')} extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('processTemplate.newTemplate')}</Button>}>
|
||||
<Table columns={columns} dataSource={templates} rowKey="id" loading={loading} pagination={false} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingTemplateId ? "编辑工程模板" : "新建工程模板"} 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>,
|
||||
<Modal title={editingTemplateId ? t('processTemplate.editTemplate') : t('processTemplate.newTemplate')} open={createModalVisible} onCancel={() => setCreateModalVisible(false)} width={800} footer={[
|
||||
<Button key="cancel" onClick={() => setCreateModalVisible(false)}>{t('processTemplate.cancel')}</Button>,
|
||||
currentStep > 0 && <Button key="prev" onClick={() => setCurrentStep(currentStep - 1)}>{t('processTemplate.prev')}</Button>,
|
||||
currentStep < 2 && <Button key="next" type="primary" onClick={() => {
|
||||
if (currentStep === 0 && !form.getFieldValue('name')) { message.warning('请输入模板名称'); return; }
|
||||
if (currentStep === 0 && !form.getFieldValue('name')) { message.warning(t('processTemplate.nameRequired')); return; }
|
||||
setCurrentStep(currentStep + 1);
|
||||
}}>下一步</Button>,
|
||||
currentStep === 2 && <Button key="save" type="primary" onClick={handleSaveTemplate}>{editingTemplateId ? '保存修改' : '确认创建'}</Button>,
|
||||
}}>{t('processTemplate.next')}</Button>,
|
||||
currentStep === 2 && <Button key="save" type="primary" onClick={handleSaveTemplate}>{editingTemplateId ? t('processTemplate.saveEdit') : t('processTemplate.confirmCreate')}</Button>,
|
||||
]}>
|
||||
<Steps current={currentStep} size="small" style={{ marginBottom: 24 }}>
|
||||
<Steps.Step title="基本信息" />
|
||||
<Steps.Step title="设计阶段" />
|
||||
<Steps.Step title="预览确认" />
|
||||
<Steps.Step title={t('processTemplate.basicInfo')} />
|
||||
<Steps.Step title={t('processTemplate.designPhase')} />
|
||||
<Steps.Step title={t('processTemplate.preview')} />
|
||||
</Steps>
|
||||
|
||||
{currentStep === 0 && (
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如:配电安装工程" />
|
||||
<Form.Item name="name" label={t('processTemplate.templateName')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('processTemplate.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="模板描述">
|
||||
<TextArea rows={3} placeholder="描述该模板适用的工程类型" />
|
||||
<Form.Item name="description" label={t('processTemplate.templateDescription')}>
|
||||
<TextArea rows={3} placeholder={t('processTemplate.descPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div>
|
||||
{phases.length === 0 ? <Empty description="暂无阶段,请点击下方添加" /> : (
|
||||
{phases.length === 0 ? <Empty description={t('processTemplate.noPhase')} /> : (
|
||||
<List bordered dataSource={phases} renderItem={(phase, index) => (
|
||||
<List.Item actions={[
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => editPhase(index)}>编辑</Button>,
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => editPhase(index)}>{t('common.edit')}</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>,
|
||||
<Popconfirm title={t('processTemplate.confirmDelete')} 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('、')}` : '无子项'}
|
||||
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)} <Text type="secondary">{t('processTemplate.dependencyLabel')}{getDependsNames(phase.depends)}</Text></Space>}
|
||||
description={phase.sub_items?.length > 0 ? `子项:${phase.sub_items.join('、')}` : t('processTemplate.emptySubItems')}
|
||||
/>
|
||||
</List.Item>
|
||||
)} />
|
||||
)}
|
||||
<Button type="dashed" block icon={<PlusOutlined />} style={{ marginTop: 16 }} onClick={addPhase}>添加阶段</Button>
|
||||
<Button type="dashed" block icon={<PlusOutlined />} style={{ marginTop: 16 }} onClick={addPhase}>{t('processTemplate.addPhase')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -247,7 +249,7 @@ const ProcessTemplates: React.FC = () => {
|
||||
<List.Item.Meta
|
||||
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)}</Space>}
|
||||
description={<>
|
||||
<Text type="secondary">依赖:{getDependsNames(phase.depends)}</Text><br />
|
||||
<Text type="secondary">{t('processTemplate.dependencyLabel')}{getDependsNames(phase.depends)}</Text><br />
|
||||
{phase.sub_items?.length > 0 && <Text>子项:{phase.sub_items.join('、')}</Text>}
|
||||
</>}
|
||||
/>
|
||||
@@ -257,26 +259,26 @@ const ProcessTemplates: React.FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Drawer title="阶段编辑" open={phaseDrawerVisible} onClose={() => setPhaseDrawerVisible(false)} width={400} extra={<Button type="primary" onClick={savePhase}>保存</Button>}>
|
||||
<Drawer title={t('processTemplate.phaseEdit')} open={phaseDrawerVisible} onClose={() => setPhaseDrawerVisible(false)} width={400} extra={<Button type="primary" onClick={savePhase}>{t('processTemplate.save')}</Button>}>
|
||||
<Form form={phaseForm} layout="vertical">
|
||||
<Form.Item name="name" label="阶段名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如:物资采购" />
|
||||
<Form.Item name="name" label={t('processTemplate.phaseName')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('processTemplate.phaseNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="阶段类型">
|
||||
<Form.Item name="type" label={t('processTemplate.phaseType')}>
|
||||
<Radio.Group>
|
||||
<Radio value="serial">串行(必须等依赖项完成)</Radio>
|
||||
<Radio value="parallel">并行(可与相邻阶段同时进行)</Radio>
|
||||
<Radio value="serial">{t('processTemplate.serial')}</Radio>
|
||||
<Radio value="parallel">{t('processTemplate.parallel')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="depends" label="依赖关系(哪些阶段完成后才能开始)">
|
||||
<Form.Item name="depends" label={t('processTemplate.dependency')}>
|
||||
<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 name="sub_items_text" label={t('processTemplate.subItems')}>
|
||||
<TextArea rows={6} placeholder={t('processTemplate.subItemsPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
@@ -288,9 +290,9 @@ const ProcessTemplates: React.FC = () => {
|
||||
<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>}
|
||||
title={<Space>{phase.order}. {phase.name} {phase.type === 'parallel' ? <Tag color="blue">{t('processTemplate.parallelLabel')}</Tag> : <Tag color="green">{t('processTemplate.serialLabel')}</Tag>}</Space>}
|
||||
description={<>
|
||||
<Text type="secondary">依赖:阶段{phase.depends?.join('、') || '无'}</Text><br />
|
||||
<Text type="secondary">{t('processTemplate.dependencyLabelShort')}{'阶段'}{phase.depends?.join('、') || '无'}</Text><br />
|
||||
{phase.sub_items?.length > 0 && <Text>子项:{phase.sub_items.join('、')}</Text>}
|
||||
</>}
|
||||
/>
|
||||
|
||||
@@ -1,106 +1,67 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Tag, Button, Space, Input, DatePicker, Select, message, Descriptions, Divider, Tabs, Modal, Result } from 'antd';
|
||||
import { EyeOutlined, CalendarOutlined, UserOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Descriptions, Divider, List } from 'antd';
|
||||
import { SearchOutlined, EyeOutlined, CheckOutlined, CloseOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Option } = Select;
|
||||
const { RangePicker } = DatePicker;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const AdvanceVerificationStatusPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [selectedAdvance, setSelectedAdvance] = useState<any>(null);
|
||||
const [unsettledAdvances, setUnsettledAdvances] = useState<any[]>([]);
|
||||
const [settledAdvances, setSettledAdvances] = useState<any[]>([]);
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
const [sortField, setSortField] = useState('');
|
||||
const [sortOrder, setSortOrder] = useState('');
|
||||
const [selectedRecord, setSelectedRecord] = useState<any>(null);
|
||||
const [fullDetail, setFullDetail] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
|
||||
// 检查权限
|
||||
const hasPermission = user?.role === 'admin' || user?.department === '财务部';
|
||||
// 所有核销记录
|
||||
const [verifications, setVerifications] = useState<any[]>([]);
|
||||
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Result
|
||||
status="403"
|
||||
title="无权限访问"
|
||||
subTitle="您没有权限访问此页面,只有管理员和财务人员可以查看预支核销状态。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取未核销和已核销的预支单
|
||||
const fetchAdvances = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取所有预支单
|
||||
const res = await fetch('/api/advances');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
// 处理数据,确保每个预支单都有total_reimbursed字段
|
||||
const processedAdvances = data.data.map((advance: any) => ({
|
||||
...advance,
|
||||
total_reimbursed: advance.total_reimbursed || 0,
|
||||
isSettled: advance.status === 'settled' || (advance.total_reimbursed || 0) >= advance.amount
|
||||
}));
|
||||
|
||||
// 分离未核销和已核销的预支单
|
||||
const unsettled = processedAdvances.filter((advance: any) => !advance.isSettled && (advance.status === 'approved' || advance.status === 'executed' || advance.status === 'partial_verification'));
|
||||
const settled = processedAdvances.filter((advance: any) => advance.isSettled || advance.status === 'settled' || advance.status === 'completed');
|
||||
|
||||
setUnsettledAdvances(unsettled);
|
||||
setSettledAdvances(settled);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取预支单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取预支单详情,包括关联的核销单
|
||||
const fetchAdvanceDetail = async (advanceId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取预支单详情
|
||||
const advanceRes = await fetch(`/api/advances/${advanceId}`);
|
||||
const advanceData = await advanceRes.json();
|
||||
|
||||
if (advanceData.success) {
|
||||
// 获取关联的核销单
|
||||
const verificationRes = await fetch(`/api/verifications?advance_id=${advanceId}`);
|
||||
const verificationData = await verificationRes.json();
|
||||
|
||||
if (verificationData.success) {
|
||||
// 只保留已执行或已批准的核销单,并且关联的预支单编号与当前预支单一致
|
||||
const validVerifications = (verificationData.data || []).filter((verification: any) =>
|
||||
(verification.status === 'approved' || verification.status === 'executed') &&
|
||||
verification.advance_code === advanceData.data.advance_code
|
||||
);
|
||||
|
||||
setSelectedAdvance({
|
||||
...advanceData.data,
|
||||
verifications: validVerifications
|
||||
});
|
||||
setDetailModalVisible(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取预支单详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// 筛选条件
|
||||
const [searchCode, setSearchCode] = useState('');
|
||||
const [searchApplicant, setSearchApplicant] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [filterSettlement, setFilterSettlement] = useState<string>('all');
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
fetchAdvances();
|
||||
fetchVerifications();
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchVerifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/verifications');
|
||||
const data = await response.json();
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setVerifications(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('advanceVerification.getListFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setProjects(data.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
@@ -108,295 +69,393 @@ const AdvanceVerificationStatusPage: React.FC = () => {
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
settled: { color: 'blue', text: '已核销' },
|
||||
pending_edit: { color: 'warning', text: '待编辑' },
|
||||
partial_verification: { color: 'orange', text: '部分核销' },
|
||||
completed: { color: 'green', text: '已完成' },
|
||||
pending: { color: 'processing', text: t('advanceVerification.pendingApproval') },
|
||||
approved: { color: 'success', text: t('advanceVerification.approved') },
|
||||
rejected: { color: 'error', text: t('advanceVerification.rejected') },
|
||||
pending_edit: { color: 'warning', text: t('advanceVerification.pendingEdit') },
|
||||
executed: { color: 'blue', text: t('advanceVerification.verified') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
// 这里可以添加搜索逻辑
|
||||
fetchAdvances();
|
||||
const getSettlementTag = (settlement: boolean) => {
|
||||
return settlement ?
|
||||
<Tag color="green">{t('advanceVerification.settlement')}</Tag> :
|
||||
<Tag color="orange">{t('advanceVerification.nonSettlement')}</Tag>;
|
||||
};
|
||||
|
||||
const handleSort = (field: string, order: string) => {
|
||||
setSortField(field);
|
||||
setSortOrder(order);
|
||||
// 这里可以添加排序逻辑
|
||||
fetchAdvances();
|
||||
// 根据项目ID获取项目名称
|
||||
const getProjectName = (projectId: any) => {
|
||||
if (!projectId) return '-';
|
||||
const project = projects.find(p => p.id === projectId);
|
||||
return project ? project.name : `项目ID: ${projectId}`;
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (record: any) => {
|
||||
try {
|
||||
const response = await fetch(`/api/verifications/${record.id}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setFullDetail(data.data);
|
||||
setSelectedRecord(record);
|
||||
form.resetFields();
|
||||
setDetailModalVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('advanceVerification.getDetailFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 查看已执行核销的详情
|
||||
const handleViewExecutedDetail = async (record: any) => {
|
||||
try {
|
||||
const response = await fetch(`/api/verifications/${record.id}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setFullDetail(data.data);
|
||||
setSelectedRecord(record);
|
||||
setDetailModalVisible(true);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('advanceVerification.getDetailFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理执行通过
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 调用执行API
|
||||
const executeResponse = await fetch('/api/executions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apply_id: selectedRecord.id,
|
||||
apply_type: 'verification',
|
||||
action: 'execute',
|
||||
execute_method: 'bank',
|
||||
voucher_files: [],
|
||||
remark: values.remark
|
||||
})
|
||||
});
|
||||
|
||||
if (executeResponse.ok) {
|
||||
// 更新本地状态
|
||||
setVerifications(verifications.map(v =>
|
||||
v.id === selectedRecord.id ? { ...v, status: 'executed' } : v
|
||||
));
|
||||
message.success(t('advanceVerification.executeSuccess', { code: selectedRecord.verification_code }));
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error(t('advanceVerification.executeFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审批操作失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理执行退回
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 调用退回API
|
||||
const rejectResponse = await fetch('/api/executions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apply_id: selectedRecord.id,
|
||||
apply_type: 'verification',
|
||||
action: 'reject',
|
||||
reject_reason: values.remark
|
||||
})
|
||||
});
|
||||
|
||||
if (rejectResponse.ok) {
|
||||
setVerifications(verifications.map(v =>
|
||||
v.id === selectedRecord.id ? { ...v, status: 'rejected' } : v
|
||||
));
|
||||
message.success(t('advanceVerification.rejectSuccess', { code: selectedRecord.verification_code }));
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error(t('advanceVerification.rejectFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审批操作失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 筛选数据
|
||||
const getFilteredData = () => {
|
||||
let data = [...verifications];
|
||||
|
||||
if (searchCode) {
|
||||
data = data.filter(v => v.verification_code && v.verification_code.toLowerCase().includes(searchCode.toLowerCase()));
|
||||
}
|
||||
|
||||
if (searchApplicant) {
|
||||
data = data.filter(v => v.applicant && v.applicant.toLowerCase().includes(searchApplicant.toLowerCase()));
|
||||
}
|
||||
|
||||
if (filterStatus !== 'all') {
|
||||
data = data.filter(v => v.status === filterStatus);
|
||||
}
|
||||
|
||||
if (filterSettlement !== 'all') {
|
||||
const isSettlement = filterSettlement === 'yes';
|
||||
data = data.filter(v => v.settlement === isSettlement);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: t('advanceVerification.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true },
|
||||
{ title: t('advanceVerification.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: t('advanceVerification.verificationCode'), dataIndex: 'verification_code', key: 'verification_code', width: 120 },
|
||||
{ title: t('advanceVerification.advanceCode'), dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||||
{ title: t('advanceVerification.advanceAmount'), dataIndex: 'advance_amount', key: 'advance_amount', width: 120, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: t('advanceVerification.verificationAmount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: t('advanceVerification.verificationDate'), dataIndex: 'verification_date', key: 'verification_date', width: 110 },
|
||||
{ title: t('advanceVerification.settlementType'), dataIndex: 'settlement', key: 'settlement', width: 100, render: (settlement: boolean) => getSettlementTag(settlement) },
|
||||
{ title: t('advanceVerification.status'), dataIndex: 'status', key: 'status', width: 80, render: (status: string) => getStatusTag(status) },
|
||||
{
|
||||
title: '事由',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
ellipsis: true,
|
||||
render: (v: string, r: any) => (
|
||||
<a onClick={() => fetchAdvanceDetail(r.id)}>{v}</a>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
dataIndex: 'applicant',
|
||||
key: 'applicant',
|
||||
width: 100,
|
||||
sorter: (a: any, b: any) => a.applicant.localeCompare(b.applicant),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('applicant', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '预支金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 140,
|
||||
render: (v: number, r: any) => formatAmount(v, r.currency),
|
||||
sorter: (a: any, b: any) => a.amount - b.amount,
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('amount', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '已核销金额',
|
||||
dataIndex: 'total_reimbursed',
|
||||
key: 'total_reimbursed',
|
||||
width: 140,
|
||||
render: (v: number, r: any) => formatAmount(v || 0, r.currency),
|
||||
sorter: (a: any, b: any) => (a.total_reimbursed || 0) - (b.total_reimbursed || 0),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('total_reimbursed', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '剩余金额',
|
||||
dataIndex: 'remaining',
|
||||
key: 'remaining',
|
||||
width: 140,
|
||||
render: (_, r: any) => formatAmount((r.amount || 0) - (r.total_reimbursed || 0), r.currency),
|
||||
sorter: (a: any, b: any) => ((a.amount || 0) - (a.total_reimbursed || 0)) - ((b.amount || 0) - (b.total_reimbursed || 0)),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('remaining', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '预支日期',
|
||||
dataIndex: 'advance_date',
|
||||
key: 'advance_date',
|
||||
width: 120,
|
||||
sorter: (a: any, b: any) => new Date(a.advance_date).getTime() - new Date(b.advance_date).getTime(),
|
||||
onHeaderCell: (column: any) => ({
|
||||
onClick: () => handleSort('advance_date', sortOrder === 'ascend' ? 'descend' : 'ascend')
|
||||
})
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => getStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: '编号',
|
||||
dataIndex: 'advance_code',
|
||||
key: 'advance_code',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
title: t('advanceVerification.action'), key: 'action', width: 140,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => fetchAdvanceDetail(record.id)}>详情</Button>
|
||||
<Space>
|
||||
<Button size="small" type="primary" icon={<EyeOutlined />} onClick={() => {
|
||||
if (record.status === 'approved') {
|
||||
handleViewDetail(record);
|
||||
} else if (record.status === 'executed') {
|
||||
handleViewExecutedDetail(record);
|
||||
} else {
|
||||
// 其他状态也可以查看详情
|
||||
handleViewExecutedDetail(record);
|
||||
}
|
||||
}}>
|
||||
{t('advanceVerification.view')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 渲染附件列表
|
||||
const renderAttachments = (attachments: any) => {
|
||||
if (!attachments || !Array.isArray(attachments) || attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>预支核销状态管理</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理预支单的核销状态和进度</p>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选区域 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Input
|
||||
placeholder="按申请人姓名搜索"
|
||||
prefix={<UserOutlined />}
|
||||
value={searchName}
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 300 }}>
|
||||
<RangePicker
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
onChange={(dates) => setDateRange(dates)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<Button type="primary" icon={<FilterOutlined />} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 预支单列表 */}
|
||||
<Card>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'unsettled',
|
||||
label: '未核销完成',
|
||||
children: (
|
||||
<Table
|
||||
dataSource={unsettledAdvances}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'settled',
|
||||
label: '已完结',
|
||||
children: (
|
||||
<Table
|
||||
dataSource={settledAdvances}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 详情模态框 */}
|
||||
<Modal
|
||||
title={`预支单详情:${selectedAdvance?.advance_code}`}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailModalVisible(false)}>关闭</Button>
|
||||
]}
|
||||
width={900}
|
||||
>
|
||||
{selectedAdvance && (
|
||||
<>
|
||||
{/* 预支单基本信息 */}
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="预支编号">{selectedAdvance.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedAdvance.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedAdvance.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支日期">{selectedAdvance.advance_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{formatAmount(selectedAdvance.amount, selectedAdvance.currency)}
|
||||
{selectedAdvance.currency !== 'CNY' && selectedAdvance.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedAdvance.amount_cny.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已核销金额">{formatAmount(selectedAdvance.total_reimbursed || 0, selectedAdvance.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余金额">{formatAmount((selectedAdvance.amount || 0) - (selectedAdvance.total_reimbursed || 0), selectedAdvance.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{selectedAdvance.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedAdvance.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 关联的核销单 */}
|
||||
{selectedAdvance.verifications && selectedAdvance.verifications.length > 0 && (
|
||||
<>
|
||||
<Divider>关联核销单</Divider>
|
||||
<Table
|
||||
dataSource={selectedAdvance.verifications}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '核销编号',
|
||||
dataIndex: 'verification_code',
|
||||
key: 'verification_code'
|
||||
},
|
||||
{
|
||||
title: '关联预支单',
|
||||
dataIndex: 'advance_code',
|
||||
key: 'advance_code'
|
||||
},
|
||||
{
|
||||
title: '核销金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (v: number, r: any) => formatAmount(v, r.currency)
|
||||
},
|
||||
{
|
||||
title: '核销日期',
|
||||
dataIndex: 'verification_date',
|
||||
key: 'verification_date'
|
||||
},
|
||||
{
|
||||
title: '是否结算',
|
||||
dataIndex: 'settlement',
|
||||
key: 'settlement',
|
||||
render: (v: boolean) => (
|
||||
<Tag color={v ? 'green' : 'orange'}>{v ? '是' : '否'}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => getStatusTag(status)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 附件 */}
|
||||
{selectedAdvance.attachments && selectedAdvance.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedAdvance.attachments.map((url: string, index: number) => (
|
||||
<div key={index} style={{ position: 'relative' }}>
|
||||
{url && url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
|
||||
{attachments.map((url: string, index: number) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`附件${index + 1}`}
|
||||
style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }}
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
/>
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
<div style={{ width: 120, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #f0f0f0', borderRadius: 4, background: '#f5f5f5' }}>
|
||||
<span style={{ color: '#666' }}>附件 {index + 1}</span>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染明细清单
|
||||
const renderDetailItems = (detailItems: any) => {
|
||||
if (!detailItems || !Array.isArray(detailItems) || detailItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={detailItems}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<List.Item>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span><strong>{t('advanceVerification.detailLabel', { index: index + 1 })}</strong> {item.description || '-'}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
|
||||
</div>
|
||||
{item.category && (
|
||||
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
|
||||
<strong>{t('advanceVerification.categoryLabel')}</strong>{item.category}
|
||||
</div>
|
||||
)}
|
||||
{item.attachments && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>{t('advanceVerification.detailAttachment')}</span>
|
||||
{renderAttachments(item.attachments)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h2>{t('advanceVerification.title')}</h2>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={fetchVerifications} loading={loading}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('advanceVerification.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input.Search
|
||||
placeholder={t('advanceVerification.searchCode')}
|
||||
value={searchCode}
|
||||
onChange={(e) => setSearchCode(e.target.value)}
|
||||
onSearch={(value) => setSearchCode(value)}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder={t('advanceVerification.searchApplicant')}
|
||||
value={searchApplicant}
|
||||
onChange={(e) => setSearchApplicant(e.target.value)}
|
||||
onSearch={(value) => setSearchApplicant(value)}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder={t('advanceVerification.filterStatus')}
|
||||
value={filterStatus}
|
||||
onChange={(value) => setFilterStatus(value)}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
<Select.Option value="all">{t('advanceVerification.statusAll')}</Select.Option>
|
||||
<Select.Option value="pending">{t('advanceVerification.pendingApproval')}</Select.Option>
|
||||
<Select.Option value="approved">{t('advanceVerification.approved')}</Select.Option>
|
||||
<Select.Option value="executed">{t('advanceVerification.verified')}</Select.Option>
|
||||
<Select.Option value="rejected">{t('advanceVerification.rejected')}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder={t('advanceVerification.filterSettlement')}
|
||||
value={filterSettlement}
|
||||
onChange={(value) => setFilterSettlement(value)}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
<Select.Option value="all">{t('advanceVerification.settlementAll')}</Select.Option>
|
||||
<Select.Option value="yes">{t('advanceVerification.settlement')}</Select.Option>
|
||||
<Select.Option value="no">{t('advanceVerification.nonSettlement')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={getFilteredData()}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 1400 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('advanceVerification.detailTitle', { code: selectedRecord?.verification_code || '' })}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => {
|
||||
setDetailModalVisible(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={fullDetail && fullDetail.status === 'approved' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>{t('common.cancel')}</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('advanceVerification.reject')}</Button>
|
||||
<Button type="primary" icon={<DollarOutlined />} onClick={handleApprove}>{t('advanceVerification.confirm')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setDetailModalVisible(false)}>{t('common.close')}</Button>
|
||||
)}
|
||||
width={1000}
|
||||
>
|
||||
{fullDetail && (
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label={t('advanceVerification.verificationCode')}>{fullDetail.verification_code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.status')}>{getStatusTag(fullDetail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.applicant')}>{fullDetail.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.advanceCode')}>{fullDetail.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.advanceAmount')}>
|
||||
{formatAmount(fullDetail.advance_amount, fullDetail.currency)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.verificationAmount')}>
|
||||
{formatAmount(fullDetail.amount, fullDetail.currency)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.verificationDate')}>{fullDetail.verification_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.settlementType')}>{getSettlementTag(fullDetail.settlement)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.advanceRemaining')}>
|
||||
{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0) - (fullDetail.amount || 0), fullDetail.currency)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.verifiedAmount')}>
|
||||
{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}
|
||||
</Descriptions.Item>
|
||||
|
||||
{/* 结算清账信息 */}
|
||||
{fullDetail.settlement && fullDetail.settlement_amount && (
|
||||
<>
|
||||
<Descriptions.Item label={t('advanceVerification.settlementType')} span={2}>✅ {t('advanceVerification.settlement')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advanceVerification.settlementAmount')} span={2}>
|
||||
{fullDetail.settlement_amount > 0 ? `${t('advanceVerification.refund')}${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `${t('advanceVerification.supplement')}${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 事由 */}
|
||||
<Descriptions.Item label={t('advanceVerification.subject')} span={2}>{fullDetail.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* 明细清单 */}
|
||||
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('advanceVerification.expenseDetail')}</Divider>
|
||||
{renderDetailItems(fullDetail.detail_items)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 凭证附件 */}
|
||||
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('advanceVerification.proofAttachment')}</Divider>
|
||||
{renderAttachments(fullDetail.attachments)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 执行表单 - 已审批待执行的核销 */}
|
||||
{fullDetail.status === 'approved' && (
|
||||
<>
|
||||
<Divider>{t('advanceVerification.executionOpinion')}</Divider>
|
||||
<Form form={form} layout="vertical">
|
||||
{fullDetail.settlement && fullDetail.settlement_amount && (
|
||||
<div style={{ marginBottom: 16, padding: '12px', backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px' }}>
|
||||
<p style={{ margin: 0, color: '#389e0d' }}>
|
||||
{t('advanceVerification.settlementInfo', {
|
||||
type: fullDetail.settlement_amount > 0 ? t('advanceVerification.refund') : t('advanceVerification.supplement'),
|
||||
amount: formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Form.Item
|
||||
name="remark"
|
||||
label={t('advanceVerification.executionNote')}
|
||||
>
|
||||
<TextArea rows={3} placeholder={t('advanceVerification.executionNotePlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Selec
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
import useFormDraft from '../../hooks/useFormDraft';
|
||||
|
||||
@@ -11,6 +12,7 @@ const { TextArea } = Input;
|
||||
|
||||
const AdvancesPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [advances, setAdvances] = useState<any[]>([]);
|
||||
const [completedAdvances, setCompletedAdvances] = useState<any[]>([]);
|
||||
const [activeTab, setActiveTab] = useState('active');
|
||||
@@ -54,7 +56,7 @@ const AdvancesPage: React.FC = () => {
|
||||
setCompletedAdvances(completed);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取预支列表失败');
|
||||
message.error(t('advance.getListFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -88,17 +90,19 @@ const AdvancesPage: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
advance_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
applicant: user?.name || user?.username || t('common.currentUser'),
|
||||
attachments: []
|
||||
});
|
||||
setWatchedAmount(null);
|
||||
setWatchedCurrency('CNY');
|
||||
setModalVisible(true);
|
||||
setTimeout(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的预支申请,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft();
|
||||
},
|
||||
@@ -108,7 +112,7 @@ const AdvancesPage: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
advance_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
applicant: user?.name || user?.username || '当前用户',
|
||||
applicant: user?.name || user?.username || t('common.currentUser'),
|
||||
attachments: []
|
||||
});
|
||||
},
|
||||
@@ -125,6 +129,8 @@ const AdvancesPage: React.FC = () => {
|
||||
advance_date: record.advance_date ? dayjs(record.advance_date) : null,
|
||||
attachments: record.attachments || []
|
||||
});
|
||||
setWatchedAmount(record.amount || null);
|
||||
setWatchedCurrency(record.currency || 'CNY');
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -136,10 +142,10 @@ const AdvancesPage: React.FC = () => {
|
||||
setSelectedRecord(data.data);
|
||||
setDetailModalVisible(true);
|
||||
} else {
|
||||
message.error('获取详情失败');
|
||||
message.error(t('advance.getDetailFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
message.error(t('advance.getDetailFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,15 +154,15 @@ const AdvancesPage: React.FC = () => {
|
||||
deleteForm.resetFields();
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
title: t('common.deleteConfirm'),
|
||||
content: (
|
||||
<Form form={deleteForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="请输入密码确认删除"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
label={t('advance.deletePassInput')}
|
||||
rules={[{ required: true, message: t('common.inputPassword') }]}
|
||||
>
|
||||
<Input.Password placeholder="输入密码" />
|
||||
<Input.Password placeholder={t('advance.deletePassPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
@@ -165,10 +171,10 @@ const AdvancesPage: React.FC = () => {
|
||||
const values = await deleteForm.validateFields();
|
||||
// 这里可以添加密码验证逻辑,暂时直接删除
|
||||
await fetch('/api/advances/' + id, { method: 'DELETE' });
|
||||
message.success('删除成功');
|
||||
message.success(t('advance.deleteSuccess'));
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
message.error(t('advance.deleteFailed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -176,15 +182,15 @@ const AdvancesPage: React.FC = () => {
|
||||
|
||||
const handleWithdraw = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: '确认撤回',
|
||||
content: '撤回后可重新编辑提交,确认撤回吗?',
|
||||
title: t('advance.withdrawConfirm'),
|
||||
content: t('advance.withdrawConfirmMsg'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await fetch('/api/advances/' + id + '/withdraw', { method: 'POST' });
|
||||
message.success('已撤回,可重新编辑');
|
||||
message.success(t('advance.withdrawSuccess'));
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('撤回失败');
|
||||
message.error(t('advance.withdrawFailed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -212,16 +218,16 @@ const AdvancesPage: React.FC = () => {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '保存成功' : '创建成功');
|
||||
message.success(editingId ? t('advance.saveSuccess') : t('advance.createSuccess'));
|
||||
clearDraft();
|
||||
setModalVisible(false);
|
||||
fetchAdvances();
|
||||
} else {
|
||||
message.error(result.error || '保存失败');
|
||||
message.error(result.error || t('common.saveFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存操作 - 错误:', error);
|
||||
message.error('保存失败');
|
||||
message.error(t('common.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,16 +253,16 @@ const AdvancesPage: React.FC = () => {
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
message.success(editingId ? '提交成功' : '创建成功');
|
||||
message.success(editingId ? t('advance.submitSuccess') : t('advance.createSuccess'));
|
||||
clearDraft();
|
||||
setModalVisible(false);
|
||||
fetchAdvances();
|
||||
} else {
|
||||
message.error(result.error || '提交失败');
|
||||
message.error(result.error || t('common.saveFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交操作 - 错误:', error);
|
||||
message.error('提交失败');
|
||||
message.error(t('common.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -266,25 +272,34 @@ const AdvancesPage: React.FC = () => {
|
||||
|
||||
const convertToCNY = (amount: number, currency: string): number => {
|
||||
if (currency === 'CNY') return amount;
|
||||
const rateKey = 'CNY_' + currency;
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount / rate;
|
||||
const rateKey = currency + '_CNY';
|
||||
if (exchangeRates[rateKey]) return amount * exchangeRates[rateKey];
|
||||
const reverseKey = 'CNY_' + currency;
|
||||
if (exchangeRates[reverseKey]) return amount / exchangeRates[reverseKey];
|
||||
// 通过 LAK 中转
|
||||
const xxxLakKey = currency + '_LAK';
|
||||
const cnyLakKey = 'CNY_LAK';
|
||||
if (exchangeRates[xxxLakKey] && exchangeRates[cnyLakKey]) {
|
||||
return (amount * exchangeRates[xxxLakKey]) / exchangeRates[cnyLakKey];
|
||||
}
|
||||
return amount;
|
||||
};
|
||||
|
||||
const amount = Form.useWatch('amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
// 使用 useState 替代 Form.useWatch 以确保稳定触发
|
||||
const [watchedAmount, setWatchedAmount] = useState<number | null>(null);
|
||||
const [watchedCurrency, setWatchedCurrency] = useState<string>('CNY');
|
||||
const amountCNY = React.useMemo(() => {
|
||||
return amount && currency ? convertToCNY(amount, currency) : 0;
|
||||
}, [amount, currency, exchangeRates]);
|
||||
return watchedAmount && watchedCurrency ? convertToCNY(watchedAmount, watchedCurrency) : 0;
|
||||
}, [watchedAmount, watchedCurrency, exchangeRates]);
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
settled: { color: 'blue', text: '已核销' },
|
||||
pending_edit: { color: 'warning', text: '待编辑' },
|
||||
pending: { color: 'processing', text: t('advance.pendingApproval') },
|
||||
approved: { color: 'success', text: t('advance.approved') },
|
||||
rejected: { color: 'error', text: t('advance.rejected') },
|
||||
withdrawn: { color: 'default', text: t('advance.withdrawn') },
|
||||
settled: { color: 'blue', text: t('advance.verified') },
|
||||
pending_edit: { color: 'warning', text: t('advance.pendingEdit') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -311,31 +326,31 @@ const AdvancesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
{ title: t('advance.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> },
|
||||
{ title: t('advance.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: t('advance.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '预支日期', dataIndex: 'advance_date', key: 'advance_date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: '编号', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||||
{ title: t('advance.advanceDate'), dataIndex: 'advance_date', key: 'advance_date', width: 100 },
|
||||
{ title: t('advance.status'), dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
|
||||
{ title: t('advance.code'), dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 250,
|
||||
title: t('advance.action'), key: 'action', width: 250,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>详情</Button>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(record)}>{t('advance.detail')}</Button>
|
||||
{record.status === 'pending' && (
|
||||
<>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>撤回</Button>
|
||||
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>{t('advance.withdraw')}</Button>
|
||||
</>
|
||||
)}
|
||||
{(record.status === 'rejected' || record.status === 'withdrawn' || record.status === 'pending_edit') && (
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑重提</Button>
|
||||
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('advance.reEdit')}</Button>
|
||||
)}
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>{t('advance.delete')}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -344,16 +359,16 @@ const AdvancesPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>预支申请</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理员工预支申请</p>
|
||||
<h2 style={{ marginBottom: 8 }}>{t('advance.title')}</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('advance.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建预支</Button>}>
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>{t('advance.newAdvance')}</Button>}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.TabPane tab="活跃申请" key="active">
|
||||
<Tabs.TabPane tab={t('advance.activeApplications')} key="active">
|
||||
<Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="已完结" key="completed">
|
||||
<Tabs.TabPane tab={t('advance.completed')} key="completed">
|
||||
<Table dataSource={completedAdvances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
@@ -361,21 +376,23 @@ const AdvancesPage: React.FC = () => {
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingId ? '编辑预支' : '新建预支'}
|
||||
title={editingId ? t('advance.editAdvance') : t('advance.newAdvance')}
|
||||
open={modalVisible}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft();
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
}
|
||||
}}
|
||||
@@ -383,69 +400,72 @@ const AdvancesPage: React.FC = () => {
|
||||
<Button key="cancel" onClick={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft();
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
setModalVisible(false);
|
||||
}
|
||||
}}>取消</Button>,
|
||||
<Button key="save" onClick={handleSave}>保存</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmitAndSubmit}>提交</Button>
|
||||
}}>{t('common.cancel')}</Button>,
|
||||
<Button key="save" onClick={handleSave}>{t('common.save')}</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmitAndSubmit}>{t('common.submit')}</Button>
|
||||
]}
|
||||
destroyOnClose
|
||||
maskClosable={false}
|
||||
width={700}
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
|
||||
<Form.Item name="applicant" label="申请人">
|
||||
<Form.Item name="applicant" label={t('advance.applicant')}>
|
||||
<Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="advance_date" label="预支日期" rules={[{ required: true }]}>
|
||||
<Form.Item name="advance_date" label={t('advance.advanceDate')} rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
|
||||
<Form.Item label="金额" required>
|
||||
<Form.Item label={t('advance.amount')} required>
|
||||
<Space>
|
||||
<Form.Item name="currency" noStyle initialValue="CNY">
|
||||
<Select style={{ width: 140 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
<Select style={{ width: 140 }} onChange={(value: string) => setWatchedCurrency(value)}>
|
||||
<Option value="CNY">{t('advance.currencyCNY')}</Option>
|
||||
<Option value="USD">{t('advance.currencyUSD')}</Option>
|
||||
<Option value="LAK">{t('advance.currencyLAK')}</Option>
|
||||
<Option value="THB">{t('advance.currencyTHB')}</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
|
||||
<Form.Item name="amount" noStyle rules={[{ required: true, message: t('advance.inputAmount') }]}>
|
||||
<InputNumber
|
||||
style={{ width: 200 }}
|
||||
min={0}
|
||||
precision={2}
|
||||
placeholder="输入金额"
|
||||
formatter={(value) => formatNumberWithSeparator(value as number, currency || 'CNY')}
|
||||
placeholder={t('advance.amountPlaceholder')}
|
||||
formatter={(value) => formatNumberWithSeparator(value as number, watchedCurrency || 'CNY')}
|
||||
parser={(value) => parseFormattedNumber(value || '0')}
|
||||
onChange={(value) => setWatchedAmount(value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
{amountCNY > 0 && (
|
||||
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
||||
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
{t('advance.equivalentCNY')}{amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={3} placeholder="请输入预支事由" />
|
||||
<Form.Item name="reason" label={t('advance.subject')} rules={[{ required: true }]}>
|
||||
<TextArea rows={3} placeholder={t('advance.subjectPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="attachments" label="凭证附件">
|
||||
<Form.Item name="attachments" label={t('advance.attachment')}>
|
||||
<FileUpload
|
||||
value={form.getFieldValue('attachments')}
|
||||
onChange={(urls) => form.setFieldsValue({ attachments: urls })}
|
||||
@@ -457,27 +477,27 @@ const AdvancesPage: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal title="预支详情" open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={800}>
|
||||
<Modal title={t('advance.detailTitle')} open={detailModalVisible} onCancel={() => setDetailModalVisible(false)} footer={null} width={800}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="预支编号">{selectedRecord.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支日期">{selectedRecord.advance_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advance.advanceCode')}>{selectedRecord.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advance.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advance.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advance.advanceDate')}>{selectedRecord.advance_date}</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="金额">
|
||||
<Descriptions.Item label={t('advance.amount')}>
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('advance.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<Divider>{t('advance.attachment')}</Divider>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Table, Tag, Button, Space, Modal, Form, Input, message, Tabs, Badge, Descriptions, Divider, List, Upload } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, EyeOutlined, EditOutlined, UndoOutlined, FileImageOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -25,7 +26,17 @@ const COMPANY_EXPENSE_CATEGORIES = [
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
// Type name to i18n key mapping
|
||||
const typeKeyMap: Record<string, string> = {
|
||||
'预支申请': 'approval.advanceApply',
|
||||
'报销申请': 'approval.reimburseApply',
|
||||
'付款申请': 'approval.paymentApply',
|
||||
'核销申请': 'approval.verificationApply',
|
||||
'采购申请': 'approval.purchaseApply',
|
||||
};
|
||||
|
||||
const ApprovalManagement: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
@@ -79,56 +90,56 @@ const ApprovalManagement: React.FC = () => {
|
||||
|
||||
switch (type) {
|
||||
case 'advances':
|
||||
typeText = '预支申请';
|
||||
typeText = t('approval.advanceApply');
|
||||
code = item.advance_code;
|
||||
date = item.advance_date;
|
||||
// 根据预支申请的状态设置操作文本
|
||||
switch (item.status) {
|
||||
case 'pending':
|
||||
action = '待审批';
|
||||
action = t('approval.pendingApproval');
|
||||
break;
|
||||
case 'approved':
|
||||
action = '通过';
|
||||
action = t('approval.approved');
|
||||
break;
|
||||
case 'rejected':
|
||||
action = '退回';
|
||||
action = t('approval.rejected');
|
||||
break;
|
||||
case 'executed':
|
||||
action = '已执行';
|
||||
action = t('approval.executed');
|
||||
break;
|
||||
case 'partial_verification':
|
||||
action = '部分核销';
|
||||
action = t('approval.partialVerified');
|
||||
break;
|
||||
case 'completed':
|
||||
action = '已完结';
|
||||
action = t('approval.completed');
|
||||
break;
|
||||
default:
|
||||
action = item.status;
|
||||
}
|
||||
break;
|
||||
case 'reimbursements':
|
||||
typeText = '报销申请';
|
||||
typeText = t('approval.reimburseApply');
|
||||
code = item.reimbursement_code;
|
||||
date = item.reimbursement_date;
|
||||
action = item.status === 'approved' ? '通过' : '退回';
|
||||
action = item.status === 'approved' ? t('approval.approved') : t('approval.rejected');
|
||||
break;
|
||||
case 'payment-requests':
|
||||
typeText = '付款申请';
|
||||
typeText = t('approval.paymentApply');
|
||||
code = item.request_code;
|
||||
date = item.payment_date;
|
||||
action = item.status === 'approved' ? '通过' : '退回';
|
||||
action = item.status === 'approved' ? t('approval.approved') : t('approval.rejected');
|
||||
break;
|
||||
case 'verifications':
|
||||
typeText = '核销申请';
|
||||
typeText = t('approval.verificationApply');
|
||||
code = item.verification_code;
|
||||
date = item.verification_date;
|
||||
action = item.status === 'approved' ? '通过' : item.status === 'rejected' ? '退回' : '待编辑';
|
||||
action = item.status === 'approved' ? t('approval.approved') : item.status === 'rejected' ? t('approval.rejected') : t('advance.pendingEdit');
|
||||
break;
|
||||
case 'purchase-requests':
|
||||
typeText = '采购申请';
|
||||
typeText = t('approval.purchaseApply');
|
||||
code = item.request_code;
|
||||
date = item.request_date;
|
||||
action = item.status === 'approved' ? '通过' : item.status === 'pending_edit' ? '待编辑' : item.status;
|
||||
action = item.status === 'approved' ? t('approval.approved') : item.status === 'pending_edit' ? t('advance.pendingEdit') : item.status;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -140,7 +151,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
amount: type === 'purchase-requests' ? item.total_amount : item.amount,
|
||||
currency: item.currency,
|
||||
action: action,
|
||||
operator: '系统管理员', // 实际应该从数据库中获取
|
||||
operator: t('common.systemAdmin'), // 实际应该从数据库中获取
|
||||
remark: item.approval_remark || '',
|
||||
timestamp: date
|
||||
});
|
||||
@@ -152,7 +163,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
setApprovalHistory(historyData);
|
||||
} catch (error) {
|
||||
console.error('获取审批历史记录失败:', error);
|
||||
message.error('获取审批历史记录失败');
|
||||
message.error(t('approval.getHistoryFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -207,7 +218,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
allPendingData.push({
|
||||
key: `adv-${item.id}`,
|
||||
id: item.id,
|
||||
type: '预支申请',
|
||||
type: t('approval.advanceApply'),
|
||||
code: item.advance_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
@@ -228,7 +239,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
allPendingData.push({
|
||||
key: `reimb-${item.id}`,
|
||||
id: item.id,
|
||||
type: '报销申请',
|
||||
type: t('approval.reimburseApply'),
|
||||
code: item.reimbursement_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
@@ -249,7 +260,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
allPendingData.push({
|
||||
key: `pay-${item.id}`,
|
||||
id: item.id,
|
||||
type: '付款申请',
|
||||
type: t('approval.paymentApply'),
|
||||
code: item.request_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
@@ -270,7 +281,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
allPendingData.push({
|
||||
key: `ver-${item.id}`,
|
||||
id: item.id,
|
||||
type: '核销申请',
|
||||
type: t('approval.verificationApply'),
|
||||
code: item.verification_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.amount,
|
||||
@@ -291,13 +302,13 @@ const ApprovalManagement: React.FC = () => {
|
||||
allPendingData.push({
|
||||
key: `pur-${item.id}`,
|
||||
id: item.id,
|
||||
type: '采购申请',
|
||||
type: t('approval.purchaseApply'),
|
||||
code: item.request_code,
|
||||
applicant: item.applicant,
|
||||
amount: item.total_amount,
|
||||
currency: item.currency,
|
||||
date: item.request_date,
|
||||
reason: item.brief_description || item.remark || '采购申请',
|
||||
reason: item.brief_description || item.remark || t('approval.purchaseApply'),
|
||||
status: item.status,
|
||||
rawData: item
|
||||
});
|
||||
@@ -308,7 +319,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
setPendingData(allPendingData);
|
||||
} catch (error) {
|
||||
console.error('获取待审批数据失败:', error);
|
||||
message.error('获取待审批数据失败');
|
||||
message.error(t('approval.getPendingFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -323,16 +334,17 @@ const ApprovalManagement: React.FC = () => {
|
||||
// 获取类型标签
|
||||
const getTypeTag = (type: string) => {
|
||||
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple', '采购申请': 'cyan' };
|
||||
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
|
||||
const key = typeKeyMap[type] || type;
|
||||
return <Tag color={colors[type] || 'default'}>{t(key)}</Tag>;
|
||||
};
|
||||
|
||||
// 获取状态标签
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
withdrawn: { color: 'default', text: '已撤回' }
|
||||
pending: { color: 'processing', text: t('approval.pendingApproval') },
|
||||
approved: { color: 'success', text: t('approval.approved') },
|
||||
rejected: { color: 'error', text: t('approval.rejected') },
|
||||
withdrawn: { color: 'default', text: t('approval.withdrawn') }
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -352,7 +364,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
form.resetFields();
|
||||
|
||||
// 获取完整详情
|
||||
if (record.type === '采购申请') {
|
||||
if (record.type === t('approval.purchaseApply')) {
|
||||
try {
|
||||
const response = await fetch(`/api/purchase-requests/${record.id}`);
|
||||
const data = await response.json();
|
||||
@@ -401,14 +413,14 @@ const ApprovalManagement: React.FC = () => {
|
||||
if (result.success) {
|
||||
// 从待审批列表中移除该申请
|
||||
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
|
||||
message.success(`审批通过:${selectedRecord.code}`);
|
||||
message.success(t('approval.approveSuccess', { code: selectedRecord.code }));
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error(result.message || '操作失败');
|
||||
message.error(result.message || t('common.operationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审批操作失败:', error);
|
||||
message.error('操作失败');
|
||||
message.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -443,27 +455,27 @@ const ApprovalManagement: React.FC = () => {
|
||||
if (result.success) {
|
||||
// 从待审批列表中移除该申请
|
||||
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
|
||||
message.success(`已退回:${selectedRecord.code}`);
|
||||
message.success(t('approval.rejectSuccess', { code: selectedRecord.code }));
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error(result.message || '操作失败');
|
||||
message.error(result.message || t('common.operationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('审批操作失败:', error);
|
||||
message.error('操作失败');
|
||||
message.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理撤回申请
|
||||
const handleWithdraw = (record: any) => {
|
||||
Modal.confirm({
|
||||
title: '撤回申请',
|
||||
content: `确认撤回申请 ${record.code} 吗?`,
|
||||
okText: '确认撤回',
|
||||
cancelText: '取消',
|
||||
title: t('approval.withdrawConfirm'),
|
||||
content: t('approval.withdrawConfirmMsg', { code: record.code }),
|
||||
okText: t('approval.withdrawConfirmBtn'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
setPendingData(pendingData.filter(item => item.key !== record.key));
|
||||
message.success('申请已撤回');
|
||||
message.success(t('approval.withdrawSuccess'));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -478,7 +490,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
// 处理编辑提交
|
||||
const handleEditSubmit = () => {
|
||||
editForm.validateFields().then(values => {
|
||||
message.success('修改成功,已重新提交审批');
|
||||
message.success(t('approval.editResubmit'));
|
||||
setEditModalVisible(false);
|
||||
});
|
||||
};
|
||||
@@ -544,22 +556,22 @@ const ApprovalManagement: React.FC = () => {
|
||||
// 特殊分类映射
|
||||
const specialCategories: Record<string, string> = {
|
||||
// Project expense categories
|
||||
accommodation: '住宿',
|
||||
food: '餐饮',
|
||||
fuel: '加油',
|
||||
materials: '零散材料',
|
||||
customer_relations: '客户关系',
|
||||
subcontract_relations: '分包关系',
|
||||
edl_relations: 'EDL关系',
|
||||
extra_construction: '额外施工',
|
||||
accommodation: t('approval.accommodation'),
|
||||
food: t('approval.catering'),
|
||||
fuel: t('approval.fuel'),
|
||||
materials: t('approval.scatteredMaterial'),
|
||||
customer_relations: t('approval.customerRelation'),
|
||||
subcontract_relations: t('approval.subcontractorRelation'),
|
||||
edl_relations: t('approval.EDLRelation'),
|
||||
extra_construction: t('approval.extraConstruction'),
|
||||
// Company expense categories
|
||||
general_operations: '通用运营(房租/耗材)',
|
||||
transportation: '交通通勤',
|
||||
business_expansion: '业扩营销',
|
||||
power_system_relations: '电力系统关系',
|
||||
employee_benefits: '员工福利',
|
||||
express_logistics: '快递物流',
|
||||
other: '其他'
|
||||
general_operations: t('approval.generalOperation'),
|
||||
transportation: t('approval.commute'),
|
||||
business_expansion: t('approval.marketing'),
|
||||
power_system_relations: t('approval.powerSystem'),
|
||||
employee_benefits: t('approval.employeeBenefit'),
|
||||
express_logistics: t('approval.expressLogistics'),
|
||||
other: t('approval.other')
|
||||
};
|
||||
// 先检查特殊分类
|
||||
if (specialCategories[category]) return specialCategories[category];
|
||||
@@ -597,17 +609,17 @@ const ApprovalManagement: React.FC = () => {
|
||||
<List.Item>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span><strong>明细 {index + 1}:</strong> {item.description || getCategoryName(item.category) || '-'}</span>
|
||||
<span><strong>{t('approval.detailLabel', { index: index + 1 })}</strong> {item.description || getCategoryName(item.category) || '-'}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
|
||||
</div>
|
||||
{item.category && (
|
||||
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
|
||||
<strong>支出分类:</strong>{getCategoryName(item.category)}
|
||||
<strong>{t('approval.categoryLabel')}</strong>{getCategoryName(item.category)}
|
||||
</div>
|
||||
)}
|
||||
{item.attachments && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>明细附件:</span>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>{t('approval.detailAttachment')}</span>
|
||||
{renderAttachments(item.attachments)}
|
||||
</div>
|
||||
)}
|
||||
@@ -620,18 +632,18 @@ const ApprovalManagement: React.FC = () => {
|
||||
|
||||
// 待审批列
|
||||
const pendingColumns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '申请日期', dataIndex: 'date', key: 'date', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: t('approval.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
|
||||
{ title: t('approval.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: t('approval.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: t('approval.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: t('approval.applicationDate'), dataIndex: 'date', key: 'date', width: 100 },
|
||||
{ title: t('approval.status'), dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
|
||||
{ title: t('approval.code'), dataIndex: 'code', key: 'code', width: 140 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
title: t('approval.action'), key: 'action', width: 100,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" type="primary" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>审批</Button>
|
||||
<Button size="small" type="primary" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>{t('approval.approve')}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -641,49 +653,49 @@ const ApprovalManagement: React.FC = () => {
|
||||
|
||||
// 审批记录列
|
||||
const historyColumns = [
|
||||
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 140 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 },
|
||||
{ title: '申请编号', dataIndex: 'applyCode', key: 'applyCode', width: 140 },
|
||||
{ title: '类型', dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
|
||||
{ title: '备注/原因', dataIndex: 'remark', key: 'remark', ellipsis: true }
|
||||
{ title: t('approval.time'), dataIndex: 'timestamp', key: 'timestamp', width: 140 },
|
||||
{ title: t('approval.operation'), dataIndex: 'action', key: 'action', width: 100 },
|
||||
{ title: t('approval.applicationCode'), dataIndex: 'applyCode', key: 'applyCode', width: 140 },
|
||||
{ title: t('approval.type'), dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: t('approval.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: t('approval.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: t('approval.operator'), dataIndex: 'operator', key: 'operator', width: 100 },
|
||||
{ title: t('approval.note'), dataIndex: 'remark', key: 'remark', ellipsis: true }
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'pending', label: <span>待审批 <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} rowKey="key" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
|
||||
{ key: 'history', label: <span>审批记录 <Badge count={approvalHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={approvalHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
|
||||
{ key: 'pending', label: <span>{t('approval.pendingTab')} <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} rowKey="key" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} /> },
|
||||
{ key: 'history', label: <span>{t('approval.historyTab')} <Badge count={approvalHistory.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={historyColumns} dataSource={approvalHistory} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1400 }} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h2>审批管理</h2>
|
||||
<h2>{t('approval.title')}</h2>
|
||||
<Button type="primary" onClick={fetchPendingData} loading={loading}>
|
||||
刷新数据
|
||||
{t('approval.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>审批预支、报销、付款等申请</p>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>{t('approval.description')}</p>
|
||||
</div>
|
||||
<Card><Tabs items={tabItems} /></Card>
|
||||
|
||||
{/* 详情模态框 */}
|
||||
<Modal
|
||||
title={`${selectedRecord?.type}详情:${selectedRecord?.code}`}
|
||||
title={t('approval.detailTitle', { type: selectedRecord?.type || '', code: selectedRecord?.code || '' })}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
width={900}
|
||||
footer={
|
||||
selectedRecord?.status === 'pending' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>取消</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退回</Button>
|
||||
<Button type="primary" icon={<CheckOutlined />} onClick={handleApprove}>通过</Button>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>{t('common.cancel')}</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('approval.reject')}</Button>
|
||||
<Button type="primary" icon={<CheckOutlined />} onClick={handleApprove}>{t('approval.pass')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setDetailModalVisible(false)}>关闭</Button>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>{t('approval.close')}</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -691,37 +703,37 @@ const ApprovalManagement: React.FC = () => {
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{selectedRecord.date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
<Descriptions.Item label={t('approval.applicationType')}>{getTypeTag(selectedRecord.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.applicationCode')}>{selectedRecord.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.applicationDate')}>{selectedRecord.date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.amount')}>
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
|
||||
{/* 付款申请特有字段 */}
|
||||
{selectedRecord.type === '付款申请' && (
|
||||
{selectedRecord.type === t('approval.paymentApply') && (
|
||||
<>
|
||||
<Descriptions.Item label="收款单位类型">
|
||||
{fullDetail.payee_type === 'subcontractor' ? '分包商' :
|
||||
fullDetail.payee_type === 'supplier' ? '供应商' :
|
||||
fullDetail.payee_type === 'customer' ? '客户' : '其他'}
|
||||
<Descriptions.Item label={t('approval.payeeType')}>
|
||||
{fullDetail.payee_type === 'subcontractor' ? t('approval.counterpartySubcontractor') :
|
||||
fullDetail.payee_type === 'supplier' ? t('approval.counterpartySupplier') :
|
||||
fullDetail.payee_type === 'customer' ? t('approval.counterpartyCustomer') : t('approval.counterpartyOther')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
<Descriptions.Item label={t('approval.payee')}>{fullDetail.payee || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.bankName')}>{fullDetail.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.bankAccount')}>{fullDetail.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.expenseType')}>
|
||||
{fullDetail.expense_type === 'company' ? t('approval.companyExpense') : t('approval.projectExpense')}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.expense_type === 'project' && fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="支出分类">
|
||||
<Descriptions.Item label={t('approval.expenseCategory')}>
|
||||
{fullDetail.expense_type === 'project'
|
||||
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
: (COMPANY_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
@@ -731,66 +743,66 @@ const ApprovalManagement: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 报销申请特有字段 */}
|
||||
{selectedRecord.type === '报销申请' && fullDetail.expense_type && (
|
||||
{selectedRecord.type === t('approval.reimburseApply') && fullDetail.expense_type && (
|
||||
<>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
<Descriptions.Item label={t('approval.expenseType')}>
|
||||
{fullDetail.expense_type === 'company' ? t('approval.companyExpense') : t('approval.projectExpense')}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 核销申请特有字段 */}
|
||||
{selectedRecord.type === '核销申请' && fullDetail.advance_code && (
|
||||
{selectedRecord.type === t('approval.verificationApply') && fullDetail.advance_code && (
|
||||
<>
|
||||
<Descriptions.Item label="关联预支单">{fullDetail.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算核销">
|
||||
<Descriptions.Item label={t('approval.relatedAdvance')}>{fullDetail.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.advanceAmount')}>{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.settlement')}>
|
||||
<span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}>
|
||||
{fullDetail.settlement ? '是' : '否'}
|
||||
{fullDetail.settlement ? t('common.is') : t('common.no')}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已核销金额">{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余核销金额">{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.verifiedAmount')}>{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.remainingAmount')}>{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
|
||||
{fullDetail.settlement && fullDetail.settlement_amount && (
|
||||
<Descriptions.Item label="核销结算金额" span={2}>
|
||||
{fullDetail.settlement_amount > 0 ? `退款 ${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `补款 ${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
|
||||
<Descriptions.Item label={t('approval.settlementAmount')} span={2}>
|
||||
{fullDetail.settlement_amount > 0 ? `${t('approval.refundText')}${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `${t('approval.supplementText')}${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 采购申请特有字段 */}
|
||||
{selectedRecord.type === '采购申请' && (
|
||||
{selectedRecord.type === t('approval.purchaseApply') && (
|
||||
<>
|
||||
<Descriptions.Item label="采购类型">
|
||||
{fullDetail.purchase_type === 'project' ? '项目采购' : '库存采购'}
|
||||
<Descriptions.Item label={t('approval.purchaseType')}>
|
||||
{fullDetail.purchase_type === 'project' ? t('approval.projectPurchase') : t('approval.stockPurchase')}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.purchase_type === 'project' && fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="供应商">{fullDetail.supplier_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出分类">
|
||||
{fullDetail.expense_category === 'material' ? '材料' :
|
||||
fullDetail.expense_category === 'equipment' ? '设备' :
|
||||
fullDetail.expense_category === 'pole' ? '电杆' : '其他'}
|
||||
<Descriptions.Item label={t('approval.supplier')}>{fullDetail.supplier_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.expenseCategory')}>
|
||||
{fullDetail.expense_category === 'material' ? t('approval.material') :
|
||||
fullDetail.expense_category === 'equipment' ? t('approval.equipment') :
|
||||
fullDetail.expense_category === 'pole' ? t('approval.pole') : t('approval.other')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{fullDetail.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{fullDetail.request_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.currency')}>{fullDetail.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.applicationDate')}>{fullDetail.request_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.subject')} span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
|
||||
{fullDetail.remark && (
|
||||
<Descriptions.Item label="备注" span={2}>{fullDetail.remark}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.remark')} span={2}>{fullDetail.remark}</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* 采购申请商品明细 */}
|
||||
{selectedRecord.type === '采购申请' && fullDetail.items && fullDetail.items.length > 0 && (
|
||||
{selectedRecord.type === t('approval.purchaseApply') && fullDetail.items && fullDetail.items.length > 0 && (
|
||||
<>
|
||||
<Divider>商品明细</Divider>
|
||||
<Divider>{t('approval.productDetail')}</Divider>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
@@ -805,8 +817,8 @@ const ApprovalManagement: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#666' }}>
|
||||
规格: {item.specification || '-'} | 单位: {item.unit || '-'} |
|
||||
数量: {item.quantity} | 单价: {fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
|
||||
{t('approval.specLabel')}{item.specification || '-'} | {t('approval.unitLabel')}{item.unit || '-'} |
|
||||
{t('approval.qtyLabel')}{item.quantity} | {t('approval.priceLabel')}{fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
@@ -818,7 +830,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
{/* 明细清单 */}
|
||||
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>明细清单</Divider>
|
||||
<Divider>{t('approval.detailList')}</Divider>
|
||||
{renderDetailItems(fullDetail.detail_items)}
|
||||
</>
|
||||
)}
|
||||
@@ -826,7 +838,7 @@ const ApprovalManagement: React.FC = () => {
|
||||
{/* 凭证附件或退款凭证 */}
|
||||
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? '退款凭证' : '凭证附件'}</Divider>
|
||||
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('approval.refundProof') : t('approval.proofAttachment')}</Divider>
|
||||
{renderAttachments(fullDetail.attachments)}
|
||||
</>
|
||||
)}
|
||||
@@ -834,13 +846,13 @@ const ApprovalManagement: React.FC = () => {
|
||||
{/* 审批备注表单 */}
|
||||
{selectedRecord.status === 'pending' && (
|
||||
<>
|
||||
<Divider>审批意见</Divider>
|
||||
<Divider>{t('approval.approvalOpinion')}</Divider>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="remark" label="审批备注">
|
||||
<TextArea rows={3} placeholder="可选:填写审批备注" />
|
||||
<Form.Item name="remark" label={t('approval.approvalNote')}>
|
||||
<TextArea rows={3} placeholder={t('approval.approvalNotePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rejectReason" label="退回原因" style={{ display: 'none' }}>
|
||||
<TextArea rows={3} placeholder="请填写退回原因" />
|
||||
<Form.Item name="rejectReason" label={t('approval.rejectReason')} style={{ display: 'none' }}>
|
||||
<TextArea rows={3} placeholder={t('approval.rejectReasonPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
@@ -849,35 +861,35 @@ const ApprovalManagement: React.FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal title={`编辑申请:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
|
||||
<Modal title={t('approval.editTitle', { code: selectedRecord?.code || '' })} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
|
||||
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
|
||||
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
|
||||
<Form.Item label={t('approval.applicationType')}><Input value={selectedRecord?.type} disabled /></Form.Item>
|
||||
<Form.Item label={t('approval.applicant')}><Input value={selectedRecord?.applicant} disabled /></Form.Item>
|
||||
<Form.Item name="amount" label={t('approval.amount')} rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="reason" label={t('approval.subject')} rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title={`${selectedRecord?.advance_code ? '预支申请' : '报销申请'}详情:${selectedRecord?.advance_code || selectedRecord?.reimbursement_code}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={800}>
|
||||
<Modal title={`${selectedRecord?.advance_code ? t('approval.advanceApply') : t('approval.reimburseApply')}${t('approval.detailTitle', { type: '', code: selectedRecord?.advance_code || selectedRecord?.reimbursement_code || '' })}`} open={historyModalVisible} onCancel={() => setHistoryModalVisible(false)} footer={null} width={800}>
|
||||
{selectedRecord && (
|
||||
<>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.advance_code || selectedRecord.reimbursement_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{selectedRecord.advance_date || selectedRecord.reimbursement_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
<Descriptions.Item label={t('approval.applicationCode')}>{selectedRecord.advance_code || selectedRecord.reimbursement_code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.applicationDate')}>{selectedRecord.advance_date || selectedRecord.reimbursement_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.amount')}>
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('approval.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>凭证附件</Divider>
|
||||
<Divider>{t('approval.proofAttachment')}</Divider>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRecord.attachments.map((url: string, index: number) => (
|
||||
<img key={index} src={url} width={100} height={100} style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Tabs, Badge, Descriptions, Divider, List, Upload } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, EyeOutlined, DollarOutlined, EditOutlined, UndoOutlined, ClockCircleOutlined, FileImageOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -26,6 +27,15 @@ const COMPANY_EXPENSE_CATEGORIES = [
|
||||
{ value: 'other', label: '其他支出' }
|
||||
];
|
||||
|
||||
// Type name to i18n key mapping
|
||||
const typeKeyMap: Record<string, string> = {
|
||||
'预支申请': 'execution.advanceApply',
|
||||
'报销申请': 'execution.reimburseApply',
|
||||
'付款申请': 'execution.paymentApply',
|
||||
'核销申请': 'execution.verificationApply',
|
||||
'采购申请': 'execution.purchaseApply',
|
||||
};
|
||||
|
||||
// 执行记录类型
|
||||
interface ExecutionRecord {
|
||||
id: string;
|
||||
@@ -45,6 +55,7 @@ interface ExecutionRecord {
|
||||
}
|
||||
|
||||
const ExecutionManagement: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
@@ -85,14 +96,14 @@ const ExecutionManagement: React.FC = () => {
|
||||
rawData: item
|
||||
})));
|
||||
} else {
|
||||
message.error('获取待执行数据失败:数据格式错误');
|
||||
message.error(t('execution.getPendingFailedFormat'));
|
||||
}
|
||||
} else {
|
||||
message.error('获取待执行数据失败:' + response.statusText);
|
||||
message.error(t('execution.getPendingFailed') + response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取待执行数据错误:', error);
|
||||
message.error('网络错误,获取待执行数据失败');
|
||||
message.error(t('execution.getPendingNetworkError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -135,14 +146,14 @@ const ExecutionManagement: React.FC = () => {
|
||||
rawData: item
|
||||
})));
|
||||
} else {
|
||||
message.error('获取已执行数据失败:数据格式错误');
|
||||
message.error(t('execution.getExecutedFailedFormat'));
|
||||
}
|
||||
} else {
|
||||
message.error('获取已执行数据失败:' + response.statusText);
|
||||
message.error(t('execution.getExecutedFailed') + response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取已执行数据错误:', error);
|
||||
message.error('网络错误,获取已执行数据失败');
|
||||
message.error(t('execution.getExecutedNetworkError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -169,15 +180,16 @@ const ExecutionManagement: React.FC = () => {
|
||||
|
||||
const getTypeTag = (type: string) => {
|
||||
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple', '采购申请': 'cyan' };
|
||||
return <Tag color={colors[type] || 'default'}>{type}</Tag>;
|
||||
const key = typeKeyMap[type] || type;
|
||||
return <Tag color={colors[type] || 'default'}>{t(key)}</Tag>;
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待执行' },
|
||||
executed: { color: 'success', text: '已执行' },
|
||||
rejected: { color: 'error', text: '已退回' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
pending: { color: 'processing', text: t('execution.pendingExecution') },
|
||||
executed: { color: 'success', text: t('execution.executed') },
|
||||
rejected: { color: 'error', text: t('execution.rejected') },
|
||||
approved: { color: 'success', text: t('execution.approved') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -201,7 +213,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
setIsRejecting(false);
|
||||
|
||||
// 获取完整详情
|
||||
if (record.type === '采购申请') {
|
||||
if (record.type === t('execution.purchaseApply')) {
|
||||
try {
|
||||
const response = await fetch(`/api/purchase-requests/${record.id}`);
|
||||
const data = await response.json();
|
||||
@@ -225,7 +237,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
// 检查是否为核销申请
|
||||
const isVerification = selectedRecord.type === '核销申请';
|
||||
const isVerification = selectedRecord.type === t('execution.verificationApply');
|
||||
// 检查是否为退款类型的核销申请
|
||||
const isRefundVerification = isVerification && fullDetail.settlement && fullDetail.settlement_amount > 0;
|
||||
// 检查是否为非结算核销
|
||||
@@ -271,10 +283,10 @@ const ExecutionManagement: React.FC = () => {
|
||||
}
|
||||
};
|
||||
fetchExecutedData();
|
||||
message.success(`执行成功:${selectedRecord.code}`);
|
||||
message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error('执行操作失败,请重试');
|
||||
message.error(t('execution.executeFailed'));
|
||||
}
|
||||
} else {
|
||||
// 其他类型的申请需要验证表单
|
||||
@@ -282,7 +294,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
|
||||
// 检查是否需要上传付款凭证
|
||||
if (!isRefundVerification && (!voucherFiles || voucherFiles.length === 0)) {
|
||||
message.error('请上传付款凭证');
|
||||
message.error(t('execution.proofRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -303,7 +315,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apply_id: selectedRecord.id,
|
||||
apply_type: selectedRecord.type === '预支申请' ? 'advance' : selectedRecord.type === '报销申请' ? 'reimbursement' : selectedRecord.type === '付款申请' ? 'payment' : selectedRecord.type === '采购申请' ? 'purchase' : 'verification',
|
||||
apply_type: selectedRecord.type === t('execution.advanceApply') ? 'advance' : selectedRecord.type === t('execution.reimburseApply') ? 'reimbursement' : selectedRecord.type === t('execution.paymentApply') ? 'payment' : selectedRecord.type === t('execution.purchaseApply') ? 'purchase' : 'verification',
|
||||
action: 'execute',
|
||||
execute_method: isRefundVerification ? 'refund' : values.execute_method,
|
||||
voucher_files: voucherFileUrls,
|
||||
@@ -332,15 +344,15 @@ const ExecutionManagement: React.FC = () => {
|
||||
}
|
||||
};
|
||||
fetchExecutedData();
|
||||
message.success(`执行成功:${selectedRecord.code}`);
|
||||
message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error('执行操作失败,请重试');
|
||||
message.error(t('execution.executeFailed'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('执行操作失败:', error);
|
||||
message.error('网络错误,操作失败');
|
||||
message.error(t('common.networkError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -361,7 +373,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apply_id: selectedRecord.id,
|
||||
apply_type: selectedRecord.type === '预支申请' ? 'advance' : selectedRecord.type === '报销申请' ? 'reimbursement' : selectedRecord.type === '付款申请' ? 'payment' : selectedRecord.type === '采购申请' ? 'purchase' : 'verification',
|
||||
apply_type: selectedRecord.type === t('execution.advanceApply') ? 'advance' : selectedRecord.type === t('execution.reimburseApply') ? 'reimbursement' : selectedRecord.type === t('execution.paymentApply') ? 'payment' : selectedRecord.type === t('execution.purchaseApply') ? 'purchase' : 'verification',
|
||||
action: 'reject',
|
||||
reject_reason: values.rejectReason
|
||||
})
|
||||
@@ -369,14 +381,14 @@ const ExecutionManagement: React.FC = () => {
|
||||
|
||||
if (rejectResponse.ok) {
|
||||
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
|
||||
message.success(`已退回:${selectedRecord.code},申请人可编辑后重新提交`);
|
||||
message.success(t('execution.rejectSuccess', { code: selectedRecord.code }));
|
||||
setDetailModalVisible(false);
|
||||
} else {
|
||||
message.error('退回操作失败,请重试');
|
||||
message.error(t('execution.rejectFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('退回操作失败:', error);
|
||||
message.error('网络错误,操作失败');
|
||||
message.error(t('common.networkError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -392,7 +404,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
|
||||
const handleEditSubmit = () => {
|
||||
editForm.validateFields().then(values => {
|
||||
message.success('修改成功,已重新提交审批');
|
||||
message.success(t('execution.editSuccess'));
|
||||
setEditModalVisible(false);
|
||||
});
|
||||
};
|
||||
@@ -448,22 +460,22 @@ const ExecutionManagement: React.FC = () => {
|
||||
// 特殊分类映射
|
||||
const specialCategories: Record<string, string> = {
|
||||
// Project expense categories
|
||||
accommodation: '住宿',
|
||||
food: '餐饮',
|
||||
fuel: '加油',
|
||||
materials: '零散材料',
|
||||
customer_relations: '客户关系',
|
||||
subcontract_relations: '分包关系',
|
||||
edl_relations: 'EDL关系',
|
||||
extra_construction: '额外施工',
|
||||
accommodation: t('execution.accommodation'),
|
||||
food: t('execution.catering'),
|
||||
fuel: t('execution.fuel'),
|
||||
materials: t('execution.scatteredMaterial'),
|
||||
customer_relations: t('execution.customerRelation'),
|
||||
subcontract_relations: t('execution.subcontractorRelation'),
|
||||
edl_relations: t('execution.EDLRelation'),
|
||||
extra_construction: t('execution.extraConstruction'),
|
||||
// Company expense categories
|
||||
general_operations: '通用运营(房租/耗材)',
|
||||
transportation: '交通通勤',
|
||||
business_expansion: '业扩营销',
|
||||
power_system_relations: '电力系统关系',
|
||||
employee_benefits: '员工福利',
|
||||
express_logistics: '快递物流',
|
||||
other: '其他'
|
||||
general_operations: t('execution.generalOperation'),
|
||||
transportation: t('execution.commute'),
|
||||
business_expansion: t('execution.marketing'),
|
||||
power_system_relations: t('execution.powerSystem'),
|
||||
employee_benefits: t('execution.employeeBenefit'),
|
||||
express_logistics: t('execution.expressLogistics'),
|
||||
other: t('execution.otherCategory')
|
||||
};
|
||||
// 先检查特殊分类
|
||||
if (specialCategories[category]) return specialCategories[category];
|
||||
@@ -501,17 +513,17 @@ const ExecutionManagement: React.FC = () => {
|
||||
<List.Item>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span><strong>明细 {index + 1}:</strong> {item.description || getCategoryName(item.category) || '-'}</span>
|
||||
<span><strong>{t('execution.detailLabel', { index: index + 1 })}</strong> {item.description || getCategoryName(item.category) || '-'}</span>
|
||||
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
|
||||
</div>
|
||||
{item.category && (
|
||||
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
|
||||
<strong>支出分类:</strong>{getCategoryName(item.category)}
|
||||
<strong>{t('execution.categoryLabel')}</strong>{getCategoryName(item.category)}
|
||||
</div>
|
||||
)}
|
||||
{item.attachments && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>明细附件:</span>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>{t('execution.detailAttachment')}</span>
|
||||
{renderAttachments(item.attachments)}
|
||||
</div>
|
||||
)}
|
||||
@@ -525,19 +537,19 @@ const ExecutionManagement: React.FC = () => {
|
||||
|
||||
|
||||
const pendingColumns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(v, r.currency)}</span> },
|
||||
{ title: '收款方', dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
|
||||
{ title: '审批日期', dataIndex: 'approveDate', key: 'approveDate', width: 100 },
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: t('execution.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> },
|
||||
{ title: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: t('execution.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: t('execution.amount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number, r: any) => <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(v, r.currency)}</span> },
|
||||
{ title: t('execution.payee'), dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
|
||||
{ title: t('execution.approvalDate'), dataIndex: 'approveDate', key: 'approveDate', width: 100 },
|
||||
{ title: t('execution.code'), dataIndex: 'code', key: 'code', width: 140 },
|
||||
{
|
||||
title: '操作', key: 'action', width: 200,
|
||||
title: t('execution.action'), key: 'action', width: 200,
|
||||
render: (_: any, record: any) => (
|
||||
<Space wrap>
|
||||
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}>执行</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}>{t('execution.execute')}</Button>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('execution.edit')}</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -583,26 +595,26 @@ const ExecutionManagement: React.FC = () => {
|
||||
};
|
||||
|
||||
const executedColumns = [
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
{ title: t('execution.subject'), dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> },
|
||||
{ title: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
|
||||
{ title: t('execution.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
|
||||
{ title: t('execution.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
|
||||
<>
|
||||
<div>{formatAmount(v, r.currency)}</div>
|
||||
{r.currency !== 'CNY' && r.amount_cny && <div style={{ color: '#999', fontSize: 12 }}>≈ ¥{r.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</div>}
|
||||
</>
|
||||
) },
|
||||
{ title: '执行日期', dataIndex: 'executeDate', key: 'executeDate', width: 100, sorter: true, render: (v: string) => v || '-' },
|
||||
{ title: '执行方式', dataIndex: 'executeMethod', key: 'executeMethod', width: 100, render: (v: string) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
|
||||
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 },
|
||||
{ title: t('execution.executionDate'), dataIndex: 'executeDate', key: 'executeDate', width: 100, sorter: true, render: (v: string) => v || '-' },
|
||||
{ title: t('execution.executionMethod'), dataIndex: 'executeMethod', key: 'executeMethod', width: 100, render: (v: string) => v || '-' },
|
||||
{ title: t('execution.status'), dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
|
||||
{ title: t('execution.code'), dataIndex: 'code', key: 'code', width: 140 },
|
||||
];
|
||||
|
||||
// 已执行列表的筛选和排序控件
|
||||
const ExecutedListControls = () => (
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Input.Search
|
||||
placeholder="搜索事由、编号或申请人"
|
||||
placeholder={t('execution.searchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onSearch={(value) => setSearchKeyword(value)}
|
||||
@@ -610,20 +622,20 @@ const ExecutionManagement: React.FC = () => {
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选类型"
|
||||
placeholder={t('execution.filterType')}
|
||||
value={filterType}
|
||||
onChange={(value) => setFilterType(value)}
|
||||
style={{ width: 150 }}
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value="预支申请">预支申请</Select.Option>
|
||||
<Select.Option value="报销申请">报销申请</Select.Option>
|
||||
<Select.Option value="付款申请">付款申请</Select.Option>
|
||||
<Select.Option value="核销申请">核销申请</Select.Option>
|
||||
<Select.Option value="采购申请">采购申请</Select.Option>
|
||||
<Select.Option value={t('execution.advanceApply')}>{t('execution.advanceApply')}</Select.Option>
|
||||
<Select.Option value={t('execution.reimburseApply')}>{t('execution.reimburseApply')}</Select.Option>
|
||||
<Select.Option value={t('execution.paymentApply')}>{t('execution.paymentApply')}</Select.Option>
|
||||
<Select.Option value={t('execution.verificationApply')}>{t('execution.verificationApply')}</Select.Option>
|
||||
<Select.Option value={t('execution.purchaseApply')}>{t('execution.purchaseApply')}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="排序方式"
|
||||
placeholder={t('execution.sortBy')}
|
||||
value={`${sortField}_${sortOrder}`}
|
||||
onChange={(value) => {
|
||||
const [field, order] = (value as string).split('_');
|
||||
@@ -632,17 +644,17 @@ const ExecutionManagement: React.FC = () => {
|
||||
}}
|
||||
style={{ width: 180 }}
|
||||
>
|
||||
<Select.Option value="executeDate_descend">执行日期(最新)</Select.Option>
|
||||
<Select.Option value="executeDate_ascend">执行日期(最早)</Select.Option>
|
||||
<Select.Option value="amount_descend">金额(从高到低)</Select.Option>
|
||||
<Select.Option value="amount_ascend">金额(从低到高)</Select.Option>
|
||||
<Select.Option value="executeDate_descend">{t('execution.sortDateNew')}</Select.Option>
|
||||
<Select.Option value="executeDate_ascend">{t('execution.sortDateOld')}</Select.Option>
|
||||
<Select.Option value="amount_descend">{t('execution.sortAmountHigh')}</Select.Option>
|
||||
<Select.Option value="amount_ascend">{t('execution.sortAmountLow')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'pending', label: <span>待执行 <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1300 }} /> },
|
||||
{ key: 'executed', label: '已执行', children: (
|
||||
{ key: 'pending', label: <span>{t('execution.pendingTab')} <Badge count={pendingData.length} style={{ marginLeft: 8 }} /></span>, children: <Table columns={pendingColumns} dataSource={pendingData} loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1300 }} /> },
|
||||
{ key: 'executed', label: t('execution.executedTab'), children: (
|
||||
<>
|
||||
<ExecutedListControls />
|
||||
<Table
|
||||
@@ -663,18 +675,28 @@ const ExecutionManagement: React.FC = () => {
|
||||
];
|
||||
|
||||
// 上传配置
|
||||
const getUploadHeaders = () => {
|
||||
try {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
const parsed = JSON.parse(authStorage);
|
||||
const token = parsed?.state?.token;
|
||||
if (token) return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
} catch (e) {}
|
||||
return {};
|
||||
};
|
||||
|
||||
const uploadProps = {
|
||||
name: 'file',
|
||||
action: '/api/upload/single',
|
||||
headers: {
|
||||
authorization: 'authorization-text',
|
||||
},
|
||||
headers: getUploadHeaders(),
|
||||
onChange(info: any) {
|
||||
// 更新文件列表状态
|
||||
setVoucherFiles(info.fileList);
|
||||
|
||||
if (info.file.status === 'done') {
|
||||
message.success(`${info.file.name} 上传成功`);
|
||||
message.success(t('execution.uploadSuccess', { name: info.file.name }));
|
||||
// 如果上传成功,将返回的URL添加到文件对象中
|
||||
const updatedFileList = info.fileList.map((file: any) => {
|
||||
if (file.uid === info.file.uid && file.response) {
|
||||
@@ -687,7 +709,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
});
|
||||
setVoucherFiles(updatedFileList);
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error(`${info.file.name} 上传失败`);
|
||||
message.error(t('execution.uploadFailed', { name: info.file.name }));
|
||||
}
|
||||
},
|
||||
fileList: voucherFiles,
|
||||
@@ -695,24 +717,24 @@ const ExecutionManagement: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}>执行管理</h2><p style={{ color: '#888', marginBottom: 0 }}>执行已审批通过的付款申请</p></div>
|
||||
<div style={{ marginBottom: 24 }}><h2 style={{ marginBottom: 8 }}>{t('execution.title')}</h2><p style={{ color: '#888', marginBottom: 0 }}>{t('execution.description')}</p></div>
|
||||
<Card><Tabs items={tabItems} /></Card>
|
||||
|
||||
{/* 详情模态框 */}
|
||||
<Modal
|
||||
title={`${selectedRecord?.type}详情`}
|
||||
title={`${selectedRecord?.type}${t('common.detail')}`}
|
||||
open={detailModalVisible}
|
||||
onCancel={() => setDetailModalVisible(false)}
|
||||
width={900}
|
||||
footer={
|
||||
selectedRecord?.status === 'approved' || selectedRecord?.status === 'pending' ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>取消</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退回</Button>
|
||||
<Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}>执行</Button>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>{t('execution.cancel')}</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('execution.pass')}</Button>
|
||||
<Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}>{t('execution.execute')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setDetailModalVisible(false)}>关闭</Button>
|
||||
<Button onClick={() => setDetailModalVisible(false)}>{t('execution.close')}</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -720,37 +742,37 @@ const ExecutionManagement: React.FC = () => {
|
||||
<>
|
||||
{/* 基本信息 */}
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{selectedRecord.date || fullDetail.advance_date || fullDetail.reimbursement_date || fullDetail.payment_date || fullDetail.verification_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
<Descriptions.Item label={t('execution.applicationType')}>{getTypeTag(selectedRecord.type)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.applicationCode')}>{selectedRecord.code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.approvalDate')}>{selectedRecord.date || fullDetail.advance_date || fullDetail.reimbursement_date || fullDetail.payment_date || fullDetail.verification_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.amount')}>
|
||||
{formatAmount(selectedRecord.amount, selectedRecord.currency)}
|
||||
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>≈ ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
|
||||
|
||||
{/* 付款申请特有字段 */}
|
||||
{selectedRecord.type === '付款申请' && (
|
||||
{selectedRecord.type === t('execution.paymentApply') && (
|
||||
<>
|
||||
<Descriptions.Item label="收款单位类型">
|
||||
{fullDetail.payee_type === 'subcontractor' ? '分包商' :
|
||||
fullDetail.payee_type === 'supplier' ? '供应商' :
|
||||
fullDetail.payee_type === 'customer' ? '客户' : '其他'}
|
||||
<Descriptions.Item label={t('execution.payeeType')}>
|
||||
{fullDetail.payee_type === 'subcontractor' ? t('execution.counterpartySubcontractor') :
|
||||
fullDetail.payee_type === 'supplier' ? t('execution.counterpartySupplier') :
|
||||
fullDetail.payee_type === 'customer' ? t('execution.counterpartyCustomer') : t('execution.counterpartyOther')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
<Descriptions.Item label={t('execution.payee')}>{fullDetail.payee || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.bankName')}>{fullDetail.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.bankAccount')}>{fullDetail.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.expenseType')}>
|
||||
{fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.expense_type === 'project' && fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="支出分类">
|
||||
<Descriptions.Item label={t('execution.expenseCategory')}>
|
||||
{fullDetail.expense_type === 'project'
|
||||
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
: (COMPANY_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category)
|
||||
@@ -760,75 +782,75 @@ const ExecutionManagement: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 报销申请特有字段 */}
|
||||
{selectedRecord.type === '报销申请' && fullDetail.expense_type && (
|
||||
{selectedRecord.type === t('execution.reimburseApply') && fullDetail.expense_type && (
|
||||
<>
|
||||
<Descriptions.Item label="支出类型">
|
||||
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'}
|
||||
<Descriptions.Item label={t('execution.expenseType')}>
|
||||
{fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 核销申请特有字段 */}
|
||||
{selectedRecord.type === '核销申请' && fullDetail.advance_code && (
|
||||
{selectedRecord.type === t('execution.verificationApply') && fullDetail.advance_code && (
|
||||
<>
|
||||
<Descriptions.Item label="关联预支单">{fullDetail.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算核销">
|
||||
<Descriptions.Item label={t('execution.relatedAdvance')}>{fullDetail.advance_code}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.advanceAmount')}>{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.settlement')}>
|
||||
<span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}>
|
||||
{fullDetail.settlement ? '是' : '否'}
|
||||
{fullDetail.settlement ? t('common.is') : t('common.no')}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已核销金额">{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label="剩余核销金额">{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.verifiedAmount')}>{formatAmount(fullDetail.total_reimbursed || 0, fullDetail.currency)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.remainingAmount')}>{formatAmount((fullDetail.advance_amount || 0) - (fullDetail.total_reimbursed || 0), fullDetail.currency)}</Descriptions.Item>
|
||||
{fullDetail.settlement && fullDetail.settlement_amount && (
|
||||
<Descriptions.Item label="核销结算金额" span={2}>
|
||||
{fullDetail.settlement_amount > 0 ? `退款 ${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `补款 ${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
|
||||
<Descriptions.Item label={t('execution.settlementAmount')} span={2}>
|
||||
{fullDetail.settlement_amount > 0 ? `${t('execution.refundText')}${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `${t('execution.supplementText')}${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 采购申请特有字段 */}
|
||||
{selectedRecord.type === '采购申请' && (
|
||||
{selectedRecord.type === t('execution.purchaseApply') && (
|
||||
<>
|
||||
<Descriptions.Item label="采购类型">
|
||||
{fullDetail.purchase_type === 'project' ? '项目采购' : '库存采购'}
|
||||
<Descriptions.Item label={t('execution.purchaseType')}>
|
||||
{fullDetail.purchase_type === 'project' ? t('execution.projectPurchase') : t('execution.stockPurchase')}
|
||||
</Descriptions.Item>
|
||||
{fullDetail.purchase_type === 'project' && fullDetail.project_id && (
|
||||
<Descriptions.Item label="关联项目">{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.relatedProject')}>{getProjectName(fullDetail.project_id)}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="供应商">{fullDetail.supplier_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="支出分类">
|
||||
{fullDetail.expense_category === 'material' ? '材料' :
|
||||
fullDetail.expense_category === 'equipment' ? '设备' :
|
||||
fullDetail.expense_category === 'pole' ? '电杆' : '其他'}
|
||||
<Descriptions.Item label={t('execution.supplier')}>{fullDetail.supplier_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.expenseCategory')}>
|
||||
{fullDetail.expense_category === 'material' ? t('execution.material') :
|
||||
fullDetail.expense_category === 'equipment' ? t('execution.equipment') :
|
||||
fullDetail.expense_category === 'pole' ? t('execution.pole') : t('execution.otherCategory')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">{fullDetail.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请日期">{fullDetail.request_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.currency')}>{fullDetail.currency}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.approvalDate')}>{fullDetail.request_date}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.subject')} span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
|
||||
{fullDetail.remark && (
|
||||
<Descriptions.Item label="备注" span={2}>{fullDetail.remark}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.remark')} span={2}>{fullDetail.remark}</Descriptions.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{/* 采购申请供应商收款信息 */}
|
||||
{selectedRecord.type === '采购申请' && fullDetail.supplier_payment_infos && fullDetail.supplier_payment_infos.length > 0 && (
|
||||
{selectedRecord.type === t('execution.purchaseApply') && fullDetail.supplier_payment_infos && fullDetail.supplier_payment_infos.length > 0 && (
|
||||
<>
|
||||
<Divider>供应商收款信息</Divider>
|
||||
<Divider>{t('execution.supplierPaymentInfo')}</Divider>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
{fullDetail.supplier_payment_infos.filter((p: any) => p.is_primary).map((payment: any, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
<Descriptions.Item label="收款户名">{payment.account_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="银行账号">{payment.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户银行">{payment.bank_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.accountName')}>{payment.account_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.bankAccount')}>{payment.bank_account || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('execution.bankName')}>{payment.bank_name || '-'}</Descriptions.Item>
|
||||
{payment.qr_code && (
|
||||
<Descriptions.Item label="收款码">
|
||||
<img src={payment.qr_code} alt="收款码" style={{ width: 100, height: 100, objectFit: 'contain' }} />
|
||||
<Descriptions.Item label={t('execution.qrCode')}>
|
||||
<img src={payment.qr_code} alt={t('execution.qrCode')} style={{ width: 100, height: 100, objectFit: 'contain' }} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</React.Fragment>
|
||||
@@ -838,9 +860,9 @@ const ExecutionManagement: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* 采购申请商品明细 */}
|
||||
{selectedRecord.type === '采购申请' && fullDetail.items && fullDetail.items.length > 0 && (
|
||||
{selectedRecord.type === t('execution.purchaseApply') && fullDetail.items && fullDetail.items.length > 0 && (
|
||||
<>
|
||||
<Divider>采购明细</Divider>
|
||||
<Divider>{t('execution.purchaseDetail')}</Divider>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
@@ -855,8 +877,8 @@ const ExecutionManagement: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#666' }}>
|
||||
规格: {item.specification || '-'} | 单位: {item.unit || '-'} |
|
||||
数量: {item.quantity} | 单价: {fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
|
||||
{t('execution.specLabel')}{item.specification || '-'} | {t('execution.unitLabel')}{item.unit || '-'} |
|
||||
{t('execution.qtyLabel')}{item.quantity} | {t('execution.priceLabel')}{fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
@@ -868,7 +890,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
{/* 明细清单 */}
|
||||
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
|
||||
<>
|
||||
<Divider>明细清单</Divider>
|
||||
<Divider>{t('execution.detailList')}</Divider>
|
||||
{renderDetailItems(fullDetail.detail_items)}
|
||||
</>
|
||||
)}
|
||||
@@ -876,7 +898,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
{/* 审批意见 */}
|
||||
{fullDetail.approval_remark && (
|
||||
<>
|
||||
<Divider>审批意见</Divider>
|
||||
<Divider>{t('execution.approvalOpinion')}</Divider>
|
||||
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<p style={{ margin: 0, color: '#666' }}>{fullDetail.approval_remark}</p>
|
||||
</div>
|
||||
@@ -886,7 +908,7 @@ const ExecutionManagement: React.FC = () => {
|
||||
{/* 申请凭证附件或退款凭证 */}
|
||||
{fullDetail.attachments && fullDetail.attachments.length > 0 && (
|
||||
<>
|
||||
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? '退款凭证' : '申请凭证附件'}</Divider>
|
||||
<Divider>{fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('execution.refundProof') : t('execution.applicationAttachment')}</Divider>
|
||||
{renderAttachments(fullDetail.attachments)}
|
||||
</>
|
||||
)}
|
||||
@@ -894,49 +916,49 @@ const ExecutionManagement: React.FC = () => {
|
||||
{/* 执行表单 */}
|
||||
{(selectedRecord.status === 'approved' || selectedRecord.status === 'pending') && (
|
||||
<>
|
||||
<Divider>执行信息</Divider>
|
||||
<Divider>{t('execution.executionInfo')}</Divider>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 执行/确认日期 */}
|
||||
<Form.Item name="execute_date" label={selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? "确认日期" : "执行日期"} rules={[{ required: true }]}>
|
||||
<Form.Item name="execute_date" label={selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? t('execution.confirmationDate') : t('execution.executionDate')} rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 执行方式或收款方式 - 非结算核销不需要 */}
|
||||
{!(selectedRecord.type === '核销申请' && !fullDetail.settlement) && (
|
||||
selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
|
||||
<Form.Item name="execute_method" label="收款方式" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'wechat', label: '微信' }, { value: 'other', label: '其他' }]} />
|
||||
{!(selectedRecord.type === t('execution.verificationApply') && !fullDetail.settlement) && (
|
||||
selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
|
||||
<Form.Item name="execute_method" label={t('execution.paymentMethod')} rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'bank', label: t('execution.bankTransfer') }, { value: 'cash', label: t('execution.cash') }, { value: 'wechat', label: t('execution.wechat') }, { value: 'other', label: t('execution.other') }]} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="execute_method" label="执行方式" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'wechat', label: '微信' }, { value: 'other', label: '其他' }]} />
|
||||
<Form.Item name="execute_method" label={t('execution.executionMethodLabel')} rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'bank', label: t('execution.bankTransfer') }, { value: 'cash', label: t('execution.cash') }, { value: 'wechat', label: t('execution.wechat') }, { value: 'other', label: t('execution.other') }]} />
|
||||
</Form.Item>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 只有退款类型的核销申请和非结算核销不需要上传付款凭证,其他类型的申请都需要 */}
|
||||
{!(selectedRecord.type === '核销申请' && (fullDetail.settlement && fullDetail.settlement_amount > 0 || !fullDetail.settlement)) && (
|
||||
<Form.Item label="付款凭证" required>
|
||||
{!(selectedRecord.type === t('execution.verificationApply') && (fullDetail.settlement && fullDetail.settlement_amount > 0 || !fullDetail.settlement)) && (
|
||||
<Form.Item label={t('execution.proofOfPayment')} required>
|
||||
<Upload {...uploadProps}>
|
||||
<Button icon={<UploadOutlined />}>上传付款凭证</Button>
|
||||
<Button icon={<UploadOutlined />}>{t('execution.uploadProof')}</Button>
|
||||
</Upload>
|
||||
<div style={{ marginTop: 8, color: '#666', fontSize: 12 }}>
|
||||
请上传付款凭证(银行转账回单、现金收据等),支持图片和PDF格式
|
||||
{t('execution.proofUploadTip')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* 退款的核销申请显示提示 */}
|
||||
{selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 && (
|
||||
{selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 && (
|
||||
<div style={{ margin: '16px 0', padding: '12px', backgroundColor: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: '4px' }}>
|
||||
<p style={{ margin: 0, color: '#389e0d' }}>此核销申请为退款类型,无需上传付款凭证</p>
|
||||
<p style={{ margin: 0, color: '#389e0d' }}>{t('execution.noProofRefund')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 非结算核销的核销申请显示提示 */}
|
||||
{selectedRecord.type === '核销申请' && !fullDetail.settlement && (
|
||||
{selectedRecord.type === t('execution.verificationApply') && !fullDetail.settlement && (
|
||||
<div style={{ margin: '16px 0', padding: '12px', backgroundColor: '#e6f7ff', border: '1px solid #91d5ff', borderRadius: '4px' }}>
|
||||
<p style={{ margin: 0, color: '#1890ff' }}>此核销申请为非结算核销,无需上传付款凭证</p>
|
||||
<p style={{ margin: 0, color: '#1890ff' }}>{t('execution.noProofNonSettlement')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -945,20 +967,20 @@ const ExecutionManagement: React.FC = () => {
|
||||
// 执行操作时显示的字段
|
||||
<>
|
||||
{/* 收款确认信息(仅退款类型)或备注 */}
|
||||
{selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
|
||||
<Form.Item name="remark" label="收款确认信息" rules={[{ required: true, message: '请填写收款确认信息' }]}>
|
||||
<TextArea rows={3} placeholder="请填写收款确认信息,如收款账号、收款时间等" />
|
||||
{selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
|
||||
<Form.Item name="remark" label={t('execution.paymentConfirmation')} rules={[{ required: true, message: t('execution.confirmRequired') }]}>
|
||||
<TextArea rows={3} placeholder={t('execution.confirmationPlaceholder')} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="remark" label="备注">
|
||||
<TextArea rows={2} placeholder="可选:填写执行备注" />
|
||||
<Form.Item name="remark" label={t('execution.remark')}>
|
||||
<TextArea rows={2} placeholder={t('execution.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// 退回操作时显示的字段
|
||||
<Form.Item name="rejectReason" label="退回原因" rules={[{ required: true, message: '请填写退回原因' }]}>
|
||||
<TextArea rows={3} placeholder="请填写退回原因" />
|
||||
<Form.Item name="rejectReason" label={t('execution.returnReason')} rules={[{ required: true, message: t('execution.rejectReasonRequired') }]}>
|
||||
<TextArea rows={3} placeholder={t('execution.returnReason')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
@@ -968,12 +990,12 @@ const ExecutionManagement: React.FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal title={`编辑申请:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
|
||||
<Modal title={`${t('execution.edit')}:${selectedRecord?.code}`} open={editModalVisible} onCancel={() => setEditModalVisible(false)} onOk={handleEditSubmit} width={600}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item>
|
||||
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item>
|
||||
<Form.Item name="amount" label="金额" rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
|
||||
<Form.Item label={t('execution.applicationType')}><Input value={selectedRecord?.type} disabled /></Form.Item>
|
||||
<Form.Item label={t('execution.applicant')}><Input value={selectedRecord?.applicant} disabled /></Form.Item>
|
||||
<Form.Item name="amount" label={t('execution.amount')} rules={[{ required: true }]}><Input type="number" style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="reason" label={t('execution.subject')} rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const LoginPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { login } = useAuthStore()
|
||||
const { t } = useLanguageStore()
|
||||
const { t, currentLanguage } = useLanguageStore()
|
||||
|
||||
const handleSubmit = async (values: { username: string; password: string }) => {
|
||||
setLoading(true)
|
||||
@@ -51,10 +51,10 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
const testAccounts = [
|
||||
{ username: 'admin', role: t('user.admin'), name: '系统管理员' },
|
||||
{ username: 'finance', role: t('user.finance'), name: '财务专员' },
|
||||
{ username: 'manager', role: t('user.manager'), name: '项目经理' },
|
||||
{ username: 'employee', role: t('user.employee'), name: '普通员工' }
|
||||
{ username: 'admin', role: t('user.admin'), name: t('common.systemAdmin') },
|
||||
{ username: 'finance', role: t('user.finance'), name: t('user.finance') },
|
||||
{ username: 'manager', role: t('user.manager'), name: t('user.manager') },
|
||||
{ username: 'employee', role: t('user.employee'), name: t('user.employee') }
|
||||
]
|
||||
|
||||
const handleTestLogin = async (username: string) => {
|
||||
@@ -65,7 +65,7 @@ const LoginPage: React.FC = () => {
|
||||
await login(username, QUICK_PASSWORD)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
setError(err instanceof Error ? err.message : t('login.loginFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ const LoginPage: React.FC = () => {
|
||||
border: '2px solid #1890ff'
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, color: '#1890ff', fontWeight: 'bold' }}>
|
||||
🌍 选择语言 / Select Language
|
||||
{t('login.selectLanguage')}
|
||||
</div>
|
||||
<LanguageSelector size="large" style={{ width: '200px' }} />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
import useFormDraft from '../../hooks/useFormDraft';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
@@ -34,9 +35,9 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
|
||||
// 表单草稿保护
|
||||
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
|
||||
form,
|
||||
storageKey: 'budget_project_create',
|
||||
@@ -46,12 +47,10 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// 保存草稿(包含外部状态)
|
||||
const handleFormChange = useCallback(() => {
|
||||
saveDraft({ attachments, surveyPhotos });
|
||||
}, [saveDraft, attachments, surveyPhotos]);
|
||||
|
||||
// 浏览器刷新/关闭拦截
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (form.isFieldsTouched()) {
|
||||
@@ -62,7 +61,6 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [form]);
|
||||
|
||||
// 移动端:页面切到后台时保存草稿
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (document.visibilityState === 'hidden' && form.isFieldsTouched()) {
|
||||
@@ -80,14 +78,13 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
};
|
||||
}, [form, saveDraft, attachments, surveyPhotos]);
|
||||
|
||||
// 页面加载时检查草稿
|
||||
useEffect(() => {
|
||||
if (hasDraft()) {
|
||||
Modal.confirm({
|
||||
title: '发现未完成的草稿',
|
||||
content: '检测到上次未提交的商谈项目,是否恢复?',
|
||||
okText: '恢复草稿',
|
||||
cancelText: '重新填写',
|
||||
title: t('common.draftFound'),
|
||||
content: t('common.draftRestore'),
|
||||
okText: t('common.restoreDraft'),
|
||||
cancelText: t('common.reFill'),
|
||||
onOk: () => {
|
||||
restoreDraft();
|
||||
},
|
||||
@@ -98,17 +95,13 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查权限,如果不是管理员,重定向到列表页面
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
message.error('您没有权限访问此页面');
|
||||
message.error(t('budget.noAccess'));
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
}, [isAdmin, navigate]);
|
||||
|
||||
// const { user: currentUser } = useAuthStore();
|
||||
|
||||
// 表单监听值
|
||||
const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,7 +153,7 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('创建成功');
|
||||
message.success(t('budget.createSuccess'));
|
||||
clearDraft();
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
@@ -168,7 +161,7 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
if (error.response?.data?.error) {
|
||||
message.error(error.response.data.error);
|
||||
} else {
|
||||
message.error('创建失败');
|
||||
message.error(t('budget.createFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -184,10 +177,10 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
onClick={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认离开',
|
||||
content: '表单数据尚未保存,离开后可通过草稿恢复。确定离开吗?',
|
||||
okText: '离开',
|
||||
cancelText: '继续编辑',
|
||||
title: t('budget.leaveConfirm'),
|
||||
content: t('budget.leaveConfirmMsg'),
|
||||
okText: t('budget.leave'),
|
||||
cancelText: t('budget.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft({ attachments, surveyPhotos });
|
||||
navigate('/budget-projects');
|
||||
@@ -198,11 +191,11 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
返回
|
||||
{t('budget.return')}
|
||||
</Button>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}>新建商谈项目</Title>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}>{t('budget.createTitle')}</Title>
|
||||
</div>
|
||||
<Paragraph type="secondary">创建新的商谈项目,添加项目基本信息</Paragraph>
|
||||
<Paragraph type="secondary">{t('budget.createDesc')}</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -212,32 +205,31 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
onValuesChange={handleFormChange}
|
||||
initialValues={{
|
||||
intermediary_fee_type: 'fixed',
|
||||
survey_date: dayjs(), // 勘察日期默认为当天
|
||||
survey_date: dayjs(),
|
||||
attachments: [],
|
||||
survey_photos: []
|
||||
}}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Divider orientation="left">基本信息</Divider>
|
||||
<Divider orientation="left">{t('budget.basicInfo')}</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="项目名称"
|
||||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||||
label={t('budget.projectName')}
|
||||
rules={[{ required: true, message: t('budget.projectNamePlaceholder') }]}
|
||||
>
|
||||
<Input placeholder="请输入项目名称" size="large" />
|
||||
<Input placeholder={t('budget.projectNamePlaceholder')} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="customer_id"
|
||||
label="客户"
|
||||
rules={[{ required: true, message: '请选择客户' }]}
|
||||
label={t('budget.customer')}
|
||||
rules={[{ required: true, message: t('budget.selectCustomer') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择客户"
|
||||
placeholder={t('budget.selectCustomer')}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
size="large"
|
||||
@@ -254,86 +246,83 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="manager_id"
|
||||
label="业务经理"
|
||||
rules={[{ required: true, message: '请选择业务经理' }]}
|
||||
label={t('budget.businessManager')}
|
||||
rules={[{ required: true, message: t('budget.selectManager') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择业务经理"
|
||||
placeholder={t('budget.selectManager')}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
size="large"
|
||||
>
|
||||
{users.map((u) => (
|
||||
<Option key={u.id} value={u.id}>{u.name} ({u.department || '未知部门'})</Option>
|
||||
<Option key={u.id} value={u.id}>{u.name} ({u.department || t('budget.unknownDept')})</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="location" label="项目地点">
|
||||
<Input placeholder="请输入项目地点" size="large" />
|
||||
<Form.Item name="location" label={t('budget.projectLocation')}>
|
||||
<Input placeholder={t('budget.locationPlaceholder')} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="survey_date" label="勘察日期">
|
||||
<Form.Item name="survey_date" label={t('budget.surveyDate')}>
|
||||
<DatePicker style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 居间人信息 */}
|
||||
<Divider orientation="left">居间人信息</Divider>
|
||||
<Divider orientation="left">{t('budget.intermediary')}</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="intermediary" label="居间人">
|
||||
<Input placeholder="请输入居间人姓名" size="large" />
|
||||
<Form.Item name="intermediary" label={t('budget.intermediaryName')}>
|
||||
<Input placeholder={t('budget.intermediaryNamePlaceholder')} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="intermediary_fee_type" label="居间费类型">
|
||||
<Form.Item name="intermediary_fee_type" label={t('budget.intermediaryType')}>
|
||||
<Radio.Group>
|
||||
<Radio value="fixed">固定金额</Radio>
|
||||
<Radio value="percentage">百分比</Radio>
|
||||
<Radio value="fixed">{t('budget.fixedAmount')}</Radio>
|
||||
<Radio value="percentage">{t('budget.percentage')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item
|
||||
name="intermediary_fee_value"
|
||||
label={intermediaryFeeType === 'percentage' ? '居间费比例(%)' : '居间费金额'}
|
||||
label={intermediaryFeeType === 'percentage' ? t('budget.intermediaryRatio') : t('budget.intermediaryAmount')}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
min={0}
|
||||
precision={intermediaryFeeType === 'percentage' ? 2 : 0}
|
||||
placeholder={intermediaryFeeType === 'percentage' ? '输入比例,如:5' : '输入金额'}
|
||||
placeholder={intermediaryFeeType === 'percentage' ? t('budget.intermediaryRatioPlaceholder') : t('budget.intermediaryAmountPlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 项目详情 */}
|
||||
<Divider orientation="left">项目详情</Divider>
|
||||
<Divider orientation="left">{t('budget.projectDetail')}</Divider>
|
||||
|
||||
<Form.Item name="customer_requirements" label="客户要求">
|
||||
<TextArea rows={4} placeholder="请输入客户的具体要求" />
|
||||
<Form.Item name="customer_requirements" label={t('budget.customerRequirement')}>
|
||||
<TextArea rows={4} placeholder={t('budget.requirementPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="project_overview" label="工程概况">
|
||||
<TextArea rows={4} placeholder="请输入工程概况描述" />
|
||||
<Form.Item name="project_overview" label={t('budget.overview')}>
|
||||
<TextArea rows={4} placeholder={t('budget.overviewPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 附件上传 */}
|
||||
<Divider orientation="left">附件</Divider>
|
||||
<Divider orientation="left">{t('budget.attachment')}</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="附件上传">
|
||||
<Form.Item label={t('budget.attachmentUpload')}>
|
||||
<FileUpload
|
||||
value={attachments}
|
||||
onChange={(urls) => { setAttachments(urls); saveDraft({ attachments: urls, surveyPhotos }); }}
|
||||
@@ -342,7 +331,7 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="勘察照片">
|
||||
<Form.Item label={t('budget.surveyPhoto')}>
|
||||
<FileUpload
|
||||
value={surveyPhotos}
|
||||
onChange={(urls) => { setSurveyPhotos(urls); saveDraft({ attachments, surveyPhotos: urls }); }}
|
||||
@@ -352,16 +341,15 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button onClick={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认离开',
|
||||
content: '表单数据尚未保存,离开后可通过草稿恢复。确定离开吗?',
|
||||
okText: '离开',
|
||||
cancelText: '继续编辑',
|
||||
title: t('budget.leaveConfirm'),
|
||||
content: t('budget.leaveConfirmMsg'),
|
||||
okText: t('budget.leave'),
|
||||
cancelText: t('budget.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft({ attachments, surveyPhotos });
|
||||
navigate('/budget-projects');
|
||||
@@ -370,14 +358,14 @@ const BudgetProjectCreate: React.FC = () => {
|
||||
} else {
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
}}>取消</Button>
|
||||
}}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
保存
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, Avatar, Badge, Modal, Input } from 'antd';
|
||||
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, Modal, Input } from 'antd';
|
||||
import { ArrowLeftOutlined, EyeOutlined, FileAddOutlined, FileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import apiClient from '../../utils/request';
|
||||
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
|
||||
import QuotationCreateModal from './QuotationCreateModal';
|
||||
import ContractCreateModal from './ContractCreateModal';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
@@ -65,6 +66,7 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const isAdmin = currentUser?.role === 'admin' || false;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,7 +83,6 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
const res = await apiClient.get(`/budget-projects/${id}`);
|
||||
if (res.data.success) {
|
||||
const projectData = res.data.data;
|
||||
// 后端已经解析了数据,直接使用
|
||||
projectData.quotations = Array.isArray(projectData.quotations) ? projectData.quotations : [];
|
||||
projectData.attachments = Array.isArray(projectData.attachments) ? projectData.attachments : [];
|
||||
projectData.survey_photos = Array.isArray(projectData.survey_photos) ? projectData.survey_photos : [];
|
||||
@@ -89,7 +90,7 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目详情失败:', error);
|
||||
message.error('获取数据失败');
|
||||
message.error(t('budget.getDataFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -97,9 +98,9 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
negotiating: { color: 'processing', text: '商谈中' },
|
||||
signed: { color: 'success', text: '已签约' },
|
||||
unsigned: { color: 'error', text: '未签约' },
|
||||
negotiating: { color: 'processing', text: t('budget.inNegotiation') },
|
||||
signed: { color: 'success', text: t('budget.signed') },
|
||||
unsigned: { color: 'error', text: t('budget.unsigned') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -107,10 +108,10 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
|
||||
const getQuotationStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
draft: { color: 'default', text: '草稿' },
|
||||
sent: { color: 'processing', text: '已发送' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已拒绝' },
|
||||
draft: { color: 'default', text: t('budget.draft') },
|
||||
sent: { color: 'processing', text: t('budget.sent') },
|
||||
approved: { color: 'success', text: t('budget.approved') },
|
||||
rejected: { color: 'error', text: t('budget.rejected') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -142,11 +143,11 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('标记未签约成功');
|
||||
message.success(t('budget.signedSuccess'));
|
||||
fetchProjectDetail();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
message.error(t('common.operationFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,9 +160,8 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
const handleQuotationDeleteConfirm = async () => {
|
||||
if (!project || !quotationDeleteId) return;
|
||||
|
||||
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
message.error(t('budget.passwordError'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,12 +173,12 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
message.success(t('budget.deleteSuccess'));
|
||||
setQuotationDeleteModalVisible(false);
|
||||
fetchProjectDetail();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
message.error(t('common.deleteFailed'));
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -210,9 +210,8 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
const handleProjectDeleteConfirm = async () => {
|
||||
if (!project) return;
|
||||
|
||||
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
message.error(t('budget.passwordError'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,12 +223,12 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
message.success(t('budget.deleteSuccess'));
|
||||
setDeleteModalVisible(false);
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
message.error(t('common.deleteFailed'));
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -247,9 +246,9 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Empty description="项目不存在" />
|
||||
<Empty description={t('budget.notFound')} />
|
||||
<Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}>
|
||||
返回列表
|
||||
{t('budget.return')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -264,60 +263,58 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/budget-projects')}
|
||||
>
|
||||
返回列表
|
||||
{t('budget.return')}
|
||||
</Button>
|
||||
<Title level={2} style={{ marginBottom: 0 }}>预算项目详情</Title>
|
||||
<Title level={2} style={{ marginBottom: 0 }}>{t('budget.detailTitle')}</Title>
|
||||
</div>
|
||||
<Paragraph type="secondary">查看项目详细信息和报价版本</Paragraph>
|
||||
<Paragraph type="secondary">{t('budget.detailDesc')}</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 项目基本信息 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Title level={4}>项目信息</Title>
|
||||
<Title level={4}>{t('budget.projectInfo')}</Title>
|
||||
<Divider />
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="业务经理">{project.manager_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="勘察日期">{project.survey_date || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{getStatusTag(project.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.projectName')}>{project.name}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.customer')}>{project.customer_name}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.businessManager')}>{project.manager_name}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.projectLocation')}>{project.location || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.surveyDate')}>{project.survey_date || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.status')}>{getStatusTag(project.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.create')}>{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="居间人">{project.intermediary || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="居间费类型">
|
||||
{project.intermediary_fee_type === 'fixed' ? '固定金额' : project.intermediary_fee_type === 'percentage' ? '百分比' : '-'}
|
||||
<Descriptions.Item label={t('budget.intermediaryName')}>{project.intermediary || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.intermediaryType')}>
|
||||
{project.intermediary_fee_type === 'fixed' ? t('budget.fixedAmount') : project.intermediary_fee_type === 'percentage' ? t('budget.percentage') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="居间费">
|
||||
<Descriptions.Item label={t('budget.intermediaryAmount')}>
|
||||
{project.intermediary_fee_value ?
|
||||
project.intermediary_fee_type === 'percentage' ?
|
||||
`${project.intermediary_fee_value}%` :
|
||||
formatAmount(project.intermediary_fee_value, 'CNY')
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户要求">{project.customer_requirements || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="工程概况">{project.project_overview || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.customerRequirement')}>{project.customer_requirements || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('budget.overview')}>{project.project_overview || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 附件和照片 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<Title level={4}>附件和照片</Title>
|
||||
<Title level={4}>{t('budget.attachment')}</Title>
|
||||
<Divider />
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>附件上传:</Text>
|
||||
<Text strong>{t('budget.attachmentUpload')}:</Text>
|
||||
{project.attachments && project.attachments.length > 0 ? (
|
||||
<List
|
||||
style={{ marginTop: 8 }}
|
||||
@@ -342,7 +339,7 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handleView}
|
||||
>
|
||||
查看
|
||||
{t('common.view')}
|
||||
</Button>
|
||||
</Space>
|
||||
</List.Item>
|
||||
@@ -350,14 +347,14 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>暂无附件</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{t('budget.noAttachment')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong>勘察照片:</Text>
|
||||
<Text strong>{t('budget.surveyPhoto')}:</Text>
|
||||
{project.survey_photos && project.survey_photos.length > 0 ? (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{project.survey_photos.map((url, index) => (
|
||||
@@ -369,30 +366,29 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0, 0, 0, 0.5)', color: '#fff', padding: 4, fontSize: 12, textAlign: 'center' }}>
|
||||
照片 {index + 1}
|
||||
{t('budget.photos')}{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>暂无勘察照片</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{t('budget.noPhotos')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 报价版本列表 */}
|
||||
<Card style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title level={4}>报价版本</Title>
|
||||
<Title level={4}>{t('budget.quotationVersions')}</Title>
|
||||
{isAdmin && project.status === 'negotiating' && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileAddOutlined />}
|
||||
onClick={openQuotationModal}
|
||||
>
|
||||
新增报价版本
|
||||
{t('budget.addVersion')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -424,7 +420,7 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
onClick={handleViewFile}
|
||||
disabled={!quotation.file_url}
|
||||
>
|
||||
查看
|
||||
{t('common.view')}
|
||||
</Button>,
|
||||
isAdmin && (
|
||||
<Button
|
||||
@@ -432,24 +428,24 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
danger
|
||||
onClick={() => handleDeleteQuotation(quotation.id)}
|
||||
>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)
|
||||
].filter(Boolean)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar style={{ backgroundColor: '#1890ff' }}>V{quotation.version}</Avatar>}
|
||||
avatar={<div style={{ width: 40, height: 40, borderRadius: '50%', backgroundColor: '#1890ff', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 'bold' }}>V{quotation.version}</div>}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>报价V{quotation.version}</Text>
|
||||
<Text strong>{t('budget.version')}V{quotation.version}</Text>
|
||||
{getQuotationStatusTag(quotation.status)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<Text>报价日期: {dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
|
||||
<Text>报价金额: {formatAmount(quotation.amount, quotation.currency)}</Text>
|
||||
{quotation.remark && <Text>备注: {quotation.remark}</Text>}
|
||||
<Text>{t('budget.versionDate')}{dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
|
||||
<Text>{t('budget.versionAmount')}{formatAmount(quotation.amount, quotation.currency)}</Text>
|
||||
{quotation.remark && <Text>{t('budget.versionRemark')}{quotation.remark}</Text>}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
@@ -458,13 +454,12 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无报价版本" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
<Empty description={t('budget.noVersion')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Card>
|
||||
<Title level={4}>操作</Title>
|
||||
<Title level={4}>{t('common.action')}</Title>
|
||||
<Divider />
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
@@ -475,14 +470,14 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={handleSign}
|
||||
>
|
||||
标记签约
|
||||
{t('budget.markSigned')}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleUnsigned}
|
||||
>
|
||||
标记未签约
|
||||
{t('budget.markUnsigned')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -492,7 +487,7 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
type="primary"
|
||||
onClick={goToProjectManagement}
|
||||
>
|
||||
进入项目管理
|
||||
{t('budget.enterProject')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -501,13 +496,12 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
danger
|
||||
onClick={handleDeleteProject}
|
||||
>
|
||||
删除项目
|
||||
{t('budget.deleteProject')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 新增报价版本弹窗 */}
|
||||
<QuotationCreateModal
|
||||
visible={quotationModalVisible}
|
||||
project={project}
|
||||
@@ -515,7 +509,6 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
onSuccess={handleQuotationSuccess}
|
||||
/>
|
||||
|
||||
{/* 合同信息录入弹窗 */}
|
||||
<ContractCreateModal
|
||||
visible={contractModalVisible}
|
||||
projectId={project?.id || 0}
|
||||
@@ -524,44 +517,42 @@ const BudgetProjectDetail: React.FC = () => {
|
||||
onSuccess={handleContractSuccess}
|
||||
/>
|
||||
|
||||
{/* 删除项目确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
title={t('budget.deleteConfirm')}
|
||||
open={deleteModalVisible}
|
||||
onOk={handleProjectDeleteConfirm}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okText={t('common.deleteConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个预算项目吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
<p>{t('budget.deleteConfirmMsg')}</p>
|
||||
<p>{t('budget.deletePassMsg')}</p>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员密码"
|
||||
placeholder={t('budget.deletePass')}
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 删除报价版本确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
title={t('budget.deleteConfirm')}
|
||||
open={quotationDeleteModalVisible}
|
||||
onOk={handleQuotationDeleteConfirm}
|
||||
onCancel={() => setQuotationDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okText={t('common.deleteConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个报价版本吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
<p>{t('budget.versionDeleteConfirm')}</p>
|
||||
<p>{t('budget.deletePassMsg')}</p>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员密码"
|
||||
placeholder={t('budget.deletePass')}
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
size="large"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
@@ -63,6 +64,7 @@ const BudgetProjectList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const isAdmin = currentUser?.role === 'admin' || false;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,7 +83,6 @@ const BudgetProjectList: React.FC = () => {
|
||||
try {
|
||||
const res = await apiClient.get('/budget-projects');
|
||||
if (res.data.success) {
|
||||
// 后端已经解析了数据,直接使用
|
||||
const projectsWithParsedData = res.data.data.map((project: any) => {
|
||||
return {
|
||||
...project,
|
||||
@@ -94,7 +95,7 @@ const BudgetProjectList: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预算项目失败:', error);
|
||||
message.error('获取数据失败');
|
||||
message.error(t('budget.getDataFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -106,9 +107,9 @@ const BudgetProjectList: React.FC = () => {
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
negotiating: { color: 'processing', text: '商谈中' },
|
||||
signed: { color: 'success', text: '已签约' },
|
||||
unsigned: { color: 'error', text: '未签约' },
|
||||
negotiating: { color: 'processing', text: t('budget.inNegotiation') },
|
||||
signed: { color: 'success', text: t('budget.signed') },
|
||||
unsigned: { color: 'error', text: t('budget.unsigned') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -116,10 +117,10 @@ const BudgetProjectList: React.FC = () => {
|
||||
|
||||
const getQuotationStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
draft: { color: 'default', text: '草稿' },
|
||||
sent: { color: 'processing', text: '已发送' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已拒绝' },
|
||||
draft: { color: 'default', text: t('budget.draft') },
|
||||
sent: { color: 'processing', text: t('budget.sent') },
|
||||
approved: { color: 'success', text: t('budget.approved') },
|
||||
rejected: { color: 'error', text: t('budget.rejected') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
@@ -140,9 +141,8 @@ const BudgetProjectList: React.FC = () => {
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteProjectId) return;
|
||||
|
||||
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
message.error(t('budget.passwordError'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,12 +154,12 @@ const BudgetProjectList: React.FC = () => {
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
message.success(t('budget.deleteSuccess'));
|
||||
setDeleteModalVisible(false);
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
message.error(t('common.deleteFailed'));
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -170,8 +170,8 @@ const BudgetProjectList: React.FC = () => {
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>预算报价管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>管理商谈项目及报价版本</Paragraph>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('budget.title')}</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('budget.description')}</Paragraph>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
@@ -180,34 +180,32 @@ const BudgetProjectList: React.FC = () => {
|
||||
onClick={() => navigate('/budget-projects/create')}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
新建商谈项目
|
||||
{t('budget.newProject')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Text strong>状态筛选:</Text>
|
||||
<Text strong>{t('budget.statusFilter')}</Text>
|
||||
<Radio.Group
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="negotiating">商谈中</Radio.Button>
|
||||
<Radio.Button value="signed">已签约</Radio.Button>
|
||||
<Radio.Button value="unsigned">未签约</Radio.Button>
|
||||
<Radio.Button value="all">{t('common.all')}</Radio.Button>
|
||||
<Radio.Button value="negotiating">{t('budget.inNegotiation')}</Radio.Button>
|
||||
<Radio.Button value="signed">{t('budget.signed')}</Radio.Button>
|
||||
<Radio.Button value="unsigned">{t('budget.unsigned')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<Card loading={loading}>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<Empty description="暂无数据" />
|
||||
<Empty description={t('common.noData')} />
|
||||
) : (
|
||||
<div>
|
||||
{filteredProjects.map((project) => (
|
||||
@@ -220,7 +218,6 @@ const BudgetProjectList: React.FC = () => {
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 项目头部 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
@@ -236,7 +233,7 @@ const BudgetProjectList: React.FC = () => {
|
||||
</Space>
|
||||
<Space>
|
||||
{getStatusTag(project.status)}
|
||||
<Text type="secondary">{project.days_in_status}天</Text>
|
||||
<Text type="secondary">{project.days_in_status}{t('common.days')}</Text>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
danger
|
||||
@@ -246,7 +243,7 @@ const BudgetProjectList: React.FC = () => {
|
||||
handleDeleteProject(project.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
@@ -254,13 +251,13 @@ const BudgetProjectList: React.FC = () => {
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text type="secondary">客户: {project.customer_name}</Text>
|
||||
<Text type="secondary">业务经理: {project.manager_name}</Text>
|
||||
<Text type="secondary">{t('budget.customerLabel')}{project.customer_name}</Text>
|
||||
<Text type="secondary">{t('budget.managerLabel')}{project.manager_name}</Text>
|
||||
{project.intermediary && (
|
||||
<Text type="secondary">
|
||||
居间人: {project.intermediary}
|
||||
{t('budget.intermediaryLabel')}{project.intermediary}
|
||||
{project.intermediary_fee_value && (
|
||||
<span> 居间费: {formatAmount(project.intermediary_fee_value, 'CNY')}</span>
|
||||
<span> {t('budget.intermediaryFee')}{formatAmount(project.intermediary_fee_value, 'CNY')}</span>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
@@ -275,22 +272,21 @@ const BudgetProjectList: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
title={t('budget.deleteConfirm')}
|
||||
open={deleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okText={t('common.deleteConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个预算项目吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
<p>{t('budget.deleteConfirmMsg')}</p>
|
||||
<p>{t('budget.deletePassMsg')}</p>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder="请输入管理员密码"
|
||||
placeholder={t('budget.deletePass')}
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
size="large"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Form, Input, DatePicker, InputNumber, Select, Space, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import apiClient from '../../utils/request';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
import useFormDraft from '../../hooks/useFormDraft';
|
||||
|
||||
interface ContractCreateModalProps {
|
||||
@@ -21,8 +22,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [contractAmount, setContractAmount] = useState(0);
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
// 表单草稿保护
|
||||
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
|
||||
form,
|
||||
storageKey: 'contract_create',
|
||||
@@ -32,7 +33,6 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
saveDraft()
|
||||
}, [saveDraft])
|
||||
|
||||
// 生成默认的合同编号(包含时间戳确保唯一性)
|
||||
const today = dayjs();
|
||||
const dateStr = today.format('YYYYMMDD');
|
||||
const timeStr = today.format('HHmmss');
|
||||
@@ -52,14 +52,10 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
}
|
||||
}, [visible, form, projectName]);
|
||||
|
||||
// 处理工期变化
|
||||
const handlePeriodChange = (value: number) => {
|
||||
// 只需要设置工期天数,不需要计算开始和结束日期
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values: any) => {
|
||||
// 构建提交数据(简化版)
|
||||
const submitData = {
|
||||
contract_code: values.contract_code,
|
||||
project_name: values.project_name,
|
||||
@@ -67,9 +63,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
currency: values.currency || 'CNY',
|
||||
contract_amount: values.contract_amount || 0,
|
||||
contract_period: values.contract_period || 180,
|
||||
warranty_deposit_percentage: 5, // 默认5%
|
||||
warranty_period: 12, // 默认12个月
|
||||
// 其他字段留空,后续在项目管理中补充
|
||||
warranty_deposit_percentage: 5,
|
||||
warranty_period: 12,
|
||||
project_overview: '',
|
||||
other_requirements: '',
|
||||
contract_file: null,
|
||||
@@ -81,39 +76,39 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
try {
|
||||
const res = await apiClient.put(`/budget-projects/${projectId}/sign`, submitData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 签约操作需要管理员权限
|
||||
'x-user-role': 'admin'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (res.data.success) {
|
||||
message.success('签约成功,项目已自动创建');
|
||||
message.success(t('budget.quickSignSuccess'));
|
||||
clearDraft()
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(res.data.message || '操作失败');
|
||||
message.error(res.data.message || t('common.operationFailed'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('签约失败:', error);
|
||||
console.error('错误响应:', error.response);
|
||||
const errorMessage = error.response?.data?.message || error.message || '操作失败';
|
||||
const errorMessage = error.response?.data?.message || error.message || t('common.operationFailed');
|
||||
message.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="快速签约"
|
||||
title={t('budget.quickSign')}
|
||||
open={visible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft()
|
||||
onCancel()
|
||||
@@ -124,8 +119,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
}
|
||||
}}
|
||||
width={600}
|
||||
okText="确认签约"
|
||||
cancelText="取消"
|
||||
okText={t('budget.markSigned')}
|
||||
cancelText={t('common.cancel')}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form
|
||||
@@ -134,44 +129,43 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
onFinish={handleSubmit}
|
||||
onValuesChange={handleFormChange}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Form.Item
|
||||
name="contract_code"
|
||||
label="合同编号"
|
||||
rules={[{ required: true, message: '请输入合同编号' }]}
|
||||
label={t('budget.contractNo')}
|
||||
rules={[{ required: true, message: t('budget.contractNoPlaceholder') }]}
|
||||
>
|
||||
<Input placeholder="请输入合同编号" />
|
||||
<Input placeholder={t('budget.contractNoPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="project_name"
|
||||
label="项目名称"
|
||||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||||
label={t('budget.projectName')}
|
||||
rules={[{ required: true, message: t('budget.projectNamePlaceholder') }]}
|
||||
>
|
||||
<Input placeholder="请输入项目名称" />
|
||||
<Input placeholder={t('budget.projectNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contract_method"
|
||||
label="承包方式"
|
||||
rules={[{ required: true, message: '请选择承包方式' }]}
|
||||
label={t('budget.contractType')}
|
||||
rules={[{ required: true, message: t('budget.selectContractType') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择承包方式"
|
||||
placeholder={t('budget.selectContractType')}
|
||||
options={[
|
||||
{ value: 'lump_sum', label: '总价包干' },
|
||||
{ value: 'unit_price', label: '单价结算' }
|
||||
{ value: 'lump_sum', label: t('budget.fixedAmount') },
|
||||
{ value: 'unit_price', label: t('budget.percentage') }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
label={t('common.currency')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择币种"
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={[
|
||||
{ value: 'CNY', label: '人民币' },
|
||||
{ value: 'USD', label: '美元' },
|
||||
@@ -183,41 +177,40 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
|
||||
<Form.Item
|
||||
name="contract_amount"
|
||||
label="总价"
|
||||
label={t('budget.totalPrice')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入总价'
|
||||
message: t('budget.totalPricePlaceholder')
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
placeholder="请输入总价"
|
||||
placeholder={t('budget.totalPricePlaceholder')}
|
||||
formatter={(value) => `¥ ${value}`}
|
||||
parser={(value) => value.replace(/¥\s?|(,*)/g, '')}
|
||||
onChange={(value) => setContractAmount(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 工期 */}
|
||||
<Form.Item
|
||||
name="contract_period"
|
||||
label="工期(天)"
|
||||
rules={[{ required: true, message: '请输入工期' }]}
|
||||
label={t('budget.durationDays')}
|
||||
rules={[{ required: true, message: t('budget.durationPlaceholder') }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={1}
|
||||
placeholder="请输入工期(天)"
|
||||
placeholder={t('budget.durationDaysPlaceholder')}
|
||||
onChange={handlePeriodChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginTop: 16, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 14, color: '#666' }}>
|
||||
注:此为快速签约流程,仅录入基本信息。详细的合同信息可在项目管理中补充。
|
||||
{t('budget.quickSignNote')}
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Modal, Form, Input, DatePicker, InputNumber, Select, Upload, Button, me
|
||||
import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import apiClient from '../../utils/request';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
import useFormDraft from '../../hooks/useFormDraft';
|
||||
|
||||
const { Option } = Select;
|
||||
@@ -47,8 +48,8 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null);
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
// 表单草稿保护
|
||||
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
|
||||
form,
|
||||
storageKey: 'quotation_create',
|
||||
@@ -61,7 +62,6 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
saveDraft({ uploadedFile })
|
||||
}, [saveDraft, uploadedFile])
|
||||
|
||||
// 计算下一个版本号
|
||||
const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0
|
||||
? Math.max(...project.quotations.map(q => q.version || 0)) + 1
|
||||
: 1;
|
||||
@@ -85,23 +85,33 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
let authHeader = '';
|
||||
try {
|
||||
const authStorage = localStorage.getItem('auth-storage');
|
||||
if (authStorage) {
|
||||
const parsed = JSON.parse(authStorage);
|
||||
const token = parsed?.state?.token;
|
||||
if (token) authHeader = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
const response = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: authHeader ? { Authorization: authHeader } : {},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
message.success('上传成功');
|
||||
message.success(t('budget.uploadSuccess'));
|
||||
setUploadedFile({ url: result.data.url, name: file.name });
|
||||
onUploadSuccess(result.data, file);
|
||||
} else {
|
||||
message.error(result.error || '上传失败');
|
||||
message.error(result.error || t('common.operationFailed'));
|
||||
onError?.(new Error(result.error));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('上传失败');
|
||||
message.error(t('common.operationFailed'));
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
@@ -126,11 +136,11 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
|
||||
const res = await apiClient.post(`/budget-projects/${project.id}/quotations`, quotationData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 创建报价版本需要管理员权限
|
||||
'x-user-role': 'admin'
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('新增报价版本成功');
|
||||
message.success(t('budget.addVersion'));
|
||||
clearDraft()
|
||||
onSuccess();
|
||||
}
|
||||
@@ -138,7 +148,7 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
if (error.response?.data?.error) {
|
||||
message.error(error.response.data.error);
|
||||
} else {
|
||||
message.error('创建失败');
|
||||
message.error(t('budget.createFailed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -164,16 +174,16 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新增报价版本"
|
||||
title={t('budget.newQuotation')}
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => {
|
||||
if (form.isFieldsTouched()) {
|
||||
Modal.confirm({
|
||||
title: '确认关闭',
|
||||
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '继续编辑',
|
||||
title: t('common.closeConfirm'),
|
||||
content: t('common.closeConfirmMsg'),
|
||||
okText: t('common.close'),
|
||||
cancelText: t('common.continueEdit'),
|
||||
onOk: () => {
|
||||
saveDraft({ uploadedFile })
|
||||
onCancel()
|
||||
@@ -185,12 +195,11 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
}}
|
||||
width={600}
|
||||
confirmLoading={loading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormChange}>
|
||||
{/* 项目信息展示 */}
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
@@ -198,46 +207,46 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{ color: '#666' }}>项目名称: </span>
|
||||
<span style={{ color: '#666' }}>{t('budget.projectLabel')}</span>
|
||||
<span style={{ fontWeight: 500 }}>{project?.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: '#666' }}>当前版本: </span>
|
||||
<span style={{ color: '#666' }}>{t('budget.version')}</span>
|
||||
<span style={{ fontWeight: 500 }}>V{nextVersion - 1}</span>
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>
|
||||
(新创建将为 V{nextVersion})
|
||||
({t('budget.newQuotation')} V{nextVersion})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="quotation_date"
|
||||
label="报价日期"
|
||||
rules={[{ required: true, message: '请选择报价日期' }]}
|
||||
label={t('budget.quotationDate')}
|
||||
rules={[{ required: true, message: t('budget.selectDate') }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="报价金额"
|
||||
rules={[{ required: true, message: '请输入报价金额' }]}
|
||||
label={t('budget.quotationAmount')}
|
||||
rules={[{ required: true, message: t('budget.amountPlaceholder') }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
precision={2}
|
||||
placeholder="请输入报价金额"
|
||||
addonAfter="元"
|
||||
placeholder={t('budget.amountPlaceholder')}
|
||||
addonAfter={t('common.yuan')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
label={t('common.currency')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<Select placeholder="请选择币种">
|
||||
<Select placeholder={t('common.pleaseSelect')}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<Option key={c.value} value={c.value}>
|
||||
{c.label} ({c.symbol})
|
||||
@@ -246,14 +255,14 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="报价文件">
|
||||
<Form.Item label={t('budget.quotationFile')}>
|
||||
{uploadedFile ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{getFileIcon()}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>{uploadedFile.name}</div>
|
||||
<a href={uploadedFile.url} target="_blank" rel="noopener noreferrer">
|
||||
查看文件
|
||||
{t('budget.viewFile')}
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
@@ -262,7 +271,7 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
onClick={handleRemoveFile}
|
||||
size="small"
|
||||
>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -272,13 +281,13 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
showUploadList={false}
|
||||
maxCount={1}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传文件</Button>
|
||||
<Button icon={<UploadOutlined />}>{t('budget.uploadFile')}</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="请输入备注信息" />
|
||||
<Form.Item name="remark" label={t('common.remark')}>
|
||||
<Input.TextArea rows={3} placeholder={t('budget.remarkPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -5,25 +5,16 @@ import { useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 天气图标映射
|
||||
const WEATHER_ICONS: Record<string, string> = {
|
||||
sunny: '☀️ 晴',
|
||||
cloudy: '⛅ 多云',
|
||||
rainy: '🌧️ 雨',
|
||||
stormy: '⛈️ 雷暴',
|
||||
windy: '💨 大风',
|
||||
};
|
||||
|
||||
// 项目状态映射
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待开始' },
|
||||
active: { color: 'processing', text: '施工中' },
|
||||
completed: { color: 'success', text: '完工' },
|
||||
suspended: { color: 'warning', text: '暂停' },
|
||||
cancelled: { color: 'error', text: '已取消' },
|
||||
sunny: '☀️',
|
||||
cloudy: '⛅',
|
||||
rainy: '🌧️',
|
||||
stormy: '⛈️',
|
||||
windy: '💨',
|
||||
};
|
||||
|
||||
interface Project {
|
||||
@@ -52,6 +43,15 @@ const ConstructionList: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: t('construction.pendingStart') },
|
||||
active: { color: 'processing', text: t('construction.underConstruction') },
|
||||
completed: { color: 'success', text: t('construction.completed') },
|
||||
suspended: { color: 'warning', text: t('construction.paused') },
|
||||
cancelled: { color: 'error', text: t('construction.cancelled') },
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
@@ -73,7 +73,7 @@ const ConstructionList: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
message.error('获取项目列表失败');
|
||||
message.error(t('construction.getListFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -94,6 +94,18 @@ const ConstructionList: React.FC = () => {
|
||||
return dayjs(dateStr).isSame(dayjs(), 'day');
|
||||
};
|
||||
|
||||
const getWeatherLabel = (weather: string) => {
|
||||
const icon = WEATHER_ICONS[weather] || '';
|
||||
const textMap: Record<string, string> = {
|
||||
sunny: t('construction.sunny'),
|
||||
cloudy: t('construction.cloudy'),
|
||||
rainy: t('construction.rain'),
|
||||
stormy: t('construction.thunderstorm'),
|
||||
windy: t('construction.windy'),
|
||||
};
|
||||
return `${icon} ${textMap[weather] || weather}`;
|
||||
};
|
||||
|
||||
const renderProjectCard = (project: Project) => {
|
||||
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
|
||||
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date);
|
||||
@@ -108,7 +120,6 @@ const ConstructionList: React.FC = () => {
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
||||
>
|
||||
{/* 项目头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
@@ -116,7 +127,7 @@ const ConstructionList: React.FC = () => {
|
||||
<Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
客户: {project.customer_name || '未指定'}
|
||||
{t('construction.customerLabel')}{project.customer_name || t('common.notSet')}
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
|
||||
@@ -124,10 +135,9 @@ const ConstructionList: React.FC = () => {
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>施工进度</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.progress')}</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{Math.round((project.progress_percentage || 0))}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
@@ -141,7 +151,6 @@ const ConstructionList: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最新日志状态 */}
|
||||
{project.status === 'active' && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
@@ -156,13 +165,13 @@ const ConstructionList: React.FC = () => {
|
||||
<>
|
||||
<span>✅</span>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
今日日志: {project.latest_log?.work_content?.substring(0, 30)}...
|
||||
{t('construction.todayLog')}{project.latest_log?.work_content?.substring(0, 30)}...
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>⚠️</span>
|
||||
<Text type="warning" style={{ fontSize: 13 }}>今日日志: 未填写</Text>
|
||||
<Text type="warning" style={{ fontSize: 13 }}>{t('construction.todayLogEmpty')}</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -170,7 +179,6 @@ const ConstructionList: React.FC = () => {
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
@@ -181,7 +189,7 @@ const ConstructionList: React.FC = () => {
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
{project.status === 'active' && !hasTodayLog ? '📝 写今日日志' : '📝 施工日志'}
|
||||
{project.status === 'active' && !hasTodayLog ? `📝 ${t('construction.writeLog')}` : `📝 ${t('construction.constructionLog')}`}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
@@ -192,7 +200,7 @@ const ConstructionList: React.FC = () => {
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📷 上传照片
|
||||
{`📷 ${t('construction.uploadPhoto')}`}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
@@ -203,7 +211,7 @@ const ConstructionList: React.FC = () => {
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📋 节点进度
|
||||
{`📋 ${t('construction.milestoneProgress')}`}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -217,42 +225,40 @@ const ConstructionList: React.FC = () => {
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}>施工管理</Title>
|
||||
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}>{t('construction.management')}</Title>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchProjects}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
查看和管理您的施工项目
|
||||
{t('construction.description')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>加载中...</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>{t('common.loading')}</Paragraph>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty
|
||||
description="暂无施工项目"
|
||||
description={t('construction.noConstructionProjects')}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Text type="secondary">请联系管理员为您分配施工项目</Text>
|
||||
<Text type="secondary">{t('construction.contactAdmin')}</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
|
||||
我的施工项目 ({projects.length})
|
||||
{t('construction.myProjects')} ({projects.length})
|
||||
</Text>
|
||||
{projects.map(project => renderProjectCard(project))}
|
||||
</div>
|
||||
|
||||
@@ -11,18 +11,18 @@ import {
|
||||
import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
// 天气选项
|
||||
const WEATHER_OPTIONS = [
|
||||
{ value: 'sunny', label: '☀️ 晴', icon: '☀️' },
|
||||
{ value: 'cloudy', label: '⛅ 多云', icon: '⛅' },
|
||||
{ value: 'rainy', label: '🌧️ 雨', icon: '🌧️' },
|
||||
{ value: 'stormy', label: '⛈️ 雷暴', icon: '⛈️' },
|
||||
{ value: 'windy', label: '💨 大风', icon: '💨' },
|
||||
{ value: 'sunny', icon: '☀️' },
|
||||
{ value: 'cloudy', icon: '⛅' },
|
||||
{ value: 'rainy', icon: '🌧️' },
|
||||
{ value: 'stormy', icon: '⛈️' },
|
||||
{ value: 'windy', icon: '💨' },
|
||||
];
|
||||
|
||||
interface Log {
|
||||
@@ -57,6 +57,7 @@ const ConstructionLog: React.FC = () => {
|
||||
const [projectInfo, setProjectInfo] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAuthStore();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
@@ -81,7 +82,7 @@ const ConstructionLog: React.FC = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取日志列表失败:', error);
|
||||
message.error('获取日志列表失败');
|
||||
message.error(t('construction.getLogFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -107,18 +108,18 @@ const ConstructionLog: React.FC = () => {
|
||||
log_date: values.log_date.format('YYYY-MM-DD'),
|
||||
weather: values.weather,
|
||||
work_content: values.work_content,
|
||||
photos: '', // 暂时为空,后续添加照片上传功能
|
||||
photos: '',
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
message.success('日志添加成功');
|
||||
message.success(t('construction.logAddSuccess'));
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加日志失败:', error);
|
||||
message.error('添加日志失败');
|
||||
message.error(t('construction.logAddFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -128,18 +129,17 @@ const ConstructionLog: React.FC = () => {
|
||||
try {
|
||||
const res = await apiClient.delete(`/construction-logs/${logId}`);
|
||||
if (res.data.success) {
|
||||
message.success('日志删除成功');
|
||||
message.success(t('construction.logDeleteSuccess'));
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除日志失败:', error);
|
||||
message.error('删除日志失败');
|
||||
message.error(t('construction.logDeleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 按日期分组
|
||||
const groupedLogs = logs.reduce((acc, log) => {
|
||||
const month = dayjs(log.log_date).format('YYYY年MM月');
|
||||
const month = dayjs(log.log_date).format(t('construction.yearMonth'));
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
@@ -149,7 +149,16 @@ const ConstructionLog: React.FC = () => {
|
||||
|
||||
const getWeatherLabel = (value: string) => {
|
||||
const option = WEATHER_OPTIONS.find(o => o.value === value);
|
||||
return option ? option.label : value;
|
||||
const textMap: Record<string, string> = {
|
||||
sunny: t('construction.sunny'),
|
||||
cloudy: t('construction.cloudy'),
|
||||
rainy: t('construction.rain'),
|
||||
stormy: t('construction.thunderstorm'),
|
||||
windy: t('construction.windy'),
|
||||
};
|
||||
const icon = option ? option.icon : '';
|
||||
const text = textMap[value] || value;
|
||||
return `${icon} ${text}`;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
@@ -168,62 +177,57 @@ const ConstructionLog: React.FC = () => {
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
||||
>
|
||||
{/* 日志头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Space>
|
||||
<CalendarOutlined style={{ color: '#1890ff' }} />
|
||||
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format('MM月DD日')}</Text>
|
||||
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format(t('construction.monthDay'))}</Text>
|
||||
<Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>记录人: {log.recorder_name || '未知'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.recorderLabel')}{log.recorder_name || t('common.unknown')}</Text>
|
||||
<Popconfirm
|
||||
title="确定删除此日志?"
|
||||
description="删除后无法恢复"
|
||||
title={t('construction.deleteLogConfirmTitle')}
|
||||
description={t('construction.deleteLogConfirmDesc')}
|
||||
onConfirm={() => handleDeleteLog(log.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 工作内容 */}
|
||||
{log.work_content && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>今日工作:</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.todayWorkLabel')}</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
||||
{log.work_content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 明日计划 */}
|
||||
{log.next_plan && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>明日计划:</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.tomorrowPlanLabel')}</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
||||
{log.next_plan}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 问题记录 */}
|
||||
{log.issues && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>问题记录:</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.issueRecordLabel')}</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
|
||||
{log.issues}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 照片展示 */}
|
||||
{log.photos && log.photos.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
|
||||
施工照片 ({log.photos.length}张):
|
||||
{t('construction.constructionPhoto')} ({log.photos.length}{t('common.sheet')}):
|
||||
</Text>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
@@ -266,7 +270,6 @@ const ConstructionLog: React.FC = () => {
|
||||
maxWidth: 800,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Button
|
||||
@@ -275,7 +278,7 @@ const ConstructionLog: React.FC = () => {
|
||||
/>
|
||||
<div>
|
||||
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
||||
施工日志
|
||||
{t('construction.constructionLog')}
|
||||
</Title>
|
||||
{projectInfo && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
@@ -286,16 +289,15 @@ const ConstructionLog: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty description="暂无施工日志">
|
||||
<Empty description={t('construction.noLog')}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
添加第一条日志
|
||||
{t('construction.addFirstLog')}
|
||||
</Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
@@ -312,7 +314,6 @@ const ConstructionLog: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部添加按钮 */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
@@ -332,19 +333,18 @@ const ConstructionLog: React.FC = () => {
|
||||
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
|
||||
}}
|
||||
>
|
||||
新增日志
|
||||
{t('construction.newLog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 新增日志弹窗 */}
|
||||
<Modal
|
||||
title="新增施工日志"
|
||||
title={t('construction.newConstructionLog')}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
confirmLoading={submitting}
|
||||
okText="提交"
|
||||
cancelText="取消"
|
||||
okText={t('common.submit')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={isMobile ? '95%' : 500}
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
@@ -360,8 +360,8 @@ const ConstructionLog: React.FC = () => {
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="log_date"
|
||||
label="日期"
|
||||
rules={[{ required: true, message: '请选择日期' }]}
|
||||
label={t('common.date')}
|
||||
rules={[{ required: true, message: t('construction.selectDate') }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
@@ -374,12 +374,12 @@ const ConstructionLog: React.FC = () => {
|
||||
<Form.Item
|
||||
name="weather"
|
||||
label="天气"
|
||||
rules={[{ required: true, message: '请选择天气' }]}
|
||||
rules={[{ required: true, message: t('construction.selectWeather') }]}
|
||||
>
|
||||
<Select size="large">
|
||||
{WEATHER_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{opt.icon} {t(`construction.${opt.value}`)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -389,33 +389,33 @@ const ConstructionLog: React.FC = () => {
|
||||
|
||||
<Form.Item
|
||||
name="work_content"
|
||||
label="今日工作"
|
||||
rules={[{ required: true, message: '请填写今日工作内容' }]}
|
||||
label={t('construction.todayWorkLabel')}
|
||||
rules={[{ required: true, message: t('construction.inputTodayWork') }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="描述今日完成的施工工作..."
|
||||
placeholder={t('construction.todayWorkPlaceholder')}
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="next_plan" label="明日计划">
|
||||
<Form.Item name="next_plan" label={t('construction.tomorrowPlanLabel')}>
|
||||
<TextArea
|
||||
rows={2}
|
||||
placeholder="明日工作计划..."
|
||||
placeholder={t('construction.tomorrowPlanPlaceholder')}
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="issues" label="问题记录">
|
||||
<Form.Item name="issues" label={t('construction.issueRecordLabel')}>
|
||||
<TextArea
|
||||
rows={2}
|
||||
placeholder="遇到的问题或需要协调的事项..."
|
||||
placeholder={t('construction.issuePlaceholder')}
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上传照片">
|
||||
<Form.Item label={t('construction.uploadPhoto')}>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
multiple
|
||||
@@ -425,11 +425,11 @@ const ConstructionLog: React.FC = () => {
|
||||
>
|
||||
<div>
|
||||
<CameraOutlined style={{ fontSize: 20 }} />
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>添加照片</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>{t('construction.addPhoto')}</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
支持上传多张照片,最多9张
|
||||
{t('construction.multiPhotoSupport')}
|
||||
</Text>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -9,42 +9,10 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 节点状态配置
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
color: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
timelineColor: string;
|
||||
}> = {
|
||||
pending: {
|
||||
color: 'default',
|
||||
text: '待开始',
|
||||
icon: <ClockCircleOutlined />,
|
||||
timelineColor: 'gray'
|
||||
},
|
||||
in_progress: {
|
||||
color: 'processing',
|
||||
text: '进行中',
|
||||
icon: <SyncOutlined spin />,
|
||||
timelineColor: 'blue'
|
||||
},
|
||||
completed: {
|
||||
color: 'success',
|
||||
text: '已完成',
|
||||
icon: <CheckCircleOutlined />,
|
||||
timelineColor: 'green'
|
||||
},
|
||||
cancelled: {
|
||||
color: 'error',
|
||||
text: '已取消',
|
||||
icon: <CloseCircleOutlined />,
|
||||
timelineColor: 'red'
|
||||
},
|
||||
};
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
node_name: string;
|
||||
@@ -62,6 +30,39 @@ const ConstructionMilestones: React.FC = () => {
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [projectInfo, setProjectInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
color: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
timelineColor: string;
|
||||
}> = {
|
||||
pending: {
|
||||
color: 'default',
|
||||
text: t('construction.pendingStart'),
|
||||
icon: <ClockCircleOutlined />,
|
||||
timelineColor: 'gray'
|
||||
},
|
||||
in_progress: {
|
||||
color: 'processing',
|
||||
text: t('construction.inProgress'),
|
||||
icon: <SyncOutlined spin />,
|
||||
timelineColor: 'blue'
|
||||
},
|
||||
completed: {
|
||||
color: 'success',
|
||||
text: t('construction.completed'),
|
||||
icon: <CheckCircleOutlined />,
|
||||
timelineColor: 'green'
|
||||
},
|
||||
cancelled: {
|
||||
color: 'error',
|
||||
text: t('construction.cancelled'),
|
||||
icon: <CloseCircleOutlined />,
|
||||
timelineColor: 'red'
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
@@ -102,7 +103,6 @@ const ConstructionMilestones: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 计算进度
|
||||
const completedCount = milestones.filter(m => m.status === 'completed').length;
|
||||
const totalCount = milestones.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
@@ -147,7 +147,7 @@ const ConstructionMilestones: React.FC = () => {
|
||||
</div>
|
||||
{milestone.due_date && (
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
计划完成: {dayjs(milestone.due_date).format('YYYY-MM-DD')}
|
||||
{t('construction.plannedComplete')}{dayjs(milestone.due_date).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
@@ -161,7 +161,6 @@ const ConstructionMilestones: React.FC = () => {
|
||||
maxWidth: 800,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Button
|
||||
@@ -170,7 +169,7 @@ const ConstructionMilestones: React.FC = () => {
|
||||
/>
|
||||
<div>
|
||||
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
||||
节点进度
|
||||
{t('construction.milestoneProgress')}
|
||||
</Title>
|
||||
{projectInfo && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
@@ -181,11 +180,10 @@ const ConstructionMilestones: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度概览 */}
|
||||
{!loading && milestones.length > 0 && (
|
||||
<Card style={{ marginBottom: 16, borderRadius: 12 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Text type="secondary">整体进度</Text>
|
||||
<Text type="secondary">{t('construction.overallProgress')}</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
|
||||
</div>
|
||||
<Progress
|
||||
@@ -199,31 +197,30 @@ const ConstructionMilestones: React.FC = () => {
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.completed')}</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>进行中</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.inProgress')}</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>总节点</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('construction.totalNodes')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 节点时间线 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : milestones.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty description="暂无施工节点">
|
||||
<Text type="secondary">节点由项目经理在项目设置中配置</Text>
|
||||
<Empty description={t('construction.noMilestones')}>
|
||||
<Text type="secondary">{t('construction.milestoneConfigured')}</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Card, Button, Progress, Tag, List, Spin, message, Empty, Space, Typogra
|
||||
import { ArrowLeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../utils/request';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -28,6 +29,7 @@ const ConstructionOverview: React.FC = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
useEffect(() => { fetchProjects(); }, []);
|
||||
|
||||
@@ -45,7 +47,7 @@ const ConstructionOverview: React.FC = () => {
|
||||
}));
|
||||
setProjects(enriched);
|
||||
}
|
||||
} catch (e) { message.error('获取项目列表失败'); }
|
||||
} catch (e) { message.error(t('construction.getListFailed')); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
@@ -55,7 +57,7 @@ const ConstructionOverview: React.FC = () => {
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = { active: '施工中', in_progress: '施工中', planning: '待开工', completed: '已完工', suspended: '暂停' };
|
||||
const map: Record<string, string> = { active: t('construction.underConstruction'), in_progress: t('construction.underConstruction'), planning: t('construction.pendingStart'), completed: t('construction.completed'), suspended: t('construction.paused') };
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
@@ -64,28 +66,28 @@ const ConstructionOverview: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card title={<Title level={4} style={{ margin: 0 }}>施工总览</Title>}>
|
||||
<Card title={<Title level={4} style={{ margin: 0 }}>{t('construction.overview')}</Title>}>
|
||||
<Spin spinning={loading}>
|
||||
{activeProjects.length === 0 && completedProjects.length === 0 ? (
|
||||
<Empty description="暂无施工项目" />
|
||||
<Empty description={t('construction.noProjects')} />
|
||||
) : (
|
||||
<>
|
||||
{activeProjects.length > 0 && (
|
||||
<>
|
||||
<Text strong style={{ fontSize: 16 }}>施工中项目:{activeProjects.length}个</Text>
|
||||
<Text strong style={{ fontSize: 16 }}>{t('construction.underConstruction')}{activeProjects.length}{t('common.unit')}</Text>
|
||||
<List
|
||||
style={{ marginTop: 16 }}
|
||||
dataSource={activeProjects}
|
||||
renderItem={(project) => (
|
||||
<List.Item
|
||||
actions={[<Button type="primary" icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>进入</Button>]}
|
||||
actions={[<Button type="primary" icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>{t('construction.enter')}</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>
|
||||
<Text type="secondary">{t('construction.currentPhase')}{project.current_phase || t('common.notSet')}</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" />
|
||||
@@ -99,16 +101,16 @@ const ConstructionOverview: React.FC = () => {
|
||||
)}
|
||||
{completedProjects.length > 0 && (
|
||||
<>
|
||||
<Text strong style={{ fontSize: 16, marginTop: 24, display: 'block' }}>已完工项目:{completedProjects.length}个</Text>
|
||||
<Text strong style={{ fontSize: 16, marginTop: 24, display: 'block' }}>{t('construction.completedProjects')}{completedProjects.length}{t('common.unit')}</Text>
|
||||
<List
|
||||
style={{ marginTop: 16 }}
|
||||
dataSource={completedProjects}
|
||||
renderItem={(project) => (
|
||||
<List.Item
|
||||
actions={[<Button icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>查看</Button>]}
|
||||
actions={[<Button icon={<RightOutlined />} onClick={() => navigate(`/construction/progress/${project.id}`)}>{t('common.view')}</Button>]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<Space><Text>{project.name}</Text><Tag>已完工</Tag></Space>}
|
||||
title={<Space><Text>{project.name}</Text><Tag>{t('construction.completed')}</Tag></Space>}
|
||||
description={<Progress percent={100} size="small" />}
|
||||
/>
|
||||
</List.Item>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,12 @@ import {
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons';
|
||||
import apiClient from '../../utils/request';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projectData, setProjectData] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState({
|
||||
@@ -32,83 +34,136 @@ const DashboardPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 从 API 获取真实项目数据
|
||||
const fetchProjects = async () => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/projects');
|
||||
// 并行获取所有数据
|
||||
const [projectsRes, advancesRes, reimburseRes, usersRes] = await Promise.allSettled([
|
||||
apiClient.get('/projects'),
|
||||
apiClient.get('/advances?status=pending'),
|
||||
apiClient.get('/reimbursements?status=pending'),
|
||||
apiClient.get('/users'),
|
||||
]);
|
||||
|
||||
if (response.data.success) {
|
||||
const projects = response.data.data.map((project: any) => ({
|
||||
// 处理项目数据
|
||||
let projects: any[] = [];
|
||||
if (projectsRes.status === 'fulfilled' && projectsRes.value.data.success) {
|
||||
projects = projectsRes.value.data.data.map((project: any) => ({
|
||||
key: project.id,
|
||||
name: project.name,
|
||||
status: project.status === 'planning' ? '规划中' :
|
||||
project.status === 'active' ? '进行中' :
|
||||
project.status === 'completed' ? '已完成' : project.status,
|
||||
budget: parseFloat(project.budget || 0),
|
||||
spent: parseFloat(project.spent || 0)
|
||||
status: project.status,
|
||||
budget: parseFloat(project.budget || project.contract_amount || 0),
|
||||
spent: parseFloat(project.total_expense || project.spent || 0)
|
||||
}));
|
||||
setProjectData(projects);
|
||||
}
|
||||
|
||||
// 计算进行中项目数
|
||||
const ongoingProjects = projects.filter(p => p.status === 'active' || p.status === 'planning').length;
|
||||
|
||||
// 计算本月报销金额
|
||||
let monthlyReimburse = 0;
|
||||
try {
|
||||
const financeRes = await apiClient.get('/financial-records?limit=999');
|
||||
if (financeRes.data.success) {
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
const currentYear = now.getFullYear();
|
||||
monthlyReimburse = financeRes.data.data
|
||||
.filter((r: any) => {
|
||||
const d = new Date(r.record_date);
|
||||
return d.getMonth() === currentMonth && d.getFullYear() === currentYear && r.txn_type === 'expense';
|
||||
})
|
||||
.reduce((sum: number, r: any) => sum + parseFloat(r.amount_cny || r.amount_original || 0), 0);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 计算待审批数
|
||||
let pendingApprovals = 0;
|
||||
if (advancesRes.status === 'fulfilled' && advancesRes.value.data.success) {
|
||||
pendingApprovals += (advancesRes.value.data.data || []).length;
|
||||
}
|
||||
if (reimburseRes.status === 'fulfilled' && reimburseRes.value.data.success) {
|
||||
pendingApprovals += (reimburseRes.value.data.data || []).length;
|
||||
}
|
||||
|
||||
// 团队成员数
|
||||
let teamMembers = 0;
|
||||
if (usersRes.status === 'fulfilled' && usersRes.value.data.success) {
|
||||
teamMembers = (usersRes.value.data.data || []).length;
|
||||
}
|
||||
|
||||
setStats({
|
||||
ongoingProjects,
|
||||
monthlyReimbursements: Math.round(monthlyReimburse),
|
||||
pendingApprovals,
|
||||
teamMembers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
console.error('获取仪表盘数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProjects();
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
// 桌面端表格列
|
||||
const statusDisplayMap: Record<string, string> = {
|
||||
planning: t('project.planning'),
|
||||
active: t('project.inProgress'),
|
||||
completed: t('project.completed'),
|
||||
paused: t('project.paused'),
|
||||
};
|
||||
|
||||
const desktopColumns = [
|
||||
{ title: '项目名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: t('dashboard.projectName'), dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: '状态',
|
||||
title: t('common.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'进行中': 'blue',
|
||||
'已完成': 'green',
|
||||
'规划中': 'orange',
|
||||
planning: 'orange',
|
||||
active: 'blue',
|
||||
completed: 'green',
|
||||
paused: 'default',
|
||||
};
|
||||
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
|
||||
return <Tag color={colorMap[status] || 'default'}>{statusDisplayMap[status] || status}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '预算',
|
||||
title: t('dashboard.budget'),
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
render: (value: number) => `¥${value.toLocaleString()}`
|
||||
},
|
||||
{
|
||||
title: '已花费',
|
||||
title: t('dashboard.spent'),
|
||||
dataIndex: 'spent',
|
||||
key: 'spent',
|
||||
render: (value: number) => `¥${value.toLocaleString()}`
|
||||
}
|
||||
];
|
||||
|
||||
// 移动端简化表格列
|
||||
const mobileColumns = [
|
||||
{ title: '项目', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: t('dashboard.project'), dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
title: t('common.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === '已完成' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
|
||||
{status}
|
||||
<Tag color={status === 'completed' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
|
||||
{statusDisplayMap[status] || status}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '预算/花费',
|
||||
title: t('dashboard.budgetSpent'),
|
||||
key: 'budget_spent',
|
||||
render: (_: any, record: any) => (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div>预算:¥{(record.budget / 10000).toFixed(0)}万</div>
|
||||
<div style={{ color: '#888' }}>已花:¥{(record.spent / 10000).toFixed(0)}万</div>
|
||||
<div>{t('dashboard.budgetLabel')}¥{(record.budget / 10000).toFixed(0)}{t('common.tenThousand')}</div>
|
||||
<div style={{ color: '#888' }}>{t('dashboard.spentLabel')}¥{(record.spent / 10000).toFixed(0)}{t('common.tenThousand')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -117,15 +172,14 @@ const DashboardPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
📊 工作台
|
||||
{t('dashboard.title')}
|
||||
</Title>
|
||||
|
||||
{/* 统计卡片 - 移动端优化 */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>进行中项目</span>}
|
||||
title={<span style={{ fontSize: 12 }}>{t('dashboard.inProgressProjects')}</span>}
|
||||
value={stats.ongoingProjects}
|
||||
prefix={<ProjectOutlined />}
|
||||
valueStyle={{ color: '#1890ff', fontSize: isMobile ? 18 : undefined }}
|
||||
@@ -135,18 +189,18 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>本月报销</span>}
|
||||
title={<span style={{ fontSize: 12 }}>{t('dashboard.monthlyReimburse')}</span>}
|
||||
value={stats.monthlyReimbursements}
|
||||
prefix={<DollarOutlined />}
|
||||
valueStyle={{ color: '#52c41a', fontSize: isMobile ? 18 : undefined }}
|
||||
suffix="元"
|
||||
suffix={t('common.yuan')}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>待审批</span>}
|
||||
title={<span style={{ fontSize: 12 }}>{t('dashboard.pendingApproval')}</span>}
|
||||
value={stats.pendingApprovals}
|
||||
prefix={<FileTextOutlined />}
|
||||
valueStyle={{ color: '#faad14', fontSize: isMobile ? 18 : undefined }}
|
||||
@@ -156,7 +210,7 @@ const DashboardPage: React.FC = () => {
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>团队成员</span>}
|
||||
title={<span style={{ fontSize: 12 }}>{t('dashboard.teamMembers')}</span>}
|
||||
value={stats.teamMembers}
|
||||
prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: '#722ed1', fontSize: isMobile ? 18 : undefined }}
|
||||
@@ -165,16 +219,15 @@ const DashboardPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<Card
|
||||
title="最近项目"
|
||||
title={t('dashboard.recentProjects')}
|
||||
size="small"
|
||||
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16, color: '#666' }}>加载项目数据...</div>
|
||||
<div style={{ marginTop: 16, color: '#666' }}>{t('common.loadingData')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
@@ -183,7 +236,7 @@ const DashboardPage: React.FC = () => {
|
||||
pagination={false}
|
||||
scroll={isMobile ? { x: 400 } : undefined}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
locale={{ emptyText: '暂无项目数据' }}
|
||||
locale={{ emptyText: t('common.noProjectData') }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,39 +1,103 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin, Button, Modal, Form, Input, InputNumber, message, Divider } from 'antd';
|
||||
import { DollarOutlined, RiseOutlined, FallOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin, Button, Modal, Form, Input, InputNumber, message, Divider, Tabs, Popconfirm, Upload } from 'antd';
|
||||
import { DollarOutlined, RiseOutlined, FallOutlined, PlusOutlined, DownloadOutlined, DeleteOutlined, UploadOutlined, FundOutlined } from '@ant-design/icons';
|
||||
import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { Title } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const LEVEL1_LABELS: Record<string, string> = { income: '收入', project: '项目支出', company: '公司支出' };
|
||||
const LEVEL2_LABELS: Record<string, string> = {
|
||||
contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入',
|
||||
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
|
||||
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
|
||||
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
|
||||
salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤',
|
||||
vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展',
|
||||
entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出'
|
||||
};
|
||||
|
||||
const COUNTERPARTY_TYPES = [
|
||||
{ value: 'supplier', label: '供应商' }, { value: 'subcontractor', label: '分包商' },
|
||||
{ value: 'customer', label: '客户' }, { value: 'employee', label: '员工' },
|
||||
{ value: 'logistics', label: '物流公司' }, { value: 'shareholder', label: '股东' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'CNY', label: 'CNY (人民币)' },
|
||||
{ value: 'LAK', label: 'LAK (老挝基普)' },
|
||||
{ value: 'USD', label: 'USD (美元)' },
|
||||
{ value: 'THB', label: 'THB (泰铢)' },
|
||||
];
|
||||
|
||||
const FinancePage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
const LEVEL1_LABELS = useMemo<Record<string, string>>(() => ({
|
||||
income: t('finance.incomeCategory'),
|
||||
project: t('finance.projectExpense'),
|
||||
company: t('finance.companyExpense'),
|
||||
finance: t('cash.financeExpense'),
|
||||
}), [t]);
|
||||
|
||||
const LEVEL2_LABELS = useMemo<Record<string, string>>(() => ({
|
||||
contract_payment: t('finance.projectRevenue'),
|
||||
deposit_refund: t('finance.warrantyReturn'),
|
||||
shareholder_investment: t('finance.shareholderInvestment'),
|
||||
other_income: t('finance.otherIncome'),
|
||||
customer_advance: t('cash.customerAdvance'),
|
||||
bank_loan: t('cash.bankLoan'),
|
||||
other_loan: t('cash.otherLoan'),
|
||||
dividend_income: t('cash.dividendIncome'),
|
||||
interest_income: t('cash.interestIncome'),
|
||||
asset_disposal: t('cash.assetDisposal'),
|
||||
tax_refund: t('cash.taxRefund'),
|
||||
government_subsidy: t('cash.governmentSubsidy'),
|
||||
material: t('finance.materialPurchase'),
|
||||
equipment: t('finance.equipmentPurchase'),
|
||||
subcontract: t('finance.constructionSubcontract'),
|
||||
construction_subcontract: t('finance.constructionSubcontract'),
|
||||
labor: t('finance.laborWage'),
|
||||
travel: t('finance.travelTransport'),
|
||||
accommodation: t('finance.accommodationFood'),
|
||||
freight: t('finance.transportLogistics'),
|
||||
transport_logistics: t('finance.transportLogistics'),
|
||||
design: t('finance.surveyDesign'),
|
||||
survey_design: t('finance.surveyDesign'),
|
||||
tools: t('finance.smallTools'),
|
||||
client_relations: t('finance.customerEDLRelation'),
|
||||
customer_edl: t('finance.customerEDLRelation'),
|
||||
other_project: t('finance.otherProjectExpense'),
|
||||
salary: t('finance.salaryWelfare'),
|
||||
rent: t('finance.rentProperty'),
|
||||
office: t('finance.officeExpense'),
|
||||
commute: t('finance.commute'),
|
||||
vehicle_maintenance: t('finance.vehicleMaintenance'),
|
||||
assets: t('finance.fixedAsset'),
|
||||
marketing: t('finance.marketing'),
|
||||
entertainment: t('finance.entertainment'),
|
||||
welfare: t('finance.employeeBenefit'),
|
||||
logistics: t('finance.expressLogistics'),
|
||||
other_company: t('finance.otherCompanyExpense'),
|
||||
loan_repayment: t('cash.loanRepayment'),
|
||||
interest_expense: t('cash.interestExpense'),
|
||||
dividend_payment: t('cash.dividendPayment'),
|
||||
tax_payment: t('cash.taxPayment'),
|
||||
deposit_payment: t('cash.depositPayment'),
|
||||
owner_expense: t('cash.ownerExpense'),
|
||||
other_finance: t('cash.otherFinance'),
|
||||
}), [t]);
|
||||
|
||||
const COUNTERPARTY_TYPES = useMemo(() => [
|
||||
{ value: 'supplier', label: t('finance.counterpartySupplier') },
|
||||
{ value: 'subcontractor', label: t('finance.counterpartySubcontractor') },
|
||||
{ value: 'customer', label: t('finance.counterpartyCustomer') },
|
||||
{ value: 'employee', label: t('finance.counterpartyEmployee') },
|
||||
{ value: 'logistics', label: t('finance.counterpartyLogistics') },
|
||||
{ value: 'shareholder', label: t('finance.counterpartyShareholder') },
|
||||
{ value: 'bank', label: t('cash.counterpartyBank') },
|
||||
{ value: 'other', label: t('finance.counterpartyOther') },
|
||||
], [t, currentLanguage]);
|
||||
|
||||
const CURRENCIES = useMemo(() => [
|
||||
{ value: 'CNY', label: t('finance.currencyCNY') },
|
||||
{ value: 'LAK', label: t('finance.currencyLAK') },
|
||||
{ value: 'USD', label: t('finance.currencyUSD') },
|
||||
{ value: 'THB', label: t('finance.currencyTHB') },
|
||||
], [t, currentLanguage]);
|
||||
|
||||
const SOURCE_MAP = useMemo<Record<string, string>>(() => ({
|
||||
manual: t('finance.manual'),
|
||||
cash_management: t('cash.sourceLabel'),
|
||||
receipt: t('cash.receiptSource'),
|
||||
advance: t('finance.advance'),
|
||||
reimbursement: t('finance.reimbursement'),
|
||||
payment_request: t('finance.payment'),
|
||||
material: t('finance.material'),
|
||||
primary_freight: t('finance.freight'),
|
||||
secondary_freight: t('finance.freight'),
|
||||
}), [t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
|
||||
const [byCategory, setByCategory] = useState<any[]>([]);
|
||||
@@ -45,12 +109,24 @@ const FinancePage: React.FC = () => {
|
||||
|
||||
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [addModalType, setAddModalType] = useState<'income' | 'expense'>('income');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [categories, setCategories] = useState<any>({ income: [], project: [], company: [] });
|
||||
const [categories, setCategories] = useState<any>({ income: [], project: [], company: [], finance: [] });
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [customers, setCustomers] = useState<any[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<any[]>([]);
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({ CNY: 1, LAK: 0.0003, USD: 7.2, THB: 0.2 });
|
||||
|
||||
const [cashRecords, setCashRecords] = useState<any[]>([]);
|
||||
const [cashPagination, setCashPagination] = useState({ page: 1, pageSize: 20, total: 0 });
|
||||
const [cashFilterType, setCashFilterType] = useState<string | undefined>(undefined);
|
||||
const [cashDateRange, setCashDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
|
||||
const [cashLoading, setCashLoading] = useState(false);
|
||||
|
||||
const [amountVal, setAmountVal] = useState<number>(0);
|
||||
const [rateVal, setRateVal] = useState<number>(1);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
@@ -65,6 +141,12 @@ const FinancePage: React.FC = () => {
|
||||
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
|
||||
if (res.data.success) setProjects(res.data.data || res.data.projects || []);
|
||||
}).catch(() => {});
|
||||
apiClient.get('/customers', { params: { pageSize: 200 } }).then(res => {
|
||||
if (res.data.success) setCustomers(res.data.data || []);
|
||||
}).catch(() => {});
|
||||
apiClient.get('/suppliers', { params: { pageSize: 200 } }).then(res => {
|
||||
if (res.data.success) setSuppliers(res.data.data || []);
|
||||
}).catch(() => {});
|
||||
apiClient.get('/exchange-rates/latest').then(res => {
|
||||
if (res.data.success && res.data.data) {
|
||||
const rates: Record<string, number> = { CNY: 1 };
|
||||
@@ -110,42 +192,53 @@ const FinancePage: React.FC = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]);
|
||||
const fetchCashRecords = async (page = 1) => {
|
||||
setCashLoading(true);
|
||||
try {
|
||||
const params: any = { page, pageSize: cashPagination.pageSize };
|
||||
if (cashDateRange && cashDateRange[0]) {
|
||||
params.date_from = cashDateRange[0].format('YYYY-MM-DD');
|
||||
params.date_to = cashDateRange[1]?.format('YYYY-MM-DD');
|
||||
}
|
||||
if (cashFilterType) params.txn_type = cashFilterType;
|
||||
const res = await apiClient.get('/cash-management/records', { params });
|
||||
if (res.data.success) {
|
||||
setCashRecords(res.data.data);
|
||||
setCashPagination(res.data.pagination);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
setCashLoading(false);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]);
|
||||
useEffect(() => { if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1); }, [activeTab, cashDateRange, cashFilterType]);
|
||||
|
||||
const handleAddIncome = () => {
|
||||
setAddModalType('income');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1 });
|
||||
setAmountVal(0);
|
||||
setRateVal(1);
|
||||
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1, txn_type: 'income', category_level1: 'income' });
|
||||
setAddModalVisible(true);
|
||||
};
|
||||
|
||||
const handleTxnTypeChange = (v: string) => {
|
||||
form.setFieldsValue({ category_level1: undefined, category_level2: undefined });
|
||||
if (v === 'income') {
|
||||
form.setFieldsValue({ category_level1: 'income' });
|
||||
}
|
||||
const handleAddExpense = () => {
|
||||
setAddModalType('expense');
|
||||
form.resetFields();
|
||||
setAmountVal(0);
|
||||
setRateVal(1);
|
||||
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1, txn_type: 'expense', category_level1: 'project' });
|
||||
setAddModalVisible(true);
|
||||
};
|
||||
|
||||
const handleLevel1Change = () => {
|
||||
form.setFieldsValue({ category_level2: undefined });
|
||||
form.setFieldsValue({ category_level2: undefined, project_id: undefined });
|
||||
};
|
||||
|
||||
const handleCurrencyChange = (currency: string) => {
|
||||
const rate = exchangeRates[currency] || 1;
|
||||
setRateVal(rate);
|
||||
form.setFieldsValue({ exchange_rate: rate });
|
||||
const amount = form.getFieldValue('amount_original') || 0;
|
||||
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
|
||||
};
|
||||
|
||||
const handleAmountChange = (val: number | null) => {
|
||||
const rate = form.getFieldValue('exchange_rate') || 1;
|
||||
const amount = val || 0;
|
||||
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
|
||||
};
|
||||
|
||||
const handleRateChange = (val: number | null) => {
|
||||
const amount = form.getFieldValue('amount_original') || 0;
|
||||
const rate = val || 1;
|
||||
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -153,37 +246,63 @@ const FinancePage: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
setAddLoading(true);
|
||||
const payload = {
|
||||
...values,
|
||||
txn_type: values.txn_type,
|
||||
category_level1: values.category_level1,
|
||||
category_level2: values.category_level2,
|
||||
project_id: values.project_id || undefined,
|
||||
amount: values.amount_original,
|
||||
currency: values.currency,
|
||||
exchange_rate: values.exchange_rate,
|
||||
record_date: values.record_date?.format('YYYY-MM-DD'),
|
||||
amount_cny: Math.round((values.amount_original || 0) * (values.exchange_rate || 1) * 100) / 100,
|
||||
source: 'manual',
|
||||
counterparty_name: values.counterparty_name,
|
||||
counterparty_type: values.counterparty_type,
|
||||
counterparty_id: values.counterparty_id,
|
||||
description: values.description,
|
||||
voucher_url: values.voucher_url,
|
||||
};
|
||||
if (payload.category_level1 !== 'project' && payload.category_level1 !== 'income') {
|
||||
payload.project_id = undefined;
|
||||
if (!['project', 'income'].includes(payload.category_level1)) {
|
||||
delete payload.project_id;
|
||||
}
|
||||
await apiClient.post('/financial-records', payload);
|
||||
message.success('录入成功');
|
||||
await apiClient.post('/cash-management', payload);
|
||||
message.success(t('finance.recordSuccess'));
|
||||
setAddModalVisible(false);
|
||||
fetchSummary();
|
||||
fetchRecords(1);
|
||||
if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1);
|
||||
} catch (e: any) {
|
||||
if (e.response?.data?.message) message.error(e.response.data.message);
|
||||
}
|
||||
setAddLoading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await apiClient.delete(`/cash-management/${id}`);
|
||||
message.success(t('common.deleteSuccess'));
|
||||
fetchSummary();
|
||||
fetchRecords(1);
|
||||
if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1);
|
||||
} catch (e: any) {
|
||||
if (e.response?.data?.message) message.error(e.response.data.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
message.loading({ content: '正在导出...', key: 'export' });
|
||||
message.loading({ content: t('finance.exporting'), key: 'export' });
|
||||
const res = await apiClient.get('/financial-records', { params: { page: 1, pageSize: 5000 } });
|
||||
if (!res.data.success) return;
|
||||
const data = res.data.data;
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
const headers = ['日期', '收支类型', '一级分类', '二级分类', '项目名称', '金额', '币种', '汇率', '等效人民币', '对方名称', '对方类型', '人员姓名', '描述', '凭证编号'];
|
||||
const headers = [
|
||||
t('finance.date'), t('finance.incomeType'), t('finance.level1Category'), t('finance.level2Category'),
|
||||
t('finance.projectName'), t('finance.amount'), t('finance.currency'), t('finance.exchangeRate'),
|
||||
t('finance.equivalentCNY'), t('finance.counterpartyName'), t('finance.counterpartyType'),
|
||||
t('finance.personName'), t('finance.desc'), t('finance.source'),
|
||||
];
|
||||
const rows = data.map((r: any) => [
|
||||
r.record_date,
|
||||
r.txn_type === 'income' ? '收入' : '支出',
|
||||
r.txn_type === 'income' ? t('finance.income') : t('finance.expense'),
|
||||
LEVEL1_LABELS[r.category_level1] || r.category_level1,
|
||||
LEVEL2_LABELS[r.category_level2] || r.category_level2,
|
||||
r.project_name || '',
|
||||
@@ -195,22 +314,25 @@ const FinancePage: React.FC = () => {
|
||||
COUNTERPARTY_TYPES.find(c => c.value === r.counterparty_type)?.label || r.counterparty_type || '',
|
||||
r.user_name || '',
|
||||
r.description || '',
|
||||
r.voucher_no || '',
|
||||
SOURCE_MAP[r.source] || r.source || '',
|
||||
]);
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
ws['!cols'] = headers.map(() => ({ wch: 14 }));
|
||||
XLSX.utils.book_append_sheet(wb, ws, '财务记账');
|
||||
XLSX.writeFile(wb, `财务记录_${dayjs().format('YYYYMMDD')}.xlsx`);
|
||||
message.success({ content: '导出成功', key: 'export' });
|
||||
XLSX.utils.book_append_sheet(wb, ws, t('finance.sheetName'));
|
||||
XLSX.writeFile(wb, `${t('finance.title')}_${dayjs().format('YYYYMMDD')}.xlsx`);
|
||||
message.success({ content: t('finance.exportSuccess'), key: 'export' });
|
||||
} catch (e) {
|
||||
message.error({ content: '导出失败', key: 'export' });
|
||||
message.error({ content: t('finance.exportFailed'), key: 'export' });
|
||||
}
|
||||
};
|
||||
|
||||
const getLevel1Options = () => {
|
||||
const txnType = form.getFieldValue('txn_type');
|
||||
if (txnType === 'income') return [{ value: 'income', label: '收入' }];
|
||||
return [{ value: 'project', label: '项目支出' }, { value: 'company', label: '公司支出' }];
|
||||
const getLevel1Options = (txnType: string) => {
|
||||
if (txnType === 'income') return [{ value: 'income', label: t('finance.incomeCategory') }];
|
||||
return [
|
||||
{ value: 'project', label: t('finance.projectExpense') },
|
||||
{ value: 'company', label: t('finance.companyExpense') },
|
||||
{ value: 'finance', label: t('cash.financeExpense') },
|
||||
];
|
||||
};
|
||||
|
||||
const getLevel2Options = () => {
|
||||
@@ -221,69 +343,93 @@ const FinancePage: React.FC = () => {
|
||||
|
||||
const projectOptions = projects.map((p: any) => ({ value: p.id, label: p.name }));
|
||||
|
||||
const getCounterpartyOptions = () => {
|
||||
const type = form.getFieldValue('counterparty_type');
|
||||
if (type === 'customer') return customers.map((c: any) => ({ value: c.id, label: c.name }));
|
||||
if (type === 'supplier') return suppliers.map((s: any) => ({ value: s.id, label: s.name }));
|
||||
return [];
|
||||
};
|
||||
|
||||
const equivalentCny = amountVal * rateVal;
|
||||
|
||||
const desktopColumns = [
|
||||
{ title: '日期', dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date) },
|
||||
{ title: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> },
|
||||
{ title: '一级分类', dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
|
||||
{ title: '二级分类', dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: '项目', dataIndex: 'project_name', width: 140, ellipsis: true },
|
||||
{ title: '原始金额', dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
|
||||
{ title: '币种', dataIndex: 'currency', width: 50 },
|
||||
{ title: '等效人民币', dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const, sorter: (a: any, b: any) => a.amount_cny - b.amount_cny },
|
||||
{ title: '人员', dataIndex: 'user_name', width: 70 },
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '来源', dataIndex: 'source', width: 80, render: (v: string) => {
|
||||
const m: Record<string, string> = { manual: '手动', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' };
|
||||
return <Tag>{m[v] || v}</Tag>;
|
||||
}},
|
||||
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date), render: (v: string) => v?.slice(0, 10) },
|
||||
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? t('finance.income') : t('finance.expense')}</Tag> },
|
||||
{ title: t('finance.level1Category'), dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
|
||||
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
|
||||
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
|
||||
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
|
||||
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const, sorter: (a: any, b: any) => a.amount_cny - b.amount_cny },
|
||||
{ title: t('finance.personName'), dataIndex: 'user_name', width: 70 },
|
||||
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
|
||||
{ title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_MAP[v] || v}</Tag> },
|
||||
];
|
||||
|
||||
const mobileColumns = [
|
||||
{ title: '日期', dataIndex: 'record_date', width: 80 },
|
||||
{ title: '收支', dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> },
|
||||
{ title: '分类', dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: '金额', dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
|
||||
{ title: t('finance.date'), dataIndex: 'record_date', width: 80, render: (v: string) => v?.slice(0, 10) },
|
||||
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> },
|
||||
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: t('finance.amount'), dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>财务管理</Title>
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
{isMobile ? '新增' : '新增记录'}
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
{isMobile ? '导出' : '导出Excel'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
const cashColumns = [
|
||||
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
|
||||
{ title: t('finance.level1Category'), dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
|
||||
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 110, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
|
||||
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
|
||||
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
|
||||
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const },
|
||||
{ title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
|
||||
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
|
||||
{
|
||||
title: '', width: 50, render: (_: any, record: any) => (
|
||||
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
const cashMobileColumns = [
|
||||
{ title: t('finance.date'), dataIndex: 'record_date', width: 80, render: (v: string) => v?.slice(0, 10) },
|
||||
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: t('finance.amount'), dataIndex: 'amount_cny', render: (v: number, r: any) => <span style={{ color: r.txn_type === 'income' ? 'green' : 'red' }}>¥{v?.toLocaleString()}</span>, align: 'right' as const },
|
||||
{
|
||||
title: '', width: 40, render: (_: any, record: any) => (
|
||||
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
const renderOverview = () => (
|
||||
<>
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>总收入</span>} value={summary.total_income} prefix="¥"
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>{t('finance.totalIncome')}</span>} value={summary.total_income} prefix="¥"
|
||||
valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>总支出</span>} value={summary.total_expense} prefix="¥"
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>{t('finance.totalExpense')}</span>} value={summary.total_expense} prefix="¥"
|
||||
valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>净利润</span>} value={summary.net_profit} prefix="¥"
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>{t('finance.netProfit')}</span>} value={summary.net_profit} prefix="¥"
|
||||
valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{byCategory.length > 0 && (
|
||||
<Card title="支出分类汇总" size="small" style={{ marginBottom: 16 }}>
|
||||
<Card title={t('finance.expenseSummary')} size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
{byCategory.filter(c => c.category_level1 !== 'income').map((c, i) => (
|
||||
<Col xs={12} sm={8} md={6} key={i}>
|
||||
@@ -295,13 +441,13 @@ const FinancePage: React.FC = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="财务明细" size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
<Card title={t('finance.detail')} size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
extra={
|
||||
<Space size="small" wrap>
|
||||
<RangePicker size="small" onChange={(dates) => setDateRange(dates as any)} />
|
||||
<Select size="small" allowClear placeholder="筛选类型" style={{ width: 100 }}
|
||||
<Select size="small" allowClear placeholder={t('finance.filterType')} style={{ width: 100 }}
|
||||
onChange={setFilterType} value={filterType}
|
||||
options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} />
|
||||
options={[{ value: 'income', label: t('finance.income') }, { value: 'expense', label: t('finance.expense') }]} />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -318,92 +464,186 @@ const FinancePage: React.FC = () => {
|
||||
total: pagination.total,
|
||||
onChange: (page) => fetchRecords(page),
|
||||
size: isMobile ? 'small' : 'default',
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
showTotal: (total) => t('finance.totalRecords', { total })
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderCashTab = (type: 'income' | 'expense') => {
|
||||
const isIncome = type === 'income';
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Space size="small" wrap>
|
||||
<RangePicker size="small" onChange={(dates) => setCashDateRange(dates as any)} />
|
||||
<Select size="small" allowClear placeholder={t('finance.level1Category')} style={{ width: 120 }}
|
||||
onChange={(v) => { setCashFilterType(v); }}
|
||||
options={isIncome
|
||||
? [{ value: 'income', label: t('finance.incomeCategory') }]
|
||||
: [
|
||||
{ value: 'project', label: t('finance.projectExpense') },
|
||||
{ value: 'company', label: t('finance.companyExpense') },
|
||||
{ value: 'finance', label: t('cash.financeExpense') },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={isIncome ? handleAddIncome : handleAddExpense}>
|
||||
{isMobile ? t('common.add') : (isIncome ? t('cash.addIncome') : t('cash.addExpense'))}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small" styles={{ body: { padding: isMobile ? 8 : 16 } }}>
|
||||
<Spin spinning={cashLoading}>
|
||||
<Table
|
||||
dataSource={cashRecords.filter(r => isIncome ? r.txn_type === 'income' : r.txn_type === 'expense')}
|
||||
columns={isMobile ? cashMobileColumns : cashColumns}
|
||||
rowKey="id"
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
scroll={isMobile ? { x: 400 } : { x: 1000 }}
|
||||
pagination={{
|
||||
current: cashPagination.page,
|
||||
pageSize: cashPagination.pageSize,
|
||||
total: cashPagination.total,
|
||||
onChange: (page) => fetchCashRecords(page),
|
||||
size: isMobile ? 'small' : 'default',
|
||||
showTotal: (total) => t('finance.totalRecords', { total })
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: t('cash.tabOverview'),
|
||||
icon: <FundOutlined />,
|
||||
children: renderOverview(),
|
||||
},
|
||||
{
|
||||
key: 'income',
|
||||
label: t('cash.tabIncome'),
|
||||
icon: <RiseOutlined />,
|
||||
children: renderCashTab('income'),
|
||||
},
|
||||
{
|
||||
key: 'expense',
|
||||
label: t('cash.tabExpense'),
|
||||
icon: <FallOutlined />,
|
||||
children: renderCashTab('expense'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<Title level={3} style={{ marginBottom: 0 }}>{t('finance.title')}</Title>
|
||||
<Space>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
{isMobile ? t('common.export') : t('finance.exportExcel')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} type="card" />
|
||||
|
||||
<Modal
|
||||
title="新增财务记录"
|
||||
title={addModalType === 'income' ? t('cash.addIncome') : t('cash.addExpense')}
|
||||
open={addModalVisible}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setAddModalVisible(false)}
|
||||
confirmLoading={addLoading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
width={isMobile ? '95%' : 600}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={isMobile ? '95%' : 640}
|
||||
style={{ top: isMobile ? 10 : 40 }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout={isMobile ? 'vertical' : 'horizontal'} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }} size="middle">
|
||||
|
||||
<Form.Item name="record_date" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<Form.Item name="record_date" label={t('finance.date')} rules={[{ required: true, message: t('finance.selectDate') }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="txn_type" label="收支类型" rules={[{ required: true, message: '请选择' }]}>
|
||||
<Select onChange={handleTxnTypeChange} options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} placeholder="请选择" />
|
||||
<Form.Item name="category_level1" label={t('finance.level1Category')} rules={[{ required: true, message: t('finance.selectLevel1') }]}>
|
||||
<Select onChange={handleLevel1Change} options={getLevel1Options(addModalType)} placeholder={t('finance.selectLevel1')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}>
|
||||
<Select onChange={handleLevel1Change} options={getLevel1Options()} placeholder="请选择" />
|
||||
<Form.Item name="category_level2" label={t('finance.level2Category')} rules={[{ required: true, message: t('finance.selectLevel2') }]}>
|
||||
<Select options={getLevel2Options()} placeholder={t('finance.selectLevel2')} notFoundContent={t('finance.selectLevel2')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="category_level2" label="二级分类" rules={[{ required: true, message: '请选择' }]}>
|
||||
<Select options={getLevel2Options()} placeholder="请先选择一级分类" notFoundContent="请先选择一级分类" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="project_id" label="项目名称"
|
||||
rules={[{ required: form.getFieldValue('category_level1') === 'project' || (form.getFieldValue('category_level1') === 'income' && form.getFieldValue('category_level2') !== 'shareholder_investment' && form.getFieldValue('category_level2') !== 'other_income'), message: '请选择项目' }]}
|
||||
>
|
||||
<Select showSearch optionFilterProp="label" options={projectOptions} placeholder="选择项目" allowClear />
|
||||
<Form.Item name="project_id" label={t('finance.projectName')}>
|
||||
<Select showSearch optionFilterProp="label" options={projectOptions} placeholder={t('finance.selectProject')} allowClear />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col span={10}>
|
||||
<Form.Item name="amount_original" label={isMobile ? '金额' : '金额'} labelCol={{ span: isMobile ? 24 : 8 }} wrapperCol={{ span: isMobile ? 24 : 16 }} rules={[{ required: true, message: '请输入' }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} onChange={handleAmountChange} placeholder="0" />
|
||||
<Form.Item name="amount_original" label={t('finance.amount')} labelCol={{ span: isMobile ? 24 : 8 }} wrapperCol={{ span: isMobile ? 24 : 16 }} rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} precision={2} onChange={(v) => setAmountVal(v || 0)} placeholder={t('finance.amountPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={7}>
|
||||
<Form.Item name="currency" label={isMobile ? '币种' : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
|
||||
<Form.Item name="currency" label={isMobile ? t('finance.currency') : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
|
||||
<Select options={CURRENCIES} onChange={handleCurrencyChange} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={7}>
|
||||
<Form.Item name="exchange_rate" label={isMobile ? '汇率' : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.0001} precision={6} onChange={handleRateChange} />
|
||||
<Form.Item name="exchange_rate" label={isMobile ? t('finance.exchangeRate') : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.0001} precision={6} onChange={(v) => setRateVal(v || 1)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="等效人民币">
|
||||
<Form.Item label={t('finance.equivalentCNY')}>
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
¥{((form.getFieldValue('amount_original') || 0) * (form.getFieldValue('exchange_rate') || 1)).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
¥{equivalentCny.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<Form.Item name="counterparty_name" label="对方名称" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Input placeholder="收款方/付款方名称" />
|
||||
<Form.Item name="counterparty_type" label={t('finance.counterpartyType')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Select options={COUNTERPARTY_TYPES} placeholder={t('finance.selectType')} allowClear onChange={() => form.setFieldsValue({ counterparty_id: undefined })} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="counterparty_type" label="对方类型" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Select options={COUNTERPARTY_TYPES} placeholder="选择类型" allowClear />
|
||||
{getCounterpartyOptions().length > 0 && (
|
||||
<Form.Item name="counterparty_id" label={t('cash.counterpartySelect')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Select showSearch optionFilterProp="label" options={getCounterpartyOptions()} placeholder={t('cash.counterpartySelectPlaceholder')} allowClear />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="counterparty_name" label={t('finance.counterpartyName')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Input placeholder={t('finance.counterpartyPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="user_name" label="人员姓名" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Input placeholder="关联员工姓名" />
|
||||
<Form.Item name="description" label={t('finance.desc')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Input.TextArea rows={2} placeholder={t('finance.descPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="描述" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Input.TextArea rows={2} placeholder="补充说明" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="voucher_no" label="凭证编号" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Input placeholder="发票号/收据号" />
|
||||
<Form.Item name="voucher_url" label={t('cash.voucherUpload')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
||||
<Upload
|
||||
maxCount={1}
|
||||
action="/api/upload/single"
|
||||
headers={{ Authorization: `Bearer ${JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.token || ''}` }}
|
||||
onChange={(info) => {
|
||||
if (info.file.status === 'done' && info.file.response?.url) {
|
||||
form.setFieldsValue({ voucher_url: info.file.response.url });
|
||||
message.success(t('cash.uploadSuccess'));
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error(t('cash.uploadFailed'));
|
||||
}
|
||||
}}
|
||||
onRemove={() => form.setFieldsValue({ voucher_url: undefined })}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>{t('cash.uploadVoucher')}</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { Card, Typography, Button, Space, Table, Tag, message, Spin, Modal, Inpu
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import apiClient from '../../utils/request';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
@@ -28,6 +29,7 @@ interface Project {
|
||||
|
||||
const ProjectsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -41,6 +43,7 @@ const ProjectsPage: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [customers, setCustomers] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin' || false;
|
||||
@@ -62,13 +65,13 @@ const ProjectsPage: React.FC = () => {
|
||||
...p,
|
||||
key: p.id.toString(),
|
||||
progress: p.status === 'completed' ? 100 : (p.phase_progress || 0),
|
||||
manager_name: p.manager_name || '未分配'
|
||||
manager_name: p.manager_name || t('project.unassigned')
|
||||
})));
|
||||
} else {
|
||||
message.error(`获取项目列表失败: ${response.data.message}`);
|
||||
message.error(`${t('project.getListFailed')}: ${response.data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取项目列表失败');
|
||||
message.error(t('project.getListFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -76,12 +79,14 @@ const ProjectsPage: React.FC = () => {
|
||||
|
||||
const fetchCreateOptions = async () => {
|
||||
try {
|
||||
const [custRes, userRes] = await Promise.all([
|
||||
const [custRes, userRes, tplRes] = await Promise.all([
|
||||
apiClient.get('/customers'),
|
||||
apiClient.get('/users'),
|
||||
apiClient.get('/process-templates'),
|
||||
]);
|
||||
if (custRes.data.success) setCustomers(custRes.data.data || []);
|
||||
if (userRes.data.success) setUsers(userRes.data.data || []);
|
||||
if (tplRes.data.success) setTemplates(tplRes.data.data || []);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
@@ -99,17 +104,18 @@ const ProjectsPage: React.FC = () => {
|
||||
const payload = {
|
||||
...values,
|
||||
manager_id: values.project_manager_id,
|
||||
type_template_id: values.type_template_id || undefined,
|
||||
start_date: values.start_date?.format('YYYY-MM-DD'),
|
||||
end_date: values.end_date?.format('YYYY-MM-DD') || null,
|
||||
contract_amount: values.contract_amount || 0,
|
||||
};
|
||||
const res = await apiClient.post('/projects', payload);
|
||||
if (res.data.success) {
|
||||
message.success('项目创建成功');
|
||||
message.success(t('project.createSuccess'));
|
||||
setCreateModalVisible(false);
|
||||
fetchProjects();
|
||||
} else {
|
||||
message.error(res.data.message || '创建失败');
|
||||
message.error(res.data.message || t('project.createFailed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.response?.data?.message) message.error(e.response.data.message);
|
||||
@@ -126,7 +132,7 @@ const ProjectsPage: React.FC = () => {
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteProjectId) return;
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
message.error(t('project.passwordError'));
|
||||
return;
|
||||
}
|
||||
setDeleteLoading(true);
|
||||
@@ -135,14 +141,14 @@ const ProjectsPage: React.FC = () => {
|
||||
headers: { 'x-user-role': 'admin' }
|
||||
});
|
||||
if (response.data.success) {
|
||||
message.success('项目删除成功');
|
||||
message.success(t('project.deleteSuccess'));
|
||||
setDeleteModalVisible(false);
|
||||
fetchProjects();
|
||||
} else {
|
||||
message.error(response.data.message || '删除失败');
|
||||
message.error(response.data.message || t('common.deleteFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除项目失败');
|
||||
message.error(t('common.operationFailed'));
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
@@ -150,21 +156,21 @@ const ProjectsPage: React.FC = () => {
|
||||
|
||||
const desktopColumns = [
|
||||
{
|
||||
title: '项目名称', dataIndex: 'name', key: 'name', width: 250, ellipsis: true,
|
||||
title: t('project.projectName'), dataIndex: 'name', key: 'name', width: 250, ellipsis: true,
|
||||
render: (text: string, record: Project) => (
|
||||
<a onClick={() => navigate(`/projects/${record.id}`)}>{text}</a>
|
||||
),
|
||||
},
|
||||
{ title: '项目经理', dataIndex: 'manager_name', key: 'manager_name', width: 100 },
|
||||
{ title: t('project.projectManager'), dataIndex: 'manager_name', key: 'manager_name', width: 100 },
|
||||
{
|
||||
title: '预算', dataIndex: 'budget', key: 'budget', width: 120,
|
||||
title: t('project.budget'), dataIndex: 'budget', key: 'budget', width: 120,
|
||||
render: (amount: string) => {
|
||||
const val = parseFloat(amount || '0');
|
||||
return val > 0 ? `¥${(val / 10000).toFixed(1)}万` : '-';
|
||||
return val > 0 ? `¥${(val / 10000).toFixed(1)}${t('common.tenThousand')}` : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '进度', dataIndex: 'progress', key: 'progress', width: 120,
|
||||
title: t('project.progress'), dataIndex: 'progress', key: 'progress', width: 120,
|
||||
render: (progress: number) => (
|
||||
<div style={{ width: 100 }}>
|
||||
<div style={{ background: '#f0f0f0', borderRadius: 10, height: 8 }}>
|
||||
@@ -178,23 +184,23 @@ const ProjectsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||
title: t('project.status'), dataIndex: 'status', key: 'status', width: 100,
|
||||
render: (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
planning: { color: 'blue', text: '规划中' }, in_progress: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: '已完成' }, suspended: { color: 'warning', text: '已暂停' },
|
||||
active: { color: 'processing', text: '进行中' },
|
||||
planning: { color: 'blue', text: t('project.planning') }, in_progress: { color: 'processing', text: t('project.inProgress') },
|
||||
completed: { color: 'success', text: t('project.completed') }, suspended: { color: 'warning', text: t('project.paused') },
|
||||
active: { color: 'processing', text: t('project.inProgress') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 140,
|
||||
title: t('common.action'), key: 'action', width: 140,
|
||||
render: (_: unknown, record: Project) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>查看</Button>
|
||||
{isAdmin && <Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteProject(record.id)}>删除</Button>}
|
||||
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>{t('common.view')}</Button>
|
||||
{isAdmin && <Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteProject(record.id)}>{t('common.delete')}</Button>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -202,13 +208,13 @@ const ProjectsPage: React.FC = () => {
|
||||
|
||||
const mobileColumns = [
|
||||
{
|
||||
title: '项目', dataIndex: 'name', key: 'name', ellipsis: true,
|
||||
title: t('dashboard.project'), dataIndex: 'name', key: 'name', ellipsis: true,
|
||||
render: (text: string, record: Project) => (
|
||||
<a onClick={() => navigate(`/projects/${record.id}`)}>{text}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '进度', dataIndex: 'progress', key: 'progress', width: 80,
|
||||
title: t('project.progress'), dataIndex: 'progress', key: 'progress', width: 80,
|
||||
render: (progress: number) => (
|
||||
<div style={{ width: 60 }}>
|
||||
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 6 }}>
|
||||
@@ -222,21 +228,21 @@ const ProjectsPage: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 70,
|
||||
title: t('project.status'), dataIndex: 'status', key: 'status', width: 70,
|
||||
render: (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
planning: { color: 'blue', text: '规划' }, in_progress: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: '完成' }, suspended: { color: 'warning', text: '暂停' },
|
||||
active: { color: 'processing', text: '进行中' },
|
||||
planning: { color: 'blue', text: t('project.plan') }, in_progress: { color: 'processing', text: t('project.inProgress') },
|
||||
completed: { color: 'success', text: t('project.complete') }, suspended: { color: 'warning', text: t('project.pause') },
|
||||
active: { color: 'processing', text: t('project.inProgress') },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color} style={{ fontSize: 10 }}>{config.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 60,
|
||||
title: t('common.action'), key: 'action', width: 60,
|
||||
render: (_: unknown, record: Project) => (
|
||||
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>查看</Button>
|
||||
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>{t('common.view')}</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -245,15 +251,15 @@ const ProjectsPage: React.FC = () => {
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>项目管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>管理项目信息、进度和预算</Paragraph>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('project.title')}</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('project.description')}</Paragraph>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateClick}>
|
||||
{isMobile ? '新建' : '新建项目'}
|
||||
{isMobile ? t('common.create') : t('project.newProject')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card title="项目列表" size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}>
|
||||
<Card title={t('project.list')} size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
|
||||
) : (
|
||||
@@ -268,32 +274,37 @@ const ProjectsPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="快速新建项目"
|
||||
title={t('project.quickCreate')}
|
||||
open={createModalVisible}
|
||||
onOk={handleCreateSubmit}
|
||||
onCancel={() => setCreateModalVisible(false)}
|
||||
confirmLoading={createLoading}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
okText={t('common.create')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={isMobile ? '95%' : 560}
|
||||
style={{ top: isMobile ? 10 : 40 }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" size="middle">
|
||||
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请输入项目名称' }]}>
|
||||
<Input placeholder="如:万象省赛塔尼县22kV线路工程" />
|
||||
<Form.Item name="name" label={t('project.projectName')} rules={[{ required: true, message: t('common.inputPlaceholder') + t('project.projectName') }]}>
|
||||
<Input placeholder={t('project.projectNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="type_template_id" label={t('project.projectTemplate')}>
|
||||
<Select allowClear placeholder={t('project.selectTemplate')}
|
||||
options={templates.map((tpl: any) => ({ value: tpl.id, label: tpl.name }))} />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="customer_id" label="客户">
|
||||
<Select showSearch optionFilterProp="label" allowClear placeholder="选择客户"
|
||||
<Form.Item name="customer_id" label={t('project.customer')}>
|
||||
<Select showSearch optionFilterProp="label" allowClear placeholder={t('project.selectCustomer')}
|
||||
options={customers.map((c: any) => ({ value: c.id, label: c.name }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="project_manager_id" label="项目经理">
|
||||
<Select showSearch optionFilterProp="label" allowClear placeholder="选择项目经理"
|
||||
<Form.Item name="project_manager_id" label={t('project.projectManager')}>
|
||||
<Select showSearch optionFilterProp="label" allowClear placeholder={t('project.selectManager')}
|
||||
options={users.map((u: any) => ({ value: u.id, label: u.name || u.username }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -301,16 +312,16 @@ const ProjectsPage: React.FC = () => {
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="contract_amount" label="合同金额">
|
||||
<Form.Item name="contract_amount" label={t('project.contractAmount')}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} placeholder="0" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="项目状态">
|
||||
<Form.Item name="status" label={t('project.projectStatus')}>
|
||||
<Select options={[
|
||||
{ value: 'planning', label: '规划中' },
|
||||
{ value: 'in_progress', label: '进行中' },
|
||||
{ value: 'completed', label: '已完成(补录历史项目)' },
|
||||
{ value: 'planning', label: t('project.planning') },
|
||||
{ value: 'in_progress', label: t('project.inProgress') },
|
||||
{ value: 'completed', label: t('project.completedHistory') },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
@@ -318,41 +329,41 @@ const ProjectsPage: React.FC = () => {
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="start_date" label="开始日期">
|
||||
<Form.Item name="start_date" label={t('project.startDate')}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="end_date" label="结束日期">
|
||||
<Form.Item name="end_date" label={t('project.endDate')}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="location" label="项目地点">
|
||||
<Input placeholder="如:老挝万象省" />
|
||||
<Form.Item name="location" label={t('project.location')}>
|
||||
<Input placeholder={t('project.locationPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="项目描述">
|
||||
<Input.TextArea rows={2} placeholder="简要描述项目内容" />
|
||||
<Form.Item name="description" label={t('project.description')}>
|
||||
<Input.TextArea rows={2} placeholder={t('project.descriptionPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除确认"
|
||||
title={t('project.deleteConfirm')}
|
||||
open={deleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
confirmLoading={deleteLoading}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okText={t('common.deleteConfirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p>确定要删除这个项目吗?此操作不可恢复。</p>
|
||||
<p>请输入管理员密码确认删除操作:</p>
|
||||
<p>{t('project.deleteConfirmMsg')}</p>
|
||||
<p>{t('project.deletePassMsg')}</p>
|
||||
</div>
|
||||
<Input.Password placeholder="请输入管理员密码" value={deletePassword} onChange={(e) => setDeletePassword(e.target.value)} size="large" />
|
||||
<Input.Password placeholder={t('common.inputPassword')} value={deletePassword} onChange={(e) => setDeletePassword(e.target.value)} size="large" />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,48 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Card, Typography, Table, DatePicker, Row, Col, Statistic, Tag, Spin } from 'antd';
|
||||
import { RiseOutlined, FallOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import apiClient from '../../utils/request';
|
||||
import dayjs from 'dayjs';
|
||||
import { useLanguageStore } from '../../store/languageStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const LEVEL2_LABELS: Record<string, string> = {
|
||||
contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入',
|
||||
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
|
||||
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
|
||||
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
|
||||
salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤',
|
||||
vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展',
|
||||
entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出'
|
||||
};
|
||||
|
||||
const ReportsPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguageStore();
|
||||
|
||||
const LEVEL2_LABELS = useMemo<Record<string, string>>(() => ({
|
||||
contract_payment: t('finance.projectRevenue'),
|
||||
deposit_refund: t('finance.warrantyReturn'),
|
||||
shareholder_investment: t('finance.shareholderInvestment'),
|
||||
other_income: t('finance.otherIncome'),
|
||||
material: t('finance.materialPurchase'),
|
||||
equipment: t('finance.equipmentPurchase'),
|
||||
subcontract: t('finance.constructionSubcontract'),
|
||||
construction_subcontract: t('finance.constructionSubcontract'),
|
||||
labor: t('finance.laborWage'),
|
||||
travel: t('finance.travelTransport'),
|
||||
accommodation: t('finance.accommodationFood'),
|
||||
freight: t('finance.transportLogistics'),
|
||||
transport_logistics: t('finance.transportLogistics'),
|
||||
design: t('finance.surveyDesign'),
|
||||
survey_design: t('finance.surveyDesign'),
|
||||
tools: t('finance.smallTools'),
|
||||
client_relations: t('finance.customerEDLRelation'),
|
||||
customer_edl: t('finance.customerEDLRelation'),
|
||||
other_project: t('finance.otherProjectExpense'),
|
||||
salary: t('finance.salaryWelfare'),
|
||||
rent: t('finance.rentProperty'),
|
||||
office: t('finance.officeExpense'),
|
||||
commute: t('finance.commute'),
|
||||
vehicle_maintenance: t('finance.vehicleMaintenance'),
|
||||
assets: t('finance.fixedAsset'),
|
||||
marketing: t('finance.marketing'),
|
||||
entertainment: t('finance.entertainment'),
|
||||
welfare: t('finance.employeeBenefit'),
|
||||
logistics: t('finance.expressLogistics'),
|
||||
other_company: t('finance.otherCompanyExpense'),
|
||||
}), [t]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
|
||||
@@ -57,31 +83,31 @@ const ReportsPage: React.FC = () => {
|
||||
}));
|
||||
|
||||
const desktopColumns = [
|
||||
{ title: '月份', dataIndex: 'month', key: 'month' },
|
||||
{ title: '总收入', dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
|
||||
{ title: '总支出', dataIndex: 'expense', key: 'expense', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
|
||||
{ title: t('reports.month'), dataIndex: 'month', key: 'month' },
|
||||
{ title: t('reports.totalIncome'), dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
|
||||
{ title: t('reports.totalExpense'), dataIndex: 'expense', key: 'expense', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
|
||||
{
|
||||
title: '净利润', dataIndex: 'profit', key: 'profit',
|
||||
title: t('reports.netProfit'), dataIndex: 'profit', key: 'profit',
|
||||
render: (v: number) => <span style={{ color: v >= 0 ? 'green' : 'red', fontWeight: 'bold' }}>¥{v.toLocaleString()}</span>
|
||||
},
|
||||
];
|
||||
|
||||
const mobileColumns = [
|
||||
{ title: '月份', dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') },
|
||||
{ title: '收入', dataIndex: 'income', key: 'income', render: (v: number) => <span style={{ color: 'green' }}>¥{(parseFloat(v) / 10000).toFixed(1)}万</span> },
|
||||
{ title: '支出', dataIndex: 'expense', key: 'expense', render: (v: number) => <span style={{ color: 'red' }}>¥{(parseFloat(v) / 10000).toFixed(1)}万</span> },
|
||||
{ title: t('reports.month'), dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') },
|
||||
{ title: t('reports.totalIncome'), dataIndex: 'income', key: 'income', render: (v: number) => <span style={{ color: 'green' }}>¥{(parseFloat(v) / 10000).toFixed(1)}{t('common.tenThousand')}</span> },
|
||||
{ title: t('reports.totalExpense'), dataIndex: 'expense', key: 'expense', render: (v: number) => <span style={{ color: 'red' }}>¥{(parseFloat(v) / 10000).toFixed(1)}{t('common.tenThousand')}</span> },
|
||||
{
|
||||
title: '利润', dataIndex: 'profit', key: 'profit',
|
||||
render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}万</b>
|
||||
title: t('reports.netProfit'), dataIndex: 'profit', key: 'profit',
|
||||
render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}{t('common.tenThousand')}</b>
|
||||
},
|
||||
];
|
||||
|
||||
const categoryColumns = [
|
||||
{ title: '分类', dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
|
||||
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
|
||||
{ title: t('finance.level2Category'), dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: t('finance.amount'), dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
|
||||
{ title: t('project.count'), dataIndex: 'count', align: 'center' as const },
|
||||
{
|
||||
title: '占比', render: (_: unknown, r: any) => {
|
||||
title: t('project.ratio'), render: (_: unknown, r: any) => {
|
||||
const total = byCategory.filter(c => c.category_level1 === r.category_level1).reduce((s, c) => s + parseFloat(c.total_amount), 0);
|
||||
return total > 0 ? `${(parseFloat(r.total_amount) / total * 100).toFixed(1)}%` : '-';
|
||||
}
|
||||
@@ -95,30 +121,30 @@ const ReportsPage: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>统计报表</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>查看项目财务报表和统计分析数据</Paragraph>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('reports.title')}</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('reports.description')}</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title="总收入" value={summary.total_income} prefix="¥" valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
|
||||
<Statistic title={t('reports.totalIncome')} value={summary.total_income} prefix="¥" valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title="总支出" value={summary.total_expense} prefix="¥" valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
<Statistic title={t('reports.totalExpense')} value={summary.total_expense} prefix="¥" valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title="净利润" value={summary.net_profit} prefix="¥" valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
<Statistic title={t('reports.netProfit')} value={summary.net_profit} prefix="¥" valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="月度财务报表" size="small" style={{ marginBottom: 16 }}
|
||||
extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder="选择月份" />}
|
||||
<Card title={t('reports.monthlyReport')} size="small" style={{ marginBottom: 16 }}
|
||||
extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder={t('reports.selectMonth')} />}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
@@ -133,7 +159,7 @@ const ReportsPage: React.FC = () => {
|
||||
return (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}><strong>合计</strong></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={0}><strong>{t('common.total')}</strong></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}><strong>¥{ti.toLocaleString()}</strong></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2}><strong>¥{te.toLocaleString()}</strong></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}>
|
||||
@@ -150,21 +176,21 @@ const ReportsPage: React.FC = () => {
|
||||
<Row gutter={[16, 16]}>
|
||||
{incomeCategories.length > 0 && (
|
||||
<Col xs={24} md={8}>
|
||||
<Card title={<span><Tag color="green">收入</Tag>收入分类</span>} size="small">
|
||||
<Card title={<span><Tag color="green">{t('reports.categoryTag')}</Tag>{t('reports.incomeCategory')}</span>} size="small">
|
||||
<Table dataSource={incomeCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
{projectCategories.length > 0 && (
|
||||
<Col xs={24} md={8}>
|
||||
<Card title={<span><Tag color="blue">项目</Tag>项目支出分类</span>} size="small">
|
||||
<Card title={<span><Tag color="blue">{t('reports.projectTag')}</Tag>{t('reports.projectExpenseCategory')}</span>} size="small">
|
||||
<Table dataSource={projectCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
{companyCategories.length > 0 && (
|
||||
<Col xs={24} md={8}>
|
||||
<Card title={<span><Tag color="orange">公司</Tag>公司支出分类</span>} size="small">
|
||||
<Card title={<span><Tag color="orange">{t('reports.companyTag')}</Tag>{t('reports.companyExpenseCategory')}</span>} size="small">
|
||||
<Table dataSource={companyCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface User {
|
||||
role: 'admin' | 'manager' | 'user' | 'finance'
|
||||
department?: string
|
||||
avatar?: string
|
||||
passport?: string
|
||||
driverLicense?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
@@ -93,7 +95,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
department: userData.department,
|
||||
avatar: userData.avatar
|
||||
avatar: userData.avatar,
|
||||
passport: userData.passport,
|
||||
driverLicense: userData.driverLicense
|
||||
}
|
||||
|
||||
set({
|
||||
|
||||
@@ -7,7 +7,7 @@ interface LanguageState {
|
||||
currentLanguage: LanguageCode
|
||||
setLanguage: (code: LanguageCode) => void
|
||||
getLanguageInfo: () => any
|
||||
t: (key: string) => string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
export const useLanguageStore = create<LanguageState>()(
|
||||
@@ -33,7 +33,7 @@ export const useLanguageStore = create<LanguageState>()(
|
||||
return getLanguage(get().currentLanguage)
|
||||
},
|
||||
|
||||
t: (key: string): string => {
|
||||
t: (key: string, params?: Record<string, string | number>): string => {
|
||||
const translation = getTranslation(get().currentLanguage)
|
||||
const keys = key.split('.')
|
||||
let result: any = translation
|
||||
@@ -42,11 +42,19 @@ export const useLanguageStore = create<LanguageState>()(
|
||||
if (result && typeof result === 'object') {
|
||||
result = result[k]
|
||||
} else {
|
||||
return key // 找不到翻译,返回key
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
return typeof result === 'string' ? result : key
|
||||
let text = typeof result === 'string' ? result : key
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||
})
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user