备份:PWA配置前的完整版本

包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
This commit is contained in:
a273825743
2026-06-13 12:44:48 +08:00
parent 5fd19822a2
commit 706dcc24eb
83 changed files with 26590 additions and 13379 deletions
+2
View File
@@ -40,6 +40,7 @@ app.use('/api/products', require('./routes/products'));
app.use('/api/customers', require('./routes/customers')); app.use('/api/customers', require('./routes/customers'));
app.use('/api/suppliers', require('./routes/suppliers')); app.use('/api/suppliers', require('./routes/suppliers'));
app.use('/api/subcontractors', require('./routes/subcontractors')); 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/projects', require('./routes/projects'));
app.use('/api/upload', require('./routes/upload')); app.use('/api/upload', require('./routes/upload'));
app.use('/api/construction', require('./routes/construction')); 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/receiving', require('./routes/receiving'));
app.use('/api/expense-categories', require('./routes/expense-categories')); app.use('/api/expense-categories', require('./routes/expense-categories'));
app.use('/api/financial-records', require('./routes/financial-records')); app.use('/api/financial-records', require('./routes/financial-records'));
app.use('/api/cash-management', require('./routes/cash-management'));
app.get('*', (req, res) => { app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
+7 -7
View File
@@ -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] [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: '更新成功' }); res.json({ success: true, message: '更新成功' });
} else { } else {
res.status(404).json({ success: false, message: '预支申请不存在' }); 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]); 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: '删除成功' }); res.json({ success: true, message: '删除成功' });
} else { } else {
res.status(404).json({ success: false, message: '预支申请不存在' }); 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]); 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: '提交成功' }); res.json({ success: true, message: '提交成功' });
} else { } else {
res.status(404).json({ success: false, message: '预支申请不存在' }); 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]); 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: '撤回成功' }); res.json({ success: true, message: '撤回成功' });
} else { } else {
res.status(404).json({ success: false, message: '预支申请不存在' }); 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]); 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: '审批通过成功' }); res.json({ success: true, message: '审批通过成功' });
} else { } else {
res.status(404).json({ success: false, message: '预支申请不存在' }); res.status(404).json({ success: false, message: '预支申请不存在' });
@@ -199,9 +199,9 @@ router.post('/:id/reject', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { rejectReason } = req.body; 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: '退回成功' }); res.json({ success: true, message: '退回成功' });
} else { } else {
res.status(404).json({ success: false, message: '预支申请不存在' }); res.status(404).json({ success: false, message: '预支申请不存在' });
+4 -1
View File
@@ -16,7 +16,7 @@ router.post('/login', async (req, res) => {
} }
const result = await db.query( 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] [username]
); );
@@ -53,6 +53,9 @@ router.post('/login', async (req, res) => {
phone: user.phone, phone: user.phone,
role: user.role, role: user.role,
department: '', department: '',
avatar: user.avatar || null,
passport: user.passport || null,
driverLicense: user.driver_license || null,
token: token token: token
} }
}); });
+280
View File
@@ -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;
+295
View File
@@ -4,6 +4,7 @@ const { authenticate, requireAdmin } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
// 获取施工项目列表
router.get('/my-projects', async (req, res) => { router.get('/my-projects', async (req, res) => {
try { try {
const result = await db.query(` 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; module.exports = router;
+1 -1
View File
@@ -85,7 +85,7 @@ router.post('/', async (req, res) => {
let status = action === 'execute' ? 'executed' : 'rejected'; let status = action === 'execute' ? 'executed' : 'rejected';
if (action === 'reject') { if (action === 'reject') {
status = 'pending_edit'; status = 'pending';
} }
const executeDate = new Date().toISOString().split('T')[0]; const executeDate = new Date().toISOString().split('T')[0];
+29 -4
View File
@@ -236,8 +236,14 @@ router.post('/batch', authenticate, async (req, res) => {
for (let i = 0; i < records.length; i++) { for (let i = 0; i < records.length; i++) {
const r = records[i]; const r = records[i];
try { try {
if (!r.txn_type || !r.category_level1 || !r.category_level2 || !r.amount_original || !r.record_date) { const missingFields = [];
errors.push({ row: i + 1, message: '缺少必填字段' }); 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; continue;
} }
@@ -246,15 +252,34 @@ router.post('/batch', authenticate, async (req, res) => {
let projectId = r.project_id || null; let projectId = r.project_id || null;
if (!projectId && r.project_name) { if (!projectId && r.project_name) {
// 先精确匹配
const projResult = await db.query("SELECT id FROM projects WHERE name = $1", [r.project_name]); const projResult = await db.query("SELECT id FROM projects WHERE name = $1", [r.project_name]);
if (projResult.rows.length > 0) { if (projResult.rows.length > 0) {
projectId = projResult.rows[0].id; 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; let userId = r.user_id || null;
if (!userId && r.user_name) { 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) { if (userResult.rows.length > 0) {
userId = userResult.rows[0].id; userId = userResult.rows[0].id;
} }
@@ -280,7 +305,7 @@ router.post('/batch', authenticate, async (req, res) => {
); );
results.push(result.rows[0]); results.push(result.rows[0]);
} catch (err) { } 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 } });
} }
} }
+26
View File
@@ -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) => { router.post('/out', async (req, res) => {
try { try {
const { project_id, product_id, quantity, unit_price, total_amount, operator, remark } = req.body; const { project_id, product_id, quantity, unit_price, total_amount, operator, remark } = req.body;
+8 -8
View File
@@ -61,7 +61,7 @@ router.post('/', async (req, res) => {
payee, bank_account, bank_name, amount, currency, reason, payment_date, payee, bank_account, bank_name, amount, currency, reason, payment_date,
request_code, status, applicant, detail_items, attachments, request_code, status, applicant, detail_items, attachments,
payee_type, payee_id, expense_type, expense_category, project_id 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', payee, finalBankAccount, finalBankName, finalAmount, currency || 'CNY',
reason, payment_date, requestCode, 'pending', applicant, reason, payment_date, requestCode, 'pending', applicant,
@@ -157,7 +157,7 @@ router.put('/:id', async (req, res) => {
params params
); );
if (result.changes > 0) { if (result.rowCount > 0) {
res.json({ success: true, message: '更新成功' }); res.json({ success: true, message: '更新成功' });
} else { } else {
res.status(404).json({ success: false, message: '付款申请不存在' }); 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]); 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: '删除成功' }); res.json({ success: true, message: '删除成功' });
} else { } else {
res.status(404).json({ success: false, message: '付款申请不存在' }); 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]); 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: '提交成功' }); res.json({ success: true, message: '提交成功' });
} else { } else {
res.status(404).json({ success: false, message: '付款申请不存在' }); 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]); 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: '撤回成功' }); res.json({ success: true, message: '撤回成功' });
} else { } else {
res.status(404).json({ success: false, message: '付款申请不存在' }); 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]); 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: '审批通过成功' }); res.json({ success: true, message: '审批通过成功' });
} else { } else {
res.status(404).json({ success: false, message: '付款申请不存在' }); res.status(404).json({ success: false, message: '付款申请不存在' });
@@ -242,9 +242,9 @@ router.post('/:id/reject', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { rejectReason } = req.body; 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: '退回成功' }); res.json({ success: true, message: '退回成功' });
} else { } else {
res.status(404).json({ success: false, message: '付款申请不存在' }); res.status(404).json({ success: false, message: '付款申请不存在' });
+1 -1
View File
@@ -106,7 +106,7 @@ router.post('/:id/copy', async (req, res) => {
const src = existing.rows[0]; const src = existing.rows[0];
const result = await db.query( const result = await db.query(
'INSERT INTO project_type_templates (name, description, phases, is_system) VALUES ($1, $2, $3, false) RETURNING id', '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 } }); res.json({ success: true, message: '模板复制成功', data: { id: result.rows[0].id } });
} catch (error) { } catch (error) {
+203
View File
@@ -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
View File
@@ -19,10 +19,17 @@ router.get('/', async (req, res) => {
p.status, p.status,
p.location, p.location,
c.name as customer_name, 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 FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN users u ON p.project_manager_id = u.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 ORDER BY p.created_at DESC
LIMIT 50 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) { if (start_date && end_date) {
const start = new Date(start_date); const start = new Date(start_date);
const end = new Date(end_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'] [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. 处理单价项 // 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) => { router.get('/:id/cost-summary', async (req, res) => {
try { try {
const { id } = req.params; 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]); const projectResult = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
if (projectResult.rows.length === 0) { if (projectResult.rows.length === 0) {
return res.status(404).json({ success: false, message: '项目不存在' }); return res.status(404).json({ success: false, message: '项目不存在' });
} }
const project = projectResult.rows[0]; const project = projectResult.rows[0];
const purchaseByCategory = {};
let totalPurchase = 0;
purchaseResult.rows.forEach(row => { const incomeResult = await db.query(`
purchaseByCategory[row.expense_category] = row.total_amount; SELECT category_level2, COALESCE(SUM(amount_cny), 0) as total_amount
totalPurchase += row.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({ res.json({
success: true, success: true,
data: { data: {
project_name: project.name, project_name: project.name,
contract_amount: project.contract_amount || 0, contract_amount: project.contract_amount || 0,
purchase_cost: { income: {
total: totalPurchase, total: totalIncome,
by_category: purchaseByCategory by_category: incomeByCategory
}, },
payment_cost: totalPayment, expense: {
total_cost: totalPurchase + totalPayment, total: totalExpense,
profit: (project.contract_amount || 0) - (totalPurchase + totalPayment) by_category: expenseByCategory,
by_level1: expenseByLevel1
},
total_cost: totalExpense,
profit: totalIncome - totalExpense
} }
}); });
} catch (error) { } catch (error) {
+6 -6
View File
@@ -203,7 +203,7 @@ router.put('/:id', async (req, res) => {
WHERE id = ? WHERE id = ?
`, [supplier_id, supplier_country, contract_url, quotation_url, remark, 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: '采购订单不存在' }); return res.status(404).json({ success: false, message: '采购订单不存在' });
} }
@@ -303,7 +303,7 @@ router.post('/:id/cancel', async (req, res) => {
[id] [id]
); );
if (result.changes === 0) { if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '采购订单不存在' }); 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 = ? WHERE id = ? AND purchase_order_id = ?
`, [product_id, product_name, specification, unit, quantity, unit_price, total_price, itemId, 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: '商品明细不存在' }); return res.status(404).json({ success: false, message: '商品明细不存在' });
} }
@@ -418,7 +418,7 @@ router.delete('/:id/items/:itemId', async (req, res) => {
[itemId, id] [itemId, id]
); );
if (result.changes === 0) { if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '商品明细不存在' }); 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 = ? WHERE id = ? AND purchase_order_id = ?
`, [stage, planned_date, planned_amount, planned_percentage, remark, planId, 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: '付款计划不存在' }); return res.status(404).json({ success: false, message: '付款计划不存在' });
} }
@@ -533,7 +533,7 @@ router.delete('/:id/payment-plans/:planId', async (req, res) => {
[planId, id] [planId, id]
); );
if (result.changes === 0) { if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '付款计划不存在' }); return res.status(404).json({ success: false, message: '付款计划不存在' });
} }
+7 -7
View File
@@ -128,7 +128,7 @@ router.post('/', async (req, res) => {
VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id`, RETURNING id`,
[requestCode, project_id, purchase_type || 'inventory', total_amount || 0, currency || 'CNY', [requestCode, project_id, purchase_type || 'inventory', total_amount || 0, currency || 'CNY',
'pending_edit', remark]); 'pending', remark]);
res.json({ res.json({
success: true, success: true,
@@ -219,7 +219,7 @@ router.post('/:id/submit', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const result = await db.query( 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] ['pending', id]
); );
@@ -256,7 +256,7 @@ router.post('/:id/approve', async (req, res) => {
try { try {
await db.query( 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] ['approved', id]
); );
@@ -297,8 +297,8 @@ router.post('/:id/reject', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const result = await db.query( 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_edit', id] ['pending', id]
); );
if (result.rowCount === 0) { if (result.rowCount === 0) {
@@ -321,7 +321,7 @@ router.post('/:id/withdraw', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const result = await db.query( 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] ['withdrawn', id]
); );
@@ -345,7 +345,7 @@ router.post('/:id/execute', async (req, res) => {
const { id } = req.params; const { id } = req.params;
await db.query( 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] ['executed', id]
); );
+7 -7
View File
@@ -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] [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: '更新成功' }); res.json({ success: true, message: '更新成功' });
} else { } else {
res.status(404).json({ success: false, message: '报销申请不存在' }); 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]); 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: '删除成功' }); res.json({ success: true, message: '删除成功' });
} else { } else {
res.status(404).json({ success: false, message: '报销申请不存在' }); 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]); 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: '提交成功' }); res.json({ success: true, message: '提交成功' });
} else { } else {
res.status(404).json({ success: false, message: '报销申请不存在' }); 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]); 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: '撤回成功' }); res.json({ success: true, message: '撤回成功' });
} else { } else {
res.status(404).json({ success: false, message: '报销申请不存在' }); 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]); 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: '审批通过成功' }); res.json({ success: true, message: '审批通过成功' });
} else { } else {
res.status(404).json({ success: false, message: '报销申请不存在' }); res.status(404).json({ success: false, message: '报销申请不存在' });
@@ -222,9 +222,9 @@ router.post('/:id/reject', async (req, res) => {
const { id } = req.params; const { id } = req.params;
const { rejectReason } = req.body; 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: '退回成功' }); res.json({ success: true, message: '退回成功' });
} else { } else {
res.status(404).json({ success: false, message: '报销申请不存在' }); res.status(404).json({ success: false, message: '报销申请不存在' });
+1 -1
View File
@@ -248,7 +248,7 @@ router.post('/:id/reject', async (req, res) => {
WHERE id = ? WHERE id = ?
`, [reason || '无', id]); `, [reason || '无', id]);
if (result.changes === 0) { if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '退库单不存在' }); return res.status(404).json({ success: false, message: '退库单不存在' });
} }
+64 -6
View File
@@ -4,6 +4,34 @@ const db = require('../db');
const { hashPassword, verifyPassword } = require('../utils/auth'); const { hashPassword, verifyPassword } = require('../utils/auth');
const { authenticate, requireAdmin } = require('../middleware/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) => { router.get('/', authenticate, requireAdmin, async (req, res) => {
try { try {
const usersResult = await db.query('SELECT id, username, name, email, phone, role, is_active, created_at, updated_at FROM users ORDER BY id'); 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) => { router.put('/:id', authenticate, requireAdmin, async (req, res) => {
try { try {
const { id } = req.params; 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) { if (password) {
const passwordHash = hashPassword(password); const passwordHash = hashPassword(password);
await db.query( await db.query(
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, password_hash = $5, updated_at = NOW() WHERE id = $6', '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, passwordHash, id] [name, email || null, phone || null, role, avatar || null, passport || null, driver_license || null, passwordHash, id]
); );
} else { } else {
await db.query( await db.query(
'UPDATE users SET name = $1, email = $2, phone = $3, role = $4, updated_at = NOW() WHERE id = $5', '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, id] [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]; const updatedUser = updatedUserResult.rows[0];
if (!updatedUser) { if (!updatedUser) {
+1 -1
View File
@@ -284,7 +284,7 @@ router.post('/:id/reject', async (req, res) => {
WHERE id = ? WHERE id = ?
`, [reason || '无', id]); `, [reason || '无', id]);
if (result.changes === 0) { if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: '验收单不存在' }); return res.status(404).json({ success: false, message: '验收单不存在' });
} }
+7 -7
View File
@@ -206,7 +206,7 @@ router.put('/:id', async (req, res) => {
// 提交事务 // 提交事务
await db.query('COMMIT'); await db.query('COMMIT');
if (result.changes > 0) { if (result.rowCount > 0) {
res.json({ success: true, message: '更新成功' }); res.json({ success: true, message: '更新成功' });
} else { } else {
res.status(404).json({ success: false, message: '核销申请不存在' }); res.status(404).json({ success: false, message: '核销申请不存在' });
@@ -249,7 +249,7 @@ router.delete('/:id', async (req, res) => {
// 提交事务 // 提交事务
await db.query('COMMIT'); await db.query('COMMIT');
if (result.changes > 0) { if (result.rowCount > 0) {
res.json({ success: true, message: '删除成功' }); res.json({ success: true, message: '删除成功' });
} else { } else {
res.status(404).json({ success: false, message: '核销申请不存在' }); 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]); 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: '提交成功' }); res.json({ success: true, message: '提交成功' });
} else { } else {
res.status(404).json({ success: false, message: '核销申请不存在' }); 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]); 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: '撤回成功' }); res.json({ success: true, message: '撤回成功' });
} else { } else {
res.status(404).json({ success: false, message: '核销申请不存在' }); 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]); 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: '审批通过成功' }); res.json({ success: true, message: '审批通过成功' });
} else { } else {
res.status(404).json({ success: false, message: '核销申请不存在' }); 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 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) { // if (advanceId && amount > 0) {
@@ -345,7 +345,7 @@ router.post('/:id/reject', async (req, res) => {
// 提交事务 // 提交事务
await db.query('COMMIT'); await db.query('COMMIT');
if (result.changes > 0) { if (result.rowCount > 0) {
res.json({ success: true, message: '退回成功' }); res.json({ success: true, message: '退回成功' });
} else { } else {
res.status(404).json({ success: false, message: '核销申请不存在' }); res.status(404).json({ success: false, message: '核销申请不存在' });
+3 -1
View File
@@ -73,6 +73,7 @@ import { useLanguageStore } from './store/languageStore'
// 路由守卫组件 // 路由守卫组件
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, user, isLoading } = useAuthStore() const { isAuthenticated, user, isLoading } = useAuthStore()
const { t, currentLanguage } = useLanguageStore()
if (isLoading) { if (isLoading) {
return ( return (
@@ -83,7 +84,7 @@ const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =>
height: '100vh', height: '100vh',
fontSize: '24px' fontSize: '24px'
}}> }}>
... {t('common.loading')}
</div> </div>
) )
} }
@@ -143,6 +144,7 @@ function App() {
<Route path="budget-projects/create" element={<BudgetProjectCreate />} /> <Route path="budget-projects/create" element={<BudgetProjectCreate />} />
<Route path="budget-projects/:id" element={<BudgetProjectDetail />} /> <Route path="budget-projects/:id" element={<BudgetProjectDetail />} />
<Route path="construction" element={<ConstructionOverview />} /> <Route path="construction" element={<ConstructionOverview />} />
<Route path="construction/:id" element={<ConstructionProgress />} />
<Route path="construction/:id/logs" element={<ConstructionLog />} /> <Route path="construction/:id/logs" element={<ConstructionLog />} />
<Route path="construction/:id/milestones" element={<ConstructionMilestones />} /> <Route path="construction/:id/milestones" element={<ConstructionMilestones />} />
<Route path="construction/progress/:id" element={<ConstructionProgress />} /> <Route path="construction/progress/:id" element={<ConstructionProgress />} />
+40 -37
View File
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { Table, Card, Row, Col, Statistic, Empty, Tag } from 'antd' import { Table, Card, Row, Col, Statistic, Empty, Tag } from 'antd'
import { DollarOutlined } from '@ant-design/icons' import { DollarOutlined } from '@ant-design/icons'
import { useLanguageStore } from '../store/languageStore'
interface LedgerItem { interface LedgerItem {
id: number id: number
@@ -56,21 +57,23 @@ const formatAmount = (amount: number | undefined, currency?: string) => {
const getStatusTag = (status: string | undefined) => { const getStatusTag = (status: string | undefined) => {
if (!status) return '-' if (!status) return '-'
const t = useLanguageStore.getState().t
const map: Record<string, { color: string; text: string }> = { const map: Record<string, { color: string; text: string }> = {
completed: { color: 'success', text: '已完成' }, completed: { color: 'success', text: t('businessLedger.completed') },
in_progress: { color: 'processing', text: '进行中' }, in_progress: { color: 'processing', text: t('businessLedger.inProgress') },
planning: { color: 'default', text: '规划中' }, planning: { color: 'default', text: t('businessLedger.planning') },
pending: { color: 'default', text: '待处理' }, pending: { color: 'default', text: t('businessLedger.pending') },
approved: { color: 'success', text: '已批准' }, approved: { color: 'success', text: t('businessLedger.approved') },
paid: { color: 'green', text: '已支付' }, paid: { color: 'green', text: t('businessLedger.paid') },
requested: { color: 'blue', text: '已申请' }, requested: { color: 'blue', text: t('businessLedger.applied') },
active: { color: 'processing', text: '进行中' }, active: { color: 'processing', text: t('businessLedger.inProgress') },
} }
const info = map[status] || { color: 'default', text: status } const info = map[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag> return <Tag color={info.color}>{info.text}</Tag>
} }
const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summary, items, loading }) => { const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summary, items, loading }) => {
const { t, currentLanguage } = useLanguageStore()
const renderSummaryCards = () => { const renderSummaryCards = () => {
switch (partnerType) { switch (partnerType) {
case 'subcontractor': case 'subcontractor':
@@ -78,22 +81,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}> <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> </Card>
</Col> </Col>
</Row> </Row>
@@ -103,22 +106,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}> <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> </Card>
</Col> </Col>
</Row> </Row>
@@ -128,22 +131,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}> <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> </Card>
</Col> </Col>
</Row> </Row>
@@ -153,22 +156,22 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#e6f7ff', border: '1px solid #91d5ff' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#f6ffed', border: '1px solid #b7eb8f' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fff2f0', border: '1px solid #ffccc7' }}> <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> </Card>
</Col> </Col>
<Col xs={12} lg={6}> <Col xs={12} lg={6}>
<Card size="small" style={{ textAlign: 'center', background: '#fffbe6', border: '1px solid #ffe58f' }}> <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> </Card>
</Col> </Col>
</Row> </Row>
@@ -178,36 +181,36 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
const getColumns = () => { const getColumns = () => {
const baseColumns: any[] = [ const baseColumns: any[] = [
{ title: '编号', dataIndex: 'code', key: 'code', width: 120 }, { title: t('businessLedger.code'), dataIndex: 'code', key: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', key: 'name', render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span> }, { title: t('businessLedger.name'), dataIndex: 'name', key: 'name', render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span> },
] ]
switch (partnerType) { switch (partnerType) {
case 'subcontractor': case 'subcontractor':
return [ return [
...baseColumns, ...baseColumns,
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) }, { title: t('businessLedger.contractAmount'), 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.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
] ]
case 'customer': case 'customer':
return [ return [
...baseColumns, ...baseColumns,
{ title: '合同金额', dataIndex: 'contract_amount', key: 'contract_amount', align: 'right' as const, render: (v: number) => formatAmount(v) }, { title: t('businessLedger.contractAmount'), 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.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
] ]
case 'supplier': case 'supplier':
return [ return [
...baseColumns, ...baseColumns,
{ title: '采购金额', dataIndex: 'order_amount', key: 'order_amount', align: 'right' as const, render: (v: number) => formatAmount(v) }, { title: t('businessLedger.purchaseAmount'), 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.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
] ]
case 'logistics': case 'logistics':
return [ return [
...baseColumns, ...baseColumns,
{ title: '项目', dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' }, { title: t('businessLedger.project'), 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: 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: '一次运费状态', dataIndex: 'primary_freight_status', key: 'primary_freight_status', align: 'center' as const, width: 100, render: getStatusTag }, { title: t('businessLedger.freight1Status'), 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.status'), dataIndex: 'status', key: 'status', align: 'center' as const, render: getStatusTag },
] ]
default: default:
return baseColumns return baseColumns
@@ -228,7 +231,7 @@ const BusinessLedgerTab: React.FC<BusinessLedgerTabProps> = ({ partnerType, summ
bordered bordered
/> />
) : ( ) : (
<Empty description="暂无业务记录" image={Empty.PRESENTED_IMAGE_SIMPLE} /> <Empty description={t('businessLedger.noRecord')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)} )}
</div> </div>
) )
+3 -3
View File
@@ -76,8 +76,8 @@ const ContactManager: React.FC<ContactManagerProps> = ({
render: (_, record) => ( render: (_, record) => (
<Space direction="vertical" size={2}> <Space direction="vertical" size={2}>
{record.mobile && <div><PhoneOutlined style={{ marginRight: 4 }} />{record.mobile}</div>} {record.mobile && <div><PhoneOutlined style={{ marginRight: 4 }} />{record.mobile}</div>}
{record.phone && <div style={{ fontSize: '12px', color: '#666' }}>: {record.phone}</div>} {record.phone && <div style={{ fontSize: '12px', color: '#666' }}>{t('component.phonePrefix')}{record.phone}</div>}
{record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>: {record.wechat}</div>} {record.wechat && <div style={{ fontSize: '12px', color: '#666' }}>{t('component.wechatPrefix')}{record.wechat}</div>}
</Space> </Space>
) )
}, },
@@ -192,7 +192,7 @@ const ContactManager: React.FC<ContactManagerProps> = ({
</Form.Item> </Form.Item>
</div> </div>
<Form.Item name="whatsapp" label="WhatsApp"> <Form.Item name="whatsapp" label="WhatsApp">
<Input placeholder="输入WhatsApp号码" /> <Input placeholder={t('component.whatsappPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="is_primary" label={t('contact.primaryContact')} valuePropName="checked"> <Form.Item name="is_primary" label={t('contact.primaryContact')} valuePropName="checked">
<Switch /> <Switch />
+28 -7
View File
@@ -1,4 +1,5 @@
import React, { Component, ReactNode } from 'react' import React, { Component, ReactNode } from 'react'
import { useLanguageStore } from '../store/languageStore'
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode children: ReactNode
@@ -8,24 +9,44 @@ interface ErrorBoundaryProps {
interface ErrorBoundaryState { interface ErrorBoundaryState {
hasError: boolean hasError: boolean
error?: Error error?: Error
currentLanguage: string
} }
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
unsubscribe: (() => void) | null = null
constructor(props: ErrorBoundaryProps) { constructor(props: ErrorBoundaryProps) {
super(props) 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 } 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) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('组件渲染错误:', error) console.error('组件渲染错误:', error)
console.error('错误信息:', errorInfo.componentStack) console.error('错误信息:', errorInfo.componentStack)
} }
render() { render() {
const t = useLanguageStore.getState().t
if (this.state.hasError) { if (this.state.hasError) {
if (this.props.fallback) { if (this.props.fallback) {
return 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)' boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}> }}>
<div style={{ fontSize: '48px', color: '#ff4d4f', marginBottom: '16px' }}></div> <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' }}> <p style={{ color: '#666', marginBottom: '24px', maxWidth: '500px' }}>
{t('errorBoundary.description')}
</p> </p>
{this.state.error && ( {this.state.error && (
<div style={{ <div style={{
@@ -61,10 +82,10 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
maxWidth: '600px', maxWidth: '600px',
overflow: 'auto' 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 && ( {this.state.error.stack && (
<div style={{ marginTop: '8px' }}> <div style={{ marginTop: '8px' }}>
<strong>:</strong> <strong>{t('errorBoundary.stackTrace')}</strong>
<pre style={{ margin: '8px 0', whiteSpace: 'pre-wrap' }}> <pre style={{ margin: '8px 0', whiteSpace: 'pre-wrap' }}>
{this.state.error.stack} {this.state.error.stack}
</pre> </pre>
@@ -85,7 +106,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
fontSize: '14px' fontSize: '14px'
}} }}
> >
{t('errorBoundary.refresh')}
</button> </button>
</div> </div>
</div> </div>
+25 -7
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Upload, Modal, Image, Spin, Progress, message } from 'antd'; import { Upload, Modal, Image, Spin, Progress, message } from 'antd';
import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons'; import { PlusOutlined, FileOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface'; import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
import { useLanguageStore } from '../store/languageStore';
interface FileUploadProps { interface FileUploadProps {
value?: string[]; value?: string[];
@@ -38,6 +39,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
const [previewImage, setPreviewImage] = useState(''); const [previewImage, setPreviewImage] = useState('');
const [fileList, setFileList] = useState<UploadFile[]>([]); const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const { t, currentLanguage } = useLanguageStore();
// 当 value 变化时,更新 fileList // 当 value 变化时,更新 fileList
useEffect(() => { useEffect(() => {
@@ -107,9 +109,25 @@ const FileUpload: React.FC<FileUploadProps> = ({
formData.append('file', file); formData.append('file', file);
try { 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', { const res = await fetch('/api/upload/single', {
method: 'POST', method: 'POST',
body: formData body: formData,
headers: authHeader ? { Authorization: authHeader } : {},
}); });
const data = await res.json(); const data = await res.json();
@@ -119,15 +137,15 @@ const FileUpload: React.FC<FileUploadProps> = ({
onProgress({ percent: 100 }); onProgress({ percent: 100 });
// 传递包含url属性的对象,这是Ant Design Upload组件在customRequest中期望的格式 // 传递包含url属性的对象,这是Ant Design Upload组件在customRequest中期望的格式
onSuccess({ url: data.data.url }, file); onSuccess({ url: data.data.url }, file);
message.success('上传成功'); message.success(t('fileUpload.uploadSuccess'));
} else { } else {
onError(new Error(data.error)); onError(new Error(data.error));
message.error(data.error || '上传失败'); message.error(data.error || t('fileUpload.uploadFailed'));
} }
} catch (error) { } catch (error) {
console.error('上传错误:', error); console.error('上传错误:', error);
onError(error); onError(error);
message.error('上传失败'); message.error(t('fileUpload.uploadFailed'));
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -136,7 +154,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
const uploadButton = ( const uploadButton = (
<div> <div>
<PlusOutlined /> <PlusOutlined />
<div style={{ marginTop: 8 }}></div> <div style={{ marginTop: 8 }}>{t('fileUpload.upload')}</div>
</div> </div>
); );
@@ -158,7 +176,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
{/* 图片预览弹窗 */} {/* 图片预览弹窗 */}
<Modal <Modal
open={previewOpen} open={previewOpen}
title="图片预览" title={t('fileUpload.preview')}
footer={null} footer={null}
onCancel={() => setPreviewOpen(false)} onCancel={() => setPreviewOpen(false)}
width="80%" width="80%"
@@ -175,7 +193,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
{uploading && ( {uploading && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<Spin size="small" /> ... <Spin size="small" /> {t('fileUpload.uploading')}
</div> </div>
)} )}
</> </>
@@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { Space, Typography } from 'antd' import { Space, Typography } from 'antd'
import { ThunderboltOutlined } from '@ant-design/icons' import { ThunderboltOutlined } from '@ant-design/icons'
import { useLanguageStore } from '../../store/languageStore'
const { Text, Title } = Typography const { Text, Title } = Typography
@@ -10,6 +11,7 @@ interface CompanyLogoProps {
} }
const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medium' }) => { const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medium' }) => {
const { t, currentLanguage } = useLanguageStore()
const sizeMap = { const sizeMap = {
small: { fontSize: 14, iconSize: 20 }, small: { fontSize: 14, iconSize: 20 },
medium: { fontSize: 16, iconSize: 28 }, medium: { fontSize: 16, iconSize: 28 },
@@ -41,7 +43,7 @@ const CompanyLogo: React.FC<CompanyLogoProps> = ({ showText = true, size = 'medi
fontWeight: 600 fontWeight: 600
}} }}
> >
ERP {t('login.title')}
</Title> </Title>
<Text <Text
type="secondary" type="secondary"
+178 -176
View File
@@ -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 { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { import {
Layout, Layout,
@@ -49,169 +49,6 @@ import LanguageSelector from '../common/LanguageSelector'
const { Header, Sider, Content } = Layout const { Header, Sider, Content } = Layout
const { Text } = Typography 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 MainLayout: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@@ -221,12 +58,175 @@ const MainLayout: React.FC = () => {
const [settingsVisible, setSettingsVisible] = useState(false) const [settingsVisible, setSettingsVisible] = useState(false)
const [openKeys, setOpenKeys] = useState<string[]>([]) const [openKeys, setOpenKeys] = useState<string[]>([])
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const { t } = useLanguageStore() const { t, currentLanguage } = useLanguageStore()
const { const {
token: { colorBgContainer, borderRadiusLG }, token: { colorBgContainer, borderRadiusLG },
} = theme.useToken() } = 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(() => { useEffect(() => {
const checkMobile = () => { const checkMobile = () => {
const mobile = window.innerWidth <= 768 const mobile = window.innerWidth <= 768
@@ -241,23 +241,23 @@ const MainLayout: React.FC = () => {
return () => window.removeEventListener('resize', checkMobile) return () => window.removeEventListener('resize', checkMobile)
}, []) }, [])
const userMenuItems = [ const userMenuItems = useMemo(() => [
{ {
key: 'profile', key: 'profile',
icon: <UserOutlined />, icon: <UserOutlined />,
label: '个人信息' label: t('menu.profile')
}, },
{ {
key: 'settings', key: 'settings',
icon: <SettingOutlined />, icon: <SettingOutlined />,
label: '系统设置' label: t('menu.settings')
}, },
...(user?.role === 'admin' ? [{ ...(user?.role === 'admin' ? [{
type: 'divider' as const type: 'divider' as const
}, { }, {
key: '/admin', key: '/admin',
icon: <SettingOutlined />, icon: <SettingOutlined />,
label: '后台管理' label: t('menu.admin')
}] : []), }] : []),
{ {
type: 'divider' as const type: 'divider' as const
@@ -265,9 +265,9 @@ const MainLayout: React.FC = () => {
{ {
key: 'logout', key: 'logout',
icon: <LogoutOutlined />, icon: <LogoutOutlined />,
label: '退出登录' label: t('menu.logout')
} }
] ], [t, currentLanguage, user?.role])
const handleMenuClick = ({ key }: { key: string }) => { const handleMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { if (key === 'logout') {
@@ -313,7 +313,9 @@ const MainLayout: React.FC = () => {
path.startsWith('/inventory')) { path.startsWith('/inventory')) {
return ['procurement'] return ['procurement']
} }
if (path.startsWith('/project-cost')) { if (path.startsWith('/project-cost') ||
path.startsWith('/finance') ||
path.startsWith('/exchange-rates')) {
return ['finance-group'] return ['finance-group']
} }
return [] return []
@@ -389,7 +391,7 @@ const MainLayout: React.FC = () => {
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
style={{ width: collapsed ? '100%' : 'auto' }} style={{ width: collapsed ? '100%' : 'auto' }}
> >
{!collapsed && '收起菜单'} {!collapsed && t('menu.collapse')}
</Button> </Button>
</div> </div>
</Sider> </Sider>
@@ -446,7 +448,7 @@ const MainLayout: React.FC = () => {
<Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems, onClick: handleMenuClick }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}> <Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} /> <Avatar icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
{!isMobile && <Text>{user?.name || user?.username || '用户'}</Text>} {!isMobile && <Text>{user?.name || user?.username || t('user.userLabel')}</Text>}
</Space> </Space>
</Dropdown> </Dropdown>
</Space> </Space>
@@ -462,12 +464,12 @@ const MainLayout: React.FC = () => {
</Layout> </Layout>
<Modal <Modal
title="系统设置" title={t('menu.settings')}
open={settingsVisible} open={settingsVisible}
onCancel={() => setSettingsVisible(false)} onCancel={() => setSettingsVisible(false)}
footer={null} footer={null}
> >
<p>...</p> <p>{t('menu.settings')}...</p>
</Modal> </Modal>
</Layout> </Layout>
) )
+13 -11
View File
@@ -14,58 +14,60 @@ import {
ImportOutlined ImportOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLanguageStore } from '../store/languageStore';
const { Sider, Content } = Layout; const { Sider, Content } = Layout;
const AdminLayout: React.FC = () => { const AdminLayout: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { t, currentLanguage } = useLanguageStore();
const menuItems = [ const menuItems = [
{ {
key: '/admin/users', key: '/admin/users',
icon: <UserOutlined />, icon: <UserOutlined />,
label: '用户管理' label: t('menu.userManagement')
}, },
{ {
key: '/admin/roles', key: '/admin/roles',
icon: <SafetyOutlined />, icon: <SafetyOutlined />,
label: '角色权限' label: t('menu.rolePermission')
}, },
{ {
key: '/admin/process', key: '/admin/process',
icon: <SettingOutlined />, icon: <SettingOutlined />,
label: '流程管理' label: t('menu.processManagement')
}, },
{ {
key: '/admin/process-templates', key: '/admin/process-templates',
icon: <AppstoreOutlined />, icon: <AppstoreOutlined />,
label: '工程模板管理' label: t('menu.templateManagement')
}, },
{ {
key: '/admin/expense-categories', key: '/admin/expense-categories',
icon: <AccountBookOutlined />, icon: <AccountBookOutlined />,
label: '财务分类管理' label: t('menu.expenseCategory')
}, },
{ {
key: '/admin/excel-import', key: '/admin/excel-import',
icon: <ImportOutlined />, icon: <ImportOutlined />,
label: 'Excel批量导入' label: t('menu.excelImport')
}, },
{ {
key: '/admin/logs', key: '/admin/logs',
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
label: '系统日志' label: t('menu.systemLogs')
}, },
{ {
key: '/admin/backup', key: '/admin/backup',
icon: <DatabaseOutlined />, icon: <DatabaseOutlined />,
label: '数据备份' label: t('menu.dataBackup')
}, },
{ {
key: '/admin/about', key: '/admin/about',
icon: <InfoCircleOutlined />, icon: <InfoCircleOutlined />,
label: '关于系统' label: t('menu.aboutSystem')
} }
]; ];
@@ -90,7 +92,7 @@ const AdminLayout: React.FC = () => {
fontWeight: 'bold', fontWeight: 'bold',
fontSize: 16 fontSize: 16
}}> }}>
{t('menu.admin')}
</div> </div>
<Menu <Menu
mode="inline" mode="inline"
@@ -115,7 +117,7 @@ const AdminLayout: React.FC = () => {
gap: 8 gap: 8
}} }}
> >
<ArrowLeftOutlined /> <ArrowLeftOutlined /> {t('menu.backToFront')}
</div> </div>
</div> </div>
</Sider> </Sider>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25 -23
View File
@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import apiClient from '../utils/request' import apiClient from '../utils/request'
import BusinessLedgerTab from '../components/BusinessLedgerTab' import BusinessLedgerTab from '../components/BusinessLedgerTab'
import { useLanguageStore } from '../store/languageStore'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -77,6 +78,7 @@ interface BudgetProject {
const CustomerDetail: React.FC = () => { const CustomerDetail: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [customer, setCustomer] = useState<Customer | null>(null) const [customer, setCustomer] = useState<Customer | null>(null)
const [budgetProjects, setBudgetProjects] = useState<BudgetProject[]>([]) const [budgetProjects, setBudgetProjects] = useState<BudgetProject[]>([])
const [loading, setLoading] = useState(true) 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 (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 = [ 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> <Text strong onClick={() => navigate(`/budget-projects/${record.id}`)} style={{ cursor: 'pointer', color: '#1890ff' }}>{v}</Text>
) }, ) },
{ title: '业务经理', dataIndex: 'manager_name', key: 'manager_name' }, { title: t('customer.businessManager'), dataIndex: 'manager_name', key: 'manager_name' },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, render: (v: string) => { { 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 }> = { const map: Record<string, { status: 'success' | 'processing' | 'error' | 'default'; text: string }> = {
negotiating: { status: 'processing', text: '商谈中' }, negotiating: { status: 'processing', text: t('customer.inNegotiation') },
signed: { status: 'success', text: '已签约' }, signed: { status: 'success', text: t('customer.signed') },
unsigned: { status: 'error', text: '未签约' } unsigned: { status: 'error', text: t('customer.unsigned') }
} }
const c = map[v] || { status: 'default', text: v } const c = map[v] || { status: 'default', text: v }
return <Badge status={c.status} text={c.text} /> return <Badge status={c.status} text={c.text} />
} }, } },
{ title: '报价版本数', dataIndex: 'quotations', key: 'quotations', align: 'center' as const, render: (q: Quotation[]) => (q || []).length }, { title: t('customer.quotationCount'), 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.createdAt'), dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.split('T')[0] || '-' }
] ]
return ( return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} style={{ marginBottom: 16 }} type="text"> <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/customers')} style={{ marginBottom: 16 }} type="text">
{t('customer.returnToList')}
</Button> </Button>
<Title level={4} style={{ marginBottom: 24 }}> <Title level={4} style={{ marginBottom: 24 }}>
@@ -143,42 +145,42 @@ const CustomerDetail: React.FC = () => {
<Card style={{ borderRadius: 8 }}> <Card style={{ borderRadius: 8 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}> <Tabs activeKey={activeTab} onChange={setActiveTab}>
{/* TAB1: 基本信息 */} {/* 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 bordered column={{ xs: 1, sm: 2 }} size="small">
<Descriptions.Item label="编号">{customer.code}</Descriptions.Item> <Descriptions.Item label={t('customer.code')}>{customer.code}</Descriptions.Item>
<Descriptions.Item label="地址">{customer.address || '-'}</Descriptions.Item> <Descriptions.Item label={t('customer.address')}>{customer.address || '-'}</Descriptions.Item>
</Descriptions> </Descriptions>
{customer.remark && ( {customer.remark && (
<div style={{ marginTop: 16 }}> <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 style={{ padding: 12, background: '#f6ffed', borderRadius: 4, border: '1px solid #b7eb8f', marginTop: 8 }}>{customer.remark}</div>
</div> </div>
)} )}
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB2: 联系人 */} {/* TAB2: 联系人 */}
<Tabs.TabPane tab={<span><PhoneOutlined /> </span>} key="contacts"> <Tabs.TabPane tab={<span><PhoneOutlined /> {t('customer.contact')}</span>} key="contacts">
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(customer.contacts || []).map((contact, i) => ( {(customer.contacts || []).map((contact, i) => (
<Col key={i} xs={24} sm={12} lg={8}> <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' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text> <Text strong>{contact.name || t('common.unnamed')}</Text>
{contact.is_primary && <Tag color="green"></Tag>} {contact.is_primary && <Tag color="green">{t('customer.mainContactTag')}</Tag>}
</div> </div>
<div style={{ color: '#666', fontSize: 13 }}> <div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>} {contact.position && <div>{t('customer.positionLabel')}{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>} {contact.phone && <div>{t('customer.phoneLabel')}{contact.phone}</div>}
</div> </div>
</Card> </Card>
</Col> </Col>
))} ))}
</Row> </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> </Tabs.TabPane>
{/* TAB3: 业务台账 */} {/* TAB3: 业务台账 */}
<Tabs.TabPane tab={<span><DollarOutlined /> </span>} key="ledger"> <Tabs.TabPane tab={<span><DollarOutlined /> {t('customer.ledger')}</span>} key="ledger">
<BusinessLedgerTab <BusinessLedgerTab
partnerType="customer" partnerType="customer"
summary={customer.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_received_amount: 0, total_receivable_amount: 0 }} 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> </Tabs.TabPane>
{/* TAB4: 关联预算 */} {/* TAB4: 关联预算 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> </span>} key="budget"> <Tabs.TabPane tab={<span><FileTextOutlined /> {t('customer.relatedBudget')}</span>} key="budget">
{budgetProjects.length > 0 ? ( {budgetProjects.length > 0 ? (
<Table columns={budgetProjectColumns} dataSource={budgetProjects} rowKey="id" size="small" pagination={{ pageSize: 10 }} bordered /> <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.TabPane>
</Tabs> </Tabs>
+62 -59
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, HomeOutline
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import FileUpload from '../components/FileUpload' import FileUpload from '../components/FileUpload'
import useFormDraft from '../hooks/useFormDraft' import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface Contact { interface Contact {
name: string name: string
@@ -37,6 +38,7 @@ interface Customer {
const CustomerPage: React.FC = () => { const CustomerPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
@@ -61,7 +63,7 @@ const CustomerPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) setCustomers(data.data || []) if (data.success) setCustomers(data.data || [])
} catch (error) { } catch (error) {
message.error('获取客户列表失败') message.error(t('customer.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -87,32 +89,32 @@ const CustomerPage: React.FC = () => {
const columns: ColumnsType<Customer> = [ const columns: ColumnsType<Customer> = [
{ {
title: '名称', dataIndex: 'name', key: 'name', title: t('customer.name'), dataIndex: 'name', key: 'name',
render: (text, record) => ( render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/customers/${record.id}`)}>{text}</Button> <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: t('customer.address'), dataIndex: 'address', key: 'address', width: 150, render: (t) => t || '-' },
{ title: '主联系人', key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) }, { title: t('customer.mainContact'), key: 'primary_contact', width: 100, render: (_, record) => getPrimaryContact(record.contacts || []) },
{ {
title: '收款信息', title: t('customer.paymentInfo'),
key: 'payment_info', key: 'payment_info',
width: 200, width: 200,
render: (_, record) => { render: (_, record) => {
const primary = getPrimaryPaymentInfo(record.payment_infos || []) const primary = getPrimaryPaymentInfo(record.payment_infos || [])
if (!primary) return <Tag></Tag> if (!primary) return <Tag>{t('common.notSet')}</Tag>
return ( return (
<div style={{ fontSize: 12 }}> <div style={{ fontSize: 12 }}>
<div><BankOutlined /> {primary.bank_name || '-'}</div> <div><BankOutlined /> {primary.bank_name || '-'}</div>
<div>: {primary.account_name || '-'}</div> <div>{t('customer.accountNameLabel')} {primary.account_name || '-'}</div>
<div>: {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div> <div>{t('customer.accountNumLabel')} {primary.bank_account ? primary.bank_account.slice(-4).padStart(primary.bank_account.length, '*') : '-'}</div>
</div> </div>
) )
} }
}, },
{ title: '合同金额', dataIndex: 'total_contract_amount', key: 'total_contract_amount', width: 100, render: (v) => `¥${(v || 0).toLocaleString()}` }, { title: t('customer.contractAmount'), 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: 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: '操作', key: 'actions', width: 100, render: (_, record) => ( { title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
<Space> <Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" /> <Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} 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() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingCustomer ? '更新成功' : '创建成功') message.success(editingCustomer ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft() clearDraft()
setModalVisible(false) setModalVisible(false)
form.resetFields() form.resetFields()
setEditingCustomer(null) setEditingCustomer(null)
fetchCustomers() fetchCustomers()
} else { } else {
message.error(data.message || '操作失败') message.error(data.message || t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
message.error('操作失败') message.error(t('common.operationFailed'))
} }
} }
@@ -198,14 +200,14 @@ const CustomerPage: React.FC = () => {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '确认删除', content: '确定要删除此客户吗?', okText: '确定', cancelText: '取消', title: t('common.confirmDelete'), content: t('customer.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
onOk: async () => { onOk: async () => {
try { try {
const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' }) const response = await fetch(`/api/customers/${id}`, { method: 'DELETE' })
const data = await response.json() const data = await response.json()
if (data.success) { message.success('删除成功'); fetchCustomers() } if (data.success) { message.success(t('common.deleteSuccess')); fetchCustomers() }
else message.error(data.message || '删除失败') else message.error(data.message || t('common.deleteFailed'))
} catch (error) { message.error('删除失败') } } catch (error) { message.error(t('common.deleteFailed')) }
} }
}) })
} }
@@ -222,10 +224,10 @@ const CustomerPage: React.FC = () => {
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的客户信息,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft() restoreDraft()
}, },
@@ -245,74 +247,75 @@ const CustomerPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 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={t('customer.totalCount')} 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={t('customer.totalContract')} 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.totalReceivable')} value={stats.totalReceivable} prefix="¥" valueStyle={{ color: stats.totalReceivable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row> </Row>
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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 }} /> <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}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('customer.newCustomer')}</Button>
</div> </div>
</Card> </Card>
<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> </Card>
<Modal <Modal
title={editingCustomer ? '编辑客户' : '新增客户'} title={editingCustomer ? t('customer.editCustomer') : t('customer.newCustomer')}
open={modalVisible} open={modalVisible}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft() saveDraft()
form.resetFields();
setModalVisible(false) setModalVisible(false)
form.resetFields()
setEditingCustomer(null) setEditingCustomer(null)
}, },
}) })
} else { } else {
form.resetFields();
setModalVisible(false) setModalVisible(false)
form.resetFields()
setEditingCustomer(null) setEditingCustomer(null)
} }
}} }}
onOk={() => form.submit()} onOk={() => form.submit()}
destroyOnClose
width={800} width={800}
maskClosable={false} maskClosable={false}
> >
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}> <Form.Item name="name" label={t('customer.name')} rules={[{ required: true, message: t('customer.nameRequired') }]}>
<Input placeholder="客户名称" /> <Input placeholder={t('customer.namePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址"> <Form.Item name="address" label={t('customer.address')}>
<Input placeholder="客户地址" /> <Input placeholder={t('customer.addressPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={2} placeholder="备注信息" /> <Input.TextArea rows={2} placeholder={t('customer.remarkPlaceholder')} />
</Form.Item> </Form.Item>
<h4></h4> <h4>{t('customer.contact')}</h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}> <Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div> <div>
{fields.map(({ key, name, ...restField }) => ( {fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="姓名" /> <Input placeholder={t('logistics.name')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="职位" /> <Input placeholder={t('common.position')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="电话" /> <Input placeholder={t('common.phone')} />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}> <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)} onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/> />
</Form.Item> </Form.Item>
<span></span> <span>{t('customer.mainContact')}</span>
</div> </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> </div>
))} ))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button> <Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('customer.addContact')}</Button>
</div> </div>
)} )}
</Form.List> </Form.List>
<h4 style={{ marginTop: 24 }}></h4> <h4 style={{ marginTop: 24 }}>{t('customer.paymentInfo')}</h4>
<Form.List name="payment_infos" initialValue={[]}> <Form.List name="payment_infos" initialValue={[]}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div> <div>
{fields.map(({ key, name, ...restField }) => ( {fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}> <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' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'account_name']} label={t('customer.accountName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="收款户名" /> <Input placeholder={t('customer.accountName')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'bank_name']} label={t('customer.bankName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="开户银行" /> <Input placeholder={t('customer.bankName')} />
</Form.Item> </Form.Item>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'bank_account']} label={t('customer.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="银行账号" /> <Input placeholder={t('customer.bankAccount')} />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}> <div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}> <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)} onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
/> />
</Form.Item> </Form.Item>
<span></span> <span>{t('customer.mainAccount')}</span>
</div> </div>
</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/*" /> <FileUpload maxCount={1} accept="image/*" />
</Form.Item> </Form.Item>
{fields.length > 0 && ( {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> </div>
))} ))}
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}> <Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
+ {t('customer.addPaymentInfo')}
</Button> </Button>
</div> </div>
)} )}
+35 -37
View File
@@ -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 { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons'; import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
import apiClient from '../utils/request'; import apiClient from '../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useLanguageStore } from '../store/languageStore';
const { Text, Title } = Typography; const { Text, Title } = Typography;
const RATE_PAIRS = [ const RATE_PAIR_KEYS = [
{ key: 'CNY_LAK', label: '中老汇率', from: 'CNY', to: 'LAK', fromLabel: '人民币', toLabel: '老挝基普' }, { key: 'CNY_LAK', i18nKey: 'CNYLAK', from: 'CNY', to: 'LAK', fromI18n: 'CNY', toI18n: 'LAK' },
{ key: 'CNY_USD', label: '中美汇率', from: 'CNY', to: 'USD', fromLabel: '人民币', toLabel: '美元' }, { key: 'CNY_USD', i18nKey: 'CNYUSD', from: 'CNY', to: 'USD', fromI18n: 'CNY', toI18n: 'USD' },
{ key: 'CNY_THB', label: '中泰汇率', from: 'CNY', to: 'THB', fromLabel: '人民币', toLabel: '泰铢' }, { key: 'CNY_THB', i18nKey: 'CNYTHB', from: 'CNY', to: 'THB', fromI18n: 'CNY', toI18n: 'THB' },
{ key: 'USD_LAK', label: '美老汇率', from: 'USD', to: 'LAK', fromLabel: '美元', toLabel: '老挝基普' }, { key: 'USD_LAK', i18nKey: 'USDLAK', from: 'USD', to: 'LAK', fromI18n: 'USD', toI18n: 'LAK' },
{ key: 'THB_LAK', label: '泰老汇率', from: 'THB', to: 'LAK', fromLabel: '泰铢', toLabel: '老挝基普' }, { key: 'THB_LAK', i18nKey: 'THBLAK', from: 'THB', to: 'LAK', fromI18n: 'THB', toI18n: 'LAK' },
]; ];
interface RateItem { interface RateItem {
@@ -30,6 +31,15 @@ interface HistoryRate {
} }
const ExchangeRatePage: React.FC = () => { 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 [rates, setRates] = useState<Record<string, RateItem>>({});
const [initialRates, setInitialRates] = useState<Record<string, number>>({}); const [initialRates, setInitialRates] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -71,7 +81,7 @@ const ExchangeRatePage: React.FC = () => {
} }
} }
} catch (error) { } catch (error) {
message.error('获取汇率失败'); message.error(t('exchangeRate.getRateFailed'));
const defaultRates: Record<string, RateItem> = {}; const defaultRates: Record<string, RateItem> = {};
const defaultInitialRates: Record<string, number> = {}; const defaultInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => { RATE_PAIRS.forEach(pair => {
@@ -97,13 +107,11 @@ const ExchangeRatePage: React.FC = () => {
} }
}; };
// 左侧输入 - 右侧自动变为1,重新计算汇率
const handleLeftChange = (key: string, value: number | null) => { const handleLeftChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return; if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key); const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return; if (!pair) return;
// 当左侧输入值时,右侧变为1,计算新的汇率
const newRate = 1 / value; const newRate = 1 / value;
setRates(prev => ({ setRates(prev => ({
@@ -116,13 +124,11 @@ const ExchangeRatePage: React.FC = () => {
})); }));
}; };
// 右侧输入 - 左侧自动变为1,重新计算汇率
const handleRightChange = (key: string, value: number | null) => { const handleRightChange = (key: string, value: number | null) => {
if (value === null || value <= 0) return; if (value === null || value <= 0) return;
const pair = RATE_PAIRS.find(p => p.key === key); const pair = RATE_PAIRS.find(p => p.key === key);
if (!pair) return; if (!pair) return;
// 当右侧输入值时,左侧变为1,计算新的汇率
const newRate = value; const newRate = value;
setRates(prev => ({ setRates(prev => ({
@@ -135,7 +141,6 @@ const ExchangeRatePage: React.FC = () => {
})); }));
}; };
// 计算实际汇率显示
const getActualRateDisplay = (key: string) => { const getActualRateDisplay = (key: string) => {
const item = rates[key]; const item = rates[key];
if (!item) return '1 : 1.00'; if (!item) return '1 : 1.00';
@@ -143,13 +148,11 @@ const ExchangeRatePage: React.FC = () => {
const pair = RATE_PAIRS.find(p => p.key === key); const pair = RATE_PAIRS.find(p => p.key === key);
const actualRate = item.actualRate; const actualRate = item.actualRate;
// 根据汇率对选择合适的小数位数
const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2; const decimalPlaces = pair?.key === 'CNY_USD' ? 5 : 2;
return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`; return `1 ${pair?.from} = ${actualRate.toFixed(decimalPlaces)} ${pair?.to}`;
}; };
// 确认保存
const handleConfirm = async () => { const handleConfirm = async () => {
setSaving(true); setSaving(true);
try { try {
@@ -160,7 +163,6 @@ const ExchangeRatePage: React.FC = () => {
const actualRate = item.rightValue / item.leftValue; const actualRate = item.rightValue / item.leftValue;
const initialRate = initialRates[pair.key]; const initialRate = initialRates[pair.key];
// 只保存有变化的汇率
if (Math.abs(actualRate - initialRate) < 0.0001) { if (Math.abs(actualRate - initialRate) < 0.0001) {
return null; return null;
} }
@@ -175,17 +177,16 @@ const ExchangeRatePage: React.FC = () => {
const validPromises = savePromises.filter(Boolean) as Promise<any>[]; const validPromises = savePromises.filter(Boolean) as Promise<any>[];
if (validPromises.length === 0) { if (validPromises.length === 0) {
message.info('没有汇率发生变化'); message.info(t('exchangeRate.noChange'));
setSaving(false); setSaving(false);
return; return;
} }
await Promise.all(validPromises); await Promise.all(validPromises);
message.success('汇率保存成功'); message.success(t('exchangeRate.saveSuccess'));
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss')); setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
fetchHistory(); fetchHistory();
// 更新初始汇率为当前汇率
const newInitialRates: Record<string, number> = {}; const newInitialRates: Record<string, number> = {};
RATE_PAIRS.forEach(pair => { RATE_PAIRS.forEach(pair => {
const item = rates[pair.key]; const item = rates[pair.key];
@@ -195,16 +196,15 @@ const ExchangeRatePage: React.FC = () => {
}); });
setInitialRates(newInitialRates); setInitialRates(newInitialRates);
} catch (error) { } catch (error) {
message.error('保存汇率失败'); message.error(t('exchangeRate.saveFailed'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
// 历史汇率表格列
const historyColumns = [ const historyColumns = [
{ {
title: '汇率对', title: t('exchangeRate.ratePair'),
dataIndex: 'from_currency', dataIndex: 'from_currency',
key: 'from_currency', key: 'from_currency',
render: (_: string, record: HistoryRate) => { render: (_: string, record: HistoryRate) => {
@@ -214,7 +214,7 @@ const ExchangeRatePage: React.FC = () => {
} }
}, },
{ {
title: '汇率', title: t('exchangeRate.rate'),
dataIndex: 'rate', dataIndex: 'rate',
key: 'rate', key: 'rate',
render: (rate: number, record: HistoryRate) => { render: (rate: number, record: HistoryRate) => {
@@ -224,19 +224,19 @@ const ExchangeRatePage: React.FC = () => {
} }
}, },
{ {
title: '生效日期', title: t('exchangeRate.effectiveDate'),
dataIndex: 'effective_date', dataIndex: 'effective_date',
key: 'effective_date', key: 'effective_date',
render: (date: string) => dayjs(date).format('YYYY-MM-DD') render: (date: string) => dayjs(date).format('YYYY-MM-DD')
}, },
{ {
title: '设置时间', title: t('exchangeRate.setTime'),
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', key: 'created_at',
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm') render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
}, },
{ {
title: '设置人', title: t('exchangeRate.setBy'),
dataIndex: 'created_by_name', dataIndex: 'created_by_name',
key: 'created_by_name', key: 'created_by_name',
render: (name: string) => name || '-' render: (name: string) => name || '-'
@@ -250,11 +250,11 @@ const ExchangeRatePage: React.FC = () => {
return ( return (
<div style={{ padding: isMobile ? 8 : 24 }}> <div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 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> <Space>
<Text type="secondary"></Text> <Text type="secondary">{t('exchangeRate.description')}</Text>
{lastUpdateTime && ( {lastUpdateTime && (
<Tag color="blue">: {lastUpdateTime}</Tag> <Tag color="blue">{t('exchangeRate.lastUpdated')}{lastUpdateTime}</Tag>
)} )}
</Space> </Space>
</div> </div>
@@ -291,7 +291,7 @@ const ExchangeRatePage: React.FC = () => {
e.target.select(); e.target.select();
} }
}} }}
placeholder={`输入${pair.fromLabel}金额`} placeholder={t('exchangeRate.inputFrom', { from: pair.fromLabel })}
/> />
</div> </div>
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff', fontWeight: 'bold' }}>=</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(); e.target.select();
} }
}} }}
placeholder={`输入${pair.toLabel}金额`} placeholder={t('exchangeRate.inputTo', { to: pair.toLabel })}
/> />
</div> </div>
</div> </div>
<Divider style={{ margin: '12px 0' }} /> <Divider style={{ margin: '12px 0' }} />
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}> <Text type="secondary" style={{ fontSize: 13 }}>
: {getActualRateDisplay(pair.key)} {t('exchangeRate.actualRate')}{getActualRateDisplay(pair.key)}
</Text> </Text>
</div> </div>
</Card> </Card>
@@ -335,7 +335,6 @@ const ExchangeRatePage: React.FC = () => {
})} })}
</Row> </Row>
{/* 确认按钮 */}
<div style={{ marginTop: 24, textAlign: 'center' }}> <div style={{ marginTop: 24, textAlign: 'center' }}>
<Button <Button
type="primary" type="primary"
@@ -345,16 +344,15 @@ const ExchangeRatePage: React.FC = () => {
loading={saving} loading={saving}
style={{ minWidth: 200 }} style={{ minWidth: 200 }}
> >
{t('exchangeRate.confirmSave')}
</Button> </Button>
</div> </div>
{/* 历史汇率表 */}
<Card <Card
title={ title={
<Space> <Space>
<HistoryOutlined /> <HistoryOutlined />
<span></span> <span>{t('exchangeRate.historyRate')}</span>
</Space> </Space>
} }
style={{ marginTop: 24 }} style={{ marginTop: 24 }}
@@ -370,7 +368,7 @@ const ExchangeRatePage: React.FC = () => {
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}> <Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
<Text type="warning"> <Text type="warning">
1 = X右侧币种"确认保存汇率" {t('exchangeRate.tipText')}
</Text> </Text>
</Card> </Card>
</div> </div>
+467 -312
View File
@@ -1,289 +1,116 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { import {
Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card, 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' } from 'antd'
import { import {
PlusOutlined, SearchOutlined, InboxOutlined, ExportOutlined PlusOutlined, EditOutlined, EditFilled, DeleteOutlined, EyeOutlined,
CheckOutlined, WarningOutlined, ArrowUpOutlined, ArrowDownOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { useLanguageStore } from '../store/languageStore'
// ==================== 类型定义 ==================== interface InventoryItem {
interface InventoryRecord {
id: number 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_id: number
product_name: string product_name: string
unit?: string | null product_code: string
total_in: number product_category: string
total_out: number product_specification: string
current_quantity: number 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 { interface Product {
id: number id: number
name: string 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 InventoryPage: React.FC = () => {
// 状态 const { t, currentLanguage } = useLanguageStore();
const [records, setRecords] = useState<InventoryRecord[]>([]) const [inventory, setInventory] = useState<InventoryItem[]>([])
const [summary, setSummary] = useState<InventorySummary[]>([]) const [logs, setLogs] = useState<InventoryLog[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [logLoading, setLogLoading] = useState(false)
const [products, setProducts] = useState<Product[]>([]) const [products, setProducts] = useState<Product[]>([])
const [projects, setProjects] = useState<Project[]>([])
// 筛选状态 const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedProductId, setSelectedProductId] = useState<number | null>(null) const [stockFilter, setStockFilter] = useState<string>('all')
const [selectedProjectId, setSelectedProjectId] = useState<number | null>(null) const [activeTab, setActiveTab] = useState('inventory')
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(null)
// 弹窗状态 const [inModalVisible, setInModalVisible] = useState(false)
const [outModalVisible, setOutModalVisible] = useState(false) const [outModalVisible, setOutModalVisible] = useState(false)
const [form] = Form.useForm() const [inForm] = Form.useForm()
const [outForm] = Form.useForm()
// Tab状态 const navigate = useNavigate()
const [activeTab, setActiveTab] = useState('records')
// ==================== 渲染 ==================== const fetchInventory = async () => {
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 () => {
setLoading(true) setLoading(true)
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
if (selectedProductId) params.append('product_id', selectedProductId.toString()) if (selectedCategory) params.append('category', selectedCategory)
if (selectedProjectId) params.append('project_id', selectedProjectId.toString()) if (stockFilter && stockFilter !== 'all') params.append('stock_filter', stockFilter)
if (selectedRecordType) params.append('record_type', selectedRecordType)
const response = await fetch(`/api/inventory?${params}`) const response = await fetch(`/api/inventory?${params}`)
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
setRecords(data.data) setInventory(data.data)
} else { } else {
message.error('获取库存记录失败') message.error(t('inventory.getListFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取库存记录失败:', error) console.error('获取库存列表失败:', error)
message.error('获取库存记录失败') message.error(t('inventory.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const fetchSummary = async () => { const fetchLogs = async () => {
setLogLoading(true)
try { try {
const response = await fetch('/api/inventory/summary') const response = await fetch('/api/inventory?limit=100')
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
setSummary(data.data) setLogs(data.data)
} else {
message.error(t('inventory.getLogFailed'))
} }
} catch (error) { } 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) setProducts(data.data)
} }
} catch (error) { } catch (error) {
console.error('获取品列表失败:', 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)
} }
} }
useEffect(() => { useEffect(() => {
fetchProducts() fetchProducts()
fetchProjects()
}, []) }, [])
useEffect(() => { useEffect(() => {
if (activeTab === 'records') { fetchInventory()
fetchRecords() }, [selectedCategory, stockFilter])
} else {
fetchSummary() 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 { 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', { const response = await fetch('/api/inventory/out', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(data)
...values,
operator: '系统管理员'
})
}) })
const result = await response.json()
const data = await response.json() if (result.success) {
message.success(t('inventory.stockOutSuccess'))
if (data.success) {
message.success('出库成功')
setOutModalVisible(false) setOutModalVisible(false)
form.resetFields() fetchInventory()
fetchRecords()
fetchSummary()
} else { } else {
message.error('出库失败') message.error(result.error || t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('出库失败:', 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 ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Card> <div style={{ marginBottom: 24 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} /> <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> </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 <Modal
title="商品出库" title={t('inventory.stockOutTitle')}
open={outModalVisible} open={outModalVisible}
onOk={handleOutModalOk} onOk={handleSaveStockOut}
onCancel={() => setOutModalVisible(false)} onCancel={() => {
outForm.resetFields();
setOutModalVisible(false);
}}
destroyOnClose
width={500}
> >
<Form form={form} layout="vertical"> <Form form={outForm} layout="vertical">
<Form.Item <Row gutter={16}>
name="project_id" <Col span={24}>
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.Item <Form.Item
name="product_id" name="product_id"
label="商品" label={t('inventory.selectProduct')}
rules={[{ required: true, message: '请选择商品' }]} rules={[{ required: true, message: t('inventory.selectProductRequired') }]}
> >
<Select placeholder="请选择商品"> <Select
{products.map(product => ( placeholder={t('inventory.selectProduct')}
<Select.Option key={product.id} value={product.id}> showSearch
{product.name} 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.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item <Form.Item
name="quantity" name="quantity"
label="出库数量" label={t('inventory.quantity')}
rules={[{ required: true, message: '请输入出库数量' }]} 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>
</Col>
<Form.Item name="unit_price" label="单价"> <Col span={12}>
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入单价" /> <Form.Item
name="date"
label={t('inventory.date')}
rules={[{ required: true, message: t('inventory.dateRequired') }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col>
<Form.Item name="total_amount" label="总金额"> </Row>
<InputNumber style={{ width: '100%' }} min={0} placeholder="请输入总金额" /> <Form.Item name="order_code" label={t('inventory.relatedOrder')}>
<Input placeholder={t('inventory.relatedOrderPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="remark" label={t('inventory.remark')}>
<Form.Item name="remark" label="备注"> <Input.TextArea rows={3} placeholder={t('inventory.remarkPlaceholder')} />
<Input.TextArea rows={3} placeholder="请输入备注" />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
+126 -113
View File
@@ -22,6 +22,7 @@ import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import BusinessLedgerTab from '../components/BusinessLedgerTab' import BusinessLedgerTab from '../components/BusinessLedgerTab'
import useFormDraft from '../hooks/useFormDraft' import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface LogisticsCompany { interface LogisticsCompany {
id: number id: number
@@ -85,6 +86,7 @@ interface OrderRecord {
} }
const LogisticsCompaniesPage: React.FC = () => { const LogisticsCompaniesPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore()
const [companies, setCompanies] = useState<LogisticsCompany[]>([]) const [companies, setCompanies] = useState<LogisticsCompany[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -123,11 +125,11 @@ const LogisticsCompaniesPage: React.FC = () => {
if (data.success) { if (data.success) {
setCompanies(data.data) setCompanies(data.data)
} else { } else {
message.error('获取物流公司列表失败') message.error(t('logistics.getListFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取物流公司列表失败:', error) console.error('获取物流公司列表失败:', error)
message.error('获取物流公司列表失败') message.error(t('logistics.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -142,11 +144,11 @@ const LogisticsCompaniesPage: React.FC = () => {
setDetailModalVisible(true) setDetailModalVisible(true)
setActiveDetailTab('basic') setActiveDetailTab('basic')
} else { } else {
message.error('获取物流公司详情失败') message.error(t('logistics.getDetailFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取物流公司详情失败:', error) console.error('获取物流公司详情失败:', error)
message.error('获取物流公司详情失败') message.error(t('logistics.getDetailFailed'))
} }
} }
@@ -162,10 +164,10 @@ const LogisticsCompaniesPage: React.FC = () => {
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的物流公司信息,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft() restoreDraft()
}, },
@@ -189,14 +191,14 @@ const LogisticsCompaniesPage: React.FC = () => {
const response = await fetch(`/api/logistics-companies/${id}`, { method: 'DELETE' }) const response = await fetch(`/api/logistics-companies/${id}`, { method: 'DELETE' })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('删除成功') message.success(t('common.deleteSuccess'))
fetchCompanies() fetchCompanies()
} else { } else {
message.error(data.message || '删除失败') message.error(data.message || t('common.deleteFailed'))
} }
} catch (error) { } catch (error) {
console.error('删除失败:', 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() clearDraft()
setModalVisible(false) setModalVisible(false)
fetchCompanies() fetchCompanies()
} else { } else {
message.error('保存失败') message.error(t('common.saveFailed'))
} }
} catch (error) { } catch (error) {
console.error('保存失败:', error) console.error('保存失败:', error)
@@ -270,11 +272,11 @@ const LogisticsCompaniesPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingContact ? '联系人更新成功' : '联系人添加成功') message.success(editingContact ? t('logistics.contactUpdateSuccess') : t('logistics.contactAddSuccess'))
setContactModalVisible(false) setContactModalVisible(false)
fetchCompanyDetail(currentCompany!.id) fetchCompanyDetail(currentCompany!.id)
} else { } else {
message.error('操作失败') message.error(t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('保存联系人失败:', error) console.error('保存联系人失败:', error)
@@ -288,10 +290,10 @@ const LogisticsCompaniesPage: React.FC = () => {
}) })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('联系人删除成功') message.success(t('logistics.contactDeleteSuccess'))
fetchCompanyDetail(currentCompany!.id) fetchCompanyDetail(currentCompany!.id)
} else { } else {
message.error('删除失败') message.error(t('common.deleteFailed'))
} }
} catch (error) { } catch (error) {
console.error('删除联系人失败:', error) console.error('删除联系人失败:', error)
@@ -326,11 +328,11 @@ const LogisticsCompaniesPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingPayment ? '收款信息更新成功' : '收款信息添加成功') message.success(editingPayment ? t('logistics.paymentInfoUpdateSuccess') : t('logistics.paymentInfoAddSuccess'))
setPaymentModalVisible(false) setPaymentModalVisible(false)
fetchCompanyDetail(currentCompany!.id) fetchCompanyDetail(currentCompany!.id)
} else { } else {
message.error('操作失败') message.error(t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('保存收款信息失败:', error) console.error('保存收款信息失败:', error)
@@ -344,10 +346,10 @@ const LogisticsCompaniesPage: React.FC = () => {
}) })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('收款信息删除成功') message.success(t('logistics.paymentInfoDeleteSuccess'))
fetchCompanyDetail(currentCompany!.id) fetchCompanyDetail(currentCompany!.id)
} else { } else {
message.error('删除失败') message.error(t('common.deleteFailed'))
} }
} catch (error) { } catch (error) {
console.error('删除收款信息失败:', error) console.error('删除收款信息失败:', error)
@@ -356,9 +358,9 @@ const LogisticsCompaniesPage: React.FC = () => {
const getFreightStatusTag = (status: string) => { const getFreightStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '待付款' }, pending: { color: 'default', text: t('logistics.pendingPayment') },
requested: { color: 'blue', text: '已申请' }, requested: { color: 'blue', text: t('logistics.applied') },
paid: { color: 'green', text: '已支付' } paid: { color: 'green', text: t('logistics.paid') }
} }
const info = statusMap[status] || { color: 'default', text: status } const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag> return <Tag color={info.color}>{info.text}</Tag>
@@ -366,7 +368,7 @@ const LogisticsCompaniesPage: React.FC = () => {
const columns: ColumnsType<LogisticsCompany> = [ const columns: ColumnsType<LogisticsCompany> = [
{ {
title: '公司名称', title: t('logistics.companyName'),
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
width: 180, width: 180,
@@ -375,13 +377,13 @@ const LogisticsCompaniesPage: React.FC = () => {
) )
}, },
{ {
title: '联系电话', title: t('logistics.phone'),
dataIndex: 'phone', dataIndex: 'phone',
key: 'phone', key: 'phone',
width: 120 width: 120
}, },
{ {
title: '报价描述', title: t('logistics.quoteDescription'),
dataIndex: 'quotation_description', dataIndex: 'quotation_description',
key: 'quotation_description', key: 'quotation_description',
width: 200, width: 200,
@@ -389,14 +391,14 @@ const LogisticsCompaniesPage: React.FC = () => {
render: (v: string) => v || '-' render: (v: string) => v || '-'
}, },
{ {
title: '创建时间', title: t('logistics.createdAt'),
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', key: 'created_at',
width: 100, width: 100,
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-' render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
}, },
{ {
title: '操作', title: t('logistics.action'),
key: 'actions', key: 'actions',
width: 150, width: 150,
fixed: 'right', fixed: 'right',
@@ -404,7 +406,7 @@ const LogisticsCompaniesPage: React.FC = () => {
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchCompanyDetail(record.id)} /> <Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchCompanyDetail(record.id)} />
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} /> <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 />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
@@ -413,18 +415,18 @@ const LogisticsCompaniesPage: React.FC = () => {
] ]
const contactColumns: ColumnsType<Contact> = [ const contactColumns: ColumnsType<Contact> = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 }, { title: t('logistics.name'), dataIndex: 'name', key: 'name', width: 100 },
{ title: '职位', dataIndex: 'position', key: 'position', width: 80 }, { title: t('logistics.position'), dataIndex: 'position', key: 'position', width: 80 },
{ title: '电话', dataIndex: 'phone', key: 'phone', width: 120 }, { title: t('logistics.phoneLabel'), 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.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', key: 'actions',
width: 100, width: 100,
render: (_, record) => ( render: (_, record) => (
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditContact(record)} /> <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 />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
@@ -433,18 +435,18 @@ const LogisticsCompaniesPage: React.FC = () => {
] ]
const paymentColumns: ColumnsType<PaymentInfo> = [ const paymentColumns: ColumnsType<PaymentInfo> = [
{ title: '收款户名', dataIndex: 'account_name', key: 'account_name', width: 120 }, { title: t('logistics.accountName'), dataIndex: 'account_name', key: 'account_name', width: 120 },
{ title: '银行账号', dataIndex: 'account_number', key: 'account_number', width: 150 }, { title: t('logistics.bankAccount'), dataIndex: 'account_number', key: 'account_number', width: 150 },
{ title: '开户银行', dataIndex: 'bank_name', key: 'bank_name', width: 120 }, { title: t('logistics.bankName'), 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.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', key: 'actions',
width: 100, width: 100,
render: (_, record) => ( render: (_, record) => (
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditPayment(record)} /> <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 />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
@@ -453,92 +455,95 @@ const LogisticsCompaniesPage: React.FC = () => {
] ]
const orderColumns: ColumnsType<OrderRecord> = [ const orderColumns: ColumnsType<OrderRecord> = [
{ title: '物流单号', dataIndex: 'code', key: 'code', width: 120 }, { title: t('logistics.trackingNumber'), dataIndex: 'code', key: 'code', width: 120 },
{ title: '采购订单', dataIndex: 'order_code', key: 'order_code', width: 120 }, { title: t('logistics.purchaseOrder'), dataIndex: 'order_code', key: 'order_code', width: 120 },
{ title: '发货日期', dataIndex: 'ship_date', key: 'ship_date', width: 100 }, { title: t('logistics.deliveryDate'), 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: 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: '一次运费状态', dataIndex: 'primary_freight_status', key: 'primary_freight_status', width: 100, render: getFreightStatusTag }, { title: t('logistics.freight1Status'), 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: 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: '二次运费状态', dataIndex: 'secondary_freight_status', key: 'secondary_freight_status', width: 100, render: getFreightStatusTag }, { title: t('logistics.freight2Status'), 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.status'), dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag>{s}</Tag> }
] ]
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2> <h2 style={{ marginBottom: 8 }}>{t('logistics.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}></p> <p style={{ color: '#888', marginBottom: 0 }}>{t('logistics.description')}</p>
</div> </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 }} /> <Table columns={columns} dataSource={companies} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="small" scroll={{ x: 1100 }} />
</Card> </Card>
{/* 编辑/新建弹窗 */} {/* 编辑/新建弹窗 */}
<Modal <Modal
title={editingCompany ? '编辑物流公司' : '新建物流公司'} title={editingCompany ? t('logistics.editCompany') : t('logistics.newCompany')}
open={modalVisible} open={modalVisible}
onOk={handleSave} onOk={handleSave}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft() saveDraft()
form.resetFields();
setModalVisible(false) setModalVisible(false)
}, },
}) })
} else { } else {
form.resetFields();
setModalVisible(false) setModalVisible(false)
} }
}} }}
destroyOnClose
width={700} width={700}
maskClosable={false} maskClosable={false}
> >
<Form form={form} layout="vertical" onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Form.Item name="name" label="公司名称" rules={[{ required: true }]}> <Form.Item name="name" label={t('logistics.companyName')} rules={[{ required: true }]}>
<Input placeholder="请输入公司名称" /> <Input placeholder={t('common.inputPlaceholder') + t('logistics.companyName')} />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址"> <Form.Item name="address" label={t('logistics.address')}>
<Input placeholder="请输入地址" /> <Input placeholder={t('logistics.addressPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="quotation_description" label="报价描述"> <Form.Item name="quotation_description" label={t('logistics.quoteDescription')}>
<Input.TextArea rows={3} placeholder="请输入报价描述(如:中国-老挝陆运报价、时效等)" /> <Input.TextArea rows={3} placeholder={t('logistics.quotePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('logistics.remark')}>
<Input.TextArea rows={2} placeholder="请输入备注" /> <Input.TextArea rows={2} placeholder={t('logistics.remarkPlaceholder')} />
</Form.Item> </Form.Item>
{!editingCompany && ( {!editingCompany && (
<Form.List name="contacts"> <Form.List name="contacts">
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<> <>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}></span> <span style={{ fontWeight: 500 }}>{t('logistics.contact')}</span>
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => add()}></Button> <Button type="dashed" size="small" icon={<PlusOutlined />} onClick={() => add()}>{t('logistics.addContact')}</Button>
</div> </div>
{fields.map(({ key, name, ...restField }) => ( {fields.map(({ key, name, ...restField }) => (
<Row key={key} gutter={8} style={{ marginBottom: 8 }}> <Row key={key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={6}> <Col span={6}>
<Form.Item {...restField} name={[name, 'name']} rules={[{ required: true, message: '必填' }]}> <Form.Item {...restField} name={[name, 'name']} rules={[{ required: true, message: t('common.required') }]}>
<Input placeholder="姓名" size="small" /> <Input placeholder={t('logistics.name')} size="small" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={5}> <Col span={5}>
<Form.Item {...restField} name={[name, 'phone']}> <Form.Item {...restField} name={[name, 'phone']}>
<Input placeholder="电话" size="small" /> <Input placeholder={t('logistics.phoneLabel')} size="small" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={5}> <Col span={5}>
<Form.Item {...restField} name={[name, 'position']}> <Form.Item {...restField} name={[name, 'position']}>
<Input placeholder="职位" size="small" /> <Input placeholder={t('logistics.position')} size="small" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={5}> <Col span={5}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked"> <Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked">
<Checkbox></Checkbox> <Checkbox>{t('logistics.mainContact')}</Checkbox>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={3}> <Col span={3}>
@@ -555,7 +560,7 @@ const LogisticsCompaniesPage: React.FC = () => {
{/* 详情弹窗 - 多TAB */} {/* 详情弹窗 - 多TAB */}
<Modal <Modal
title={`物流公司详情 - ${currentCompany?.name || ''}`} title={t('logistics.detailTitle', { name: currentCompany?.name || '' })}
open={detailModalVisible} open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)} onCancel={() => setDetailModalVisible(false)}
footer={null} footer={null}
@@ -564,32 +569,32 @@ const LogisticsCompaniesPage: React.FC = () => {
{currentCompany && ( {currentCompany && (
<Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}> <Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}>
{/* TAB1: 基本信息 */} {/* 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 bordered column={2}>
<Descriptions.Item label="公司名称">{currentCompany.name}</Descriptions.Item> <Descriptions.Item label={t('logistics.companyName')}>{currentCompany.name}</Descriptions.Item>
<Descriptions.Item label="联系电话">{currentCompany.phone || '-'}</Descriptions.Item> <Descriptions.Item label={t('logistics.phone')}>{currentCompany.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{currentCompany.email || '-'}</Descriptions.Item> <Descriptions.Item label={t('logistics.email')}>{currentCompany.email || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{currentCompany.created_at}</Descriptions.Item> <Descriptions.Item label={t('logistics.createdAt')}>{currentCompany.created_at}</Descriptions.Item>
<Descriptions.Item label="地址" span={2}>{currentCompany.address || '-'}</Descriptions.Item> <Descriptions.Item label={t('logistics.address')} span={2}>{currentCompany.address || '-'}</Descriptions.Item>
<Descriptions.Item label="报价描述" span={2}>{currentCompany.quotation_description || '-'}</Descriptions.Item> <Descriptions.Item label={t('logistics.quoteDescription')} span={2}>{currentCompany.quotation_description || '-'}</Descriptions.Item>
{currentCompany.remark && <Descriptions.Item label="备注" span={2}>{currentCompany.remark}</Descriptions.Item>} {currentCompany.remark && <Descriptions.Item label={t('logistics.remark')} span={2}>{currentCompany.remark}</Descriptions.Item>}
</Descriptions> </Descriptions>
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB2: 联系人 */} {/* TAB2: 联系人 */}
<Tabs.TabPane tab={<span><PhoneOutlined /> </span>} key="contacts"> <Tabs.TabPane tab={<span><PhoneOutlined /> {t('logistics.contact')}</span>} key="contacts">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddContact} style={{ marginBottom: 16 }}></Button> <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" /> <Table columns={contactColumns} dataSource={currentCompany.contacts || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB3: 收款信息 */} {/* TAB3: 收款信息 */}
<Tabs.TabPane tab={<span><BankOutlined /> </span>} key="payment"> <Tabs.TabPane tab={<span><BankOutlined /> {t('logistics.paymentTab')}</span>} key="payment">
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddPayment} style={{ marginBottom: 16 }}></Button> <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" /> <Table columns={paymentColumns} dataSource={currentCompany.payment_infos || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB4: 业务台账 */} {/* TAB4: 业务台账 */}
<Tabs.TabPane tab={<span><DollarOutlined /> </span>} key="orders"> <Tabs.TabPane tab={<span><DollarOutlined /> {t('logistics.ledger')}</span>} key="orders">
<BusinessLedgerTab <BusinessLedgerTab
partnerType="logistics" 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 }} 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 <Modal
title={editingContact ? '编辑联系人' : '添加联系人'} title={editingContact ? t('logistics.editContact') : t('logistics.addContactTitle')}
open={contactModalVisible} open={contactModalVisible}
onOk={handleSaveContact} onOk={handleSaveContact}
onCancel={() => setContactModalVisible(false)} onCancel={() => {
contactForm.resetFields();
setContactModalVisible(false);
}}
destroyOnClose
width={500} width={500}
> >
<Form form={contactForm} layout="vertical"> <Form form={contactForm} layout="vertical">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}> <Form.Item name="name" label={t('logistics.name')} rules={[{ required: true }]}>
<Input placeholder="请输入姓名" /> <Input placeholder={t('common.inputPlaceholder') + t('logistics.name')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="position" label="职位"> <Form.Item name="position" label={t('logistics.position')}>
<Input placeholder="请输入职位" /> <Input placeholder={t('common.inputPlaceholder') + t('logistics.position')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="phone" label="电话"> <Form.Item name="phone" label={t('logistics.phoneLabel')}>
<Input placeholder="请输入电话" /> <Input placeholder={t('common.inputPlaceholder') + t('logistics.phoneLabel')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="is_primary" label="主联系人"> <Form.Item name="is_primary" label={t('logistics.mainContact')}>
<Select placeholder="是否为主联系人"> <Select placeholder={t('logistics.isMainContact')}>
<Select.Option value={true}></Select.Option> <Select.Option value={true}>{t('common.is')}</Select.Option>
<Select.Option value={false}></Select.Option> <Select.Option value={false}>{t('common.no')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Form> </Form>
@@ -639,35 +648,39 @@ const LogisticsCompaniesPage: React.FC = () => {
{/* 收款信息编辑弹窗 */} {/* 收款信息编辑弹窗 */}
<Modal <Modal
title={editingPayment ? '编辑收款信息' : '添加收款信息'} title={editingPayment ? t('logistics.editPaymentInfo') : t('logistics.addPaymentInfoTitle')}
open={paymentModalVisible} open={paymentModalVisible}
onOk={handleSavePayment} onOk={handleSavePayment}
onCancel={() => setPaymentModalVisible(false)} onCancel={() => {
paymentForm.resetFields();
setPaymentModalVisible(false);
}}
destroyOnClose
width={500} width={500}
> >
<Form form={paymentForm} layout="vertical"> <Form form={paymentForm} layout="vertical">
<Form.Item name="account_name" label="收款户名" rules={[{ required: true }]}> <Form.Item name="account_name" label={t('logistics.accountName')} rules={[{ required: true }]}>
<Input placeholder="请输入收款户名" /> <Input placeholder={t('common.inputPlaceholder') + t('logistics.accountName')} />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="account_number" label="银行账号" rules={[{ required: true }]}> <Form.Item name="account_number" label={t('logistics.bankAccount')} rules={[{ required: true }]}>
<Input placeholder="请输入银行账号" /> <Input placeholder={t('common.inputPlaceholder') + t('logistics.bankAccount')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true }]}> <Form.Item name="bank_name" label={t('logistics.bankName')} rules={[{ required: true }]}>
<Input placeholder="请输入开户银行" /> <Input placeholder={t('common.inputPlaceholder') + t('logistics.bankName')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="qr_code" label="收款码"> <Form.Item name="qr_code" label={t('logistics.qrCode')}>
<Input placeholder="请输入收款码图片URL" /> <Input placeholder={t('logistics.qrCodeRequired')} />
</Form.Item> </Form.Item>
<Form.Item name="is_default" label="默认账户"> <Form.Item name="is_default" label={t('logistics.defaultAccount')}>
<Select placeholder="是否为默认账户"> <Select placeholder={t('logistics.isDefaultAccount')}>
<Select.Option value={true}></Select.Option> <Select.Option value={true}>{t('common.is')}</Select.Option>
<Select.Option value={false}></Select.Option> <Select.Option value={false}>{t('common.no')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Form> </Form>
+84 -79
View File
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useLanguageStore } from '../store/languageStore'
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
interface PaymentPlan { interface PaymentPlan {
@@ -37,6 +38,7 @@ interface PurchaseOrder {
// ==================== 组件 ==================== // ==================== 组件 ====================
const PaymentPlansPage: React.FC = () => { const PaymentPlansPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
// 状态 // 状态
const [paymentPlans, setPaymentPlans] = useState<PaymentPlan[]>([]) const [paymentPlans, setPaymentPlans] = useState<PaymentPlan[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -63,11 +65,11 @@ const PaymentPlansPage: React.FC = () => {
if (data.success) { if (data.success) {
setPaymentPlans(data.data) setPaymentPlans(data.data)
} else { } else {
message.error('获取付款计划列表失败') message.error(t('paymentPlan.getListFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取付款计划列表失败:', error) console.error('获取付款计划列表失败:', error)
message.error('获取付款计划列表失败') message.error(t('paymentPlan.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -93,11 +95,11 @@ const PaymentPlansPage: React.FC = () => {
setViewingPlan(data.data) setViewingPlan(data.data)
setDetailModalVisible(true) setDetailModalVisible(true)
} else { } else {
message.error('获取付款计划详情失败') message.error(t('paymentPlan.getDetailFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取付款计划详情失败:', error) console.error('获取付款计划详情失败:', error)
message.error('获取付款计划详情失败') message.error(t('paymentPlan.getDetailFailed'))
} }
} }
@@ -119,7 +121,7 @@ const PaymentPlansPage: React.FC = () => {
currency: 'CNY', currency: 'CNY',
payment_type: 'partial', payment_type: 'partial',
status: 'pending', status: 'pending',
created_by: '系统管理员' created_by: t('common.systemAdmin')
}) })
setModalVisible(true) setModalVisible(true)
} }
@@ -150,15 +152,15 @@ const PaymentPlansPage: React.FC = () => {
payment_type: fullRecord.payment_type || 'partial', payment_type: fullRecord.payment_type || 'partial',
status: fullRecord.status || 'pending', status: fullRecord.status || 'pending',
description: fullRecord.description, description: fullRecord.description,
created_by: fullRecord.created_by || '系统管理员' created_by: fullRecord.created_by || t('common.systemAdmin')
}); });
}, 100); }, 100);
} else { } else {
message.error('获取付款计划详情失败') message.error(t('paymentPlan.getDetailFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取付款计划详情失败:', error) console.error('获取付款计划详情失败:', error)
message.error('获取付款计划详情失败') message.error(t('paymentPlan.getDetailFailed'))
} }
} }
@@ -191,15 +193,15 @@ const PaymentPlansPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingPlan ? '保存成功' : '创建成功') message.success(editingPlan ? t('common.saveSuccess') : t('common.createSuccess'))
setModalVisible(false) setModalVisible(false)
fetchPaymentPlans() fetchPaymentPlans()
} else { } else {
message.error(editingPlan ? '保存失败' : '创建失败') message.error(editingPlan ? t('common.saveFailed') : t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('保存失败:', error) console.error('保存失败:', error)
message.error('保存失败') message.error(t('common.saveFailed'))
} }
} }
@@ -207,10 +209,10 @@ const PaymentPlansPage: React.FC = () => {
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'blue', text: '待处理' }, pending: { color: 'blue', text: t('paymentPlan.pending') },
approved: { color: 'green', text: '已审批' }, approved: { color: 'green', text: t('paymentPlan.approved') },
executed: { color: 'purple', text: '已执行' }, executed: { color: 'purple', text: t('paymentPlan.executed') },
cancelled: { color: 'red', text: '已取消' } cancelled: { color: 'red', text: t('paymentPlan.cancelled') }
} }
const info = statusMap[status] || { color: 'default', text: status } const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag> return <Tag color={info.color}>{info.text}</Tag>
@@ -218,8 +220,8 @@ const PaymentPlansPage: React.FC = () => {
const getPaymentTypeTag = (type: string) => { const getPaymentTypeTag = (type: string) => {
const typeMap: Record<string, { color: string; text: string }> = { const typeMap: Record<string, { color: string; text: string }> = {
partial: { color: 'blue', text: '部分付款' }, partial: { color: 'blue', text: t('paymentPlan.partialPayment') },
full: { color: 'green', text: '全额付款' } full: { color: 'green', text: t('paymentPlan.fullPayment') }
} }
const info = typeMap[type] || { color: 'default', text: type } const info = typeMap[type] || { color: 'default', text: type }
return <Tag color={info.color}>{info.text}</Tag> return <Tag color={info.color}>{info.text}</Tag>
@@ -227,14 +229,14 @@ const PaymentPlansPage: React.FC = () => {
const columns: ColumnsType<PaymentPlan> = [ const columns: ColumnsType<PaymentPlan> = [
{ {
title: '计划编号', title: t('paymentPlan.planCode'),
dataIndex: 'code', dataIndex: 'code',
key: 'code', key: 'code',
width: 150, width: 150,
ellipsis: true ellipsis: true
}, },
{ {
title: '采购订单', title: t('paymentPlan.purchaseOrder'),
dataIndex: 'purchase_order_id', dataIndex: 'purchase_order_id',
key: 'purchase_order_id', key: 'purchase_order_id',
width: 140, width: 140,
@@ -244,14 +246,14 @@ const PaymentPlansPage: React.FC = () => {
} }
}, },
{ {
title: '付款日期', title: t('paymentPlan.paymentDate'),
dataIndex: 'payment_date', dataIndex: 'payment_date',
key: 'payment_date', key: 'payment_date',
width: 110, width: 110,
render: (date) => dayjs(date).format('MM-DD') render: (date) => dayjs(date).format('MM-DD')
}, },
{ {
title: '金额', title: t('paymentPlan.amount'),
dataIndex: 'amount', dataIndex: 'amount',
key: 'amount', key: 'amount',
width: 120, width: 120,
@@ -263,7 +265,7 @@ const PaymentPlansPage: React.FC = () => {
) )
}, },
{ {
title: '付款类型', title: t('paymentPlan.paymentType'),
dataIndex: 'payment_type', dataIndex: 'payment_type',
key: 'payment_type', key: 'payment_type',
width: 100, width: 100,
@@ -271,7 +273,7 @@ const PaymentPlansPage: React.FC = () => {
render: getPaymentTypeTag render: getPaymentTypeTag
}, },
{ {
title: '状态', title: t('paymentPlan.status'),
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 90, width: 90,
@@ -279,13 +281,13 @@ const PaymentPlansPage: React.FC = () => {
render: getStatusTag render: getStatusTag
}, },
{ {
title: '创建人', title: t('paymentPlan.creator'),
dataIndex: 'created_by', dataIndex: 'created_by',
key: 'created_by', key: 'created_by',
width: 100 width: 100
}, },
{ {
title: '操作', title: t('paymentPlan.action'),
key: 'actions', key: 'actions',
width: 150, width: 150,
fixed: 'right', fixed: 'right',
@@ -297,7 +299,7 @@ const PaymentPlansPage: React.FC = () => {
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => fetchPlanDetail(record.id)} onClick={() => fetchPlanDetail(record.id)}
> >
{t('common.detail')}
</Button> </Button>
<Button <Button
size="small" size="small"
@@ -305,7 +307,7 @@ const PaymentPlansPage: React.FC = () => {
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => handleEdit(record)} onClick={() => handleEdit(record)}
> >
{t('common.edit')}
</Button> </Button>
</Space> </Space>
) )
@@ -315,11 +317,11 @@ const PaymentPlansPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2> <h2 style={{ marginBottom: 8 }}>{t('paymentPlan.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}></p> <p style={{ color: '#888', marginBottom: 0 }}>{t('paymentPlan.description')}</p>
</div> </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 <Table
columns={columns} columns={columns}
dataSource={paymentPlans} dataSource={paymentPlans}
@@ -333,19 +335,22 @@ const PaymentPlansPage: React.FC = () => {
{/* 编辑/新建弹窗 */} {/* 编辑/新建弹窗 */}
<Modal <Modal
title={editingPlan ? '编辑付款计划' : '新建付款计划'} title={editingPlan ? t('paymentPlan.editPlan') : t('paymentPlan.newPlan')}
open={modalVisible} open={modalVisible}
onCancel={() => { onCancel={() => {
setModalVisible(false) form.resetFields();
setEditingPlan(null) setModalVisible(false);
setEditingPlan(null);
}} }}
footer={[ footer={[
<Button key="cancel" onClick={() => { <Button key="cancel" onClick={() => {
setModalVisible(false) form.resetFields();
setEditingPlan(null) setModalVisible(false);
}}></Button>, setEditingPlan(null);
<Button key="save" type="primary" onClick={handleSave}></Button> }}>{t('common.cancel')}</Button>,
<Button key="save" type="primary" onClick={handleSave}>{t('common.save')}</Button>
]} ]}
destroyOnClose
width={600} width={600}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
@@ -353,10 +358,10 @@ const PaymentPlansPage: React.FC = () => {
<Col span={24}> <Col span={24}>
<Form.Item <Form.Item
name="purchase_order_id" name="purchase_order_id"
label="关联采购订单" label={t('paymentPlan.purchaseOrder')}
rules={[{ required: true, message: '请选择采购订单' }]} rules={[{ required: true, message: t('paymentPlan.selectOrder') }]}
> >
<Select placeholder="请选择采购订单" allowClear> <Select placeholder={t('paymentPlan.selectOrder')} allowClear>
{purchaseOrders.map(order => ( {purchaseOrders.map(order => (
<Select.Option key={order.id} value={order.id}> <Select.Option key={order.id} value={order.id}>
{order.code} - {order.supplier_name} ({order.currency} {order.total_amount}) {order.code} - {order.supplier_name} ({order.currency} {order.total_amount})
@@ -371,8 +376,8 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="payment_date" name="payment_date"
label="付款日期" label={t('paymentPlan.paymentDate')}
rules={[{ required: true, message: '请选择付款日期' }]} rules={[{ required: true, message: t('paymentPlan.selectDate') }]}
> >
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
@@ -380,10 +385,10 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="amount" name="amount"
label="付款金额" label={t('paymentPlan.amount')}
rules={[{ required: true, message: '请输入付款金额' }]} 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> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -392,26 +397,26 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="currency" name="currency"
label="币种" label={t('common.currency')}
rules={[{ required: true, message: '请选择币种' }]} rules={[{ required: true, message: t('paymentPlan.selectCurrency') }]}
> >
<Select placeholder="请选择币种"> <Select placeholder={t('paymentPlan.selectCurrency')}>
<Select.Option value="CNY"></Select.Option> <Select.Option value="CNY">{t('paymentPlan.currencyCNY')}</Select.Option>
<Select.Option value="USD"></Select.Option> <Select.Option value="USD">{t('paymentPlan.currencyUSD')}</Select.Option>
<Select.Option value="LAK"></Select.Option> <Select.Option value="LAK">{t('paymentPlan.currencyLAK')}</Select.Option>
<Select.Option value="THB"></Select.Option> <Select.Option value="THB">{t('paymentPlan.currencyTHB')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="payment_type" name="payment_type"
label="付款类型" label={t('paymentPlan.paymentType')}
rules={[{ required: true, message: '请选择付款类型' }]} rules={[{ required: true, message: t('paymentPlan.selectType') }]}
> >
<Select placeholder="请选择付款类型"> <Select placeholder={t('paymentPlan.selectType')}>
<Select.Option value="partial"></Select.Option> <Select.Option value="partial">{t('paymentPlan.partialPayment')}</Select.Option>
<Select.Option value="full"></Select.Option> <Select.Option value="full">{t('paymentPlan.fullPayment')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
@@ -421,40 +426,40 @@ const PaymentPlansPage: React.FC = () => {
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="status" name="status"
label="状态" label={t('paymentPlan.status')}
rules={[{ required: true, message: '请选择状态' }]} rules={[{ required: true, message: t('paymentPlan.selectStatus') }]}
> >
<Select placeholder="请选择状态"> <Select placeholder={t('paymentPlan.selectStatus')}>
<Select.Option value="pending"></Select.Option> <Select.Option value="pending">{t('paymentPlan.pending')}</Select.Option>
<Select.Option value="approved"></Select.Option> <Select.Option value="approved">{t('paymentPlan.approved')}</Select.Option>
<Select.Option value="executed"></Select.Option> <Select.Option value="executed">{t('paymentPlan.executed')}</Select.Option>
<Select.Option value="cancelled"></Select.Option> <Select.Option value="cancelled">{t('paymentPlan.cancelled')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="created_by" name="created_by"
label="创建人" label={t('paymentPlan.creator')}
rules={[{ required: true, message: '请输入创建人' }]} rules={[{ required: true, message: t('paymentPlan.inputCreator') }]}
> >
<Input placeholder="请输入创建人" /> <Input placeholder={t('paymentPlan.inputCreator')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item <Form.Item
name="description" name="description"
label="描述" label={t('common.remark')}
> >
<Input.TextArea rows={3} placeholder="请输入付款计划描述" /> <Input.TextArea rows={3} placeholder={t('paymentPlan.descPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
{/* 详情弹窗 */} {/* 详情弹窗 */}
<Modal <Modal
title="付款计划详情" title={t('paymentPlan.detailTitle')}
open={detailModalVisible} open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)} onCancel={() => setDetailModalVisible(false)}
footer={null} footer={null}
@@ -463,20 +468,20 @@ const PaymentPlansPage: React.FC = () => {
{viewingPlan && ( {viewingPlan && (
<> <>
<Descriptions bordered column={2} size="small"> <Descriptions bordered column={2} size="small">
<Descriptions.Item label="计划编号">{viewingPlan.code}</Descriptions.Item> <Descriptions.Item label={t('paymentPlan.detailCode')}>{viewingPlan.code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(viewingPlan.status)}</Descriptions.Item> <Descriptions.Item label={t('paymentPlan.status')}>{getStatusTag(viewingPlan.status)}</Descriptions.Item>
<Descriptions.Item label="采购订单"> <Descriptions.Item label={t('paymentPlan.purchaseOrder')}>
{(() => { {(() => {
const order = purchaseOrders.find(o => o.id == viewingPlan.purchase_order_id) const order = purchaseOrders.find(o => o.id == viewingPlan.purchase_order_id)
return order ? order.code : viewingPlan.purchase_order_id return order ? order.code : viewingPlan.purchase_order_id
})()} })()}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="付款类型">{getPaymentTypeTag(viewingPlan.payment_type)}</Descriptions.Item> <Descriptions.Item label={t('paymentPlan.paymentType')}>{getPaymentTypeTag(viewingPlan.payment_type)}</Descriptions.Item>
<Descriptions.Item label="付款日期">{viewingPlan.payment_date}</Descriptions.Item> <Descriptions.Item label={t('paymentPlan.paymentDate')}>{viewingPlan.payment_date}</Descriptions.Item>
<Descriptions.Item label="币种">{viewingPlan.currency}</Descriptions.Item> <Descriptions.Item label={t('common.currency')}>{viewingPlan.currency}</Descriptions.Item>
<Descriptions.Item label="金额" span={2}>{viewingPlan.amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</Descriptions.Item> <Descriptions.Item label={t('paymentPlan.amount')} 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={t('common.remark')} span={2}>{viewingPlan.description || '-'}</Descriptions.Item>
<Descriptions.Item label="创建人" span={2}>{viewingPlan.created_by}</Descriptions.Item> <Descriptions.Item label={t('paymentPlan.creator')} span={2}>{viewingPlan.created_by}</Descriptions.Item>
</Descriptions> </Descriptions>
</> </>
)} )}
+146 -114
View File
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import FileUpload from '../components/FileUpload'; import FileUpload from '../components/FileUpload';
import useFormDraft from '../hooks/useFormDraft'; import useFormDraft from '../hooks/useFormDraft';
import { useLanguageStore } from '../store/languageStore';
const { Option } = Select; const { Option } = Select;
const { TextArea } = Input; const { TextArea } = Input;
@@ -59,6 +60,7 @@ interface PayeeEntity {
} }
const PaymentRequestsPage: React.FC = () => { const PaymentRequestsPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const { user } = useAuthStore(); const { user } = useAuthStore();
const [requests, setRequests] = useState<any[]>([]); const [requests, setRequests] = useState<any[]>([]);
const [completedRequests, setCompletedRequests] = useState<any[]>([]); const [completedRequests, setCompletedRequests] = useState<any[]>([]);
@@ -116,7 +118,7 @@ const PaymentRequestsPage: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('获取付款申请列表失败:', error); console.error('获取付款申请列表失败:', error);
message.error('获取付款申请列表失败'); message.error(t('paymentRequest.getListFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -217,19 +219,21 @@ const PaymentRequestsPage: React.FC = () => {
form.setFieldsValue({ form.setFieldsValue({
application_date: dayjs(), application_date: dayjs(),
currency: 'CNY', currency: 'CNY',
applicant: user?.name || user?.username || '当前用户', applicant: user?.name || user?.username || t('common.currentUser'),
attachments: [], attachments: [],
payee_type: 'other', payee_type: 'other',
expense_type: 'company' expense_type: 'company'
}); });
setWatchedAmount(null);
setWatchedCurrency('CNY');
setModalVisible(true); setModalVisible(true);
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的付款申请,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft(); restoreDraft();
}, },
@@ -239,7 +243,7 @@ const PaymentRequestsPage: React.FC = () => {
form.setFieldsValue({ form.setFieldsValue({
application_date: dayjs(), application_date: dayjs(),
currency: 'CNY', currency: 'CNY',
applicant: user?.name || user?.username || '当前用户', applicant: user?.name || user?.username || t('common.currentUser'),
attachments: [], attachments: [],
payee_type: 'other', payee_type: 'other',
expense_type: 'company' 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), application_date: record.application_date ? dayjs(record.application_date) : (record.payment_date ? dayjs(record.payment_date) : null),
attachments: record.attachments || [] attachments: record.attachments || []
}); });
setWatchedAmount(record.amount || null);
setWatchedCurrency(record.currency || 'CNY');
setModalVisible(true); setModalVisible(true);
}; };
@@ -267,15 +273,15 @@ const PaymentRequestsPage: React.FC = () => {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: t('common.deleteConfirm'),
content: '确定要删除这条付款申请吗?', content: t('paymentRequest.deleteConfirmMsg') || t('common.confirmDeleteMsg'),
onOk: async () => { onOk: async () => {
try { try {
await fetch('/api/payment-requests/' + id, { method: 'DELETE' }); await fetch('/api/payment-requests/' + id, { method: 'DELETE' });
message.success('删除成功'); message.success(t('paymentRequest.deleteSuccess'));
fetchRequests(); fetchRequests();
} catch (error) { } catch (error) {
message.error('删除失败'); message.error(t('paymentRequest.deleteFailed'));
} }
} }
}); });
@@ -283,15 +289,15 @@ const PaymentRequestsPage: React.FC = () => {
const handleWithdraw = async (id: number) => { const handleWithdraw = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '确认撤回', title: t('paymentRequest.withdrawConfirm'),
content: '撤回后可重新编辑提交,确认撤回吗?', content: t('paymentRequest.withdrawConfirmMsg'),
onOk: async () => { onOk: async () => {
try { try {
await fetch('/api/payment-requests/' + id + '/withdraw', { method: 'POST' }); await fetch('/api/payment-requests/' + id + '/withdraw', { method: 'POST' });
message.success('已撤回,可重新编辑'); message.success(t('paymentRequest.withdrawSuccess'));
fetchRequests(); fetchRequests();
} catch (error) { } catch (error) {
message.error('撤回失败'); message.error(t('paymentRequest.withdrawFailed'));
} }
} }
}); });
@@ -342,32 +348,56 @@ const PaymentRequestsPage: React.FC = () => {
}); });
const result = await res.json(); const result = await res.json();
if (result.success) { if (result.success) {
message.success(editingId ? '更新成功' : '创建成功'); message.success(editingId ? t('common.updateSuccess') : t('common.createSuccess'));
clearDraft(); clearDraft();
setModalVisible(false); setModalVisible(false);
fetchRequests(); fetchRequests();
} else { } else {
message.error(result.error || '操作失败'); message.error(result.error || t('common.operationFailed'));
} }
} catch (error) { } catch (error) {
message.error('操作失败'); message.error(t('common.operationFailed'));
} }
}; };
const convertToCNY = (amount: number, curr: string): number => { const convertToCNY = (amount: number, curr: string): number => {
if (curr === "CNY") return amount; if (curr === "CNY") return amount;
// 先尝试 XXX_CNY 格式
const rateKey = curr + "_CNY"; const rateKey = curr + "_CNY";
const rate = exchangeRates[rateKey] || 1; if (exchangeRates[rateKey]) {
return amount * rate; 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 getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' }, pending: { color: 'processing', text: t('paymentRequest.pendingApproval') },
approved: { color: 'success', text: '已批准' }, approved: { color: 'success', text: t('paymentRequest.approved') },
rejected: { color: 'error', text: '已退回' }, rejected: { color: 'error', text: t('paymentRequest.rejected') },
withdrawn: { color: 'default', text: '已撤回' }, withdrawn: { color: 'default', text: t('paymentRequest.withdrawn') },
paid: { color: 'blue', text: '已付款' }, paid: { color: 'blue', text: t('paymentRequest.paid') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -393,48 +423,48 @@ const PaymentRequestsPage: React.FC = () => {
}; };
const columns = [ const columns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> }, { title: t('paymentRequest.subject'), 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: t('paymentRequest.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '收款单位', dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true }, { title: t('paymentRequest.payee'), dataIndex: 'payee', key: 'payee', width: 150, ellipsis: true },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => ( { title: t('paymentRequest.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<> <>
<div>{formatAmount(v, r.currency)}</div> <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>} {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: t('paymentRequest.applicationDate'), 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: t('paymentRequest.status'), dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: '编号', dataIndex: 'request_code', key: 'request_code', width: 120 }, { 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) => ( render: (_: any, record: any) => (
<Space wrap> <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' && ( {record.status === 'pending' && (
<> <>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button> <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('paymentRequest.edit')}</Button>
<Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}></Button> <Button size="small" icon={<UndoOutlined />} onClick={() => handleWithdraw(record.id)}>{t('paymentRequest.withdraw')}</Button>
</> </>
)} )}
{(record.status === 'rejected' || record.status === 'withdrawn') && ( {(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> </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 payeeType = Form.useWatch('payee_type', form);
const payeeSelect = Form.useWatch('payee_select', form); const payeeSelect = Form.useWatch('payee_select', form);
const expenseType = Form.useWatch('expense_type', form); const expenseType = Form.useWatch('expense_type', form);
const amount = Form.useWatch('amount', form);
const currency = Form.useWatch('currency', form);
const amountCNY = React.useMemo(() => { const amountCNY = React.useMemo(() => {
return amount && currency ? convertToCNY(amount, currency) : 0; return watchedAmount && watchedCurrency ? convertToCNY(watchedAmount, watchedCurrency) : 0;
}, [amount, currency, exchangeRates]); }, [watchedAmount, watchedCurrency, exchangeRates]);
// 当选择收款单位时,自动填充收款信息 // 当选择收款单位时,自动填充收款信息
useEffect(() => { useEffect(() => {
@@ -454,45 +484,47 @@ const PaymentRequestsPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2> <h2 style={{ marginBottom: 8 }}>{t('paymentRequest.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}></p> <p style={{ color: '#888', marginBottom: 0 }}>{t('paymentRequest.description')}</p>
</div> </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 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 }} /> <Table dataSource={requests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Tabs.TabPane> </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 }} /> <Table dataSource={completedRequests} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
</Card> </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()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft(); saveDraft();
form.resetFields();
setModalVisible(false); setModalVisible(false);
}, },
}); });
} else { } else {
form.resetFields();
setModalVisible(false); setModalVisible(false);
} }
}} maskClosable={false} width={900}> }} maskClosable={false} width={900} destroyOnClose>
<Form form={form} layout="vertical" onValuesChange={handleFormChange}> <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' }} /> <Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
</Form.Item> </Form.Item>
{/* 第2项:支出类型和支出分类 */} {/* 第2项:支出类型和支出分类 */}
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}> <Form.Item name="expense_type" label={t('paymentRequest.expenseType')} rules={[{ required: true }]}>
<Select placeholder="选择支出类型"> <Select placeholder={t('paymentRequest.selectExpenseType')}>
{EXPENSE_TYPES.map(type => ( {EXPENSE_TYPES.map(type => (
<Option key={type.value} value={type.value}>{type.label}</Option> <Option key={type.value} value={type.value}>{type.label}</Option>
))} ))}
@@ -501,8 +533,8 @@ const PaymentRequestsPage: React.FC = () => {
{/* 项目支出 - 选择项目 */} {/* 项目支出 - 选择项目 */}
{expenseType === 'project' && ( {expenseType === 'project' && (
<Form.Item name="project_id" label="关联项目" rules={[{ required: true }]}> <Form.Item name="project_id" label={t('paymentRequest.relatedProject')} rules={[{ required: true }]}>
<Select placeholder="选择项目" showSearch optionFilterProp="children"> <Select placeholder={t('paymentRequest.selectProject')} showSearch optionFilterProp="children">
{projects.map(proj => ( {projects.map(proj => (
<Option key={proj.id} value={proj.id}>{proj.name}</Option> <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 }]}> <Form.Item name="expense_category" label={t('paymentRequest.expenseCategory')} rules={[{ required: true }]}>
<Select placeholder="选择支出分类"> <Select placeholder={t('paymentRequest.selectCategory')}>
{(expenseType === 'project' ? PROJECT_EXPENSE_CATEGORIES : COMPANY_EXPENSE_CATEGORIES).map(cat => ( {(expenseType === 'project' ? PROJECT_EXPENSE_CATEGORIES : COMPANY_EXPENSE_CATEGORIES).map(cat => (
<Option key={cat.value} value={cat.value}>{cat.label}</Option> <Option key={cat.value} value={cat.value}>{cat.label}</Option>
))} ))}
@@ -520,13 +552,13 @@ const PaymentRequestsPage: React.FC = () => {
</Form.Item> </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%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
{/* 收款单位 - 二级选择 */} {/* 收款单位 - 二级选择 */}
<Form.Item name="payee_type" label="收款单位类型" rules={[{ required: true }]}> <Form.Item name="payee_type" label={t('paymentRequest.payeeType')} rules={[{ required: true }]}>
<Select placeholder="选择收款单位类型"> <Select placeholder={t('paymentRequest.selectPayeeType')}>
{PAYEE_TYPES.map(type => ( {PAYEE_TYPES.map(type => (
<Option key={type.value} value={type.value}>{type.label}</Option> <Option key={type.value} value={type.value}>{type.label}</Option>
))} ))}
@@ -534,8 +566,8 @@ const PaymentRequestsPage: React.FC = () => {
</Form.Item> </Form.Item>
{payeeType === 'subcontractor' && ( {payeeType === 'subcontractor' && (
<Form.Item name="payee_select" label="选择分包商" rules={[{ required: true }]}> <Form.Item name="payee_select" label={t('paymentRequest.selectSubcontractor')} rules={[{ required: true }]}>
<Select placeholder="选择分包商" showSearch optionFilterProp="children"> <Select placeholder={t('paymentRequest.selectSubcontractor')} showSearch optionFilterProp="children">
{subcontractors.map(sub => ( {subcontractors.map(sub => (
<Option key={sub.id} value={sub.id}>{sub.name}</Option> <Option key={sub.id} value={sub.id}>{sub.name}</Option>
))} ))}
@@ -544,8 +576,8 @@ const PaymentRequestsPage: React.FC = () => {
)} )}
{payeeType === 'supplier' && ( {payeeType === 'supplier' && (
<Form.Item name="payee_select" label="选择供应商" rules={[{ required: true }]}> <Form.Item name="payee_select" label={t('paymentRequest.selectSupplier')} rules={[{ required: true }]}>
<Select placeholder="选择供应商" showSearch optionFilterProp="children"> <Select placeholder={t('paymentRequest.selectSupplier')} showSearch optionFilterProp="children">
{suppliers.map(sup => ( {suppliers.map(sup => (
<Option key={sup.id} value={sup.id}>{sup.name}</Option> <Option key={sup.id} value={sup.id}>{sup.name}</Option>
))} ))}
@@ -554,8 +586,8 @@ const PaymentRequestsPage: React.FC = () => {
)} )}
{payeeType === 'customer' && ( {payeeType === 'customer' && (
<Form.Item name="payee_select" label="选择客户" rules={[{ required: true }]}> <Form.Item name="payee_select" label={t('paymentRequest.selectCustomer')} rules={[{ required: true }]}>
<Select placeholder="选择客户" showSearch optionFilterProp="children"> <Select placeholder={t('paymentRequest.selectCustomer')} showSearch optionFilterProp="children">
{customers.map(cust => ( {customers.map(cust => (
<Option key={cust.id} value={cust.id}>{cust.name}</Option> <Option key={cust.id} value={cust.id}>{cust.name}</Option>
))} ))}
@@ -564,102 +596,102 @@ const PaymentRequestsPage: React.FC = () => {
)} )}
{payeeType === 'other' && ( {payeeType === 'other' && (
<Form.Item name="payee_input" label="收款单位" rules={[{ required: true }]}> <Form.Item name="payee_input" label={t('paymentRequest.payee')} rules={[{ required: true }]}>
<Input placeholder="手动输入收款单位名称" /> <Input placeholder={t('paymentRequest.payeeNamePlaceholder')} />
</Form.Item> </Form.Item>
)} )}
{/* 收款户名 - 新增字段 */} {/* 收款户名 - 新增字段 */}
<Form.Item name="account_name" label="收款户名"> <Form.Item name="account_name" label={t('paymentRequest.accountName')}>
<Input placeholder="收款户名(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} /> <Input placeholder={t('paymentRequest.accountNamePlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
</Form.Item> </Form.Item>
<Form.Item name="bank_account" label="银行账号"> <Form.Item name="bank_account" label={t('paymentRequest.bankAccount')}>
<Input placeholder="收款银行账号(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} /> <Input placeholder={t('paymentRequest.bankAccountPlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
</Form.Item> </Form.Item>
<Form.Item name="bank_name" label="开户银行"> <Form.Item name="bank_name" label={t('paymentRequest.bankName')}>
<Input placeholder="开户银行名称(选择分包商/供应商/客户时自动填充)" readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} /> <Input placeholder={t('paymentRequest.bankNamePlaceholder')} readOnly={['subcontractor', 'supplier', 'customer'].includes(payeeType)} />
</Form.Item> </Form.Item>
{/* 收款码 - 新增字段 */} {/* 收款码 - 新增字段 */}
<Form.Item name="qr_code" label="收款码"> <Form.Item name="qr_code" label={t('paymentRequest.qrCode')}>
<FileUpload maxCount={1} accept="image/*" /> <FileUpload maxCount={1} accept="image/*" />
</Form.Item> </Form.Item>
<Form.Item name="currency" label="币种" rules={[{ required: true }]}> <Form.Item name="currency" label={t('common.currency')} rules={[{ required: true }]}>
<Select style={{ width: 200 }}> <Select style={{ width: 200 }} onChange={(value: string) => setWatchedCurrency(value)}>
<Option value="CNY"> (CNY)</Option> <Option value="CNY">{t('paymentRequest.currencyCNY')}</Option>
<Option value="USD"> (USD)</Option> <Option value="USD">{t('paymentRequest.currencyUSD')}</Option>
<Option value="LAK"> (LAK)</Option> <Option value="LAK">{t('paymentRequest.currencyLAK')}</Option>
<Option value="THB"> (THB)</Option> <Option value="THB">{t('paymentRequest.currencyTHB')}</Option>
</Select> </Select>
</Form.Item> </Form.Item>
{/* 金额 - 直接输入 */} {/* 金额 - 直接输入 */}
<Form.Item name="amount" label="付款金额" rules={[{ required: true }]}> <Form.Item name="amount" label={t('paymentRequest.paymentAmount')} rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="输入付款金额" /> <InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder={t('paymentRequest.paymentAmountPlaceholder')} onChange={(value) => setWatchedAmount(value)} />
{amount && currency !== 'CNY' && amountCNY > 0 && ( </Form.Item>
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}> {watchedAmount && watchedCurrency !== 'CNY' && amountCNY > 0 && (
¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <div style={{ marginTop: -20, marginBottom: 24, color: '#888', fontSize: 13 }}>
{t('paymentRequest.equivalentCNY')}{amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div> </div>
)} )}
<Form.Item name="reason" label={t('paymentRequest.paymentReason')} rules={[{ required: true }]}>
<TextArea rows={2} placeholder={t('paymentRequest.paymentReasonPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="reason" label="付款事由" rules={[{ required: true }]}> <Divider>{t('paymentRequest.proofAttachment')}</Divider>
<TextArea rows={2} placeholder="付款原因" /> <Form.Item name="attachments" label={t('paymentRequest.uploadProof')}>
</Form.Item>
<Divider></Divider>
<Form.Item name="attachments" label="上传凭证附件">
<FileUpload maxCount={9} accept="image/*" /> <FileUpload maxCount={9} accept="image/*" />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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 && ( {selectedRecord && (
<> <>
<Descriptions bordered column={2} size="small"> <Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请编号">{selectedRecord.request_code}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.applicationCode')}>{selectedRecord.request_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item> <Descriptions.Item label={t('common.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期">{selectedRecord.application_date || selectedRecord.payment_date}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.applicationDate')}>{selectedRecord.application_date || selectedRecord.payment_date}</Descriptions.Item>
<Descriptions.Item label="收款单位类型">{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.payeeType')}>{getPayeeTypeLabel(selectedRecord.payee_type)}</Descriptions.Item>
<Descriptions.Item label="收款单位">{selectedRecord.payee}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.payee')}>{selectedRecord.payee}</Descriptions.Item>
<Descriptions.Item label="收款户名">{selectedRecord.account_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.accountName')}>{selectedRecord.account_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{selectedRecord.bank_account || '-'}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.bankAccount')}>{selectedRecord.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="开户银行">{selectedRecord.bank_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.bankName')}>{selectedRecord.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label="支出类型"> <Descriptions.Item label={t('paymentRequest.expenseType')}>
{selectedRecord.expense_type === 'company' ? '公司支出' : '项目支出'} {selectedRecord.expense_type === 'company' ? t('paymentRequest.companyExpense') : t('paymentRequest.projectExpense')}
</Descriptions.Item> </Descriptions.Item>
{selectedRecord.expense_type === 'project' && ( {selectedRecord.expense_type === 'project' && (
<Descriptions.Item label="关联项目"> <Descriptions.Item label={t('paymentRequest.relatedProject')}>
{projects.find(p => p.id === selectedRecord.project_id)?.name || '-'} {projects.find(p => p.id === selectedRecord.project_id)?.name || '-'}
</Descriptions.Item> </Descriptions.Item>
)} )}
<Descriptions.Item label="支出分类"> <Descriptions.Item label={t('paymentRequest.expenseCategory')}>
{getExpenseCategoryLabel(selectedRecord.expense_type, selectedRecord.expense_category)} {getExpenseCategoryLabel(selectedRecord.expense_type, selectedRecord.expense_category)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="金额"> <Descriptions.Item label={t('common.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)} {formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && ( {selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span> <span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="付款事由" span={2}>{selectedRecord.reason}</Descriptions.Item> <Descriptions.Item label={t('paymentRequest.paymentReason')} span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions> </Descriptions>
{selectedRecord.qr_code && ( {selectedRecord.qr_code && (
<> <>
<Divider></Divider> <Divider>{t('paymentRequest.qrCode')}</Divider>
<Image src={selectedRecord.qr_code} width={200} style={{ borderRadius: 4 }} /> <Image src={selectedRecord.qr_code} width={200} style={{ borderRadius: 4 }} />
</> </>
)} )}
{selectedRecord.attachments && selectedRecord.attachments.length > 0 && ( {selectedRecord.attachments && selectedRecord.attachments.length > 0 && (
<> <>
<Divider></Divider> <Divider>{t('paymentRequest.proofAttachment')}</Divider>
<Image.PreviewGroup> <Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => ( {selectedRecord.attachments.map((url: string, index: number) => (
+47 -42
View File
@@ -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 { 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 { PlusOutlined, SearchOutlined, ShoppingOutlined } from '@ant-design/icons';
import useFormDraft from '../hooks/useFormDraft'; import useFormDraft from '../hooks/useFormDraft';
import { useLanguageStore } from '../store/languageStore';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const ProcurementPage: React.FC = () => { const ProcurementPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false); const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -22,15 +24,15 @@ const ProcurementPage: React.FC = () => {
}, [saveDraft]) }, [saveDraft])
const columns = [ const columns = [
{ title: '采购单号', dataIndex: 'code', key: 'code', width: 140 }, { title: t('procurement.orderCode'), dataIndex: 'code', key: 'code', width: 140 },
{ title: '采购日期', dataIndex: 'date', key: 'date', width: 120 }, { title: t('procurement.purchaseDate'), dataIndex: 'date', key: 'date', width: 120 },
{ title: '供应商', dataIndex: 'supplier', key: 'supplier' }, { title: t('procurement.supplier'), dataIndex: 'supplier', key: 'supplier' },
{ title: '物料名称', dataIndex: 'material', key: 'material' }, { title: t('procurement.materialName'), dataIndex: 'material', key: 'material' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80 }, { title: t('procurement.quantity'), dataIndex: 'quantity', key: 'quantity', width: 80 },
{ title: '单价', dataIndex: 'unitPrice', key: 'unitPrice', width: 100, render: (v: number) => `¥${v}` }, { title: t('procurement.unitPrice'), 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.totalAmount'), dataIndex: 'amount', key: 'amount', width: 120, render: (v: number) => `¥${v?.toLocaleString()}` },
{ {
title: '状态', title: t('procurement.status'),
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 100, width: 100,
@@ -42,22 +44,22 @@ const ProcurementPage: React.FC = () => {
rejected: 'error' rejected: 'error'
}; };
const texts: Record<string, string> = { const texts: Record<string, string> = {
pending: '待审批', pending: t('procurement.pendingApproval'),
approved: '已批准', approved: t('procurement.approved'),
received: '已入库', received: t('procurement.stocked'),
rejected: '已拒绝' rejected: t('procurement.rejected')
}; };
return <Tag color={colors[v]}>{texts[v]}</Tag>; return <Tag color={colors[v]}>{texts[v]}</Tag>;
} }
}, },
{ {
title: '操作', title: t('procurement.action'),
key: 'action', key: 'action',
width: 150, width: 150,
render: () => ( render: () => (
<Space> <Space>
<Button size="small" type="link"></Button> <Button size="small" type="link">{t('procurement.view')}</Button>
<Button size="small" type="link"></Button> <Button size="small" type="link">{t('procurement.approve')}</Button>
</Space> </Space>
) )
} }
@@ -70,7 +72,7 @@ const ProcurementPage: React.FC = () => {
]; ];
const handleSubmit = () => { const handleSubmit = () => {
message.success('采购申请已提交'); message.success(t('procurement.submitSuccess'));
clearDraft() clearDraft()
setModalVisible(false); setModalVisible(false);
}; };
@@ -79,22 +81,22 @@ const ProcurementPage: React.FC = () => {
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <div>
<Title level={3} style={{ marginBottom: 0 }}></Title> <Title level={3} style={{ marginBottom: 0 }}>{t('procurement.title')}</Title>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('procurement.description')}</Paragraph>
</div> </div>
<Space> <Space>
<RangePicker placeholder={['开始日期', '结束日期']} /> <RangePicker placeholder={[t('procurement.startDate'), t('procurement.endDate')]} />
<Input.Search placeholder="搜索采购单号" style={{ width: 200 }} /> <Input.Search placeholder={t('procurement.searchOrder')} style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => { <Button type="primary" icon={<PlusOutlined />} onClick={() => {
setModalVisible(true) setModalVisible(true)
// 检查是否有草稿,提示用户是否恢复 // 检查是否有草稿,提示用户是否恢复
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的采购信息,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft() restoreDraft()
}, },
@@ -106,7 +108,7 @@ const ProcurementPage: React.FC = () => {
} }
}, 0) }, 0)
}}> }}>
{t('procurement.newProcurement')}
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -114,22 +116,22 @@ const ProcurementPage: React.FC = () => {
<Row gutter={16} style={{ marginBottom: 24 }}> <Row gutter={16} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Card> <Card>
<Statistic title="待审批" value={5} prefix={<ShoppingOutlined />} /> <Statistic title={t('procurement.pendingApproval')} value={5} prefix={<ShoppingOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Card> <Card>
<Statistic title="已批准" value={12} valueStyle={{ color: '#1890ff' }} /> <Statistic title={t('procurement.approved')} value={12} valueStyle={{ color: '#1890ff' }} />
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Card> <Card>
<Statistic title="已入库" value={28} valueStyle={{ color: '#52c41a' }} /> <Statistic title={t('procurement.stocked')} value={28} valueStyle={{ color: '#52c41a' }} />
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} md={6}> <Col xs={24} sm={12} md={6}>
<Card> <Card>
<Statistic title="本月采购额" value={156000} prefix="¥" /> <Statistic title={t('procurement.monthPurchase')} value={156000} prefix="¥" />
</Card> </Card>
</Col> </Col>
</Row> </Row>
@@ -139,53 +141,56 @@ const ProcurementPage: React.FC = () => {
</Card> </Card>
<Modal <Modal
title="新建采购申请" title={t('procurement.newApplication')}
open={modalVisible} open={modalVisible}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft() saveDraft()
form.resetFields()
setModalVisible(false) setModalVisible(false)
}, },
}) })
} else { } else {
form.resetFields()
setModalVisible(false) setModalVisible(false)
} }
}} }}
onOk={handleSubmit} onOk={handleSubmit}
destroyOnClose
width={600} width={600}
maskClosable={false} maskClosable={false}
> >
<Form form={form} layout="vertical" onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Form.Item label="供应商" name="supplier" rules={[{ required: true }]}> <Form.Item label={t('procurement.supplier')} name="supplier" rules={[{ required: true }]}>
<Select placeholder="选择供应商" options={[ <Select placeholder={t('procurement.selectSupplier')} options={[
{ value: 'supplier1', label: '老挝电力设备公司' }, { value: 'supplier1', label: '老挝电力设备公司' },
{ value: 'supplier2', label: '万象建材供应商' }, { value: 'supplier2', label: '万象建材供应商' },
{ value: 'supplier3', label: '沙湾五金店' } { value: 'supplier3', label: '沙湾五金店' }
]} /> ]} />
</Form.Item> </Form.Item>
<Form.Item label="物料名称" name="material" rules={[{ required: true }]}> <Form.Item label={t('procurement.materialName')} name="material" rules={[{ required: true }]}>
<Input placeholder="请输入物料名称" /> <Input placeholder={t('procurement.inputMaterialName')} />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <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} /> <InputNumber style={{ width: '100%' }} min={1} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <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="¥" /> <InputNumber style={{ width: '100%' }} min={0} precision={2} prefix="¥" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item label="备注" name="remark"> <Form.Item label={t('procurement.remark')} name="remark">
<Input.TextArea rows={3} placeholder="请输入备注说明" /> <Input.TextArea rows={3} placeholder={t('procurement.remarkPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
File diff suppressed because it is too large Load Diff
+175 -172
View File
@@ -1,14 +1,15 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal } from 'antd'; import { Card, Typography, Form, Input, Button, Avatar, Space, Upload, message, Row, Col, Modal, Image, Popconfirm } from 'antd';
import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined, PhoneOutlined, MailOutlined, UploadOutlined, EyeOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import useFormDraft from '../hooks/useFormDraft'; import { useLanguageStore } from '../store/languageStore';
import apiClient from '../utils/request'; import apiClient from '../utils/request';
const { Title, Paragraph } = Typography; const { Title, Paragraph, Text } = Typography;
const ProfilePage: React.FC = () => { const ProfilePage: React.FC = () => {
const { user, setUser } = useAuthStore(); const { user, setUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [passwordForm] = Form.useForm(); const [passwordForm] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -16,96 +17,79 @@ const ProfilePage: React.FC = () => {
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(user?.avatar); const [avatarUrl, setAvatarUrl] = useState<string | undefined>(user?.avatar);
const [passportUrl, setPassportUrl] = useState<string | undefined>(user?.passport); const [passportUrl, setPassportUrl] = useState<string | undefined>(user?.passport);
const [driverLicenseUrl, setDriverLicenseUrl] = useState<string | undefined>(user?.driverLicense); 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(() => { useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => { const checkMobile = () => setIsMobile(window.innerWidth <= 768);
if (form.isFieldsTouched()) { checkMobile();
e.preventDefault() window.addEventListener('resize', checkMobile);
} return () => window.removeEventListener('resize', checkMobile);
} }, []);
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [form])
// 页面加载时检查草稿
useEffect(() => {
if (hasDraft()) {
Modal.confirm({
title: '发现未完成的草稿',
content: '检测到上次未保存的个人信息修改,是否恢复?',
okText: '恢复草稿',
cancelText: '放弃草稿',
onOk: () => {
restoreDraft()
},
onCancel: () => {
clearDraft()
},
})
}
}, [])
useEffect(() => { useEffect(() => {
if (user) { if (user) {
form.setFieldsValue({ form.setFieldsValue({ name: user.name, phone: user.phone, email: user.email });
name: user.name,
phone: user.phone,
email: user.email
});
setAvatarUrl(user.avatar); setAvatarUrl(user.avatar);
setPassportUrl(user.passport); setPassportUrl(user.passport);
setDriverLicenseUrl(user.driverLicense); 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]); }, [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 () => { const handleSubmit = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
setLoading(true); setLoading(true);
const response = await apiClient.put(`/users/${user?.id}/profile`, {
const response = await apiClient.put(`/users/${user?.id}`, {
...values, ...values,
avatar: avatarUrl, avatar: avatarUrl,
passport: passportUrl, passport: passportUrl,
driverLicense: driverLicenseUrl driver_license: driverLicenseUrl
}); });
const data = response.data; const data = response.data;
if (data.success) { if (data.success) {
message.success('个人信息已更新'); message.success(t('user.profileUpdated'));
// 更新authStore中的用户信息
if (user) { if (user) {
const updatedUser = { setUser({ ...user, name: values.name, email: values.email, phone: values.phone, avatar: avatarUrl, passport: passportUrl, driverLicense: driverLicenseUrl });
...user,
name: values.name,
email: values.email,
phone: values.phone,
avatar: avatarUrl
};
setUser(updatedUser);
} }
} else { } else {
message.error(data.message || '更新失败'); message.error(data.message || t('user.updateFailed'));
} }
} catch (error) { } catch (error) {
console.error('提交失败:', error); message.error(t('user.updateRetry'));
message.error('更新失败,请重试');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -114,200 +98,219 @@ const ProfilePage: React.FC = () => {
const handlePasswordSubmit = async () => { const handlePasswordSubmit = async () => {
try { try {
const values = await passwordForm.validateFields(); const values = await passwordForm.validateFields();
const response = await apiClient.put(`/users/${user?.id}/password`, { const response = await apiClient.put(`/users/${user?.id}/password`, {
currentPassword: values.currentPassword, currentPassword: values.currentPassword,
newPassword: values.newPassword newPassword: values.newPassword
}); });
const data = response.data; const data = response.data;
if (data.success) { if (data.success) {
message.success('密码已更新'); message.success(t('user.passwordUpdated'));
setPasswordModalVisible(false); setPasswordModalVisible(false);
passwordForm.resetFields(); passwordForm.resetFields();
} else { } else {
message.error(data.message || '密码更新失败'); message.error(data.message || t('user.passwordUpdateFailed'));
} }
} catch (error) { } catch (error) {
console.error('提交失败:', error); message.error(t('user.passwordUpdateRetry'));
message.error('密码更新失败,请重试');
} }
}; };
const handleAvatarChange = (info: any) => { const handleAvatarUpload = async (options: any) => {
if (info.file.status === 'done') { setUploading('avatar');
setAvatarUrl(URL.createObjectURL(info.file.originFileObj)); const url = await uploadToCos(options.file);
message.success('头像上传成功'); if (url) setAvatarUrl(url);
} else if (info.file.status === 'error') { setUploading(null);
message.error('头像上传失败');
}
}; };
const handlePassportChange = (info: any) => { const handlePassportUpload = async (options: any) => {
if (info.file.status === 'done') { setUploading('passport');
setPassportUrl(URL.createObjectURL(info.file.originFileObj)); const url = await uploadToCos(options.file);
message.success('护照上传成功'); if (url) setPassportUrl(url);
} else if (info.file.status === 'error') { setUploading(null);
message.error('护照上传失败');
}
}; };
const handleDriverLicenseChange = (info: any) => { const handleDriverLicenseUpload = async (options: any) => {
if (info.file.status === 'done') { setUploading('driverLicense');
setDriverLicenseUrl(URL.createObjectURL(info.file.originFileObj)); const url = await uploadToCos(options.file);
message.success('驾照上传成功'); if (url) setDriverLicenseUrl(url);
} else if (info.file.status === 'error') { setUploading(null);
message.error('驾照上传失败');
}
}; };
const showPreview = (title: string, url: string) => {
setPreviewTitle(title);
setPreviewUrl(url);
setPreviewVisible(true);
};
const colSpan = isMobile ? 24 : 12;
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Title level={3}></Title> <Title level={3}>{t('user.profile')}</Title>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('user.managingProfile')}</Paragraph>
</div> </div>
<Card> <Card>
<div style={{ textAlign: 'center', marginBottom: 24 }}> <div style={{ textAlign: 'center', marginBottom: 24 }}>
<Space direction="vertical" style={{ alignItems: 'center' }}> <Space direction="vertical" style={{ alignItems: 'center' }}>
<Upload <Upload
name="avatar" customRequest={handleAvatarUpload}
listType="picture-circle"
showUploadList={false} showUploadList={false}
onChange={handleAvatarChange} accept="image/*"
maxCount={1}
> >
{avatarUrl ? ( {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> </Upload>
<Typography.Text></Typography.Text> <Text>{t('user.clickToChangeAvatar')}</Text>
<Typography.Text strong>{user?.name || user?.username}</Typography.Text> <Text strong>{user?.name || user?.username}</Text>
<Typography.Text type="secondary">{user?.role === 'admin' ? '管理员' : user?.role === 'manager' ? '经理' : '普通用户'}</Typography.Text> <Text type="secondary">{user?.role === 'admin' ? t('user.admin') : user?.role === 'manager' ? t('user.manager') : t('user.employee')}</Text>
</Space> </Space>
</div> </div>
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={colSpan}>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}> <Form.Item label={t('user.name')} name="name" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<Input placeholder="请输入姓名" /> <Input placeholder={t('common.inputPlaceholder')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={colSpan}>
<Form.Item label="手机号" name="phone" rules={[{ required: true, message: '请输入手机号' }]}> <Form.Item label={t('user.phone')} name="phone" rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<Input placeholder="请输入手机号" prefix={<PhoneOutlined />} /> <Input placeholder={t('common.inputPlaceholder')} prefix={<PhoneOutlined />} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={colSpan}>
<Form.Item label="邮箱" name="email" rules={[{ required: true, message: '请输入邮箱' }, { type: 'email', message: '请输入正确的邮箱地址' }]}> <Form.Item label={t('user.email')} name="email" rules={[{ required: true, message: t('common.inputPlaceholder') }, { type: 'email', message: 'Invalid email' }]}>
<Input placeholder="请输入邮箱" prefix={<MailOutlined />} /> <Input placeholder="email@example.com" prefix={<MailOutlined />} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={colSpan}>
<Form.Item label="用户名" disabled> <Form.Item label={t('user.username')} disabled>
<Input value={user?.username} placeholder="用户名" /> <Input value={user?.username} placeholder={t('user.username')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 16, marginTop: 8 }}>
<Title level={4}></Title> <Title level={4}>{t('user.idDocument')}</Title>
<Text type="secondary">{t('user.idDocTip')}</Text>
</div> </div>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={colSpan}>
<Form.Item label="护照">
<Upload
name="passport"
listType="picture"
showUploadList={false}
onChange={handlePassportChange}
maxCount={1}
>
<Card <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 ? ( {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' }} /> <UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<Typography.Text></Typography.Text> <div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadPassport')}</Text></div>
</Space> </div>
</Upload>
)} )}
</Card> </Card>
</Upload>
</Form.Item>
</Col> </Col>
<Col span={12}> <Col span={colSpan}>
<Form.Item label="驾照">
<Upload
name="driverLicense"
listType="picture"
showUploadList={false}
onChange={handleDriverLicenseChange}
maxCount={1}
>
<Card <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 ? ( {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' }} /> <UploadOutlined style={{ fontSize: 32, color: '#1890ff' }} />
<Typography.Text></Typography.Text> <div style={{ marginTop: 8 }}><Text type="secondary">{t('user.uploadDriverLicense')}</Text></div>
</Space> </div>
</Upload>
)} )}
</Card> </Card>
</Upload>
</Form.Item>
</Col> </Col>
</Row> </Row>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24, gap: 8 }}>
<Button type="primary" htmlType="submit" loading={loading}> <Button type="primary" htmlType="submit" loading={loading}>{t('user.saveProfile')}</Button>
<Button onClick={() => setPasswordModalVisible(true)}>{t('user.changePassword')}</Button>
</Button>
<Button style={{ marginLeft: 16 }} onClick={() => setPasswordModalVisible(true)}>
</Button>
</div> </div>
</Form> </Form>
</Card> </Card>
<Modal <Modal
title="修改密码" title={t('user.changePassword')}
open={passwordModalVisible} open={passwordModalVisible}
onCancel={() => setPasswordModalVisible(false)} onCancel={() => setPasswordModalVisible(false)}
onOk={handlePasswordSubmit} onOk={handlePasswordSubmit}
width={400} width={400}
> >
<Form form={passwordForm} layout="vertical"> <Form form={passwordForm} layout="vertical">
<Form.Item label="当前密码" name="currentPassword" rules={[{ required: true, message: '请输入当前密码' }]}> <Form.Item label={t('user.currentPassword')} name="currentPassword" rules={[{ required: true, message: t('user.currentPasswordPlaceholder') }]}>
<Input.Password placeholder="请输入当前密码" prefix={<LockOutlined />} /> <Input.Password placeholder={t('user.currentPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item> </Form.Item>
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度至少为6位' }]}> <Form.Item label={t('user.newPassword')} name="newPassword" rules={[{ required: true, message: t('user.newPasswordPlaceholder') }, { min: 6, message: t('user.passwordMinLen') }]}>
<Input.Password placeholder="请输入新密码" prefix={<LockOutlined />} /> <Input.Password placeholder={t('user.newPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item> </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) { validator(_, value) {
if (!value || getFieldValue('newPassword') === value) { if (!value || getFieldValue('newPassword') === value) return Promise.resolve();
return Promise.resolve(); return Promise.reject(new Error(t('user.passwordMismatch')));
}
return Promise.reject(new Error('两次输入的密码不一致'));
} }
})]}> })]}>
<Input.Password placeholder="请确认新密码" prefix={<LockOutlined />} /> <Input.Password placeholder={t('user.confirmPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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> </div>
); );
}; };
+377 -219
View File
@@ -1,249 +1,407 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import { 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' } from 'antd'
import { BarChartOutlined, DollarOutlined, ShoppingOutlined } from '@ant-design/icons' import { RiseOutlined, FallOutlined, UnorderedListOutlined, BarChartOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import { useLanguageStore } from '../store/languageStore'
import apiClient from '../utils/request'
import dayjs from 'dayjs'
// ==================== 类型定义 ==================== const { Title } = Typography
interface Project { const { RangePicker } = DatePicker
id: number
name: string
code: string
contract_amount: number
}
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 ProjectCostPage: React.FC = () => {
// 状态 const { t } = useLanguageStore()
const [projects, setProjects] = useState<Project[]>([])
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 [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 [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 () => { const [modalVisible, setModalVisible] = useState(false)
try { const [modalTitle, setModalTitle] = useState('')
const response = await fetch('/api/projects') const [modalRecords, setModalRecords] = useState<any[]>([])
const data = await response.json() const [modalPagination, setModalPagination] = useState({ page: 1, pageSize: 20, total: 0 })
if (data.success) { const [modalLoading, setModalLoading] = useState(false)
setProjects(data.data) const [modalFilter, setModalFilter] = useState<any>({})
}
} 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)
}
}
useEffect(() => { useEffect(() => {
fetchProjects() apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setProjects(res.data.data || res.data.projects || [])
}).catch(() => {})
}, []) }, [])
useEffect(() => { useEffect(() => {
if (selectedProjectId) { 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 { } else {
setCostSummary(null) setCostSummary(null)
} }
}, [selectedProjectId]) }, [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 ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Card> <Title level={3} style={{ marginBottom: 24 }}>{t('projectCost.title')}</Title>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={12}> <Card style={{ marginBottom: 16 }}>
<Select <Select
placeholder="请选择项目查看成本统计" placeholder={t('projectCost.selectProject')}
style={{ width: '100%' }} style={{ width: '100%', maxWidth: 500 }}
showSearch optionFilterProp="label"
value={selectedProjectId} 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 => ( <Spin spinning={modalLoading}>
<Select.Option key={project.id} value={project.id}> <Table
{project.name} dataSource={modalRecords}
</Select.Option> columns={modalColumns}
))} rowKey="id"
</Select> size="small"
</Col> scroll={{ x: 700 }}
</Row> pagination={{
current: modalPagination.page,
{loading ? ( pageSize: modalPagination.pageSize,
<div style={{ textAlign: 'center', padding: 40 }}> total: modalPagination.total,
<Spin size="large" /> onChange: (page) => fetchModalRecords(page),
</div> showTotal: (total) => t('finance.totalRecords', { total })
) : costSummary ? ( }}
<>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="合同金额"
value={costSummary.contract_amount}
precision={2}
prefix={<DollarOutlined />}
valueStyle={{ color: '#1890ff' }}
/> />
</Card> </Spin>
</Col> </Modal>
<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>
</div> </div>
) )
} }
+139 -129
View File
@@ -18,6 +18,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useLanguageStore } from '../store/languageStore'
interface PurchaseOrder { interface PurchaseOrder {
id: number id: number
@@ -94,6 +95,7 @@ interface Supplier { id: number; name: string; country: string }
interface Product { id: number; name: string; specification: string; unit: string } interface Product { id: number; name: string; specification: string; unit: string }
const PurchaseOrdersPage: React.FC = () => { const PurchaseOrdersPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [orders, setOrders] = useState<PurchaseOrder[]>([]) const [orders, setOrders] = useState<PurchaseOrder[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
@@ -128,11 +130,11 @@ const PurchaseOrdersPage: React.FC = () => {
if (data.success) { if (data.success) {
setOrders(data.data) setOrders(data.data)
} else { } else {
message.error('获取采购订单列表失败') message.error(t('purchaseOrder.getListFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取采购订单列表失败:', error) console.error('获取采购订单列表失败:', error)
message.error('获取采购订单列表失败') message.error(t('purchaseOrder.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -177,11 +179,11 @@ const PurchaseOrdersPage: React.FC = () => {
setDetailModalVisible(true) setDetailModalVisible(true)
setActiveDetailTab('basic') setActiveDetailTab('basic')
} else { } else {
message.error('获取订单详情失败') message.error(t('purchaseOrder.getDetailFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取订单详情失败:', error) console.error('获取订单详情失败:', error)
message.error('获取订单详情失败') message.error(t('purchaseOrder.getDetailFailed'))
} }
} }
@@ -204,14 +206,14 @@ const PurchaseOrdersPage: React.FC = () => {
}) })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('订单确认成功') message.success(t('purchaseOrder.orderConfirmSuccess'))
fetchOrders() fetchOrders()
} else { } else {
message.error(data.message || '订单确认失败') message.error(data.message || t('purchaseOrder.orderConfirmFailed'))
} }
} catch (error) { } catch (error) {
console.error('订单确认失败:', 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 response = await fetch(`/api/purchase-orders/${id}/cancel`, { method: 'POST' })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('订单已取消') message.success(t('purchaseOrder.orderCancelSuccess'))
fetchOrders() fetchOrders()
} else { } else {
message.error('取消订单失败') message.error(t('purchaseOrder.orderCancelFailed'))
} }
} catch (error) { } catch (error) {
console.error('取消订单失败:', 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 response = await fetch(`/api/purchase-orders/${id}`, { method: 'DELETE' })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('订单删除成功') message.success(t('purchaseOrder.orderDeleteSuccess'))
fetchOrders() fetchOrders()
} else { } else {
message.error('删除订单失败') message.error(t('purchaseOrder.orderDeleteFailed'))
} }
} catch (error) { } catch (error) {
console.error('删除订单失败:', error) console.error('删除订单失败:', error)
message.error('删除订单失败') message.error(t('purchaseOrder.orderDeleteFailed'))
} }
} }
@@ -275,11 +277,11 @@ const PurchaseOrdersPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingItem ? '商品更新成功' : '商品添加成功') message.success(editingItem ? t('purchaseOrder.productUpdateSuccess') : t('purchaseOrder.productAddSuccess'))
setItemModalVisible(false) setItemModalVisible(false)
fetchOrderDetail(currentOrder!.id) fetchOrderDetail(currentOrder!.id)
} else { } else {
message.error('操作失败') message.error(t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('保存商品失败:', error) console.error('保存商品失败:', error)
@@ -293,10 +295,10 @@ const PurchaseOrdersPage: React.FC = () => {
}) })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('商品删除成功') message.success(t('purchaseOrder.productDeleteSuccess'))
fetchOrderDetail(currentOrder!.id) fetchOrderDetail(currentOrder!.id)
} else { } else {
message.error('删除失败') message.error(t('common.deleteFailed'))
} }
} catch (error) { } catch (error) {
console.error('删除商品失败:', error) console.error('删除商品失败:', error)
@@ -338,11 +340,11 @@ const PurchaseOrdersPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingPayment ? '付款计划更新成功' : '付款计划添加成功') message.success(editingPayment ? t('purchaseOrder.paymentPlanUpdateSuccess') : t('purchaseOrder.paymentPlanAddSuccess'))
setPaymentModalVisible(false) setPaymentModalVisible(false)
fetchOrderDetail(currentOrder!.id) fetchOrderDetail(currentOrder!.id)
} else { } else {
message.error('操作失败') message.error(t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('保存付款计划失败:', error) console.error('保存付款计划失败:', error)
@@ -356,10 +358,10 @@ const PurchaseOrdersPage: React.FC = () => {
}) })
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('付款计划删除成功') message.success(t('purchaseOrder.paymentPlanDeleteSuccess'))
fetchOrderDetail(currentOrder!.id) fetchOrderDetail(currentOrder!.id)
} else { } else {
message.error('删除失败') message.error(t('common.deleteFailed'))
} }
} catch (error) { } catch (error) {
console.error('删除付款计划失败:', error) console.error('删除付款计划失败:', error)
@@ -368,14 +370,14 @@ const PurchaseOrdersPage: React.FC = () => {
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' }, draft: { color: 'default', text: t('purchaseOrder.draft') },
confirmed: { color: 'blue', text: '已确认' }, confirmed: { color: 'blue', text: t('purchaseOrder.confirmed') },
partial_paid: { color: 'orange', text: '部分付款' }, partial_paid: { color: 'orange', text: t('purchaseOrder.partialPayment') },
paid: { color: 'green', text: '已付清' }, paid: { color: 'green', text: t('purchaseOrder.paidOff') },
shipping: { color: 'cyan', text: '物流中' }, shipping: { color: 'cyan', text: t('purchaseOrder.inTransit') },
verified: { color: 'purple', text: '已验收' }, verified: { color: 'purple', text: t('purchaseOrder.accepted') },
closed: { color: 'success', text: '已关闭' }, closed: { color: 'success', text: t('purchaseOrder.closed') },
cancelled: { color: 'error', text: '已取消' } cancelled: { color: 'error', text: t('purchaseOrder.cancelled') }
} }
const info = statusMap[status] || { color: 'default', text: status } const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag> return <Tag color={info.color}>{info.text}</Tag>
@@ -390,7 +392,7 @@ const PurchaseOrdersPage: React.FC = () => {
const columns: ColumnsType<PurchaseOrder> = [ const columns: ColumnsType<PurchaseOrder> = [
{ {
title: '订单号', title: t('purchaseOrder.orderCode'),
dataIndex: 'code', dataIndex: 'code',
key: 'code', key: 'code',
width: 150, width: 150,
@@ -399,21 +401,21 @@ const PurchaseOrdersPage: React.FC = () => {
) )
}, },
{ {
title: '供应商', title: t('purchaseOrder.supplier'),
dataIndex: 'supplier_name', dataIndex: 'supplier_name',
key: 'supplier_name', key: 'supplier_name',
width: 140, width: 140,
render: (v: string) => v || '-' render: (v: string) => v || '-'
}, },
{ {
title: '项目', title: t('purchaseOrder.relatedProject'),
dataIndex: 'project_name', dataIndex: 'project_name',
key: 'project_name', key: 'project_name',
width: 120, width: 120,
render: (v: string) => v || '-' render: (v: string) => v || '-'
}, },
{ {
title: '金额', title: t('purchaseOrder.amount'),
dataIndex: 'total_amount', dataIndex: 'total_amount',
key: 'total_amount', key: 'total_amount',
width: 140, width: 140,
@@ -428,7 +430,7 @@ const PurchaseOrdersPage: React.FC = () => {
} }
}, },
{ {
title: '已付', title: t('purchaseOrder.paid'),
dataIndex: 'paid_amount', dataIndex: 'paid_amount',
key: 'paid_amount', key: 'paid_amount',
width: 120, width: 120,
@@ -440,7 +442,7 @@ const PurchaseOrdersPage: React.FC = () => {
) )
}, },
{ {
title: '状态', title: t('purchaseOrder.status'),
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 100, width: 100,
@@ -448,14 +450,14 @@ const PurchaseOrdersPage: React.FC = () => {
render: getStatusTag render: getStatusTag
}, },
{ {
title: '创建日期', title: t('purchaseOrder.createDate'),
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', key: 'created_at',
width: 100, width: 100,
render: (v: string) => v ? dayjs(v).format('MM-DD') : '-' render: (v: string) => v ? dayjs(v).format('MM-DD') : '-'
}, },
{ {
title: '操作', title: t('purchaseOrder.action'),
key: 'actions', key: 'actions',
width: 180, width: 180,
fixed: 'right', fixed: 'right',
@@ -464,14 +466,14 @@ const PurchaseOrdersPage: React.FC = () => {
<Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchOrderDetail(record.id)} /> <Button size="small" type="text" icon={<EyeOutlined />} onClick={() => fetchOrderDetail(record.id)} />
{record.status === 'draft' && ( {record.status === 'draft' && (
<> <>
<Button size="small" type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} onClick={() => handleConfirmOrder(record.id)}></Button> <Button size="small" type="text" icon={<CheckOutlined />} style={{ color: '#52c41a' }} onClick={() => handleConfirmOrder(record.id)}>{t('purchaseOrder.confirm')}</Button>
<Popconfirm title="确定要取消吗?" onConfirm={() => handleCancelOrder(record.id)}> <Popconfirm title={t('purchaseOrder.confirmCancel')} onConfirm={() => handleCancelOrder(record.id)}>
<Button size="small" type="text" danger></Button> <Button size="small" type="text" danger>{t('purchaseOrder.cancel')}</Button>
</Popconfirm> </Popconfirm>
</> </>
)} )}
{record.status === 'cancelled' && ( {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 />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
)} )}
@@ -481,20 +483,20 @@ const PurchaseOrdersPage: React.FC = () => {
] ]
const itemColumns: ColumnsType<OrderItem> = [ const itemColumns: ColumnsType<OrderItem> = [
{ title: '商品名称', dataIndex: 'product_name', key: 'product_name', width: 150 }, { title: t('purchaseOrder.productName'), dataIndex: 'product_name', key: 'product_name', width: 150 },
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 100 }, { title: t('purchaseOrder.spec'), dataIndex: 'specification', key: 'specification', width: 100 },
{ title: '单位', dataIndex: 'unit', key: 'unit', width: 60 }, { title: t('purchaseOrder.unit'), dataIndex: 'unit', key: 'unit', width: 60 },
{ title: '数量', dataIndex: 'quantity', key: 'quantity', width: 80, align: 'right' }, { title: t('purchaseOrder.quantity'), 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: t('purchaseOrder.unitPrice'), 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.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', key: 'actions',
width: 100, width: 100,
render: (_, record) => currentOrder?.status === 'draft' && ( render: (_, record) => currentOrder?.status === 'draft' && (
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditItem(record)} /> <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 />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
@@ -503,28 +505,28 @@ const PurchaseOrdersPage: React.FC = () => {
] ]
const paymentColumns: ColumnsType<PaymentPlan> = [ const paymentColumns: ColumnsType<PaymentPlan> = [
{ title: '阶段', dataIndex: 'stage', key: 'stage', width: 80 }, { title: t('purchaseOrder.phase'), dataIndex: 'stage', key: 'stage', width: 80 },
{ title: '计划日期', dataIndex: 'planned_date', key: 'planned_date', width: 100 }, { title: t('purchaseOrder.plannedDate'), 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: t('purchaseOrder.plannedAmount'), 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: t('purchaseOrder.ratioPercent'), 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: t('purchaseOrder.actualAmount'), 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.status'), dataIndex: 'status', key: 'status', width: 80, render: (s: string) => {
const map: Record<string, { color: string; text: string }> = { const map: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '待付款' }, pending: { color: 'default', text: t('purchaseOrder.pendingPayment') },
requested: { color: 'blue', text: '已申请' }, requested: { color: 'blue', text: t('purchaseOrder.applied') },
paid: { color: 'green', text: '已支付' } paid: { color: 'green', text: t('purchaseOrder.paidStatus') }
} }
const info = map[s] || { color: 'default', text: s } const info = map[s] || { color: 'default', text: s }
return <Tag color={info.color}>{info.text}</Tag> return <Tag color={info.color}>{info.text}</Tag>
}}, }},
{ {
title: '操作', title: t('purchaseOrder.action'),
key: 'actions', key: 'actions',
width: 100, width: 100,
render: (_, record) => record.status === 'pending' && ( render: (_, record) => record.status === 'pending' && (
<Space size={4}> <Space size={4}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => handleEditPayment(record)} /> <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 />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
@@ -533,46 +535,46 @@ const PurchaseOrdersPage: React.FC = () => {
] ]
const logisticsColumns: ColumnsType<LogisticsRecord> = [ const logisticsColumns: ColumnsType<LogisticsRecord> = [
{ title: '物流单号', dataIndex: 'code', key: 'code', width: 120 }, { title: t('purchaseOrder.trackingNumber'), dataIndex: 'code', key: 'code', width: 120 },
{ title: '发货地', dataIndex: 'ship_from', key: 'ship_from', width: 80, render: (v: string) => v === 'China' ? '中国' : '老挝' }, { title: t('purchaseOrder.origin'), dataIndex: 'ship_from', key: 'ship_from', width: 80, render: (v: string) => v === 'China' ? t('purchaseOrder.china') : t('purchaseOrder.laos') },
{ title: '物流公司', dataIndex: 'logistics_company_name', key: 'logistics_company_name', width: 120 }, { title: t('purchaseOrder.logisticsCompany'), dataIndex: 'logistics_company_name', key: 'logistics_company_name', width: 120 },
{ title: '发货日期', dataIndex: 'ship_date', key: 'ship_date', width: 100 }, { title: t('purchaseOrder.deliveryDate'), 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: t('purchaseOrder.freight1'), 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: t('purchaseOrder.freight2'), 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.status'), dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
] ]
const verificationColumns: ColumnsType<VerificationRecord> = [ const verificationColumns: ColumnsType<VerificationRecord> = [
{ title: '验收单号', dataIndex: 'code', key: 'code', width: 120 }, { title: t('purchaseOrder.acceptanceCode'), dataIndex: 'code', key: 'code', width: 120 },
{ title: '验收日期', dataIndex: 'verification_date', key: 'verification_date', width: 100 }, { title: t('purchaseOrder.acceptanceDate'), dataIndex: 'verification_date', key: 'verification_date', width: 100 },
{ title: '验收人', dataIndex: 'verifier', key: 'verifier', width: 80 }, { title: t('purchaseOrder.acceptor'), dataIndex: 'verifier', key: 'verifier', width: 80 },
{ title: '验收数量', dataIndex: 'total_verified', key: 'total_verified', width: 100, align: 'right' }, { title: t('purchaseOrder.acceptedQty'), dataIndex: 'total_verified', key: 'total_verified', width: 100, align: 'right' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: getStatusTag } { title: t('purchaseOrder.status'), dataIndex: 'status', key: 'status', width: 80, render: getStatusTag }
] ]
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2> <h2 style={{ marginBottom: 8 }}>{t('purchaseOrder.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}>TAB</p> <p style={{ color: '#888', marginBottom: 0 }}>{t('purchaseOrder.description')}</p>
</div> </div>
<Card> <Card>
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}> <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>)} {projects.map(p => <Select.Option key={p.id} value={p.id}>{p.name}</Select.Option>)}
</Select> </Select>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Select placeholder="选择状态筛选" allowClear style={{ width: '100%' }} onChange={(v) => setSelectedStatus(v)}> <Select placeholder={t('purchaseOrder.selectStatus')} allowClear style={{ width: '100%' }} onChange={(v) => setSelectedStatus(v)}>
<Select.Option value="draft">稿</Select.Option> <Select.Option value="draft">{t('purchaseOrder.draft')}</Select.Option>
<Select.Option value="confirmed"></Select.Option> <Select.Option value="confirmed">{t('purchaseOrder.confirmed')}</Select.Option>
<Select.Option value="partial_paid"></Select.Option> <Select.Option value="partial_paid">{t('purchaseOrder.partialPayment')}</Select.Option>
<Select.Option value="paid"></Select.Option> <Select.Option value="paid">{t('purchaseOrder.paidOff')}</Select.Option>
<Select.Option value="shipping"></Select.Option> <Select.Option value="shipping">{t('purchaseOrder.inTransit')}</Select.Option>
<Select.Option value="verified"></Select.Option> <Select.Option value="verified">{t('purchaseOrder.accepted')}</Select.Option>
<Select.Option value="cancelled"></Select.Option> <Select.Option value="cancelled">{t('purchaseOrder.cancelled')}</Select.Option>
</Select> </Select>
</Col> </Col>
</Row> </Row>
@@ -581,7 +583,7 @@ const PurchaseOrdersPage: React.FC = () => {
{/* 订单详情弹窗 - 多TAB */} {/* 订单详情弹窗 - 多TAB */}
<Modal <Modal
title={`采购订单详情 - ${currentOrder?.code || ''}`} title={t('purchaseOrder.detailTitle').replace('{code}', currentOrder?.code || '')}
open={detailModalVisible} open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)} onCancel={() => setDetailModalVisible(false)}
footer={null} footer={null}
@@ -590,59 +592,59 @@ const PurchaseOrdersPage: React.FC = () => {
{currentOrder && ( {currentOrder && (
<Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}> <Tabs activeKey={activeDetailTab} onChange={setActiveDetailTab}>
{/* TAB1: 基本信息 */} {/* 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 bordered column={2}>
<Descriptions.Item label="订单号">{currentOrder.code}</Descriptions.Item> <Descriptions.Item label={t('purchaseOrder.orderCode')}>{currentOrder.code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(currentOrder.status)}</Descriptions.Item> <Descriptions.Item label={t('purchaseOrder.status')}>{getStatusTag(currentOrder.status)}</Descriptions.Item>
<Descriptions.Item label="供应商">{currentOrder.supplier_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('purchaseOrder.supplier')}>{currentOrder.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label="供应商国家">{currentOrder.supplier_country === 'China' ? '中国' : (currentOrder.supplier_country || '老挝')}</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="项目">{currentOrder.project_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('purchaseOrder.relatedProject')}>{currentOrder.project_name || '-'}</Descriptions.Item>
<Descriptions.Item label="币种">{currentOrder.currency}</Descriptions.Item> <Descriptions.Item label={t('common.currency')}>{currentOrder.currency}</Descriptions.Item>
<Descriptions.Item label="预计金额"> <Descriptions.Item label={t('purchaseOrder.estimatedAmount')}>
<span style={{ color: '#999' }}>{currentOrder.estimated_amount?.toFixed(2) || '0.00'}</span> <span style={{ color: '#999' }}>{currentOrder.estimated_amount?.toFixed(2) || '0.00'}</span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="订单金额"> <Descriptions.Item label={t('purchaseOrder.orderAmount')}>
<span style={{ fontWeight: 'bold', color: getAmountColor(currentOrder.status) }}> <span style={{ fontWeight: 'bold', color: getAmountColor(currentOrder.status) }}>
{currentOrder.total_amount?.toFixed(2) || '0.00'} {currentOrder.total_amount?.toFixed(2) || '0.00'}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="已付金额"> <Descriptions.Item label={t('purchaseOrder.paidAmount')}>
<span style={{ color: '#52c41a' }}>{currentOrder.paid_amount?.toFixed(2) || '0.00'}</span> <span style={{ color: '#52c41a' }}>{currentOrder.paid_amount?.toFixed(2) || '0.00'}</span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="创建时间">{currentOrder.created_at}</Descriptions.Item> <Descriptions.Item label={t('purchaseOrder.createdAt')}>{currentOrder.created_at}</Descriptions.Item>
{currentOrder.remark && <Descriptions.Item label="备注" span={2}>{currentOrder.remark}</Descriptions.Item>} {currentOrder.remark && <Descriptions.Item label={t('purchaseOrder.remark')} span={2}>{currentOrder.remark}</Descriptions.Item>}
</Descriptions> </Descriptions>
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB2: 商品明细 */} {/* TAB2: 商品明细 */}
<Tabs.TabPane tab={<span><FileTextOutlined /> </span>} key="items"> <Tabs.TabPane tab={<span><FileTextOutlined /> {t('purchaseOrder.productDetails')}</span>} key="items">
{currentOrder.status === 'draft' && ( {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" /> <Table columns={itemColumns} dataSource={currentOrder.items || []} rowKey="id" pagination={false} size="small" />
<div style={{ marginTop: 16, textAlign: 'right' }}> <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> </div>
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB3: 付款信息 */} {/* 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' && ( {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" /> <Table columns={paymentColumns} dataSource={currentOrder.payment_plans || []} rowKey="id" pagination={false} size="small" />
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB4: 物流信息 */} {/* 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" /> <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> </Tabs.TabPane>
{/* TAB5: 验收记录 */} {/* 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" /> <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.TabPane>
</Tabs> </Tabs>
)} )}
@@ -650,17 +652,21 @@ const PurchaseOrdersPage: React.FC = () => {
{/* 商品明细编辑弹窗 */} {/* 商品明细编辑弹窗 */}
<Modal <Modal
title={editingItem ? '编辑商品' : '添加商品'} title={editingItem ? t('purchaseOrder.editProduct') : t('purchaseOrder.addProductTitle')}
open={itemModalVisible} open={itemModalVisible}
onOk={handleSaveItem} onOk={handleSaveItem}
onCancel={() => setItemModalVisible(false)} onCancel={() => {
itemForm.resetFields();
setItemModalVisible(false);
}}
destroyOnClose
width={600} width={600}
> >
<Form form={itemForm} layout="vertical"> <Form form={itemForm} layout="vertical">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="product_id" label="选择商品"> <Form.Item name="product_id" label={t('purchaseOrder.selectProduct')}>
<Select placeholder="选择商品" allowClear showSearch optionFilterProp="children" onChange={(v) => { <Select placeholder={t('purchaseOrder.selectProduct')} allowClear showSearch optionFilterProp="children" onChange={(v) => {
const product = products.find(p => p.id === v) const product = products.find(p => p.id === v)
if (product) { if (product) {
itemForm.setFieldsValue({ itemForm.setFieldsValue({
@@ -675,29 +681,29 @@ const PurchaseOrdersPage: React.FC = () => {
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="product_name" label="商品名称" rules={[{ required: true }]}> <Form.Item name="product_name" label={t('purchaseOrder.productNameInput')} rules={[{ required: true }]}>
<Input placeholder="商品名称" /> <Input placeholder={t('purchaseOrder.productNameInput')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={8}> <Col span={8}>
<Form.Item name="specification" label="规格"> <Form.Item name="specification" label={t('purchaseOrder.specInput')}>
<Input placeholder="规格" /> <Input placeholder={t('purchaseOrder.specInput')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={4}> <Col span={4}>
<Form.Item name="unit" label="单位"> <Form.Item name="unit" label={t('purchaseOrder.unitInput')}>
<Input placeholder="单位" /> <Input placeholder={t('purchaseOrder.unitInput')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={6}> <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%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={6}> <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%' }} /> <InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col> </Col>
@@ -707,43 +713,47 @@ const PurchaseOrdersPage: React.FC = () => {
{/* 付款计划编辑弹窗 */} {/* 付款计划编辑弹窗 */}
<Modal <Modal
title={editingPayment ? '编辑付款计划' : '添加付款计划'} title={editingPayment ? t('purchaseOrder.editPaymentPlan') : t('purchaseOrder.addPaymentPlanTitle')}
open={paymentModalVisible} open={paymentModalVisible}
onOk={handleSavePayment} onOk={handleSavePayment}
onCancel={() => setPaymentModalVisible(false)} onCancel={() => {
paymentForm.resetFields();
setPaymentModalVisible(false);
}}
destroyOnClose
width={500} width={500}
> >
<Form form={paymentForm} layout="vertical"> <Form form={paymentForm} layout="vertical">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="stage" label="阶段" rules={[{ required: true }]}> <Form.Item name="stage" label={t('purchaseOrder.phase')} rules={[{ required: true }]}>
<Select placeholder="选择阶段"> <Select placeholder={t('purchaseOrder.selectPhase')}>
<Select.Option value="预付款"></Select.Option> <Select.Option value="预付款">{t('purchaseOrder.advancePayment')}</Select.Option>
<Select.Option value="发货款"></Select.Option> <Select.Option value="发货款">{t('purchaseOrder.deliveryPayment')}</Select.Option>
<Select.Option value="验收款"></Select.Option> <Select.Option value="验收款">{t('purchaseOrder.acceptancePayment')}</Select.Option>
<Select.Option value="尾款"></Select.Option> <Select.Option value="尾款">{t('purchaseOrder.finalPayment')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="planned_date" label="计划日期"> <Form.Item name="planned_date" label={t('purchaseOrder.plannedDate')}>
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <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%' }} /> <InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <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%' }} /> <InputNumber min={0} max={100} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('purchaseOrder.remark')}>
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
</Form> </Form>
+158 -141
View File
@@ -22,6 +22,7 @@ import {
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import useFormDraft from '../hooks/useFormDraft' import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface PurchaseRequest { interface PurchaseRequest {
id: number id: number
@@ -50,6 +51,7 @@ interface Project {
} }
const PurchaseRequestsPage: React.FC = () => { const PurchaseRequestsPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [purchaseRequests, setPurchaseRequests] = useState<PurchaseRequest[]>([]) const [purchaseRequests, setPurchaseRequests] = useState<PurchaseRequest[]>([])
const [completedRequests, setCompletedRequests] = useState<PurchaseRequest[]>([]) const [completedRequests, setCompletedRequests] = useState<PurchaseRequest[]>([])
const [activeTab, setActiveTab] = useState('active') const [activeTab, setActiveTab] = useState('active')
@@ -113,11 +115,11 @@ const PurchaseRequestsPage: React.FC = () => {
setPurchaseRequests(active) setPurchaseRequests(active)
setCompletedRequests(completed) setCompletedRequests(completed)
} else { } else {
message.error('获取采购申请列表失败') message.error(t('purchaseRequest.getListFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取采购申请列表失败:', error) console.error('获取采购申请列表失败:', error)
message.error('获取采购申请列表失败') message.error(t('purchaseRequest.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -143,11 +145,11 @@ const PurchaseRequestsPage: React.FC = () => {
setViewingRequest(data.data) setViewingRequest(data.data)
setDetailModalVisible(true) setDetailModalVisible(true)
} else { } else {
message.error('获取采购申请详情失败') message.error(t('purchaseRequest.getDetailFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取采购申请详情失败:', 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'), expected_date: dayjs().add(7, 'day'),
currency: 'CNY', currency: 'CNY',
expense_category: 'material', expense_category: 'material',
applicant: '系统管理员', applicant: t('common.systemAdmin'),
total_amount: 0, total_amount: 0,
attachments: [] attachments: []
}) })
@@ -207,10 +209,10 @@ const PurchaseRequestsPage: React.FC = () => {
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的采购申请,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft() restoreDraft()
}, },
@@ -223,7 +225,7 @@ const PurchaseRequestsPage: React.FC = () => {
expected_date: dayjs().add(7, 'day'), expected_date: dayjs().add(7, 'day'),
currency: 'CNY', currency: 'CNY',
expense_category: 'material', expense_category: 'material',
applicant: '系统管理员', applicant: t('common.systemAdmin'),
total_amount: 0, total_amount: 0,
attachments: [] attachments: []
}) })
@@ -281,11 +283,11 @@ const PurchaseRequestsPage: React.FC = () => {
}) })
}, 100) }, 100)
} else { } else {
message.error('获取采购申请详情失败') message.error(t('purchaseRequest.getDetailFailed'))
} }
} catch (error) { } catch (error) {
console.error('获取采购申请详情失败:', error) console.error('获取采购申请详情失败:', error)
message.error('获取采购申请详情失败') message.error(t('purchaseRequest.getDetailFailed'))
} }
} }
@@ -295,14 +297,14 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('删除成功') message.success(t('purchaseRequest.deleteSuccess'))
fetchPurchaseRequests() fetchPurchaseRequests()
} else { } else {
message.error('删除失败') message.error(t('purchaseRequest.deleteFailed'))
} }
} catch (error) { } catch (error) {
console.error('删除失败:', error) console.error('删除失败:', error)
message.error('删除失败') message.error(t('purchaseRequest.deleteFailed'))
} }
} }
@@ -319,7 +321,7 @@ const PurchaseRequestsPage: React.FC = () => {
...values, ...values,
request_date: values.request_date.format('YYYY-MM-DD'), request_date: values.request_date.format('YYYY-MM-DD'),
expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null, expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null,
applicant: '系统管理员', applicant: t('common.systemAdmin'),
status: saveStatus, status: saveStatus,
attachments: attachmentsUrl attachments: attachmentsUrl
} }
@@ -342,7 +344,7 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingRequest ? '保存成功' : '创建成功') message.success(editingRequest ? t('purchaseRequest.saveSuccess') : t('purchaseRequest.createSuccess'))
clearDraft() clearDraft()
if (!editingRequest) { if (!editingRequest) {
setEditingRequest(data.data) setEditingRequest(data.data)
@@ -351,7 +353,7 @@ const PurchaseRequestsPage: React.FC = () => {
fetchPurchaseRequests() fetchPurchaseRequests()
setModalVisible(false) setModalVisible(false)
} else { } else {
message.error(editingRequest ? '保存失败' : '创建失败') message.error(editingRequest ? t('common.saveFailed') : t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('保存失败:', error) console.error('保存失败:', error)
@@ -370,7 +372,7 @@ const PurchaseRequestsPage: React.FC = () => {
...values, ...values,
request_date: values.request_date.format('YYYY-MM-DD'), request_date: values.request_date.format('YYYY-MM-DD'),
expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null, expected_date: values.expected_date ? values.expected_date.format('YYYY-MM-DD') : null,
applicant: '系统管理员', applicant: t('common.systemAdmin'),
status: 'pending_edit', status: 'pending_edit',
attachments: attachmentsUrl attachments: attachmentsUrl
} }
@@ -406,20 +408,20 @@ const PurchaseRequestsPage: React.FC = () => {
const submitData = await submitResponse.json() const submitData = await submitResponse.json()
if (submitData.success) { if (submitData.success) {
message.success(editingRequest ? '提交成功' : '创建并提交成功') message.success(editingRequest ? t('common.submit') : t('purchaseRequest.submitSuccess'))
clearDraft() clearDraft()
setModalVisible(false) setModalVisible(false)
setSelectedStatus(null) setSelectedStatus(null)
fetchPurchaseRequests() fetchPurchaseRequests()
} else { } else {
message.error('提交失败') message.error(t('purchaseRequest.submitFailed'))
} }
} else { } else {
message.error(editingRequest ? '保存失败' : '创建失败') message.error(editingRequest ? t('common.saveFailed') : t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
console.error('提交失败:', error) console.error('提交失败:', error)
message.error('提交失败') message.error(t('purchaseRequest.submitFailed'))
} }
} }
@@ -429,15 +431,15 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('撤回成功') message.success(t('purchaseRequest.withdrawSuccess'))
setSelectedStatus(null) setSelectedStatus(null)
fetchPurchaseRequests() fetchPurchaseRequests()
} else { } else {
message.error('撤回失败') message.error(t('purchaseRequest.withdrawFailed'))
} }
} catch (error) { } catch (error) {
console.error('撤回失败:', error) console.error('撤回失败:', error)
message.error('撤回失败') message.error(t('purchaseRequest.withdrawFailed'))
} }
} }
@@ -447,15 +449,15 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(data.message || '审批通过成功') message.success(data.message || t('purchaseRequest.approveSuccess'))
setSelectedStatus(null) setSelectedStatus(null)
fetchPurchaseRequests() fetchPurchaseRequests()
} else { } else {
message.error('审批通过失败') message.error(t('purchaseRequest.approveFailed'))
} }
} catch (error) { } catch (error) {
console.error('审批通过失败:', error) console.error('审批通过失败:', error)
message.error('审批通过失败') message.error(t('purchaseRequest.approveFailed'))
} }
} }
@@ -465,25 +467,25 @@ const PurchaseRequestsPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success('驳回成功') message.success(t('purchaseRequest.rejectSuccess'))
setSelectedStatus(null) setSelectedStatus(null)
fetchPurchaseRequests() fetchPurchaseRequests()
} else { } else {
message.error('驳回失败') message.error(t('purchaseRequest.rejectFailed'))
} }
} catch (error) { } catch (error) {
console.error('驳回失败:', error) console.error('驳回失败:', error)
message.error('驳回失败') message.error(t('purchaseRequest.rejectFailed'))
} }
} }
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending_edit: { color: 'default', text: '待编辑' }, pending_edit: { color: 'default', text: t('purchaseRequest.pendingEdit') },
pending: { color: 'blue', text: '待审批' }, pending: { color: 'blue', text: t('purchaseRequest.pendingApproval') },
approved: { color: 'green', text: '已审批' }, approved: { color: 'green', text: t('purchaseRequest.approved') },
executed: { color: 'purple', text: '已执行' }, executed: { color: 'purple', text: t('purchaseRequest.executed') },
withdrawn: { color: 'orange', text: '已撤回' } withdrawn: { color: 'orange', text: t('purchaseRequest.withdrawn') }
} }
const info = statusMap[status] || { color: 'default', text: status } const info = statusMap[status] || { color: 'default', text: status }
return <Tag color={info.color}>{info.text}</Tag> return <Tag color={info.color}>{info.text}</Tag>
@@ -491,7 +493,7 @@ const PurchaseRequestsPage: React.FC = () => {
const columns: ColumnsType<PurchaseRequest> = [ const columns: ColumnsType<PurchaseRequest> = [
{ {
title: '事由', title: t('purchaseRequest.subject'),
dataIndex: 'brief_description', dataIndex: 'brief_description',
key: 'brief_description', key: 'brief_description',
width: 180, width: 180,
@@ -501,7 +503,7 @@ const PurchaseRequestsPage: React.FC = () => {
) )
}, },
{ {
title: '项目', title: t('purchaseRequest.project'),
dataIndex: 'project_name', dataIndex: 'project_name',
key: 'project_name', key: 'project_name',
width: 120, width: 120,
@@ -509,22 +511,22 @@ const PurchaseRequestsPage: React.FC = () => {
render: (v: string) => v || '-' render: (v: string) => v || '-'
}, },
{ {
title: '分类', title: t('purchaseRequest.category'),
dataIndex: 'expense_category', dataIndex: 'expense_category',
key: 'expense_category', key: 'expense_category',
width: 80, width: 80,
render: (category) => { render: (category) => {
const categoryMap: Record<string, string> = { const categoryMap: Record<string, string> = {
material: '材料', material: t('purchaseRequest.material'),
equipment: '设备', equipment: t('purchaseRequest.equipment'),
pole: '电杆', pole: t('purchaseRequest.pole'),
other: '其他' other: t('purchaseRequest.other')
} }
return <Tag size="small">{categoryMap[category] || category}</Tag> return <Tag size="small">{categoryMap[category] || category}</Tag>
} }
}, },
{ {
title: '预计金额', title: t('purchaseRequest.estimatedAmount'),
dataIndex: 'total_amount', dataIndex: 'total_amount',
key: 'total_amount', key: 'total_amount',
width: 130, width: 130,
@@ -536,14 +538,14 @@ const PurchaseRequestsPage: React.FC = () => {
) )
}, },
{ {
title: '需求日期', title: t('purchaseRequest.demandDate'),
dataIndex: 'expected_date', dataIndex: 'expected_date',
key: 'expected_date', key: 'expected_date',
width: 100, width: 100,
render: (date) => date ? dayjs(date).format('MM-DD') : '-' render: (date) => date ? dayjs(date).format('MM-DD') : '-'
}, },
{ {
title: '状态', title: t('purchaseRequest.status'),
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
width: 90, width: 90,
@@ -551,20 +553,20 @@ const PurchaseRequestsPage: React.FC = () => {
render: getStatusTag render: getStatusTag
}, },
{ {
title: '申请日期', title: t('purchaseRequest.applicationDate'),
dataIndex: 'request_date', dataIndex: 'request_date',
key: 'request_date', key: 'request_date',
width: 100, width: 100,
render: (date) => date ? dayjs(date).format('MM-DD') : '-' render: (date) => date ? dayjs(date).format('MM-DD') : '-'
}, },
{ {
title: '申请人', title: t('purchaseRequest.applicant'),
dataIndex: 'applicant', dataIndex: 'applicant',
key: 'applicant', key: 'applicant',
width: 90 width: 90
}, },
{ {
title: '编号', title: t('purchaseRequest.code'),
dataIndex: 'request_code', dataIndex: 'request_code',
key: 'request_code', key: 'request_code',
width: 150, width: 150,
@@ -572,7 +574,7 @@ const PurchaseRequestsPage: React.FC = () => {
render: (v: string) => <span style={{ fontSize: 12, color: '#999' }}>{v || '-'}</span> render: (v: string) => <span style={{ fontSize: 12, color: '#999' }}>{v || '-'}</span>
}, },
{ {
title: '操作', title: t('purchaseRequest.action'),
key: 'actions', key: 'actions',
width: 200, width: 200,
fixed: 'right', fixed: 'right',
@@ -594,7 +596,7 @@ const PurchaseRequestsPage: React.FC = () => {
onClick={() => handleApprove(record.id)} onClick={() => handleApprove(record.id)}
style={{ color: '#52c41a' }} style={{ color: '#52c41a' }}
> >
{t('purchaseRequest.approve')}
</Button> </Button>
<Button <Button
size="small" size="small"
@@ -603,7 +605,7 @@ const PurchaseRequestsPage: React.FC = () => {
onClick={() => handleReject(record.id)} onClick={() => handleReject(record.id)}
danger danger
> >
{t('purchaseRequest.reject')}
</Button> </Button>
<Button <Button
size="small" size="small"
@@ -611,7 +613,7 @@ const PurchaseRequestsPage: React.FC = () => {
icon={<UndoOutlined />} icon={<UndoOutlined />}
onClick={() => handleWithdraw(record.id)} onClick={() => handleWithdraw(record.id)}
> >
{t('purchaseRequest.withdraw')}
</Button> </Button>
</> </>
)} )}
@@ -624,10 +626,10 @@ const PurchaseRequestsPage: React.FC = () => {
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => handleEdit(record)} onClick={() => handleEdit(record)}
> >
{t('purchaseRequest.edit')}
</Button> </Button>
<Popconfirm <Popconfirm
title="确定要删除吗?" title={t('purchaseRequest.confirmDelete')}
onConfirm={() => handleDelete(record.id)} onConfirm={() => handleDelete(record.id)}
> >
<Button size="small" type="text" danger icon={<DeleteOutlined />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
@@ -642,17 +644,17 @@ const PurchaseRequestsPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2> <h2 style={{ marginBottom: 8 }}>{t('purchaseRequest.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}></p> <p style={{ color: '#888', marginBottom: 0 }}>{t('purchaseRequest.description')}</p>
</div> </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 activeKey={activeTab} onChange={setActiveTab}>
<Tabs.TabPane tab="活跃申请" key="active"> <Tabs.TabPane tab={t('purchaseRequest.activeApplications')} key="active">
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}> <Col span={6}>
<Select <Select
placeholder="选择项目筛选" placeholder={t('purchaseRequest.selectProjectFilter')}
allowClear allowClear
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={(value) => setSelectedProjectId(value)} onChange={(value) => setSelectedProjectId(value)}
@@ -666,14 +668,14 @@ const PurchaseRequestsPage: React.FC = () => {
</Col> </Col>
<Col span={6}> <Col span={6}>
<Select <Select
placeholder="选择状态筛选" placeholder={t('purchaseRequest.selectStatusFilter')}
allowClear allowClear
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={(value) => setSelectedStatus(value)} onChange={(value) => setSelectedStatus(value)}
> >
<Select.Option value="pending"></Select.Option> <Select.Option value="pending">{t('purchaseRequest.pendingApproval')}</Select.Option>
<Select.Option value="withdrawn"></Select.Option> <Select.Option value="withdrawn">{t('purchaseRequest.withdrawn')}</Select.Option>
<Select.Option value="pending_edit"></Select.Option> <Select.Option value="pending_edit">{t('purchaseRequest.pendingEdit')}</Select.Option>
</Select> </Select>
</Col> </Col>
</Row> </Row>
@@ -687,11 +689,11 @@ const PurchaseRequestsPage: React.FC = () => {
scroll={{ x: 1200 }} scroll={{ x: 1200 }}
/> />
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab="已完成" key="completed"> <Tabs.TabPane tab={t('purchaseRequest.completed')} key="completed">
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}> <Col span={6}>
<Select <Select
placeholder="选择项目筛选" placeholder={t('purchaseRequest.selectProjectFilter')}
allowClear allowClear
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={(value) => setSelectedProjectId(value)} onChange={(value) => setSelectedProjectId(value)}
@@ -719,21 +721,23 @@ const PurchaseRequestsPage: React.FC = () => {
{/* 编辑/新建弹窗 - 简化版 */} {/* 编辑/新建弹窗 - 简化版 */}
<Modal <Modal
title={editingRequest ? '编辑采购申请' : '新建采购申请'} title={editingRequest ? t('purchaseRequest.editRequest') : t('purchaseRequest.newRequest')}
open={modalVisible} open={modalVisible}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft({ attachments, purchaseType, currency }) saveDraft({ attachments, purchaseType, currency })
form.resetFields()
setModalVisible(false) setModalVisible(false)
}, },
}) })
} else { } else {
form.resetFields()
setModalVisible(false) setModalVisible(false)
} }
}} }}
@@ -741,39 +745,42 @@ const PurchaseRequestsPage: React.FC = () => {
<Button key="cancel" onClick={() => { <Button key="cancel" onClick={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft({ attachments, purchaseType, currency }) saveDraft({ attachments, purchaseType, currency })
form.resetFields()
setModalVisible(false) setModalVisible(false)
}, },
}) })
} else { } else {
form.resetFields()
setModalVisible(false) setModalVisible(false)
} }
}}></Button>, }}>{t('common.cancel')}</Button>,
<Button key="save" onClick={handleSave}></Button>, <Button key="save" onClick={handleSave}>{t('common.save')}</Button>,
<Button key="submit" type="primary" onClick={handleFormSubmit}></Button> <Button key="submit" type="primary" onClick={handleFormSubmit}>{t('purchaseRequest.submitApproval')}</Button>
]} ]}
width={700} width={700}
maskClosable={false} maskClosable={false}
destroyOnClose
> >
<Form form={form} layout="vertical" onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onValuesChange={handleFormChange}>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="purchase_type" name="purchase_type"
label="采购类型" label={t('purchaseRequest.purchaseType')}
rules={[{ required: true, message: '请选择采购类型' }]} rules={[{ required: true, message: t('purchaseRequest.purchaseTypeRequired') }]}
> >
<Select <Select
placeholder="请选择采购类型" placeholder={t('purchaseRequest.selectPurchaseType')}
onChange={(value) => setPurchaseType(value as 'inventory' | 'project')} onChange={(value) => setPurchaseType(value as 'inventory' | 'project')}
> >
<Select.Option value="inventory"></Select.Option> <Select.Option value="inventory">{t('purchaseRequest.stockPurchase')}</Select.Option>
<Select.Option value="project"></Select.Option> <Select.Option value="project">{t('purchaseRequest.projectPurchase')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
@@ -781,10 +788,10 @@ const PurchaseRequestsPage: React.FC = () => {
{purchaseType === 'project' && ( {purchaseType === 'project' && (
<Form.Item <Form.Item
name="project_id" name="project_id"
label="关联项目" label={t('purchaseRequest.relatedProject')}
rules={[{ required: true, message: '项目采购必须关联项目' }]} rules={[{ required: true, message: t('purchaseRequest.projectRequired') }]}
> >
<Select placeholder="请选择项目" allowClear> <Select placeholder={t('purchaseRequest.selectProject')} allowClear>
{projects.map(project => ( {projects.map(project => (
<Select.Option key={project.id} value={project.id}> <Select.Option key={project.id} value={project.id}>
{project.name} {project.name}
@@ -800,18 +807,18 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="applicant" name="applicant"
label="申请人" label={t('purchaseRequest.applicantLabel')}
initialValue="系统管理员" initialValue={t('common.systemAdmin')}
rules={[{ required: true, message: '请输入申请人' }]} rules={[{ required: true, message: t('purchaseRequest.applicantRequired') }]}
> >
<Input placeholder="请输入申请人" disabled /> <Input placeholder={t('purchaseRequest.applicantPlaceholder')} disabled />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="request_date" name="request_date"
label="申请日期" label={t('purchaseRequest.applicationDateLabel')}
rules={[{ required: true, message: '请选择申请日期' }]} rules={[{ required: true, message: t('purchaseRequest.dateRequired') }]}
> >
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
@@ -820,15 +827,15 @@ const PurchaseRequestsPage: React.FC = () => {
<Form.Item <Form.Item
name="brief_description" name="brief_description"
label="事由描述" label={t('purchaseRequest.subjectDescription')}
rules={[ rules={[
{ required: true, message: '请输入事由描述' }, { required: true, message: t('purchaseRequest.subjectRequired') },
{ max: 100, message: '事由描述不能超过100个字符' } { max: 100, message: t('purchaseRequest.subjectMaxLength') }
]} ]}
> >
<Input.TextArea <Input.TextArea
rows={2} rows={2}
placeholder="请简要描述采购需求(如:采购XX项目所需电缆、电杆等材料)" placeholder={t('purchaseRequest.subjectPlaceholder')}
maxLength={100} maxLength={100}
showCount showCount
/> />
@@ -838,26 +845,26 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={8}> <Col span={8}>
<Form.Item <Form.Item
name="expense_category" name="expense_category"
label="支出分类" label={t('purchaseRequest.expenseCategory')}
rules={[{ required: true, message: '请选择支出分类' }]} rules={[{ required: true, message: t('purchaseRequest.categoryRequired') }]}
> >
<Select placeholder="请选择支出分类"> <Select placeholder={t('purchaseRequest.selectCategory')}>
<Select.Option value="material"></Select.Option> <Select.Option value="material">{t('purchaseRequest.material')}</Select.Option>
<Select.Option value="equipment"></Select.Option> <Select.Option value="equipment">{t('purchaseRequest.equipment')}</Select.Option>
<Select.Option value="pole"></Select.Option> <Select.Option value="pole">{t('purchaseRequest.pole')}</Select.Option>
<Select.Option value="other"></Select.Option> <Select.Option value="other">{t('purchaseRequest.other')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Form.Item <Form.Item
name="total_amount" name="total_amount"
label="预计金额" label={t('purchaseRequest.estimatedAmountLabel')}
rules={[{ required: true, message: '请输入预计金额' }]} rules={[{ required: true, message: t('purchaseRequest.amountRequired') }]}
> >
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="预计金额" placeholder={t('purchaseRequest.estimatedAmountPlaceholder')}
min={0} min={0}
precision={2} precision={2}
/> />
@@ -866,14 +873,14 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={8}> <Col span={8}>
<Form.Item <Form.Item
name="currency" name="currency"
label="币种" label={t('purchaseRequest.currency')}
rules={[{ required: true, message: '请选择币种' }]} rules={[{ required: true, message: t('purchaseRequest.currencyRequired') }]}
> >
<Select placeholder="请选择币种" onChange={(value) => setCurrency(value)}> <Select placeholder={t('purchaseRequest.selectCurrency')} onChange={(value) => setCurrency(value)}>
<Select.Option value="CNY"></Select.Option> <Select.Option value="CNY">{t('purchaseRequest.currencyCNY')}</Select.Option>
<Select.Option value="USD"></Select.Option> <Select.Option value="USD">{t('purchaseRequest.currencyUSD')}</Select.Option>
<Select.Option value="LAK"></Select.Option> <Select.Option value="LAK">{t('purchaseRequest.currencyLAK')}</Select.Option>
<Select.Option value="THB"></Select.Option> <Select.Option value="THB">{t('purchaseRequest.currencyTHB')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
@@ -883,19 +890,19 @@ const PurchaseRequestsPage: React.FC = () => {
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="expected_date" name="expected_date"
label="需求日期" label={t('purchaseRequest.demandDateLabel')}
rules={[{ required: true, message: '请选择需求日期' }]} rules={[{ required: true, message: t('purchaseRequest.dateRequired') }]}
> >
<DatePicker style={{ width: '100%' }} placeholder="期望到货日期" /> <DatePicker style={{ width: '100%' }} placeholder={t('purchaseRequest.demandDatePlaceholder')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('purchaseRequest.remarkLabel')}>
<Input.TextArea rows={2} placeholder="请输入备注(选填)" /> <Input.TextArea rows={2} placeholder={t('purchaseRequest.remarkPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="attachments" label="附件"> <Form.Item name="attachments" label={t('purchaseRequest.attachment')}>
<Upload <Upload
name="file" name="file"
listType="text" listType="text"
@@ -915,9 +922,19 @@ const PurchaseRequestsPage: React.FC = () => {
formData.append('file', file as File) formData.append('file', file as File)
try { 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', { const response = await fetch('/api/upload/single', {
method: 'POST', method: 'POST',
body: formData body: formData,
headers: authHeader ? { Authorization: authHeader } : {},
}) })
const data = await response.json() const data = await response.json()
@@ -943,7 +960,7 @@ const PurchaseRequestsPage: React.FC = () => {
window.open(file.url, '_blank') window.open(file.url, '_blank')
}} }}
> >
<Button icon={<UploadOutlined />}></Button> <Button icon={<UploadOutlined />}>{t('purchaseRequest.selectFile')}</Button>
</Upload> </Upload>
</Form.Item> </Form.Item>
</Form> </Form>
@@ -951,46 +968,46 @@ const PurchaseRequestsPage: React.FC = () => {
{/* 详情弹窗 */} {/* 详情弹窗 */}
<Modal <Modal
title="采购申请详情" title={t('purchaseRequest.detailTitle')}
open={detailModalVisible} open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)} onCancel={() => setDetailModalVisible(false)}
footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}></Button>]} footer={[<Button key="close" onClick={() => setDetailModalVisible(false)}>{t('common.close')}</Button>]}
width={700} width={700}
> >
{viewingRequest && ( {viewingRequest && (
<div> <div>
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<Descriptions bordered column={2}> <Descriptions bordered column={2}>
<Descriptions.Item label="申请编号" span={1}>{viewingRequest.request_code || viewingRequest.code}</Descriptions.Item> <Descriptions.Item label={t('purchaseRequest.applicationCode')} span={1}>{viewingRequest.request_code || viewingRequest.code}</Descriptions.Item>
<Descriptions.Item label="状态" span={1}>{getStatusTag(viewingRequest.status)}</Descriptions.Item> <Descriptions.Item label={t('purchaseRequest.status')} span={1}>{getStatusTag(viewingRequest.status)}</Descriptions.Item>
<Descriptions.Item label="采购类型" span={1}> <Descriptions.Item label={t('purchaseRequest.purchaseType')} span={1}>
{viewingRequest.purchase_type === 'project' ? '项目采购' : '库存采购'} {viewingRequest.purchase_type === 'project' ? t('purchaseRequest.projectPurchase') : t('purchaseRequest.stockPurchase')}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="项目" span={1}>{viewingRequest.project_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('purchaseRequest.project')} span={1}>{viewingRequest.project_name || '-'}</Descriptions.Item>
<Descriptions.Item label="申请人" span={1}>{viewingRequest.applicant}</Descriptions.Item> <Descriptions.Item label={t('purchaseRequest.applicant')} span={1}>{viewingRequest.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期" span={1}>{viewingRequest.request_date}</Descriptions.Item> <Descriptions.Item label={t('purchaseRequest.applicationDate')} span={1}>{viewingRequest.request_date}</Descriptions.Item>
<Descriptions.Item label="事由描述" span={2}>{viewingRequest.brief_description || '-'}</Descriptions.Item> <Descriptions.Item label={t('purchaseRequest.subjectDescription')} span={2}>{viewingRequest.brief_description || '-'}</Descriptions.Item>
<Descriptions.Item label="支出分类" span={1}> <Descriptions.Item label={t('purchaseRequest.expenseCategory')} span={1}>
{{ {{
material: '材料', material: t('purchaseRequest.material'),
equipment: '设备', equipment: t('purchaseRequest.equipment'),
pole: '电杆', pole: t('purchaseRequest.pole'),
other: '其他' other: t('purchaseRequest.other')
}[viewingRequest.expense_category] || viewingRequest.expense_category} }[viewingRequest.expense_category] || viewingRequest.expense_category}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="预计金额" span={1}> <Descriptions.Item label={t('purchaseRequest.estimatedAmount')} span={1}>
<strong style={{ fontSize: 16, color: '#999' }}> <strong style={{ fontSize: 16, color: '#999' }}>
{viewingRequest.currency} {viewingRequest.total_amount?.toFixed(2) || '0.00'} {viewingRequest.currency} {viewingRequest.total_amount?.toFixed(2) || '0.00'}
</strong> </strong>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="需求日期" span={1}> <Descriptions.Item label={t('purchaseRequest.demandDate')} span={1}>
{viewingRequest.expected_date || '-'} {viewingRequest.expected_date || '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="创建时间" span={1}> <Descriptions.Item label={t('purchaseRequest.createdAt')} span={1}>
{viewingRequest.created_at} {viewingRequest.created_at}
</Descriptions.Item> </Descriptions.Item>
{viewingRequest.remark && ( {viewingRequest.remark && (
<Descriptions.Item label="备注" span={2}>{viewingRequest.remark}</Descriptions.Item> <Descriptions.Item label={t('purchaseRequest.remarkLabel')} span={2}>{viewingRequest.remark}</Descriptions.Item>
)} )}
</Descriptions> </Descriptions>
</Card> </Card>
+46 -44
View File
@@ -1,91 +1,93 @@
import React from 'react'; import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Checkbox, message, Tree } from 'antd'; import { Card, Typography, Button, Table, Tag, Space, Modal, Form, Input, Checkbox, message, Tree } from 'antd';
import { PlusOutlined, SafetyOutlined } from '@ant-design/icons'; import { PlusOutlined, SafetyOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../store/languageStore';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
const RolesPage: React.FC = () => { const RolesPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading] = React.useState(false); const [loading] = React.useState(false);
const [modalVisible, setModalVisible] = React.useState(false); const [modalVisible, setModalVisible] = React.useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const permissionTree = [ const permissionTree = [
{ {
title: '项目管理', title: t('roles.permProject'),
key: 'project', key: 'project',
children: [ children: [
{ title: '查看项目', key: 'project:view' }, { title: t('roles.permViewProject'), key: 'project:view' },
{ title: '创建项目', key: 'project:create' }, { title: t('roles.permCreateProject'), key: 'project:create' },
{ title: '编辑项目', key: 'project:edit' }, { title: t('roles.permEditProject'), key: 'project:edit' },
{ title: '删除项目', key: 'project:delete' }, { title: t('roles.permDeleteProject'), key: 'project:delete' },
], ],
}, },
{ {
title: '财务管理', title: t('roles.permFinance'),
key: 'finance', key: 'finance',
children: [ children: [
{ title: '查看财务', key: 'finance:view' }, { title: t('roles.permViewFinance'), key: 'finance:view' },
{ title: '预支审批', key: 'finance:advance' }, { title: t('roles.permApproveAdvance'), key: 'finance:advance' },
{ title: '报销审批', key: 'finance:reimburse' }, { title: t('roles.permApproveReimburse'), key: 'finance:reimburse' },
{ title: '付款审批', key: 'finance:payment' }, { title: t('roles.permApprovePayment'), key: 'finance:payment' },
], ],
}, },
{ {
title: '采购管理', title: t('roles.permProcurement'),
key: 'procurement', key: 'procurement',
children: [ children: [
{ title: '查看采购', key: 'procurement:view' }, { title: t('roles.permViewProcurement'), key: 'procurement:view' },
{ title: '创建采购', key: 'procurement:create' }, { title: t('roles.permCreateProcurement'), key: 'procurement:create' },
{ title: '审批采购', key: 'procurement:approve' }, { title: t('roles.permApproveProcurement'), key: 'procurement:approve' },
], ],
}, },
{ {
title: '系统设置', title: t('roles.permSystem'),
key: 'system', key: 'system',
children: [ children: [
{ title: '用户管理', key: 'system:users' }, { title: t('roles.permUserManagement'), key: 'system:users' },
{ title: '角色管理', key: 'system:roles' }, { title: t('roles.permRoleManagement'), key: 'system:roles' },
{ title: '系统配置', key: 'system:config' }, { title: t('roles.permSystemConfig'), key: 'system:config' },
], ],
}, },
]; ];
const columns = [ const columns = [
{ title: '角色ID', dataIndex: 'id', key: 'id', width: 100 }, { title: t('roles.roleId'), dataIndex: 'id', key: 'id', width: 100 },
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 150 }, { title: t('roles.roleName'), dataIndex: 'name', key: 'name', width: 150 },
{ title: '角色描述', dataIndex: 'description', key: 'description' }, { title: t('roles.roleDesc'), dataIndex: 'description', key: 'description' },
{ {
title: '权限数量', title: t('roles.permCount'),
dataIndex: 'permissionCount', dataIndex: 'permissionCount',
key: 'permissionCount', key: 'permissionCount',
width: 100, 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: t('roles.createdAt'), dataIndex: 'createdAt', key: 'createdAt', width: 150 },
{ title: '创建人', dataIndex: 'creator', key: 'creator', width: 120 }, { title: t('roles.creator'), dataIndex: 'creator', key: 'creator', width: 120 },
{ {
title: '操作', title: t('roles.action'),
key: 'action', key: 'action',
width: 180, width: 180,
render: () => ( render: () => (
<Space> <Space>
<Button size="small" type="link"></Button> <Button size="small" type="link">{t('roles.viewPerm')}</Button>
<Button size="small" type="link"></Button> <Button size="small" type="link">{t('roles.edit')}</Button>
<Button size="small" type="link" danger></Button> <Button size="small" type="link" danger>{t('roles.delete')}</Button>
</Space> </Space>
) )
} }
]; ];
const data = [ const data = [
{ key: '1', id: 'R001', name: '超级管理员', description: '拥有系统所有权限', permissionCount: 50, createdAt: '2026-01-01', creator: '系统' }, { 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: '项目经理', description: '项目管理、施工管理权限', permissionCount: 25, createdAt: '2026-01-15', creator: '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: '财务经理', description: '财务管理、审批权限', permissionCount: 18, createdAt: '2026-02-01', 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: '普通员工', description: '查看和申请权限', permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' }, { key: '4', id: 'R004', name: t('roles.employee'), description: t('roles.employeeDesc'), permissionCount: 10, createdAt: '2026-02-15', creator: 'admin' },
]; ];
const handleSubmit = () => { const handleSubmit = () => {
message.success('角色已创建'); message.success(t('roles.roleCreated'));
setModalVisible(false); setModalVisible(false);
}; };
@@ -93,13 +95,13 @@ const RolesPage: React.FC = () => {
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <div>
<Title level={3} style={{ marginBottom: 0 }}></Title> <Title level={3} style={{ marginBottom: 0 }}>{t('roles.title')}</Title>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('roles.description')}</Paragraph>
</div> </div>
<Space> <Space>
<Input.Search placeholder="搜索角色" style={{ width: 200 }} /> <Input.Search placeholder={t('roles.searchRole')} style={{ width: 200 }} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
{t('roles.newRole')}
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -109,20 +111,20 @@ const RolesPage: React.FC = () => {
</Card> </Card>
<Modal <Modal
title="新增角色" title={t('roles.newRole')}
open={modalVisible} open={modalVisible}
onCancel={() => setModalVisible(false)} onCancel={() => setModalVisible(false)}
onOk={handleSubmit} onOk={handleSubmit}
width={600} width={600}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="角色名称" name="name" rules={[{ required: true }]}> <Form.Item label={t('roles.roleName')} name="name" rules={[{ required: true }]}>
<Input placeholder="请输入角色名称" prefix={<SafetyOutlined />} /> <Input placeholder={t('roles.roleNameRequired')} prefix={<SafetyOutlined />} />
</Form.Item> </Form.Item>
<Form.Item label="角色描述" name="description"> <Form.Item label={t('roles.roleDesc')} name="description">
<Input placeholder="请输入角色描述" /> <Input placeholder={t('roles.roleDescRequired')} />
</Form.Item> </Form.Item>
<Form.Item label="权限配置" name="permissions"> <Form.Item label={t('roles.permConfig')} name="permissions">
<Tree <Tree
checkable checkable
defaultExpandedKeys={['project', 'finance']} defaultExpandedKeys={['project', 'finance']}
+24 -22
View File
@@ -8,6 +8,7 @@ import {
FileTextOutlined, DollarOutlined, BankOutlined FileTextOutlined, DollarOutlined, BankOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import BusinessLedgerTab from '../components/BusinessLedgerTab' import BusinessLedgerTab from '../components/BusinessLedgerTab'
import { useLanguageStore } from '../store/languageStore'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -68,6 +69,7 @@ interface Subcontractor {
const SubcontractorDetail: React.FC = () => { const SubcontractorDetail: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [subcontractor, setSubcontractor] = useState<Subcontractor | null>(null) const [subcontractor, setSubcontractor] = useState<Subcontractor | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('basic') 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 (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 ( return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/subcontractors')} style={{ marginBottom: 16 }} type="text"> <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/subcontractors')} style={{ marginBottom: 16 }} type="text">
{t('subcontractor.returnToList')}
</Button> </Button>
<Title level={4} style={{ marginBottom: 24 }}> <Title level={4} style={{ marginBottom: 24 }}>
@@ -105,65 +107,65 @@ const SubcontractorDetail: React.FC = () => {
<Card style={{ borderRadius: 8 }}> <Card style={{ borderRadius: 8 }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}> <Tabs activeKey={activeTab} onChange={setActiveTab}>
{/* TAB1: 基本信息 */} {/* 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 bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
<Descriptions.Item label="编号">{subcontractor.code}</Descriptions.Item> <Descriptions.Item label={t('subcontractor.code')}>{subcontractor.code}</Descriptions.Item>
<Descriptions.Item label="承包范围">{subcontractor.scope || '-'}</Descriptions.Item> <Descriptions.Item label={t('subcontractor.scope')}>{subcontractor.scope || '-'}</Descriptions.Item>
<Descriptions.Item label="国家"><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item> <Descriptions.Item label={t('subcontractor.country')}><Tag color="purple">{subcontractor.country || '-'}</Tag></Descriptions.Item>
</Descriptions> </Descriptions>
{subcontractor.features && ( {subcontractor.features && (
<div style={{ marginTop: 16 }}> <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 style={{ padding: 12, background: '#f9f0ff', borderRadius: 4, border: '1px solid #d3adf7', marginTop: 8 }}>{subcontractor.features}</div>
</div> </div>
)} )}
{subcontractor.remark && ( {subcontractor.remark && (
<div style={{ marginTop: 16 }}> <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 style={{ padding: 12, background: '#fafafa', borderRadius: 4, marginTop: 8 }}>{subcontractor.remark}</div>
</div> </div>
)} )}
</Tabs.TabPane> </Tabs.TabPane>
{/* TAB2: 联系人 */} {/* TAB2: 联系人 */}
<Tabs.TabPane tab={<span><PhoneOutlined /> </span>} key="contacts"> <Tabs.TabPane tab={<span><PhoneOutlined /> {t('subcontractor.contact')}</span>} key="contacts">
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(subcontractor.contacts || []).map((contact, i) => ( {(subcontractor.contacts || []).map((contact, i) => (
<Col key={i} xs={24} sm={12} lg={8}> <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' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text> <Text strong>{contact.name || t('common.unnamed')}</Text>
{contact.is_primary && <Tag color="purple"></Tag>} {contact.is_primary && <Tag color="purple">{t('subcontractor.mainContactTag')}</Tag>}
</div> </div>
<div style={{ color: '#666', fontSize: 13 }}> <div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>} {contact.position && <div>{t('subcontractor.positionLabel')}{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>} {contact.phone && <div>{t('subcontractor.phoneLabel')}{contact.phone}</div>}
</div> </div>
</Card> </Card>
</Col> </Col>
))} ))}
</Row> </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> </Tabs.TabPane>
{/* TAB3: 收款信息 */} {/* TAB3: 收款信息 */}
<Tabs.TabPane tab={<span><BankOutlined /> </span>} key="payment"> <Tabs.TabPane tab={<span><BankOutlined /> {t('subcontractor.paymentInfo')}</span>} key="payment">
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(subcontractor.payment_infos || []).map((payment, i) => ( {(subcontractor.payment_infos || []).map((payment, i) => (
<Col key={i} xs={24} sm={12} lg={8}> <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' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{payment.account_name}</Text> <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>
<div style={{ color: '#666', fontSize: 13 }}> <div style={{ color: '#666', fontSize: 13 }}>
<div>{payment.bank_name}</div> <div>{t('subcontractor.bankLabel')}{payment.bank_name}</div>
<div>{payment.bank_account}</div> <div>{t('subcontractor.accountLabel')}{payment.bank_account}</div>
{payment.qr_code && ( {payment.qr_code && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<Text type="secondary"></Text> <Text type="secondary">{t('subcontractor.qrCodeLabel')}</Text>
<div style={{ marginTop: 4 }}> <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>
</div> </div>
)} )}
@@ -172,11 +174,11 @@ const SubcontractorDetail: React.FC = () => {
</Col> </Col>
))} ))}
</Row> </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> </Tabs.TabPane>
{/* TAB4: 业务台账 */} {/* TAB4: 业务台账 */}
<Tabs.TabPane tab={<span><DollarOutlined /> </span>} key="ledger"> <Tabs.TabPane tab={<span><DollarOutlined /> {t('subcontractor.ledger')}</span>} key="ledger">
<BusinessLedgerTab <BusinessLedgerTab
partnerType="subcontractor" partnerType="subcontractor"
summary={subcontractor.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_paid_amount: 0, total_unpaid_amount: 0 }} summary={subcontractor.ledger?.summary || { item_count: 0, total_contract_amount: 0, total_paid_amount: 0, total_unpaid_amount: 0 }}
+63 -60
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, SolutionOut
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import FileUpload from '../components/FileUpload' import FileUpload from '../components/FileUpload'
import useFormDraft from '../hooks/useFormDraft' import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface Contact { interface Contact {
name: string name: string
@@ -39,6 +40,7 @@ interface Subcontractor {
const SubcontractorPage: React.FC = () => { const SubcontractorPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [subcontractors, setSubcontractors] = useState<Subcontractor[]>([]) const [subcontractors, setSubcontractors] = useState<Subcontractor[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
@@ -63,7 +65,7 @@ const SubcontractorPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) setSubcontractors(data.data || []) if (data.success) setSubcontractors(data.data || [])
} catch (error) { } catch (error) {
message.error('获取分包商列表失败') message.error(t('subcontractor.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -89,16 +91,16 @@ const SubcontractorPage: React.FC = () => {
const columns: ColumnsType<Subcontractor> = [ const columns: ColumnsType<Subcontractor> = [
{ {
title: '名称', dataIndex: 'name', key: 'name', title: t('subcontractor.name'), dataIndex: 'name', key: 'name',
render: (text, record) => ( render: (text, record) => (
<Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/subcontractors/${record.id}`)}>{text}</Button> <Button type="link" style={{ padding: 0, fontWeight: 'bold' }} onClick={() => navigate(`/subcontractors/${record.id}`)}>{text}</Button>
) )
}, },
{ title: '承包范围', dataIndex: 'scope', key: 'scope', width: 120 }, { title: t('subcontractor.scope'), dataIndex: 'scope', key: 'scope', width: 120 },
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (c) => <Tag>{c || '-'}</Tag> }, { title: t('subcontractor.country'), 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: t('subcontractor.contractAmount'), 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: 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: '操作', key: 'actions', width: 100, render: (_, record) => ( { title: t('common.action'), key: 'actions', width: 100, render: (_, record) => (
<Space> <Space>
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" /> <Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} size="small" />
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} 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() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingSubcontractor ? '更新成功' : '创建成功') message.success(editingSubcontractor ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft() clearDraft()
setModalVisible(false) setModalVisible(false)
form.resetFields() form.resetFields()
setEditingSubcontractor(null) setEditingSubcontractor(null)
fetchSubcontractors() fetchSubcontractors()
} else { } else {
message.error(data.message || '操作失败') message.error(data.message || t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
message.error('操作失败') message.error(t('common.operationFailed'))
} }
} }
@@ -186,14 +188,14 @@ const SubcontractorPage: React.FC = () => {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '确认删除', content: '确定要删除此分包商吗?', okText: '确定', cancelText: '取消', title: t('common.confirmDelete'), content: t('subcontractor.confirmDeleteMsg'), okText: t('common.confirm'), cancelText: t('common.cancel'),
onOk: async () => { onOk: async () => {
try { try {
const response = await fetch(`/api/subcontractors/${id}`, { method: 'DELETE' }) const response = await fetch(`/api/subcontractors/${id}`, { method: 'DELETE' })
const data = await response.json() const data = await response.json()
if (data.success) { message.success('删除成功'); fetchSubcontractors() } if (data.success) { message.success(t('common.deleteSuccess')); fetchSubcontractors() }
else message.error(data.message || '删除失败') else message.error(data.message || t('common.deleteFailed'))
} catch (error) { message.error('删除失败') } } catch (error) { message.error(t('common.deleteFailed')) }
} }
}) })
} }
@@ -211,10 +213,10 @@ const SubcontractorPage: React.FC = () => {
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的分包商信息,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft() restoreDraft()
}, },
@@ -235,89 +237,90 @@ const SubcontractorPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 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={t('subcontractor.totalCount')} 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={t('subcontractor.totalContract')} 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.totalPayable')} value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row> </Row>
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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 }} /> <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}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('subcontractor.newSubcontractor')}</Button>
</div> </div>
</Card> </Card>
<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> </Card>
<Modal <Modal
title={editingSubcontractor ? '编辑分包商' : '新增分包商'} title={editingSubcontractor ? t('subcontractor.editSubcontractor') : t('subcontractor.newSubcontractor')}
open={modalVisible} open={modalVisible}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft() saveDraft()
form.resetFields();
setModalVisible(false) setModalVisible(false)
form.resetFields()
setEditingSubcontractor(null) setEditingSubcontractor(null)
}, },
}) })
} else { } else {
form.resetFields();
setModalVisible(false) setModalVisible(false)
form.resetFields()
setEditingSubcontractor(null) setEditingSubcontractor(null)
} }
}} }}
onOk={() => form.submit()} onOk={() => form.submit()}
destroyOnClose
width={800} width={800}
maskClosable={false} maskClosable={false}
> >
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}> <Form.Item name="name" label={t('subcontractor.name')} rules={[{ required: true, message: t('subcontractor.nameRequired') }]}>
<Input placeholder="分包商名称" /> <Input placeholder={t('subcontractor.namePlaceholder')} />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="scope" label="承包范围"> <Form.Item name="scope" label={t('subcontractor.scope')}>
<Input placeholder="手填:如电力安装、土建工程" /> <Input placeholder={t('subcontractor.scopePlaceholder')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="country" label="国家" initialValue="Laos"> <Form.Item name="country" label={t('subcontractor.country')} initialValue="Laos">
<Select> <Select>
<Select.Option value="China"></Select.Option> <Select.Option value="China">{t('subcontractor.china')}</Select.Option>
<Select.Option value="Laos"></Select.Option> <Select.Option value="Laos">{t('subcontractor.laos')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="features" label="特点"> <Form.Item name="features" label={t('subcontractor.feature')}>
<Input.TextArea rows={2} placeholder="手填:如专业团队、设备齐全、价格合理等" /> <Input.TextArea rows={2} placeholder={t('subcontractor.featurePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={2} placeholder="备注信息" /> <Input.TextArea rows={2} placeholder={t('subcontractor.remarkPlaceholder')} />
</Form.Item> </Form.Item>
<h4></h4> <h4>{t('subcontractor.contact')}</h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}> <Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div> <div>
{fields.map(({ key, name, ...restField }) => ( {fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="姓名" /> <Input placeholder={t('logistics.name')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="职位" /> <Input placeholder={t('common.position')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="电话" /> <Input placeholder={t('common.phone')} />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}> <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)} onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/> />
</Form.Item> </Form.Item>
<span></span> <span>{t('subcontractor.mainContact')}</span>
</div> </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> </div>
))} ))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button> <Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('subcontractor.addContact')}</Button>
</div> </div>
)} )}
</Form.List> </Form.List>
<h4 style={{ marginTop: 24 }}></h4> <h4 style={{ marginTop: 24 }}>{t('subcontractor.paymentInfo')}</h4>
<Form.List name="payment_infos" initialValue={[]}> <Form.List name="payment_infos" initialValue={[]}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div> <div>
{fields.map(({ key, name, ...restField }) => ( {fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}> <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' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'account_name']} label={t('subcontractor.accountName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="收款户名" /> <Input placeholder={t('subcontractor.accountName')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'bank_name']} label={t('subcontractor.bankName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="开户银行" /> <Input placeholder={t('subcontractor.bankName')} />
</Form.Item> </Form.Item>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'bank_account']} label={t('subcontractor.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="银行账号" /> <Input placeholder={t('subcontractor.bankAccount')} />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}> <div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}> <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)} onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
/> />
</Form.Item> </Form.Item>
<span></span> <span>{t('subcontractor.mainAccount')}</span>
</div> </div>
</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/*" /> <FileUpload maxCount={1} accept="image/*" />
</Form.Item> </Form.Item>
{fields.length > 0 && ( {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> </div>
))} ))}
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}> <Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
+ {t('subcontractor.addPaymentInfo')}
</Button> </Button>
</div> </div>
)} )}
+24 -22
View File
@@ -7,6 +7,7 @@ import {
ArrowLeftOutlined, ShopOutlined, UserOutlined, PhoneOutlined, BankOutlined, DollarOutlined ArrowLeftOutlined, ShopOutlined, UserOutlined, PhoneOutlined, BankOutlined, DollarOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import BusinessLedgerTab from '../components/BusinessLedgerTab' import BusinessLedgerTab from '../components/BusinessLedgerTab'
import { useLanguageStore } from '../store/languageStore'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -66,6 +67,7 @@ interface Supplier {
const SupplierDetail: React.FC = () => { const SupplierDetail: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const { t, currentLanguage } = useLanguageStore()
const [supplier, setSupplier] = useState<Supplier | null>(null) const [supplier, setSupplier] = useState<Supplier | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('basic') 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 (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 = [ const tabItems = [
{ {
key: 'basic', key: 'basic',
label: <span><UserOutlined /> </span>, label: <span><UserOutlined /> {t('supplier.basicInfo')}</span>,
children: ( children: (
<> <>
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small"> <Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }} size="small">
<Descriptions.Item label="编号">{supplier.code}</Descriptions.Item> <Descriptions.Item label={t('supplier.code')}>{supplier.code}</Descriptions.Item>
<Descriptions.Item label="供应类别">{supplier.supply_category || '-'}</Descriptions.Item> <Descriptions.Item label={t('supplier.supplyCategory')}>{supplier.supply_category || '-'}</Descriptions.Item>
<Descriptions.Item label="国家"><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item> <Descriptions.Item label={t('supplier.country')}><Tag color="blue">{supplier.country || '-'}</Tag></Descriptions.Item>
</Descriptions> </Descriptions>
{supplier.remark && ( {supplier.remark && (
<div style={{ marginTop: 16 }}> <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 style={{ padding: 12, background: '#e6f7ff', borderRadius: 4, border: '1px solid #91d5ff', marginTop: 8 }}>{supplier.remark}</div>
</div> </div>
)} )}
@@ -111,7 +113,7 @@ const SupplierDetail: React.FC = () => {
}, },
{ {
key: 'contacts', key: 'contacts',
label: <span><PhoneOutlined /> </span>, label: <span><PhoneOutlined /> {t('supplier.contact')}</span>,
children: ( children: (
<> <>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -119,24 +121,24 @@ const SupplierDetail: React.FC = () => {
<Col key={i} xs={24} sm={12} lg={8}> <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' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{contact.name || '未命名'}</Text> <Text strong>{contact.name || t('common.unnamed')}</Text>
{contact.is_primary && <Tag color="blue"></Tag>} {contact.is_primary && <Tag color="blue">{t('supplier.mainContactTag')}</Tag>}
</div> </div>
<div style={{ color: '#666', fontSize: 13 }}> <div style={{ color: '#666', fontSize: 13 }}>
{contact.position && <div>{contact.position}</div>} {contact.position && <div>{t('supplier.positionLabel')}{contact.position}</div>}
{contact.phone && <div>{contact.phone}</div>} {contact.phone && <div>{t('supplier.phoneLabel')}{contact.phone}</div>}
</div> </div>
</Card> </Card>
</Col> </Col>
))} ))}
</Row> </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', key: 'payment',
label: <span><BankOutlined /> </span>, label: <span><BankOutlined /> {t('supplier.paymentInfo')}</span>,
children: ( children: (
<> <>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@@ -144,17 +146,17 @@ const SupplierDetail: React.FC = () => {
<Col key={i} xs={24} sm={12} lg={8}> <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' }}> <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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Text strong>{payment.bank_name || '未命名'}</Text> <Text strong>{payment.bank_name || t('common.unnamed')}</Text>
{payment.is_primary && <Tag color="blue"></Tag>} {payment.is_primary && <Tag color="blue">{t('supplier.mainAccountTag')}</Tag>}
</div> </div>
<div style={{ color: '#666', fontSize: 13 }}> <div style={{ color: '#666', fontSize: 13 }}>
{payment.account_name && <div>{payment.account_name}</div>} {payment.account_name && <div>{t('supplier.accountNameLabel')}{payment.account_name}</div>}
{payment.bank_account && <div>{payment.bank_account}</div>} {payment.bank_account && <div>{t('supplier.accountNumLabel')}{payment.bank_account}</div>}
{payment.qr_code && ( {payment.qr_code && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<Text type="secondary"></Text> <Text type="secondary">{t('supplier.qrCodeLabel')}</Text>
<div style={{ marginTop: 4 }}> <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>
</div> </div>
)} )}
@@ -163,13 +165,13 @@ const SupplierDetail: React.FC = () => {
</Col> </Col>
))} ))}
</Row> </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', key: 'ledger',
label: <span><DollarOutlined /> </span>, label: <span><DollarOutlined /> {t('supplier.ledger')}</span>,
children: ( children: (
<BusinessLedgerTab <BusinessLedgerTab
partnerType="supplier" partnerType="supplier"
@@ -183,7 +185,7 @@ const SupplierDetail: React.FC = () => {
return ( return (
<div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}> <div style={{ padding: '16px', maxWidth: 1200, margin: '0 auto' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/suppliers')} style={{ marginBottom: 16 }} type="text"> <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/suppliers')} style={{ marginBottom: 16 }} type="text">
{t('supplier.returnToList')}
</Button> </Button>
<Title level={4} style={{ marginBottom: 24 }}> <Title level={4} style={{ marginBottom: 24 }}>
+64 -61
View File
@@ -5,6 +5,7 @@ import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ShopOutline
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import FileUpload from '../components/FileUpload' import FileUpload from '../components/FileUpload'
import useFormDraft from '../hooks/useFormDraft' import useFormDraft from '../hooks/useFormDraft'
import { useLanguageStore } from '../store/languageStore'
interface Contact { interface Contact {
name: string name: string
@@ -39,6 +40,7 @@ interface Supplier {
const SupplierPage: React.FC = () => { const SupplierPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { t, currentLanguage } = useLanguageStore()
const [suppliers, setSuppliers] = useState<Supplier[]>([]) const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false) const [modalVisible, setModalVisible] = useState(false)
@@ -66,7 +68,7 @@ const SupplierPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) setSuppliers(data.data || []) if (data.success) setSuppliers(data.data || [])
} catch (error) { } catch (error) {
message.error('获取供应商列表失败') message.error(t('supplier.getListFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -92,7 +94,7 @@ const SupplierPage: React.FC = () => {
const columns: ColumnsType<Supplier> = [ const columns: ColumnsType<Supplier> = [
{ {
title: '名称', title: t('supplier.name'),
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
render: (text, record) => ( render: (text, record) => (
@@ -101,12 +103,12 @@ const SupplierPage: React.FC = () => {
</Button> </Button>
) )
}, },
{ title: '供应类别', dataIndex: 'supply_category', key: 'supply_category', width: 120 }, { title: t('supplier.supplyCategory'), dataIndex: 'supply_category', key: 'supply_category', width: 120 },
{ title: '国家', dataIndex: 'country', key: 'country', width: 80, render: (country) => <Tag>{country || '-'}</Tag> }, { title: t('supplier.country'), 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: t('supplier.purchaseAmount'), 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.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', key: 'actions',
width: 100, width: 100,
render: (_, record) => ( render: (_, record) => (
@@ -169,7 +171,7 @@ const SupplierPage: React.FC = () => {
const data = await response.json() const data = await response.json()
if (data.success) { if (data.success) {
message.success(editingSupplier ? '更新成功' : '创建成功') message.success(editingSupplier ? t('common.updateSuccess') : t('common.createSuccess'))
clearDraft() clearDraft()
setModalVisible(false) setModalVisible(false)
form.resetFields() form.resetFields()
@@ -181,10 +183,10 @@ const SupplierPage: React.FC = () => {
navigate(returnTo, { state: { supplierCreated: true } }) navigate(returnTo, { state: { supplierCreated: true } })
} }
} else { } else {
message.error(data.message || '操作失败') message.error(data.message || t('common.operationFailed'))
} }
} catch (error) { } catch (error) {
message.error('操作失败') message.error(t('common.operationFailed'))
} }
} }
@@ -203,18 +205,18 @@ const SupplierPage: React.FC = () => {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: t('common.confirmDelete'),
content: '确定要删除此供应商吗?', content: t('supplier.confirmDeleteMsg'),
okText: '确定', okText: t('common.confirm'),
cancelText: '取消', cancelText: t('common.cancel'),
onOk: async () => { onOk: async () => {
try { try {
const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' }) const response = await fetch(`/api/suppliers/${id}`, { method: 'DELETE' })
const data = await response.json() const data = await response.json()
if (data.success) { message.success('删除成功'); fetchSuppliers() } if (data.success) { message.success(t('common.deleteSuccess')); fetchSuppliers() }
else message.error(data.message || '删除失败') else message.error(data.message || t('common.deleteFailed'))
} catch (error) { } catch (error) {
message.error('删除失败') message.error(t('common.deleteFailed'))
} }
} }
}) })
@@ -233,10 +235,10 @@ const SupplierPage: React.FC = () => {
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的供应商信息,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft() restoreDraft()
}, },
@@ -264,86 +266,87 @@ const SupplierPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 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={t('supplier.totalCount')} 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={t('supplier.totalPurchase')} 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.totalPayable')} value={stats.totalPayable} prefix="¥" valueStyle={{ color: stats.totalPayable > 0 ? '#ff4d4f' : '#52c41a' }} /></Card></Col>
</Row> </Row>
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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 }} /> <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}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('supplier.newSupplier')}</Button>
</div> </div>
</Card> </Card>
<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> </Card>
<Modal <Modal
title={editingSupplier ? '编辑供应商' : '新增供应商'} title={editingSupplier ? t('supplier.editSupplier') : t('supplier.newSupplier')}
open={modalVisible} open={modalVisible}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft() saveDraft()
form.resetFields();
setModalVisible(false) setModalVisible(false)
form.resetFields()
setEditingSupplier(null) setEditingSupplier(null)
}, },
}) })
} else { } else {
form.resetFields();
setModalVisible(false) setModalVisible(false)
form.resetFields()
setEditingSupplier(null) setEditingSupplier(null)
} }
}} }}
onOk={() => form.submit()} onOk={() => form.submit()}
destroyOnClose
width={800} width={800}
maskClosable={false} maskClosable={false}
> >
<Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onFinish={handleSubmit} onValuesChange={handleFormChange}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}> <Form.Item name="name" label={t('supplier.name')} rules={[{ required: true, message: t('supplier.nameRequired') }]}>
<Input placeholder="供应商名称" /> <Input placeholder={t('supplier.namePlaceholder')} />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="supply_category" label="供应类别"> <Form.Item name="supply_category" label={t('supplier.supplyCategory')}>
<Input placeholder="手填:如电力设备、建筑材料" /> <Input placeholder={t('supplier.categoryPlaceholder')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="country" label="国家" initialValue="Laos"> <Form.Item name="country" label={t('supplier.country')} initialValue="Laos">
<Select> <Select>
<Select.Option value="China"></Select.Option> <Select.Option value="China">{t('supplier.china')}</Select.Option>
<Select.Option value="Laos"></Select.Option> <Select.Option value="Laos">{t('supplier.laos')}</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={2} placeholder="备注信息" /> <Input.TextArea rows={2} placeholder={t('supplier.remarkPlaceholder')} />
</Form.Item> </Form.Item>
<h4></h4> <h4>{t('supplier.contact')}</h4>
<Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}> <Form.List name="contacts" initialValue={[{ name: '', position: '', phone: '', is_primary: true }]}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div> <div>
{fields.map(({ key, name, ...restField }) => ( {fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <div key={key} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'name']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="姓名" /> <Input placeholder={t('logistics.name')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'position']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="职位" /> <Input placeholder={t('common.position')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'phone']} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="电话" /> <Input placeholder={t('common.phone')} />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}> <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)} onChange={(e) => handleContactChange(name, 'is_primary', e.target.checked)}
/> />
</Form.Item> </Form.Item>
<span></span> <span>{t('supplier.mainContact')}</span>
</div> </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> </div>
))} ))}
<Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>+ </Button> <Button type="dashed" onClick={() => add()} style={{ width: '100%' }}>{t('supplier.addContact')}</Button>
</div> </div>
)} )}
</Form.List> </Form.List>
<h4 style={{ marginTop: 24 }}></h4> <h4 style={{ marginTop: 24 }}>{t('supplier.paymentInfo')}</h4>
<Form.List name="payment_infos" initialValue={[]}> <Form.List name="payment_infos" initialValue={[]}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<div> <div>
{fields.map(({ key, name, ...restField }) => ( {fields.map(({ key, name, ...restField }) => (
<div key={key} style={{ border: '1px solid #e8e8e8', padding: 16, marginBottom: 16, borderRadius: 4, backgroundColor: '#fafafa' }}> <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' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'account_name']} label="收款户名" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'account_name']} label={t('supplier.accountName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="收款户名" /> <Input placeholder={t('supplier.accountName')} />
</Form.Item> </Form.Item>
<Form.Item {...restField} name={[name, 'bank_name']} label="开户银行" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'bank_name']} label={t('supplier.bankName')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="开户银行" /> <Input placeholder={t('supplier.bankName')} />
</Form.Item> </Form.Item>
</div> </div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
<Form.Item {...restField} name={[name, 'bank_account']} label="银行账号" style={{ marginBottom: 0, flex: 1 }}> <Form.Item {...restField} name={[name, 'bank_account']} label={t('supplier.bankAccount')} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="银行账号" /> <Input placeholder={t('supplier.bankAccount')} />
</Form.Item> </Form.Item>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}> <div style={{ display: 'flex', alignItems: 'center', marginTop: 30 }}>
<Form.Item {...restField} name={[name, 'is_primary']} valuePropName="checked" style={{ marginBottom: 0, marginRight: 8 }}> <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)} onChange={(e) => handlePaymentInfoChange(name, 'is_primary', e.target.checked)}
/> />
</Form.Item> </Form.Item>
<span></span> <span>{t('supplier.mainAccount')}</span>
</div> </div>
</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 <FileUpload
maxCount={1} maxCount={1}
accept="image/*" accept="image/*"
@@ -405,12 +408,12 @@ const SupplierPage: React.FC = () => {
/> />
</Form.Item> </Form.Item>
{fields.length > 0 && ( {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> </div>
))} ))}
<Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}> <Button type="dashed" onClick={() => add({ account_name: '', bank_account: '', bank_name: '', is_primary: false })} style={{ width: '100%' }}>
+ {t('supplier.addPaymentInfo')}
</Button> </Button>
</div> </div>
)} )}
+27 -25
View File
@@ -1,18 +1,20 @@
import React from 'react'; import React from 'react';
import { Card, Typography, Button, Table, Tag, Space, Select, DatePicker, Input } from 'antd'; import { Card, Typography, Button, Table, Tag, Space, Select, DatePicker, Input } from 'antd';
import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons'; import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../store/languageStore';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const SystemLogsPage: React.FC = () => { const SystemLogsPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading] = React.useState(false); const [loading] = React.useState(false);
const columns = [ const columns = [
{ title: '日志ID', dataIndex: 'id', key: 'id', width: 80 }, { title: t('systemLogs.logId'), dataIndex: 'id', key: 'id', width: 80 },
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 180 }, { title: t('systemLogs.time'), dataIndex: 'timestamp', key: 'timestamp', width: 180 },
{ {
title: '级别', title: t('systemLogs.level'),
dataIndex: 'level', dataIndex: 'level',
key: 'level', key: 'level',
width: 100, width: 100,
@@ -21,47 +23,47 @@ const SystemLogsPage: React.FC = () => {
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>; return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
} }
}, },
{ title: '模块', dataIndex: 'module', key: 'module', width: 120 }, { title: t('systemLogs.module'), dataIndex: 'module', key: 'module', width: 120 },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120 }, { title: t('systemLogs.operator'), dataIndex: 'operator', key: 'operator', width: 120 },
{ title: '操作', dataIndex: 'action', key: 'action' }, { title: t('systemLogs.operation'), dataIndex: 'action', key: 'action' },
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 130 }, { title: t('systemLogs.ipAddress'), dataIndex: 'ip', key: 'ip', width: 130 },
{ title: '详情', dataIndex: 'detail', key: 'detail', ellipsis: true }, { title: t('systemLogs.detail'), dataIndex: 'detail', key: 'detail', ellipsis: true },
]; ];
const data = [ 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: '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: '项目管理', operator: 'manager', action: '创建项目', ip: '192.168.1.101', detail: '创建新项目: 博纳斯线路改造' }, { 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: '财务管理', operator: 'admin', action: '审批预支', ip: '192.168.1.100', detail: '预支申请单 ADV20260318001 审批通过' }, { 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: '系统', operator: 'system', action: '数据备份', ip: '127.0.0.1', detail: '自动备份完成,耗时 45 秒' }, { 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: '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: '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: '用户管理', operator: 'admin', action: '修改角色', ip: '192.168.1.100', detail: '修改用户 zhang 的角色为项目经理' }, { 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 ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <div>
<Title level={3} style={{ marginBottom: 0 }}></Title> <Title level={3} style={{ marginBottom: 0 }}>{t('systemLogs.title')}</Title>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('systemLogs.description')}</Paragraph>
</div> </div>
<Space> <Space>
<Select placeholder="日志级别" style={{ width: 120 }} allowClear options={[ <Select placeholder={t('systemLogs.logLevel')} style={{ width: 120 }} allowClear options={[
{ value: 'info', label: 'Info' }, { value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' }, { value: 'warning', label: 'Warning' },
{ value: 'error', label: 'Error' }, { value: 'error', label: 'Error' },
{ value: 'success', label: 'Success' } { value: 'success', label: 'Success' }
]} /> ]} />
<Select placeholder="模块" style={{ width: 150 }} allowClear options={[ <Select placeholder={t('systemLogs.module')} style={{ width: 150 }} allowClear options={[
{ value: 'user', label: '用户管理' }, { value: 'user', label: t('systemLogs.moduleUser') },
{ value: 'project', label: '项目管理' }, { value: 'project', label: t('systemLogs.moduleProject') },
{ value: 'finance', label: '财务管理' }, { value: 'finance', label: t('systemLogs.moduleFinance') },
{ value: 'system', label: '系统' } { value: 'system', label: t('systemLogs.moduleSystem') }
]} /> ]} />
<RangePicker placeholder={['开始日期', '结束日期']} /> <RangePicker placeholder={['开始日期', '结束日期']} />
<Input.Search placeholder="搜索日志内容" style={{ width: 200 }} /> <Input.Search placeholder={t('systemLogs.searchPlaceholder')} style={{ width: 200 }} />
<Button icon={<DownloadOutlined />}></Button> <Button icon={<DownloadOutlined />}>{t('systemLogs.export')}</Button>
<Button icon={<DeleteOutlined />} danger></Button> <Button icon={<DeleteOutlined />} danger>{t('systemLogs.clear')}</Button>
</Space> </Space>
</div> </div>
+11 -10
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import apiClient from '../utils/request'; import apiClient from '../utils/request';
import { useLanguageStore } from '../store/languageStore';
const UserManagement: React.FC = () => { const UserManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 从API获取用户数据
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -15,11 +16,11 @@ const UserManagement: React.FC = () => {
if (response.data.success) { if (response.data.success) {
setUsers(response.data.data || []); setUsers(response.data.data || []);
} else { } else {
throw new Error('API返回失败: ' + (response.data.message || '未知错误')); throw new Error(t('userManagement.apiFailed') + (response.data.message || t('userManagement.unknownError')));
} }
} catch (error) { } catch (error) {
console.error('获取用户列表失败:', error); console.error('获取用户列表失败:', error);
setError(error instanceof Error ? error.message : '未知错误'); setError(error instanceof Error ? error.message : t('userManagement.unknownError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -31,23 +32,23 @@ const UserManagement: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<h1></h1> <h1>{t('userManagement.title')}</h1>
<p>API调用是否正常</p> <p>{t('userManagement.testPage')}</p>
<button onClick={fetchUsers} disabled={loading}> <button onClick={fetchUsers} disabled={loading}>
{loading ? '加载中...' : '刷新用户列表'} {loading ? t('common.loading') : t('userManagement.refreshList')}
</button> </button>
{error && ( {error && (
<div style={{ marginTop: 10, color: 'red' }}> <div style={{ marginTop: 10, color: 'red' }}>
: {error} {t('userManagement.errorPrefix')}{error}
</div> </div>
)} )}
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<h2>API返回的数据:</h2> <h2>{t('userManagement.apiResult')}</h2>
<pre>{JSON.stringify(users, null, 2)}</pre> <pre>{JSON.stringify(users, null, 2)}</pre>
</div> </div>
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<h2>:</h2> <h2>{t('userManagement.loadStatus')}</h2>
<p>{loading ? '加载中...' : '加载完成'}</p> <p>{loading ? t('common.loading') : t('userManagement.loadComplete')}</p>
</div> </div>
</div> </div>
); );
+66 -64
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import apiClient from '../utils/request'; import apiClient from '../utils/request';
import { Card, Button, Table, Tag, Space, Modal, Form, Input, Select, message, Row, Col, Avatar, Popconfirm } from 'antd'; 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 { PlusOutlined, UserOutlined, LockOutlined, EditOutlined, DeleteOutlined, ReloadOutlined, KeyOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../store/languageStore';
interface User { interface User {
id: number; id: number;
@@ -16,6 +17,7 @@ interface User {
} }
const UsersPage: React.FC = () => { const UsersPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [addModalVisible, setAddModalVisible] = useState(false); const [addModalVisible, setAddModalVisible] = useState(false);
@@ -27,10 +29,10 @@ const UsersPage: React.FC = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
const roles = [ const roles = [
{ value: 'admin', label: '系统管理员' }, { value: 'admin', label: t('user.admin') },
{ value: 'finance', label: '财务专员' }, { value: 'finance', label: t('user.finance') },
{ value: 'manager', label: '项目经理' }, { value: 'manager', label: t('user.manager') },
{ value: 'employee', label: '普通员工' } { value: 'employee', label: t('user.employee') }
]; ];
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -40,14 +42,14 @@ const UsersPage: React.FC = () => {
if (response.data.success) { if (response.data.success) {
setUsers(response.data.data || []); setUsers(response.data.data || []);
} else { } else {
message.error('获取用户列表失败: ' + (response.data.message || '未知错误')); message.error(t('users.getListFailedLog') + (response.data.message || t('users.unknownError')));
} }
} catch (error: any) { } catch (error: any) {
console.error('获取用户列表失败:', error); console.error('获取用户列表失败:', error);
if (error.response?.status === 403) { if (error.response?.status === 403) {
message.error('权限不足,仅管理员可访问'); message.error(t('users.notAdmin'));
} else { } else {
message.error('获取用户列表失败'); message.error(t('users.getListFailed'));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -63,12 +65,12 @@ const UsersPage: React.FC = () => {
const values = await addForm.validateFields(); const values = await addForm.validateFields();
const response = await apiClient.post('/users', values); const response = await apiClient.post('/users', values);
if (response.data.success) { if (response.data.success) {
message.success('用户已添加'); message.success(t('users.addSuccess'));
setAddModalVisible(false); setAddModalVisible(false);
addForm.resetFields(); addForm.resetFields();
fetchUsers(); fetchUsers();
} else { } else {
message.error(response.data.message || '添加失败'); message.error(response.data.message || t('common.failed'));
} }
} catch (error: any) { } catch (error: any) {
if (error.response?.data?.message) { if (error.response?.data?.message) {
@@ -76,7 +78,7 @@ const UsersPage: React.FC = () => {
} else if (error.errorFields) { } else if (error.errorFields) {
return; return;
} else { } else {
message.error('添加失败,请重试'); message.error(t('common.failed'));
} }
} }
}; };
@@ -98,13 +100,13 @@ const UsersPage: React.FC = () => {
if (currentUser) { if (currentUser) {
const response = await apiClient.put(`/users/${currentUser.id}`, values); const response = await apiClient.put(`/users/${currentUser.id}`, values);
if (response.data.success) { if (response.data.success) {
message.success('用户已更新'); message.success(t('common.updateSuccess'));
setEditModalVisible(false); setEditModalVisible(false);
editForm.resetFields(); editForm.resetFields();
setCurrentUser(null); setCurrentUser(null);
fetchUsers(); fetchUsers();
} else { } else {
message.error(response.data.message || '更新失败'); message.error(response.data.message || t('common.failed'));
} }
} }
} catch (error: any) { } catch (error: any) {
@@ -113,7 +115,7 @@ const UsersPage: React.FC = () => {
} else if (error.errorFields) { } else if (error.errorFields) {
return; return;
} else { } else {
message.error('更新失败,请重试'); message.error(t('common.failed'));
} }
} }
}; };
@@ -136,12 +138,12 @@ const UsersPage: React.FC = () => {
password: values.newPassword password: values.newPassword
}); });
if (response.data.success) { if (response.data.success) {
message.success('密码已重置'); message.success(t('common.success'));
setResetPwdModalVisible(false); setResetPwdModalVisible(false);
resetPwdForm.resetFields(); resetPwdForm.resetFields();
setCurrentUser(null); setCurrentUser(null);
} else { } else {
message.error(response.data.message || '重置失败'); message.error(response.data.message || t('common.failed'));
} }
} }
} catch (error: any) { } catch (error: any) {
@@ -150,7 +152,7 @@ const UsersPage: React.FC = () => {
} else if (error.errorFields) { } else if (error.errorFields) {
return; return;
} else { } else {
message.error('重置失败,请重试'); message.error(t('common.failed'));
} }
} }
}; };
@@ -159,64 +161,64 @@ const UsersPage: React.FC = () => {
try { try {
const response = await apiClient.delete(`/users/${user.id}`); const response = await apiClient.delete(`/users/${user.id}`);
if (response.data.success) { if (response.data.success) {
message.success('用户已删除'); message.success(t('common.deleteSuccess'));
fetchUsers(); fetchUsers();
} else { } else {
message.error(response.data.message || '删除失败'); message.error(response.data.message || t('common.deleteFailed'));
} }
} catch (error: any) { } catch (error: any) {
if (error.response?.data?.message) { if (error.response?.data?.message) {
message.error(error.response.data.message); message.error(error.response.data.message);
} else { } else {
message.error('删除失败,请重试'); message.error(t('common.deleteFailed'));
} }
} }
}; };
const columns = [ 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', key: 'avatar',
width: 60, width: 60,
render: () => <Avatar icon={<UserOutlined />} /> render: () => <Avatar icon={<UserOutlined />} />
}, },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 }, { title: t('users.username'), dataIndex: 'username', key: 'username', width: 120 },
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 }, { title: t('users.name'), dataIndex: 'name', key: 'name', width: 120 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180, render: (v: string) => v || '-' }, { title: t('users.email'), dataIndex: 'email', key: 'email', width: 180, render: (v: string) => v || '-' },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130, 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', dataIndex: 'role',
key: 'role', key: 'role',
width: 120, width: 120,
render: (v: string) => { render: (v: string) => {
const colors: Record<string, string> = { 'admin': 'red', 'manager': 'blue', 'finance': 'orange', 'employee': 'green' }; const colors: Record<string, string> = { 'admin': 'red', 'manager': 'blue', 'finance': 'orange', 'employee': 'green' };
const roleMap = roles.find(role => role.value === v); 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', key: 'action',
width: 240, width: 240,
render: (_: any, record: User) => ( render: (_: any, record: User) => (
<Space> <Space>
<Button size="small" type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}> <Button size="small" type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
{t('users.edit')}
</Button> </Button>
<Button size="small" type="link" icon={<KeyOutlined />} onClick={() => handleResetPassword(record)}> <Button size="small" type="link" icon={<KeyOutlined />} onClick={() => handleResetPassword(record)}>
{t('users.resetPassword')}
</Button> </Button>
<Popconfirm <Popconfirm
title="确认删除" title={t('users.confirmDelete')}
description={`确定要删除用户 ${record.username} 吗?`} description={t('users.confirmDeleteMsg', { name: record.username })}
onConfirm={() => handleDelete(record)} onConfirm={() => handleDelete(record)}
okText="确定" okText={t('common.confirm')}
cancelText="取消" cancelText={t('common.cancel')}
> >
<Button size="small" type="link" danger icon={<DeleteOutlined />}> <Button size="small" type="link" danger icon={<DeleteOutlined />}>
{t('common.delete')}
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
@@ -227,12 +229,12 @@ const UsersPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2></h2> <h2>{t('users.title')}</h2>
<Space> <Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>
{t('users.newUser')}
</Button> </Button>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}></Button> <Button icon={<ReloadOutlined />} onClick={fetchUsers}>{t('common.refresh')}</Button>
</Space> </Space>
</div> </div>
@@ -247,7 +249,7 @@ const UsersPage: React.FC = () => {
</Card> </Card>
<Modal <Modal
title="新增用户" title={t('users.newUser')}
open={addModalVisible} open={addModalVisible}
onCancel={() => { setAddModalVisible(false); addForm.resetFields(); }} onCancel={() => { setAddModalVisible(false); addForm.resetFields(); }}
onOk={handleAdd} onOk={handleAdd}
@@ -256,37 +258,37 @@ const UsersPage: React.FC = () => {
<Form form={addForm} layout="vertical"> <Form form={addForm} layout="vertical">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: '请输入用户名' }]}> <Form.Item label={t('users.username')} name="username" rules={[{ required: true, message: t('users.usernamePlaceholder') }]}>
<Input placeholder="请输入用户名" prefix={<UserOutlined />} /> <Input placeholder={t('users.usernamePlaceholder')} prefix={<UserOutlined />} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}> <Form.Item label={t('users.name')} name="name" rules={[{ required: true, message: t('users.namePlaceholder') }]}>
<Input placeholder="请输入姓名" /> <Input placeholder={t('users.namePlaceholder')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="邮箱" name="email"> <Form.Item label={t('users.email')} name="email">
<Input placeholder="email@example.com" /> <Input placeholder="email@example.com" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="手机号" name="phone"> <Form.Item label={t('users.phone')} name="phone">
<Input placeholder="+856 20 xxxx xxxx" /> <Input placeholder="+856 20 xxxx xxxx" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]} initialValue="employee"> <Form.Item label={t('users.role')} name="role" rules={[{ required: true, message: t('users.selectRolePlaceholder') }]} initialValue="employee">
<Select placeholder="选择角色" options={roles} /> <Select placeholder={t('users.selectRole')} options={roles} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="初始密码" name="password" rules={[{ required: true, message: '请输入初始密码' }, { min: 6, message: '密码至少6位' }]}> <Form.Item label={t('users.initialPassword')} name="password" rules={[{ required: true, message: t('users.initialPasswordPlaceholder') }, { min: 6, message: t('users.passwordMinLen') }]}>
<Input.Password placeholder="请输入初始密码" prefix={<LockOutlined />} /> <Input.Password placeholder={t('users.initialPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -294,7 +296,7 @@ const UsersPage: React.FC = () => {
</Modal> </Modal>
<Modal <Modal
title="编辑用户" title={t('users.editUser')}
open={editModalVisible} open={editModalVisible}
onCancel={() => { onCancel={() => {
setEditModalVisible(false); setEditModalVisible(false);
@@ -307,32 +309,32 @@ const UsersPage: React.FC = () => {
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="用户名"> <Form.Item label={t('users.username')}>
<Input value={currentUser?.username} disabled prefix={<UserOutlined />} /> <Input value={currentUser?.username} disabled prefix={<UserOutlined />} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true, message: '请输入姓名' }]}> <Form.Item label={t('users.name')} name="name" rules={[{ required: true, message: t('users.namePlaceholder') }]}>
<Input placeholder="请输入姓名" /> <Input placeholder={t('users.namePlaceholder')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="邮箱" name="email"> <Form.Item label={t('users.email')} name="email">
<Input placeholder="email@example.com" /> <Input placeholder="email@example.com" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="手机号" name="phone"> <Form.Item label={t('users.phone')} name="phone">
<Input placeholder="+856 20 xxxx xxxx" /> <Input placeholder="+856 20 xxxx xxxx" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="角色" name="role" rules={[{ required: true, message: '请选择角色' }]}> <Form.Item label={t('users.role')} name="role" rules={[{ required: true, message: t('users.selectRolePlaceholder') }]}>
<Select placeholder="选择角色" options={roles} /> <Select placeholder={t('users.selectRole')} options={roles} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -340,7 +342,7 @@ const UsersPage: React.FC = () => {
</Modal> </Modal>
<Modal <Modal
title={`重置密码 - ${currentUser?.username || ''}`} title={`${t('users.resetPassword')} - ${currentUser?.username || ''}`}
open={resetPwdModalVisible} open={resetPwdModalVisible}
onCancel={() => { onCancel={() => {
setResetPwdModalVisible(false); setResetPwdModalVisible(false);
@@ -351,21 +353,21 @@ const UsersPage: React.FC = () => {
width={400} width={400}
> >
<Form form={resetPwdForm} layout="vertical"> <Form form={resetPwdForm} layout="vertical">
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少6位' }]}> <Form.Item label={t('user.newPassword')} name="newPassword" rules={[{ required: true, message: t('users.newPasswordPlaceholder') }, { min: 6, message: t('users.passwordMinLen') }]}>
<Input.Password placeholder="请输入新密码" prefix={<LockOutlined />} /> <Input.Password placeholder={t('users.newPasswordPlaceholder')} prefix={<LockOutlined />} />
</Form.Item> </Form.Item>
<Form.Item label="确认密码" name="confirmPassword" rules={[ <Form.Item label={t('users.confirmPassword')} name="confirmPassword" rules={[
{ required: true, message: '请确认密码' }, { required: true, message: t('users.confirmPasswordPlaceholder') },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('newPassword') === value) { if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve(); 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.Item>
</Form> </Form>
</Modal> </Modal>
File diff suppressed because it is too large Load Diff
+35 -32
View File
@@ -7,74 +7,77 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
InfoCircleOutlined InfoCircleOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
const AboutPage: React.FC = () => { const AboutPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title> <Title level={3} style={{ marginBottom: 8 }}>{t('about.title')}</Title>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('about.description')}</Paragraph>
</div> </div>
<Row gutter={24}> <Row gutter={24}>
<Col span={16}> <Col span={16}>
<Card title={<><InfoCircleOutlined /> </>}> <Card title={<><InfoCircleOutlined /> {t('about.systemInfo')}</>}>
<Descriptions bordered column={2}> <Descriptions bordered column={2}>
<Descriptions.Item label="系统名称">ERP</Descriptions.Item> <Descriptions.Item label={t('about.systemName')}>{t('about.systemNameValue')}</Descriptions.Item>
<Descriptions.Item label="系统版本">V1.0.0</Descriptions.Item> <Descriptions.Item label={t('about.version')}>{t('about.versionValue')}</Descriptions.Item>
<Descriptions.Item label="开发团队"></Descriptions.Item> <Descriptions.Item label={t('about.devTeam')}>{t('about.devTeamValue')}</Descriptions.Item>
<Descriptions.Item label="上线日期">20263</Descriptions.Item> <Descriptions.Item label={t('about.onlineDate')}>{t('about.onlineDateValue')}</Descriptions.Item>
<Descriptions.Item label="技术架构"> <Descriptions.Item label={t('about.techArchitecture')}>
<Tag color="blue">React 18</Tag> <Tag color="blue">React 18</Tag>
<Tag color="green">Ant Design 5</Tag> <Tag color="green">Ant Design 5</Tag>
<Tag color="purple">Node.js</Tag> <Tag color="purple">Node.js</Tag>
<Tag color="orange">PostgreSQL</Tag> <Tag color="orange">PostgreSQL</Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="部署环境"> <Descriptions.Item label={t('about.deployEnv')}>
<Tag color="cyan"></Tag> <Tag color="cyan">{t('about.deployEnvValue')}</Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="前端框架">Vite + React + TypeScript</Descriptions.Item> <Descriptions.Item label={t('about.frontend')}>{t('about.frontendValue')}</Descriptions.Item>
<Descriptions.Item label="后端框架">Express.js + PostgreSQL</Descriptions.Item> <Descriptions.Item label={t('about.backend')}>{t('about.backendValue')}</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
<Card title={<><CheckCircleOutlined /> </>} style={{ marginTop: 24 }}> <Card title={<><CheckCircleOutlined /> {t('about.modules')}</>} style={{ marginTop: 24 }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}> <Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title> <Title level={5}>{t('menu.projects')}</Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}> <Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title> <Title level={5}>{t('menu.financeManagement')}</Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}> <Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title> <Title level={5}>{t('menu.procurement')}</Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}> <Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title> <Title level={5}>{t('menu.partners')}</Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}> <Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title> <Title level={5}>{t('menu.construction')}</Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card size="small" style={{ textAlign: 'center' }}> <Card size="small" style={{ textAlign: 'center' }}>
<Title level={5}></Title> <Title level={5}>{t('menu.budgetQuotation')}</Title>
<Text type="secondary"></Text> <Text type="secondary"></Text>
</Card> </Card>
</Col> </Col>
@@ -83,45 +86,45 @@ const AboutPage: React.FC = () => {
</Col> </Col>
<Col span={8}> <Col span={8}>
<Card title={<><CloudServerOutlined /> </>}> <Card title={<><CloudServerOutlined /> {t('about.serverStatus')}</>}>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Text type="secondary">CPU使用率</Text> <Text type="secondary">{t('about.cpuUsage')}</Text>
<Progress percent={45} status="active" /> <Progress percent={45} status="active" />
</div> </div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Text type="secondary">使</Text> <Text type="secondary">{t('about.memoryUsage')}</Text>
<Progress percent={60} strokeColor="#52c41a" /> <Progress percent={60} strokeColor="#52c41a" />
</div> </div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Text type="secondary"></Text> <Text type="secondary">{t('about.diskSpace')}</Text>
<Progress percent={35} strokeColor="#1890ff" /> <Progress percent={35} strokeColor="#1890ff" />
</div> </div>
<Divider /> <Divider />
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="服务器IP">43.161.248.209</Descriptions.Item> <Descriptions.Item label={t('about.serverIp')}>43.161.248.209</Descriptions.Item>
<Descriptions.Item label="操作系统">OpenCloudOS 9</Descriptions.Item> <Descriptions.Item label={t('about.os')}>{t('about.osValue')}</Descriptions.Item>
<Descriptions.Item label="Node版本">v22.22.1</Descriptions.Item> <Descriptions.Item label={t('about.nodeVersion')}>v22.22.1</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
<Card title={<><DatabaseOutlined /> </>} style={{ marginTop: 24 }}> <Card title={<><DatabaseOutlined /> {t('about.databaseStatus')}</>} style={{ marginTop: 24 }}>
<div style={{ textAlign: 'center', padding: 20 }}> <div style={{ textAlign: 'center', padding: 20 }}>
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a' }} /> <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> <Text type="secondary">PostgreSQL 15</Text>
</div> </div>
<Divider /> <Divider />
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
<Descriptions.Item label="数据库名">company_finance_db</Descriptions.Item> <Descriptions.Item label={t('about.dbName')}>company_finance_db</Descriptions.Item>
<Descriptions.Item label="连接状态"></Descriptions.Item> <Descriptions.Item label={t('about.connectionStatus')}>{t('about.normal')}</Descriptions.Item>
<Descriptions.Item label="最近备份">2026-03-19 00:00</Descriptions.Item> <Descriptions.Item label={t('about.lastBackup')}>2026-03-19 00:00</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
</Col> </Col>
</Row> </Row>
<Card style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}> <Card style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Text>© 2026 ERP系统 - V1.0.0</Text> <Text>{t('about.footer')}</Text>
</Card> </Card>
</div> </div>
); );
+21 -19
View File
@@ -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 { 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 { DownloadOutlined, UploadOutlined, DeleteOutlined, ClockCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
const BackupPage: React.FC = () => { const BackupPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [backuping, setBackuping] = React.useState(false); const [backuping, setBackuping] = React.useState(false);
const columns = [ const columns = [
{ title: '备份名称', dataIndex: 'name', key: 'name' }, { title: t('backup.backupName'), dataIndex: 'name', key: 'name' },
{ title: '备份时间', dataIndex: 'time', key: 'time' }, { title: t('backup.backupTime'), dataIndex: 'time', key: 'time' },
{ title: '文件大小', dataIndex: 'size', key: 'size' }, { title: t('backup.fileSize'), dataIndex: 'size', key: 'size' },
{ title: '备份类型', dataIndex: 'type', key: 'type', render: (v: string) => <Tag color={v === 'auto' ? 'blue' : 'green'}>{v === 'auto' ? '自动' : '手动'}</Tag> }, { 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: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'success' ? 'success' : 'error'}>{v === 'success' ? '成功' : '失败'}</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', key: 'action',
render: () => ( render: () => (
<Space> <Space>
<Button size="small" type="link" icon={<DownloadOutlined />}></Button> <Button size="small" type="link" icon={<DownloadOutlined />}>{t('backup.download')}</Button>
<Button size="small" type="link" icon={<UploadOutlined />}></Button> <Button size="small" type="link" icon={<UploadOutlined />}>{t('backup.restore')}</Button>
<Button size="small" danger type="link" icon={<DeleteOutlined />}></Button> <Button size="small" danger type="link" icon={<DeleteOutlined />}>{t('backup.delete')}</Button>
</Space> </Space>
) )
} }
@@ -37,7 +39,7 @@ const BackupPage: React.FC = () => {
const handleBackup = () => { const handleBackup = () => {
setBackuping(true); setBackuping(true);
setTimeout(() => { setTimeout(() => {
message.success('备份创建成功'); message.success(t('backup.backupCreated'));
setBackuping(false); setBackuping(false);
}, 2000); }, 2000);
}; };
@@ -45,32 +47,32 @@ const BackupPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title> <Title level={3} style={{ marginBottom: 8 }}>{t('backup.title')}</Title>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('backup.description')}</Paragraph>
</div> </div>
<Row gutter={16} style={{ marginBottom: 24 }}> <Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}> <Col span={6}>
<Card> <Card>
<Text type="secondary"></Text> <Text type="secondary">{t('backup.totalBackups')}</Text>
<Title level={2} style={{ margin: '8px 0 0' }}>3</Title> <Title level={2} style={{ margin: '8px 0 0' }}>3</Title>
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card> <Card>
<Text type="secondary"></Text> <Text type="secondary">{t('backup.totalSize')}</Text>
<Title level={2} style={{ margin: '8px 0 0' }}>44.5 MB</Title> <Title level={2} style={{ margin: '8px 0 0' }}>44.5 MB</Title>
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card> <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> <Title level={4} style={{ margin: '8px 0 0' }}>2026-03-19 00:00</Title>
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card> <Card>
<Text type="secondary"></Text> <Text type="secondary">{t('backup.storageSpace')}</Text>
<Progress percent={30} size="small" style={{ marginTop: 8 }} /> <Progress percent={30} size="small" style={{ marginTop: 8 }} />
<Text type="secondary">300 MB / 1 GB</Text> <Text type="secondary">300 MB / 1 GB</Text>
</Card> </Card>
@@ -78,12 +80,12 @@ const BackupPage: React.FC = () => {
</Row> </Row>
<Card <Card
title="备份列表" title={t('backup.backupList')}
extra={ extra={
<Space> <Space>
<Button icon={<ClockCircleOutlined />}></Button> <Button icon={<ClockCircleOutlined />}>{t('backup.autoBackupSetting')}</Button>
<Button type="primary" icon={<DownloadOutlined />} loading={backuping} onClick={handleBackup}> <Button type="primary" icon={<DownloadOutlined />} loading={backuping} onClick={handleBackup}>
{t('backup.immediateBackup')}
</Button> </Button>
</Space> </Space>
} }
+72 -44
View File
@@ -4,6 +4,7 @@ import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutl
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title } = Typography; const { Title } = Typography;
@@ -34,6 +35,7 @@ interface ParsedRow {
} }
const ExcelImport: React.FC = () => { const ExcelImport: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [parsedData, setParsedData] = useState<ParsedRow[]>([]); const [parsedData, setParsedData] = useState<ParsedRow[]>([]);
const [categories, setCategories] = useState<any>({}); const [categories, setCategories] = useState<any>({});
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
@@ -42,7 +44,10 @@ const ExcelImport: React.FC = () => {
useEffect(() => { useEffect(() => {
apiClient.get('/expense-categories/grouped').then(res => { apiClient.get('/expense-categories/grouped').then(res => {
if (res.data.success) setCategories(res.data.data); if (res.data.success) setCategories(res.data.data);
}).catch(() => {}); }).catch((err) => {
console.error('加载支出分类失败:', err);
message.error('加载支出分类失败,请刷新页面重试');
});
}, []); }, []);
const getCategoryMap = () => { const getCategoryMap = () => {
@@ -63,16 +68,29 @@ const ExcelImport: React.FC = () => {
const json = XLSX.utils.sheet_to_json(sheet, { header: 1 }); const json = XLSX.utils.sheet_to_json(sheet, { header: 1 });
if (json.length < 2) { if (json.length < 2) {
message.error('Excel文件没有数据行'); message.error(t('excelImport.noData'));
return; return;
} }
const categoryMap = getCategoryMap(); 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[] = []; const rows: ParsedRow[] = [];
for (let i = 1; i < json.length; i++) { for (let i = 1; i < json.length; i++) {
const r: any[] = json[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 errors: string[] = [];
const dateVal = r[0]; const dateVal = r[0];
@@ -98,17 +116,27 @@ const ExcelImport: React.FC = () => {
const description = String(r[12] || '').trim(); const description = String(r[12] || '').trim();
const voucherNo = String(r[13] || '').trim(); const voucherNo = String(r[13] || '').trim();
const txnType = TXN_TYPE_MAP[txnTypeLabel] || ''; const txnType = TXN_TYPE_MAP[txnTypeLabel] || TXN_TYPE_MAP[txnTypeLabel.replace(/\s/g, '')] || '';
const level1 = LEVEL1_MAP[level1Label] || ''; const level1 = LEVEL1_MAP[level1Label] || LEVEL1_MAP[level1Label.replace(/\s/g, '')] || '';
const level2 = categoryMap[level2Label] || ''; let level2 = categoryMap[level2Label] || categoryMap[level2Label.replace(/\s/g, '')] || '';
if (!recordDate) errors.push('日期为空'); // 如果精确匹配失败,尝试模糊匹配(包含关系)
if (!txnType) errors.push(`收支类型无效: ${txnTypeLabel}`); if (!level2 && level2Label) {
if (!level1) errors.push(`一级分类无效: ${level1Label}`); for (const [label, value] of Object.entries(categoryMap)) {
if (!level2) errors.push(`二级分类无效: ${level2Label}`); if (label.includes(level2Label) || level2Label.includes(label)) {
if (amountOriginal <= 0) errors.push('金额必须大于0'); 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) { if ((level1 === 'project' || (level1 === 'income' && level2 !== 'shareholder_investment')) && !projectName) {
errors.push('项目支出/项目收入必须填写项目名称'); errors.push(t('excelImport.projectRequired'));
} }
rows.push({ rows.push({
@@ -133,9 +161,9 @@ const ExcelImport: React.FC = () => {
setParsedData(rows); setParsedData(rows);
setImportResult(null); setImportResult(null);
message.success(`解析完成,共 ${rows.length} 条记录`); message.success(t('excelImport.parseComplete', { count: rows.length }));
} catch (err: any) { } catch (err: any) {
message.error('解析Excel失败: ' + err.message); message.error(t('excelImport.parseFailed') + err.message);
} }
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
@@ -145,7 +173,7 @@ const ExcelImport: React.FC = () => {
const handleImport = async () => { const handleImport = async () => {
const validRows = parsedData.filter(r => r.errors.length === 0); const validRows = parsedData.filter(r => r.errors.length === 0);
if (validRows.length === 0) { if (validRows.length === 0) {
message.error('没有有效的数据可导入'); message.error(t('excelImport.noValidData'));
return; return;
} }
@@ -172,10 +200,10 @@ const ExcelImport: React.FC = () => {
const res = await apiClient.post('/financial-records/batch', { records }); const res = await apiClient.post('/financial-records/batch', { records });
if (res.data.success) { if (res.data.success) {
setImportResult(res.data.data); 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) { } catch (e: any) {
message.error('导入失败: ' + (e.response?.data?.message || e.message)); message.error(t('excelImport.importFailed') + (e.response?.data?.message || e.message));
} }
setImporting(false); setImporting(false);
}; };
@@ -184,22 +212,22 @@ const ExcelImport: React.FC = () => {
const validCount = parsedData.filter(r => r.errors.length === 0).length; const validCount = parsedData.filter(r => r.errors.length === 0).length;
const columns = [ const columns = [
{ title: '行号', dataIndex: 'row', width: 50 }, { title: t('excelImport.rowNum'), dataIndex: 'row', width: 50 },
{ title: '日期', dataIndex: 'record_date', width: 100 }, { title: t('excelImport.date'), dataIndex: 'record_date', width: 100 },
{ title: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> }, { 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: '一级', dataIndex: 'category_level1', width: 80, render: (v: string) => { { title: t('excelImport.level1'), dataIndex: 'category_level1', width: 80, render: (v: string) => {
const m: Record<string, string> = { income: '收入', project: '项目', company: '公司' }; const m: Record<string, string> = { income: t('excelImport.income'), project: t('excelImport.projectLabel'), company: t('excelImport.companyLabel') };
return m[v] || v; return m[v] || v;
}}, }},
{ title: '二级', dataIndex: 'category_level2', width: 100 }, { title: t('excelImport.level2'), dataIndex: 'category_level2', width: 100 },
{ title: '项目', dataIndex: 'project_name', width: 120, ellipsis: true }, { title: t('excelImport.projectLabel'), dataIndex: 'project_name', width: 120, ellipsis: true },
{ title: '金额', dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() }, { title: t('excelImport.amount'), dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() },
{ title: '币种', dataIndex: 'currency', width: 50 }, { title: t('excelImport.currency'), dataIndex: 'currency', width: 50 },
{ title: '汇率', dataIndex: 'exchange_rate', width: 60 }, { title: t('excelImport.rate'), dataIndex: 'exchange_rate', width: 60 },
{ title: '等效人民币', dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() }, { title: t('excelImport.equivalentCNY'), dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() },
{ title: '描述', dataIndex: 'description', width: 150, ellipsis: true }, { 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 render: (_: unknown, r: ParsedRow) => r.errors.length === 0
? <CheckCircleOutlined style={{ color: '#52c41a' }} /> ? <CheckCircleOutlined style={{ color: '#52c41a' }} />
: <CloseCircleOutlined style={{ color: '#ff4d4f' }} title={r.errors.join('; ')} /> : <CloseCircleOutlined style={{ color: '#ff4d4f' }} title={r.errors.join('; ')} />
@@ -209,18 +237,18 @@ const ExcelImport: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Card> <Card>
<Title level={4}>Excel </Title> <Title level={4}>{t('excelImport.title')}</Title>
<Alert <Alert
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="Excel 模板格式要求" message={t('excelImport.templateRequirements')}
description={ description={
<div> <div>
<p> | | | | | | | | | | | | | </p> <p>{t('excelImport.columnOrder')}</p>
<p> / / / CNY / USD / LAK / THB</p> <p>{t('excelImport.formatRequirements')}</p>
<p>使</p> <p>{t('excelImport.categoryRequirements')}</p>
</div> </div>
} }
/> />
@@ -230,20 +258,20 @@ const ExcelImport: React.FC = () => {
showUploadList={false} showUploadList={false}
beforeUpload={handleFileUpload} beforeUpload={handleFileUpload}
> >
<Button icon={<FileExcelOutlined />} type="primary"> Excel </Button> <Button icon={<FileExcelOutlined />} type="primary">{t('excelImport.selectFile')}</Button>
</Upload> </Upload>
<a href="/templates/财务记账导入模板.xlsx" download="财务记账导入模板.xlsx" style={{ marginLeft: 12 }}> <a href="/templates/财务记账导入模板.xlsx" download={t('excelImport.templateFileName')} style={{ marginLeft: 12 }}>
<Button icon={<DownloadOutlined />}></Button> <Button icon={<DownloadOutlined />}>{t('excelImport.downloadTemplate')}</Button>
</a> </a>
{parsedData.length > 0 && ( {parsedData.length > 0 && (
<> <>
<div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space> <Space>
<span> {parsedData.length} </span> <span>{t('excelImport.countPrefix')} {parsedData.length} {t('excelImport.countSuffix')}</span>
<Tag color="green"> {validCount}</Tag> <Tag color="green">{t('excelImport.validPrefix')} {validCount}</Tag>
{errorCount > 0 && <Tag color="red"> {errorCount}</Tag>} {errorCount > 0 && <Tag color="red">{t('excelImport.errorPrefix')} {errorCount}</Tag>}
</Space> </Space>
<Button <Button
type="primary" type="primary"
@@ -251,7 +279,7 @@ const ExcelImport: React.FC = () => {
loading={importing} loading={importing}
disabled={validCount === 0} disabled={validCount === 0}
> >
{validCount} {t('excelImport.importPrefix')} {validCount} {t('excelImport.importSuffix')}
</Button> </Button>
</div> </div>
@@ -270,10 +298,10 @@ const ExcelImport: React.FC = () => {
type={importResult.failed > 0 ? 'warning' : 'success'} type={importResult.failed > 0 ? 'warning' : 'success'}
showIcon showIcon
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
message={`导入完成:成功 ${importResult.imported} 条,失败 ${importResult.failed}`} message={t('excelImport.importComplete', { success: importResult.imported, fail: importResult.failed })}
description={ description={
importResult.errors?.length > 0 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 : undefined
} }
/> />
+27 -25
View File
@@ -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 { Table, Button, Modal, Form, Input, Select, Switch, Tag, message, Space, Card, Typography } from 'antd';
import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title } = Typography; const { Title } = Typography;
@@ -12,6 +13,7 @@ const LEVEL1_OPTIONS = [
]; ];
const ExpenseCategories: React.FC = () => { const ExpenseCategories: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [categories, setCategories] = useState<any[]>([]); const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
@@ -26,7 +28,7 @@ const ExpenseCategories: React.FC = () => {
setCategories(res.data.data); setCategories(res.data.data);
} }
} catch (e) { } catch (e) {
message.error('获取分类失败'); message.error(t('expenseCategory.getFailed'));
} }
setLoading(false); setLoading(false);
}; };
@@ -50,10 +52,10 @@ const ExpenseCategories: React.FC = () => {
const values = await form.validateFields(); const values = await form.validateFields();
if (editingId) { if (editingId) {
await apiClient.put(`/expense-categories/${editingId}`, values); await apiClient.put(`/expense-categories/${editingId}`, values);
message.success('更新成功'); message.success(t('common.updateSuccess'));
} else { } else {
await apiClient.post('/expense-categories', values); await apiClient.post('/expense-categories', values);
message.success('创建成功'); message.success(t('common.createSuccess'));
} }
setModalVisible(false); setModalVisible(false);
fetchCategories(); fetchCategories();
@@ -65,10 +67,10 @@ const ExpenseCategories: React.FC = () => {
const handleToggleActive = async (id: number, isActive: boolean) => { const handleToggleActive = async (id: number, isActive: boolean) => {
try { try {
await apiClient.put(`/expense-categories/${id}`, { is_active: isActive }); await apiClient.put(`/expense-categories/${id}`, { is_active: isActive });
message.success(isActive ? '已启用' : '已禁用'); message.success(isActive ? t('expenseCategory.enabled') : t('expenseCategory.disabled'));
fetchCategories(); fetchCategories();
} catch (e) { } catch (e) {
message.error('操作失败'); message.error(t('common.operationFailed'));
} }
}; };
@@ -78,22 +80,22 @@ const ExpenseCategories: React.FC = () => {
}; };
const columns = [ const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 }, { title: t('expenseCategory.id'), dataIndex: 'id', width: 60 },
{ title: '一级分类', dataIndex: 'category_level1', width: 120, render: getLevel1Tag }, { title: t('expenseCategory.level1'), dataIndex: 'category_level1', width: 120, render: getLevel1Tag },
{ title: '二级编码', dataIndex: 'category_level2', width: 160 }, { title: t('expenseCategory.level2Code'), dataIndex: 'category_level2', width: 160 },
{ title: '显示名称', dataIndex: 'label', width: 140 }, { title: t('expenseCategory.displayName'), dataIndex: 'label', width: 140 },
{ title: '说明', dataIndex: 'description', ellipsis: true }, { title: t('expenseCategory.desc'), dataIndex: 'description', ellipsis: true },
{ title: '排序', dataIndex: 'sort_order', width: 70 }, { 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) => ( render: (v: boolean, r: any) => (
<Switch size="small" checked={v} onChange={(checked) => handleToggleActive(r.id, checked)} /> <Switch size="small" checked={v} onChange={(checked) => handleToggleActive(r.id, checked)} />
) )
}, },
{ {
title: '操作', width: 100, title: t('expenseCategory.action'), width: 100,
render: (_: unknown, r: any) => ( 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 }}> <div style={{ padding: 24 }}>
<Card> <Card>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <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> <Space>
<Button icon={<ReloadOutlined />} onClick={fetchCategories}></Button> <Button icon={<ReloadOutlined />} onClick={fetchCategories}>{t('expenseCategory.refresh')}</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>{t('expenseCategory.add')}</Button>
</Space> </Space>
</div> </div>
<Table <Table
@@ -119,26 +121,26 @@ const ExpenseCategories: React.FC = () => {
</Card> </Card>
<Modal <Modal
title={editingId ? '编辑分类' : '新增分类'} title={editingId ? t('expenseCategory.editCategory') : t('expenseCategory.addCategory')}
open={modalVisible} open={modalVisible}
onOk={handleSave} onOk={handleSave}
onCancel={() => setModalVisible(false)} onCancel={() => setModalVisible(false)}
destroyOnClose destroyOnClose
> >
<Form form={form} layout="vertical"> <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 }))} /> <Select options={LEVEL1_OPTIONS.map(o => ({ value: o.value, label: o.label }))} />
</Form.Item> </Form.Item>
<Form.Item name="category_level2" label="二级编码" rules={[{ required: true, message: '请输入' }]}> <Form.Item name="category_level2" label={t('expenseCategory.level2Code')} rules={[{ required: true, message: t('expenseCategory.inputLevel2') }]}>
<Input placeholder="如 material, salary 等" /> <Input placeholder={t('expenseCategory.codePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="label" label="显示名称" rules={[{ required: true, message: '请输入' }]}> <Form.Item name="label" label={t('expenseCategory.displayName')} rules={[{ required: true, message: t('expenseCategory.inputLevel2') }]}>
<Input placeholder="如 材料采购" /> <Input placeholder={t('expenseCategory.namePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="description" label="说明"> <Form.Item name="description" label={t('expenseCategory.desc')}>
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}> <Form.Item name="sort_order" label={t('expenseCategory.order')} initialValue={0}>
<Input type="number" /> <Input type="number" />
</Form.Item> </Form.Item>
</Form> </Form>
+38 -43
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Card, Typography, Table, Button, Space, Modal, Form, Select, Input, message, Tag, Steps, Divider, Switch, Badge } from 'antd'; 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 { EditOutlined, PlusOutlined, SettingOutlined, CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
const { Option } = Select; const { Option } = Select;
@@ -15,32 +16,30 @@ interface ProcessNode {
} }
const ProcessManagement: React.FC = () => { const ProcessManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [editingNode, setEditingNode] = useState<ProcessNode | null>(null); const [editingNode, setEditingNode] = useState<ProcessNode | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
// 流程节点数据
const [nodes, setNodes] = useState<ProcessNode[]>([ const [nodes, setNodes] = useState<ProcessNode[]>([
{ id: '1', name: '发起申请', role: 'applicant', roleName: '申请人(任意角色)', order: 1, enabled: true }, { id: '1', name: t('processManagement.submitApplication'), role: 'applicant', roleName: t('processManagement.roleApplicant'), order: 1, enabled: true },
{ id: '2', name: '审批', role: 'admin', roleName: '管理员', order: 2, enabled: true }, { id: '2', name: t('processManagement.approvalNode'), role: 'admin', roleName: t('processManagement.roleAdmin'), order: 2, enabled: true },
{ id: '3', name: '执行付款', role: 'admin', roleName: '管理员', order: 3, enabled: true }, { id: '3', name: t('processManagement.executePayment'), role: 'admin', roleName: t('processManagement.roleAdmin'), order: 3, enabled: true },
]); ]);
// 角色选项
const roleOptions = [ const roleOptions = [
{ value: 'applicant', label: '申请人(任意角色)' }, { value: 'applicant', label: t('processManagement.roleApplicant') },
{ value: 'admin', label: '管理员' }, { value: 'admin', label: t('processManagement.roleAdmin') },
{ value: 'finance', label: '财务专员' }, { value: 'finance', label: t('processManagement.roleFinance') },
{ value: 'manager', label: '项目经理' }, { value: 'manager', label: t('processManagement.roleManager') },
]; ];
// 流程类型
const processTypes = [ const processTypes = [
{ key: 'advance', name: '预支申请', description: '员工预支款项申请流程' }, { key: 'advance', name: t('processManagement.advanceProcess'), description: t('processManagement.advanceProcessDesc') },
{ key: 'reimbursement', name: '报销申请', description: '费用报销申请流程' }, { key: 'reimbursement', name: t('processManagement.reimburseProcess'), description: t('processManagement.reimburseProcessDesc') },
{ key: 'payment', name: '付款申请', description: '供应商付款申请流程' }, { key: 'payment', name: t('processManagement.paymentProcess'), description: t('processManagement.paymentProcessDesc') },
{ key: 'verification', name: '核销申请', description: '单据核销申请流程' }, { key: 'verification', name: t('processManagement.verificationProcess'), description: t('processManagement.verificationProcessDesc') },
]; ];
const handleEdit = (node: ProcessNode) => { const handleEdit = (node: ProcessNode) => {
@@ -62,14 +61,14 @@ const ProcessManagement: React.FC = () => {
return n; return n;
}); });
setNodes(updatedNodes); setNodes(updatedNodes);
message.success('节点配置已保存'); message.success(t('processManagement.nodeSaved'));
} }
setModalVisible(false); setModalVisible(false);
}); });
}; };
const getStatusTag = (enabled: boolean) => { 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) => { const getStepStatus = (order: number) => {
@@ -80,15 +79,15 @@ const ProcessManagement: React.FC = () => {
const columns = [ const columns = [
{ {
title: '顺序', title: t('processManagement.sequence'),
dataIndex: 'order', dataIndex: 'order',
key: 'order', key: 'order',
width: 80, width: 80,
render: (v: number) => <Badge count={v} style={{ backgroundColor: '#1890ff' }} /> 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', dataIndex: 'roleName',
key: 'roleName', key: 'roleName',
render: (v: string, r: ProcessNode) => ( render: (v: string, r: ProcessNode) => (
@@ -100,20 +99,20 @@ const ProcessManagement: React.FC = () => {
) )
}, },
{ {
title: '状态', title: t('processManagement.status'),
dataIndex: 'enabled', dataIndex: 'enabled',
key: 'enabled', key: 'enabled',
width: 100, width: 100,
render: (v: boolean) => getStatusTag(v) render: (v: boolean) => getStatusTag(v)
}, },
{ {
title: '操作', title: t('processManagement.action'),
key: 'action', key: 'action',
width: 120, width: 120,
render: (_: any, record: ProcessNode) => ( render: (_: any, record: ProcessNode) => (
<Space> <Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}> <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
{t('processManagement.edit')}
</Button> </Button>
</Space> </Space>
) )
@@ -123,14 +122,13 @@ const ProcessManagement: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 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"> <Paragraph type="secondary">
{t('processManagement.description')}
</Paragraph> </Paragraph>
</div> </div>
{/* 流程图示 */} <Card title={t('processManagement.flowchart')} style={{ marginBottom: 24 }}>
<Card title="当前流程图" style={{ marginBottom: 24 }}>
<Steps current={1} style={{ marginTop: 16 }}> <Steps current={1} style={{ marginTop: 16 }}>
{nodes.filter(n => n.enabled).map((node, index) => ( {nodes.filter(n => n.enabled).map((node, index) => (
<Steps.Step <Steps.Step
@@ -148,13 +146,12 @@ const ProcessManagement: React.FC = () => {
</Steps> </Steps>
<Divider /> <Divider />
<Paragraph type="secondary" style={{ marginBottom: 0 }}> <Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong></Text> <Text strong>{t('processManagement.tipTitle')}</Text>
{t('processManagement.tipContent')}
</Paragraph> </Paragraph>
</Card> </Card>
{/* 节点配置表 */} <Card title={t('processManagement.nodeConfig')}>
<Card title="节点配置">
<Table <Table
columns={columns} columns={columns}
dataSource={nodes} dataSource={nodes}
@@ -164,17 +161,16 @@ const ProcessManagement: React.FC = () => {
/> />
</Card> </Card>
{/* 流程类型说明 */} <Card title={t('processManagement.applicableProcess')} style={{ marginTop: 24 }}>
<Card title="适用流程" style={{ marginTop: 24 }}>
<Table <Table
columns={[ columns={[
{ title: '流程类型', dataIndex: 'name', key: 'name', width: 150 }, { title: t('processManagement.processType'), dataIndex: 'name', key: 'name', width: 150 },
{ title: '说明', dataIndex: 'description', key: 'description' }, { title: t('processManagement.desc'), dataIndex: 'description', key: 'description' },
{ {
title: '状态', title: t('processManagement.status'),
key: 'status', key: 'status',
width: 100, width: 100,
render: () => <Tag color="success"></Tag> render: () => <Tag color="success">{t('processManagement.enabled')}</Tag>
} }
]} ]}
dataSource={processTypes} dataSource={processTypes}
@@ -184,24 +180,23 @@ const ProcessManagement: React.FC = () => {
/> />
</Card> </Card>
{/* 编辑节点弹窗 */}
<Modal <Modal
title={`编辑节点:${editingNode?.name}`} title={t('processManagement.editNode', { name: editingNode?.name || '' })}
open={modalVisible} open={modalVisible}
onCancel={() => setModalVisible(false)} onCancel={() => setModalVisible(false)}
onOk={handleSave} onOk={handleSave}
width={500} width={500}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="节点名称"> <Form.Item label={t('processManagement.nodeName')}>
<Input value={editingNode?.name} disabled /> <Input value={editingNode?.name} disabled />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="role" name="role"
label="执行角色" label={t('processManagement.executeRole')}
rules={[{ required: true, message: '请选择执行角色' }]} rules={[{ required: true, message: t('processManagement.selectRolePlaceholder') }]}
> >
<Select placeholder="选择执行角色"> <Select placeholder={t('processManagement.selectRole')}>
{roleOptions.map(opt => ( {roleOptions.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option> <Option key={opt.value} value={opt.value}>{opt.label}</Option>
))} ))}
@@ -210,7 +205,7 @@ const ProcessManagement: React.FC = () => {
</Form> </Form>
<div style={{ padding: 12, background: '#fffbe6', borderRadius: 6, marginTop: 16 }}> <div style={{ padding: 12, background: '#fffbe6', borderRadius: 6, marginTop: 16 }}>
<Text type="warning"> <Text type="warning">
使 {t('processManagement.warning')}
</Text> </Text>
</div> </div>
</Modal> </Modal>
+63 -61
View File
@@ -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 { 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 { PlusOutlined, CopyOutlined, EditOutlined, DeleteOutlined, EyeOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
const { Option } = Select; const { Option } = Select;
@@ -26,6 +27,7 @@ interface Template {
} }
const ProcessTemplates: React.FC = () => { const ProcessTemplates: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [templates, setTemplates] = useState<Template[]>([]); const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false); const [createModalVisible, setCreateModalVisible] = useState(false);
@@ -46,7 +48,7 @@ const ProcessTemplates: React.FC = () => {
try { try {
const res = await apiClient.get('/process-templates'); const res = await apiClient.get('/process-templates');
if (res.data.success) setTemplates(res.data.data); if (res.data.success) setTemplates(res.data.data);
} catch (e) { message.error('获取模板列表失败'); } } catch (e) { message.error(t('processTemplate.getListFailed')); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
@@ -58,32 +60,32 @@ const ProcessTemplates: React.FC = () => {
setCreateModalVisible(true); setCreateModalVisible(true);
}; };
const handleEdit = (t: Template) => { const handleEdit = (template: Template) => {
form.setFieldsValue({ name: t.name, description: t.description }); form.setFieldsValue({ name: template.name, description: template.description });
setPhases(t.phases || []); setPhases(template.phases || []);
setCurrentStep(1); setCurrentStep(0);
setEditingTemplateId(t.id); setEditingTemplateId(template.id);
setCreateModalVisible(true); setCreateModalVisible(true);
}; };
const handleCopy = async (t: Template) => { const handleCopy = async (template: Template) => {
try { try {
await apiClient.post(`/process-templates/${t.id}/copy`, { name: `${t.name} (副本)` }); await apiClient.post(`/process-templates/${template.id}/copy`, { name: `${template.name} (副本)` });
message.success('复制成功'); message.success(t('processTemplate.copySuccess'));
fetchTemplates(); fetchTemplates();
} catch (e) { message.error('复制失败'); } } catch (e) { message.error(t('processTemplate.copyFailed')); }
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await apiClient.delete(`/process-templates/${id}`); await apiClient.delete(`/process-templates/${id}`);
message.success('删除成功'); message.success(t('processTemplate.deleteSuccess'));
fetchTemplates(); fetchTemplates();
} catch (e) { message.error('删除失败'); } } catch (e) { message.error(t('processTemplate.deleteFailed')); }
}; };
const handleView = (t: Template) => { const handleView = (template: Template) => {
setCurrentTemplate(t); setCurrentTemplate(template);
setViewDrawerVisible(true); setViewDrawerVisible(true);
}; };
@@ -91,19 +93,19 @@ const ProcessTemplates: React.FC = () => {
try { try {
const name = form.getFieldValue('name'); const name = form.getFieldValue('name');
const description = form.getFieldValue('description'); const description = form.getFieldValue('description');
if (!name) { message.warning('请输入模板名称'); setCurrentStep(0); return; } if (!name) { message.warning(t('processTemplate.nameRequired')); setCurrentStep(0); return; }
if (phases.length === 0) { message.warning('请至少添加一个阶段'); setCurrentStep(1); return; } if (phases.length === 0) { message.warning(t('processTemplate.phaseRequired')); setCurrentStep(1); return; }
if (editingTemplateId) { if (editingTemplateId) {
await apiClient.put(`/process-templates/${editingTemplateId}`, { name, description, phases }); await apiClient.put(`/process-templates/${editingTemplateId}`, { name, description, phases });
message.success('模板更新成功'); message.success(t('processTemplate.updateSuccess'));
} else { } else {
await apiClient.post('/process-templates', { name, description, phases }); await apiClient.post('/process-templates', { name, description, phases });
message.success('模板创建成功'); message.success(t('processTemplate.createSuccess'));
} }
setCreateModalVisible(false); setCreateModalVisible(false);
setEditingTemplateId(null); setEditingTemplateId(null);
fetchTemplates(); fetchTemplates();
} catch (e) { message.error('保存失败'); } } catch (e) { message.error(t('common.saveFailed')); }
}; };
const addPhase = () => { const addPhase = () => {
@@ -135,7 +137,7 @@ const ProcessTemplates: React.FC = () => {
depends: values.depends || [], depends: values.depends || [],
sub_items: subItems, sub_items: subItems,
}; };
if (!phaseData.name) { message.warning('阶段名称不能为空'); return; } if (!phaseData.name) { message.warning(t('processTemplate.phaseNameEmpty')); return; }
const newPhases = [...phases]; const newPhases = [...phases];
if (editingPhaseIndex >= 0) { if (editingPhaseIndex >= 0) {
phaseData.order = newPhases[editingPhaseIndex].order; phaseData.order = newPhases[editingPhaseIndex].order;
@@ -162,7 +164,7 @@ const ProcessTemplates: React.FC = () => {
setPhases(newPhases); 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[]) => { const getDependsNames = (depends: number[]) => {
return depends.map(d => { return depends.map(d => {
@@ -172,69 +174,69 @@ const ProcessTemplates: React.FC = () => {
}; };
const columns = [ const columns = [
{ title: '模板名称', dataIndex: 'name', key: 'name', render: (text: string, r: Template) => <Space>{text}{r.is_system && <Tag color="gold"></Tag>}</Space> }, { 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: '阶段数', key: 'phases', render: (_: unknown, r: Template) => r.phases?.length || 0 }, { title: t('processTemplate.phaseCount'), key: 'phases', render: (_: unknown, r: Template) => r.phases?.length || 0 },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true }, { title: t('processTemplate.desc'), dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '操作', key: 'action', render: (_: unknown, r: Template) => ( { title: t('processTemplate.action'), key: 'action', render: (_: unknown, r: Template) => (
<Space> <Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => handleView(r)}></Button> <Button size="small" icon={<EyeOutlined />} onClick={() => handleView(r)}>{t('common.view')}</Button>
<Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(r)}></Button> <Button size="small" type="primary" icon={<EditOutlined />} onClick={() => handleEdit(r)}>{t('common.edit')}</Button>
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(r)}></Button> <Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(r)}>{t('processTemplate.copy')}</Button>
{!r.is_system && <Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}><Button size="small" danger icon={<DeleteOutlined />}></Button></Popconfirm>} {!r.is_system && <Popconfirm title={t('processTemplate.confirmDelete')} onConfirm={() => handleDelete(r.id)}><Button size="small" danger icon={<DeleteOutlined />}>{t('processTemplate.delete')}</Button></Popconfirm>}
</Space> </Space>
)}, )},
]; ];
return ( return (
<div style={{ padding: 24 }}> <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} /> <Table columns={columns} dataSource={templates} rowKey="id" loading={loading} pagination={false} />
</Card> </Card>
<Modal title={editingTemplateId ? "编辑工程模板" : "新建工程模板"} open={createModalVisible} onCancel={() => setCreateModalVisible(false)} width={800} footer={[ <Modal title={editingTemplateId ? t('processTemplate.editTemplate') : t('processTemplate.newTemplate')} open={createModalVisible} onCancel={() => setCreateModalVisible(false)} width={800} footer={[
<Button key="cancel" onClick={() => setCreateModalVisible(false)}></Button>, <Button key="cancel" onClick={() => setCreateModalVisible(false)}>{t('processTemplate.cancel')}</Button>,
currentStep > 0 && <Button key="prev" onClick={() => setCurrentStep(currentStep - 1)}></Button>, currentStep > 0 && <Button key="prev" onClick={() => setCurrentStep(currentStep - 1)}>{t('processTemplate.prev')}</Button>,
currentStep < 2 && <Button key="next" type="primary" onClick={() => { 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); setCurrentStep(currentStep + 1);
}}></Button>, }}>{t('processTemplate.next')}</Button>,
currentStep === 2 && <Button key="save" type="primary" onClick={handleSaveTemplate}>{editingTemplateId ? '保存修改' : '确认创建'}</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 current={currentStep} size="small" style={{ marginBottom: 24 }}>
<Steps.Step title="基本信息" /> <Steps.Step title={t('processTemplate.basicInfo')} />
<Steps.Step title="设计阶段" /> <Steps.Step title={t('processTemplate.designPhase')} />
<Steps.Step title="预览确认" /> <Steps.Step title={t('processTemplate.preview')} />
</Steps> </Steps>
{currentStep === 0 && ( {currentStep === 0 && (
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="name" label="模板名称" rules={[{ required: true }]}> <Form.Item name="name" label={t('processTemplate.templateName')} rules={[{ required: true }]}>
<Input placeholder="例如:配电安装工程" /> <Input placeholder={t('processTemplate.namePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="description" label="模板描述"> <Form.Item name="description" label={t('processTemplate.templateDescription')}>
<TextArea rows={3} placeholder="描述该模板适用的工程类型" /> <TextArea rows={3} placeholder={t('processTemplate.descPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
)} )}
{currentStep === 1 && ( {currentStep === 1 && (
<div> <div>
{phases.length === 0 ? <Empty description="暂无阶段,请点击下方添加" /> : ( {phases.length === 0 ? <Empty description={t('processTemplate.noPhase')} /> : (
<List bordered dataSource={phases} renderItem={(phase, index) => ( <List bordered dataSource={phases} renderItem={(phase, index) => (
<List.Item actions={[ <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={<ArrowUpOutlined />} onClick={() => movePhase(index, 'up')} disabled={index === 0} />,
<Button size="small" icon={<ArrowDownOutlined />} onClick={() => movePhase(index, 'down')} disabled={index === phases.length - 1} />, <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 <List.Item.Meta
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)} <Text type="secondary">{getDependsNames(phase.depends)}</Text></Space>} 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('、')}` : '无子项'} description={phase.sub_items?.length > 0 ? `子项:${phase.sub_items.join('、')}` : t('processTemplate.emptySubItems')}
/> />
</List.Item> </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> </div>
)} )}
@@ -247,7 +249,7 @@ const ProcessTemplates: React.FC = () => {
<List.Item.Meta <List.Item.Meta
title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)}</Space>} title={<Space>{phase.order}. {phase.name} {getTypeTag(phase.type)}</Space>}
description={<> 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>} {phase.sub_items?.length > 0 && <Text>{phase.sub_items.join('、')}</Text>}
</>} </>}
/> />
@@ -257,26 +259,26 @@ const ProcessTemplates: React.FC = () => {
)} )}
</Modal> </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 form={phaseForm} layout="vertical">
<Form.Item name="name" label="阶段名称" rules={[{ required: true }]}> <Form.Item name="name" label={t('processTemplate.phaseName')} rules={[{ required: true }]}>
<Input placeholder="例如:物资采购" /> <Input placeholder={t('processTemplate.phaseNamePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="type" label="阶段类型"> <Form.Item name="type" label={t('processTemplate.phaseType')}>
<Radio.Group> <Radio.Group>
<Radio value="serial"></Radio> <Radio value="serial">{t('processTemplate.serial')}</Radio>
<Radio value="parallel"></Radio> <Radio value="parallel">{t('processTemplate.parallel')}</Radio>
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
<Form.Item name="depends" label="依赖关系(哪些阶段完成后才能开始)"> <Form.Item name="depends" label={t('processTemplate.dependency')}>
<Checkbox.Group> <Checkbox.Group>
{phases.filter((_, i) => i !== editingPhaseIndex).map(p => ( {phases.filter((_, i) => i !== editingPhaseIndex).map(p => (
<Checkbox key={p.order} value={p.order} style={{ display: 'block' }}>{p.order}. {p.name}</Checkbox> <Checkbox key={p.order} value={p.order} style={{ display: 'block' }}>{p.order}. {p.name}</Checkbox>
))} ))}
</Checkbox.Group> </Checkbox.Group>
</Form.Item> </Form.Item>
<Form.Item name="sub_items_text" label="子项列表(每行一个)"> <Form.Item name="sub_items_text" label={t('processTemplate.subItems')}>
<TextArea rows={6} placeholder={"电杆采购\n变压器采购\n电缆采购"} /> <TextArea rows={6} placeholder={t('processTemplate.subItemsPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>
@@ -288,9 +290,9 @@ const ProcessTemplates: React.FC = () => {
<List bordered dataSource={currentTemplate.phases || []} renderItem={(phase: PhaseItem) => ( <List bordered dataSource={currentTemplate.phases || []} renderItem={(phase: PhaseItem) => (
<List.Item> <List.Item>
<List.Item.Meta <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={<> 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>} {phase.sub_items?.length > 0 && <Text>{phase.sub_items.join('、')}</Text>}
</>} </>}
/> />
@@ -1,106 +1,67 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Table, Card, Tag, Button, Space, Input, DatePicker, Select, message, Descriptions, Divider, Tabs, Modal, Result } from 'antd'; import { Card, Table, Tag, Button, Space, Modal, Form, Input, Select, DatePicker, message, Descriptions, Divider, List } from 'antd';
import { EyeOutlined, CalendarOutlined, UserOutlined, FilterOutlined } from '@ant-design/icons'; import { SearchOutlined, EyeOutlined, CheckOutlined, CloseOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Option } = Select; const { TextArea } = Input;
const { RangePicker } = DatePicker;
const AdvanceVerificationStatusPage: React.FC = () => { const AdvanceVerificationStatusPage: React.FC = () => {
const { user } = useAuthStore(); const { user } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false); const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedAdvance, setSelectedAdvance] = useState<any>(null); const [selectedRecord, setSelectedRecord] = useState<any>(null);
const [unsettledAdvances, setUnsettledAdvances] = useState<any[]>([]); const [fullDetail, setFullDetail] = useState<any>(null);
const [settledAdvances, setSettledAdvances] = useState<any[]>([]); const [form] = Form.useForm();
const [searchName, setSearchName] = useState(''); const [projects, setProjects] = useState<any[]>([]);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [sortField, setSortField] = useState('');
const [sortOrder, setSortOrder] = useState('');
// 检查权限 // 所有核销记录
const hasPermission = user?.role === 'admin' || user?.department === '财务部'; const [verifications, setVerifications] = useState<any[]>([]);
if (!hasPermission) { // 筛选条件
return ( const [searchCode, setSearchCode] = useState('');
<div style={{ padding: 24, textAlign: 'center' }}> const [searchApplicant, setSearchApplicant] = useState('');
<Result const [filterStatus, setFilterStatus] = useState<string>('all');
status="403" const [filterSettlement, setFilterSettlement] = useState<string>('all');
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);
}
};
// 加载数据
useEffect(() => { 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 formatAmount = (amount: number, currency: string = 'CNY') => {
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' }; const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 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 getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' }, pending: { color: 'processing', text: t('advanceVerification.pendingApproval') },
approved: { color: 'success', text: '已批准' }, approved: { color: 'success', text: t('advanceVerification.approved') },
rejected: { color: 'error', text: '已退回' }, rejected: { color: 'error', text: t('advanceVerification.rejected') },
withdrawn: { color: 'default', text: '已撤回' }, pending_edit: { color: 'warning', text: t('advanceVerification.pendingEdit') },
settled: { color: 'blue', text: '已核销' }, executed: { color: 'blue', text: t('advanceVerification.verified') },
pending_edit: { color: 'warning', text: '待编辑' },
partial_verification: { color: 'orange', text: '部分核销' },
completed: { color: 'green', text: '已完成' },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
}; };
const handleSearch = () => { const getSettlementTag = (settlement: boolean) => {
// 这里可以添加搜索逻辑 return settlement ?
fetchAdvances(); <Tag color="green">{t('advanceVerification.settlement')}</Tag> :
<Tag color="orange">{t('advanceVerification.nonSettlement')}</Tag>;
}; };
const handleSort = (field: string, order: string) => { // 根据项目ID获取项目名称
setSortField(field); const getProjectName = (projectId: any) => {
setSortOrder(order); if (!projectId) return '-';
// 这里可以添加排序逻辑 const project = projects.find(p => p.id === projectId);
fetchAdvances(); 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 = [ 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: '事由', title: t('advanceVerification.action'), key: 'action', width: 140,
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,
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<Space wrap> <Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => fetchAdvanceDetail(record.id)}></Button> <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> </Space>
) )
} }
]; ];
// 渲染附件列表
const renderAttachments = (attachments: any) => {
if (!attachments || !Array.isArray(attachments) || attachments.length === 0) return null;
return ( 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 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedAdvance.attachments.map((url: string, index: number) => ( {attachments.map((url: string, index: number) => (
<div key={index} style={{ position: 'relative' }}>
{url && url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? (
<img <img
key={index}
src={url} src={url}
alt={`附件${index + 1}`} alt={`附件${index + 1}`}
style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }} style={{ width: 120, height: 120, objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0', cursor: 'pointer' }}
onClick={() => window.open(url, '_blank')} 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> </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> </Modal>
</Card>
</div> </div>
); );
}; };
+110 -90
View File
@@ -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 { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined, UndoOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
import FileUpload from '../../components/FileUpload'; import FileUpload from '../../components/FileUpload';
import useFormDraft from '../../hooks/useFormDraft'; import useFormDraft from '../../hooks/useFormDraft';
@@ -11,6 +12,7 @@ const { TextArea } = Input;
const AdvancesPage: React.FC = () => { const AdvancesPage: React.FC = () => {
const { user } = useAuthStore(); const { user } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const [advances, setAdvances] = useState<any[]>([]); const [advances, setAdvances] = useState<any[]>([]);
const [completedAdvances, setCompletedAdvances] = useState<any[]>([]); const [completedAdvances, setCompletedAdvances] = useState<any[]>([]);
const [activeTab, setActiveTab] = useState('active'); const [activeTab, setActiveTab] = useState('active');
@@ -54,7 +56,7 @@ const AdvancesPage: React.FC = () => {
setCompletedAdvances(completed); setCompletedAdvances(completed);
} }
} catch (error) { } catch (error) {
message.error('获取预支列表失败'); message.error(t('advance.getListFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -88,17 +90,19 @@ const AdvancesPage: React.FC = () => {
form.setFieldsValue({ form.setFieldsValue({
advance_date: dayjs(), advance_date: dayjs(),
currency: 'CNY', currency: 'CNY',
applicant: user?.name || user?.username || '当前用户', applicant: user?.name || user?.username || t('common.currentUser'),
attachments: [] attachments: []
}); });
setWatchedAmount(null);
setWatchedCurrency('CNY');
setModalVisible(true); setModalVisible(true);
setTimeout(() => { setTimeout(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的预支申请,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft(); restoreDraft();
}, },
@@ -108,7 +112,7 @@ const AdvancesPage: React.FC = () => {
form.setFieldsValue({ form.setFieldsValue({
advance_date: dayjs(), advance_date: dayjs(),
currency: 'CNY', currency: 'CNY',
applicant: user?.name || user?.username || '当前用户', applicant: user?.name || user?.username || t('common.currentUser'),
attachments: [] attachments: []
}); });
}, },
@@ -125,6 +129,8 @@ const AdvancesPage: React.FC = () => {
advance_date: record.advance_date ? dayjs(record.advance_date) : null, advance_date: record.advance_date ? dayjs(record.advance_date) : null,
attachments: record.attachments || [] attachments: record.attachments || []
}); });
setWatchedAmount(record.amount || null);
setWatchedCurrency(record.currency || 'CNY');
setModalVisible(true); setModalVisible(true);
}; };
@@ -136,10 +142,10 @@ const AdvancesPage: React.FC = () => {
setSelectedRecord(data.data); setSelectedRecord(data.data);
setDetailModalVisible(true); setDetailModalVisible(true);
} else { } else {
message.error('获取详情失败'); message.error(t('advance.getDetailFailed'));
} }
} catch (error) { } catch (error) {
message.error('获取详情失败'); message.error(t('advance.getDetailFailed'));
} }
}; };
@@ -148,15 +154,15 @@ const AdvancesPage: React.FC = () => {
deleteForm.resetFields(); deleteForm.resetFields();
Modal.confirm({ Modal.confirm({
title: '确认删除', title: t('common.deleteConfirm'),
content: ( content: (
<Form form={deleteForm} layout="vertical"> <Form form={deleteForm} layout="vertical">
<Form.Item <Form.Item
name="password" name="password"
label="请输入密码确认删除" label={t('advance.deletePassInput')}
rules={[{ required: true, message: '请输入密码' }]} rules={[{ required: true, message: t('common.inputPassword') }]}
> >
<Input.Password placeholder="输入密码" /> <Input.Password placeholder={t('advance.deletePassPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
), ),
@@ -165,10 +171,10 @@ const AdvancesPage: React.FC = () => {
const values = await deleteForm.validateFields(); const values = await deleteForm.validateFields();
// 这里可以添加密码验证逻辑,暂时直接删除 // 这里可以添加密码验证逻辑,暂时直接删除
await fetch('/api/advances/' + id, { method: 'DELETE' }); await fetch('/api/advances/' + id, { method: 'DELETE' });
message.success('删除成功'); message.success(t('advance.deleteSuccess'));
fetchAdvances(); fetchAdvances();
} catch (error) { } catch (error) {
message.error('删除失败'); message.error(t('advance.deleteFailed'));
} }
} }
}); });
@@ -176,15 +182,15 @@ const AdvancesPage: React.FC = () => {
const handleWithdraw = async (id: number) => { const handleWithdraw = async (id: number) => {
Modal.confirm({ Modal.confirm({
title: '确认撤回', title: t('advance.withdrawConfirm'),
content: '撤回后可重新编辑提交,确认撤回吗?', content: t('advance.withdrawConfirmMsg'),
onOk: async () => { onOk: async () => {
try { try {
await fetch('/api/advances/' + id + '/withdraw', { method: 'POST' }); await fetch('/api/advances/' + id + '/withdraw', { method: 'POST' });
message.success('已撤回,可重新编辑'); message.success(t('advance.withdrawSuccess'));
fetchAdvances(); fetchAdvances();
} catch (error) { } catch (error) {
message.error('撤回失败'); message.error(t('advance.withdrawFailed'));
} }
} }
}); });
@@ -212,16 +218,16 @@ const AdvancesPage: React.FC = () => {
}); });
const result = await res.json(); const result = await res.json();
if (result.success) { if (result.success) {
message.success(editingId ? '保存成功' : '创建成功'); message.success(editingId ? t('advance.saveSuccess') : t('advance.createSuccess'));
clearDraft(); clearDraft();
setModalVisible(false); setModalVisible(false);
fetchAdvances(); fetchAdvances();
} else { } else {
message.error(result.error || '保存失败'); message.error(result.error || t('common.saveFailed'));
} }
} catch (error) { } catch (error) {
console.error('保存操作 - 错误:', error); console.error('保存操作 - 错误:', error);
message.error('保存失败'); message.error(t('common.saveFailed'));
} }
}; };
@@ -247,16 +253,16 @@ const AdvancesPage: React.FC = () => {
}); });
const result = await res.json(); const result = await res.json();
if (result.success) { if (result.success) {
message.success(editingId ? '提交成功' : '创建成功'); message.success(editingId ? t('advance.submitSuccess') : t('advance.createSuccess'));
clearDraft(); clearDraft();
setModalVisible(false); setModalVisible(false);
fetchAdvances(); fetchAdvances();
} else { } else {
message.error(result.error || '提交失败'); message.error(result.error || t('common.saveFailed'));
} }
} catch (error) { } catch (error) {
console.error('提交操作 - 错误:', 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 => { const convertToCNY = (amount: number, currency: string): number => {
if (currency === 'CNY') return amount; if (currency === 'CNY') return amount;
const rateKey = 'CNY_' + currency; const rateKey = currency + '_CNY';
const rate = exchangeRates[rateKey] || 1; if (exchangeRates[rateKey]) return amount * exchangeRates[rateKey];
return amount / rate; 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); // 使用 useState 替代 Form.useWatch 以确保稳定触发
const currency = Form.useWatch('currency', form); const [watchedAmount, setWatchedAmount] = useState<number | null>(null);
const [watchedCurrency, setWatchedCurrency] = useState<string>('CNY');
const amountCNY = React.useMemo(() => { const amountCNY = React.useMemo(() => {
return amount && currency ? convertToCNY(amount, currency) : 0; return watchedAmount && watchedCurrency ? convertToCNY(watchedAmount, watchedCurrency) : 0;
}, [amount, currency, exchangeRates]); }, [watchedAmount, watchedCurrency, exchangeRates]);
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' }, pending: { color: 'processing', text: t('advance.pendingApproval') },
approved: { color: 'success', text: '已批准' }, approved: { color: 'success', text: t('advance.approved') },
rejected: { color: 'error', text: '已退回' }, rejected: { color: 'error', text: t('advance.rejected') },
withdrawn: { color: 'default', text: '已撤回' }, withdrawn: { color: 'default', text: t('advance.withdrawn') },
settled: { color: 'blue', text: '已核销' }, settled: { color: 'blue', text: t('advance.verified') },
pending_edit: { color: 'warning', text: '待编辑' }, pending_edit: { color: 'warning', text: t('advance.pendingEdit') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -311,31 +326,31 @@ const AdvancesPage: React.FC = () => {
}; };
const columns = [ const columns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleView(r)}>{v}</a> }, { title: t('advance.subject'), 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: t('advance.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => ( { title: t('advance.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<> <>
<div>{formatAmount(v, r.currency)}</div> <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>} {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: t('advance.advanceDate'), dataIndex: 'advance_date', key: 'advance_date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) }, { title: t('advance.status'), dataIndex: 'status', key: 'status', width: 100, render: (status: string) => getStatusTag(status) },
{ title: '编号', dataIndex: 'advance_code', key: 'advance_code', width: 120 }, { 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) => ( render: (_: any, record: any) => (
<Space wrap> <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' && ( {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') && ( {(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> </Space>
) )
} }
@@ -344,16 +359,16 @@ const AdvancesPage: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<h2 style={{ marginBottom: 8 }}></h2> <h2 style={{ marginBottom: 8 }}>{t('advance.title')}</h2>
<p style={{ color: '#888', marginBottom: 0 }}></p> <p style={{ color: '#888', marginBottom: 0 }}>{t('advance.description')}</p>
</div> </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 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 }} /> <Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Tabs.TabPane> </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 }} /> <Table dataSource={completedAdvances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
@@ -361,21 +376,23 @@ const AdvancesPage: React.FC = () => {
{/* 新建/编辑弹窗 */} {/* 新建/编辑弹窗 */}
<Modal <Modal
title={editingId ? '编辑预支' : '新建预支'} title={editingId ? t('advance.editAdvance') : t('advance.newAdvance')}
open={modalVisible} open={modalVisible}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft(); saveDraft();
form.resetFields();
setModalVisible(false); setModalVisible(false);
}, },
}); });
} else { } else {
form.resetFields();
setModalVisible(false); setModalVisible(false);
} }
}} }}
@@ -383,69 +400,72 @@ const AdvancesPage: React.FC = () => {
<Button key="cancel" onClick={() => { <Button key="cancel" onClick={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft(); saveDraft();
form.resetFields();
setModalVisible(false); setModalVisible(false);
}, },
}); });
} else { } else {
form.resetFields();
setModalVisible(false); setModalVisible(false);
} }
}}></Button>, }}>{t('common.cancel')}</Button>,
<Button key="save" onClick={handleSave}></Button>, <Button key="save" onClick={handleSave}>{t('common.save')}</Button>,
<Button key="submit" type="primary" onClick={handleSubmitAndSubmit}></Button> <Button key="submit" type="primary" onClick={handleSubmitAndSubmit}>{t('common.submit')}</Button>
]} ]}
destroyOnClose
maskClosable={false} maskClosable={false}
width={700} width={700}
> >
<Form form={form} layout="vertical" onValuesChange={handleFormChange}> <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' }} /> <Input disabled style={{ color: 'rgba(0,0,0,0.85)', backgroundColor: '#f5f5f5' }} />
</Form.Item> </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%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item label={t('advance.amount')} required>
<Form.Item label="金额" required>
<Space> <Space>
<Form.Item name="currency" noStyle initialValue="CNY"> <Form.Item name="currency" noStyle initialValue="CNY">
<Select style={{ width: 140 }}> <Select style={{ width: 140 }} onChange={(value: string) => setWatchedCurrency(value)}>
<Option value="CNY"> (CNY)</Option> <Option value="CNY">{t('advance.currencyCNY')}</Option>
<Option value="USD"> (USD)</Option> <Option value="USD">{t('advance.currencyUSD')}</Option>
<Option value="LAK"> (LAK)</Option> <Option value="LAK">{t('advance.currencyLAK')}</Option>
<Option value="THB"> (THB)</Option> <Option value="THB">{t('advance.currencyTHB')}</Option>
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}> <Form.Item name="amount" noStyle rules={[{ required: true, message: t('advance.inputAmount') }]}>
<InputNumber <InputNumber
style={{ width: 200 }} style={{ width: 200 }}
min={0} min={0}
precision={2} precision={2}
placeholder="输入金额" placeholder={t('advance.amountPlaceholder')}
formatter={(value) => formatNumberWithSeparator(value as number, currency || 'CNY')} formatter={(value) => formatNumberWithSeparator(value as number, watchedCurrency || 'CNY')}
parser={(value) => parseFormattedNumber(value || '0')} parser={(value) => parseFormattedNumber(value || '0')}
onChange={(value) => setWatchedAmount(value)}
/> />
</Form.Item> </Form.Item>
</Space> </Space>
{amountCNY > 0 && ( {amountCNY > 0 && (
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}> <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> </div>
)} )}
</Form.Item> </Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}> <Form.Item name="reason" label={t('advance.subject')} rules={[{ required: true }]}>
<TextArea rows={3} placeholder="请输入预支事由" /> <TextArea rows={3} placeholder={t('advance.subjectPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="attachments" label="凭证附件"> <Form.Item name="attachments" label={t('advance.attachment')}>
<FileUpload <FileUpload
value={form.getFieldValue('attachments')} value={form.getFieldValue('attachments')}
onChange={(urls) => form.setFieldsValue({ attachments: urls })} onChange={(urls) => form.setFieldsValue({ attachments: urls })}
@@ -457,27 +477,27 @@ const AdvancesPage: React.FC = () => {
</Modal> </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 && ( {selectedRecord && (
<> <>
<Descriptions bordered column={2} size="small"> <Descriptions bordered column={2} size="small">
<Descriptions.Item label="预支编号">{selectedRecord.advance_code}</Descriptions.Item> <Descriptions.Item label={t('advance.advanceCode')}>{selectedRecord.advance_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item> <Descriptions.Item label={t('advance.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item> <Descriptions.Item label={t('advance.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="预支日期">{selectedRecord.advance_date}</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)} {formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && ( {selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span> <span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item> <Descriptions.Item label={t('advance.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions> </Descriptions>
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && ( {Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
<> <>
<Divider></Divider> <Divider>{t('advance.attachment')}</Divider>
<Image.PreviewGroup> <Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => ( {selectedRecord.attachments.map((url: string, index: number) => (
+168 -156
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; 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 { 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 { CheckOutlined, CloseOutlined, EyeOutlined, EditOutlined, UndoOutlined, FileImageOutlined, DeleteOutlined } from '@ant-design/icons';
import { useLanguageStore } from '../../store/languageStore';
const { TextArea } = Input; const { TextArea } = Input;
@@ -25,7 +26,17 @@ const COMPANY_EXPENSE_CATEGORIES = [
{ value: 'other', label: '其他支出' } { 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 ApprovalManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false); const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false); const [editModalVisible, setEditModalVisible] = useState(false);
@@ -79,56 +90,56 @@ const ApprovalManagement: React.FC = () => {
switch (type) { switch (type) {
case 'advances': case 'advances':
typeText = '预支申请'; typeText = t('approval.advanceApply');
code = item.advance_code; code = item.advance_code;
date = item.advance_date; date = item.advance_date;
// 根据预支申请的状态设置操作文本 // 根据预支申请的状态设置操作文本
switch (item.status) { switch (item.status) {
case 'pending': case 'pending':
action = '待审批'; action = t('approval.pendingApproval');
break; break;
case 'approved': case 'approved':
action = '通过'; action = t('approval.approved');
break; break;
case 'rejected': case 'rejected':
action = '退回'; action = t('approval.rejected');
break; break;
case 'executed': case 'executed':
action = '已执行'; action = t('approval.executed');
break; break;
case 'partial_verification': case 'partial_verification':
action = '部分核销'; action = t('approval.partialVerified');
break; break;
case 'completed': case 'completed':
action = '已完结'; action = t('approval.completed');
break; break;
default: default:
action = item.status; action = item.status;
} }
break; break;
case 'reimbursements': case 'reimbursements':
typeText = '报销申请'; typeText = t('approval.reimburseApply');
code = item.reimbursement_code; code = item.reimbursement_code;
date = item.reimbursement_date; date = item.reimbursement_date;
action = item.status === 'approved' ? '通过' : '退回'; action = item.status === 'approved' ? t('approval.approved') : t('approval.rejected');
break; break;
case 'payment-requests': case 'payment-requests':
typeText = '付款申请'; typeText = t('approval.paymentApply');
code = item.request_code; code = item.request_code;
date = item.payment_date; date = item.payment_date;
action = item.status === 'approved' ? '通过' : '退回'; action = item.status === 'approved' ? t('approval.approved') : t('approval.rejected');
break; break;
case 'verifications': case 'verifications':
typeText = '核销申请'; typeText = t('approval.verificationApply');
code = item.verification_code; code = item.verification_code;
date = item.verification_date; 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; break;
case 'purchase-requests': case 'purchase-requests':
typeText = '采购申请'; typeText = t('approval.purchaseApply');
code = item.request_code; code = item.request_code;
date = item.request_date; 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; break;
} }
@@ -140,7 +151,7 @@ const ApprovalManagement: React.FC = () => {
amount: type === 'purchase-requests' ? item.total_amount : item.amount, amount: type === 'purchase-requests' ? item.total_amount : item.amount,
currency: item.currency, currency: item.currency,
action: action, action: action,
operator: '系统管理员', // 实际应该从数据库中获取 operator: t('common.systemAdmin'), // 实际应该从数据库中获取
remark: item.approval_remark || '', remark: item.approval_remark || '',
timestamp: date timestamp: date
}); });
@@ -152,7 +163,7 @@ const ApprovalManagement: React.FC = () => {
setApprovalHistory(historyData); setApprovalHistory(historyData);
} catch (error) { } catch (error) {
console.error('获取审批历史记录失败:', error); console.error('获取审批历史记录失败:', error);
message.error('获取审批历史记录失败'); message.error(t('approval.getHistoryFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -207,7 +218,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({ allPendingData.push({
key: `adv-${item.id}`, key: `adv-${item.id}`,
id: item.id, id: item.id,
type: '预支申请', type: t('approval.advanceApply'),
code: item.advance_code, code: item.advance_code,
applicant: item.applicant, applicant: item.applicant,
amount: item.amount, amount: item.amount,
@@ -228,7 +239,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({ allPendingData.push({
key: `reimb-${item.id}`, key: `reimb-${item.id}`,
id: item.id, id: item.id,
type: '报销申请', type: t('approval.reimburseApply'),
code: item.reimbursement_code, code: item.reimbursement_code,
applicant: item.applicant, applicant: item.applicant,
amount: item.amount, amount: item.amount,
@@ -249,7 +260,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({ allPendingData.push({
key: `pay-${item.id}`, key: `pay-${item.id}`,
id: item.id, id: item.id,
type: '付款申请', type: t('approval.paymentApply'),
code: item.request_code, code: item.request_code,
applicant: item.applicant, applicant: item.applicant,
amount: item.amount, amount: item.amount,
@@ -270,7 +281,7 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({ allPendingData.push({
key: `ver-${item.id}`, key: `ver-${item.id}`,
id: item.id, id: item.id,
type: '核销申请', type: t('approval.verificationApply'),
code: item.verification_code, code: item.verification_code,
applicant: item.applicant, applicant: item.applicant,
amount: item.amount, amount: item.amount,
@@ -291,13 +302,13 @@ const ApprovalManagement: React.FC = () => {
allPendingData.push({ allPendingData.push({
key: `pur-${item.id}`, key: `pur-${item.id}`,
id: item.id, id: item.id,
type: '采购申请', type: t('approval.purchaseApply'),
code: item.request_code, code: item.request_code,
applicant: item.applicant, applicant: item.applicant,
amount: item.total_amount, amount: item.total_amount,
currency: item.currency, currency: item.currency,
date: item.request_date, date: item.request_date,
reason: item.brief_description || item.remark || '采购申请', reason: item.brief_description || item.remark || t('approval.purchaseApply'),
status: item.status, status: item.status,
rawData: item rawData: item
}); });
@@ -308,7 +319,7 @@ const ApprovalManagement: React.FC = () => {
setPendingData(allPendingData); setPendingData(allPendingData);
} catch (error) { } catch (error) {
console.error('获取待审批数据失败:', error); console.error('获取待审批数据失败:', error);
message.error('获取待审批数据失败'); message.error(t('approval.getPendingFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -323,16 +334,17 @@ const ApprovalManagement: React.FC = () => {
// 获取类型标签 // 获取类型标签
const getTypeTag = (type: string) => { const getTypeTag = (type: string) => {
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple', '采购申请': 'cyan' }; 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 getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' }, pending: { color: 'processing', text: t('approval.pendingApproval') },
approved: { color: 'success', text: '已通过' }, approved: { color: 'success', text: t('approval.approved') },
rejected: { color: 'error', text: '已退回' }, rejected: { color: 'error', text: t('approval.rejected') },
withdrawn: { color: 'default', text: '已撤回' } withdrawn: { color: 'default', text: t('approval.withdrawn') }
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -352,7 +364,7 @@ const ApprovalManagement: React.FC = () => {
form.resetFields(); form.resetFields();
// 获取完整详情 // 获取完整详情
if (record.type === '采购申请') { if (record.type === t('approval.purchaseApply')) {
try { try {
const response = await fetch(`/api/purchase-requests/${record.id}`); const response = await fetch(`/api/purchase-requests/${record.id}`);
const data = await response.json(); const data = await response.json();
@@ -401,14 +413,14 @@ const ApprovalManagement: React.FC = () => {
if (result.success) { if (result.success) {
// 从待审批列表中移除该申请 // 从待审批列表中移除该申请
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key)); setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`审批通过:${selectedRecord.code}`); message.success(t('approval.approveSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false); setDetailModalVisible(false);
} else { } else {
message.error(result.message || '操作失败'); message.error(result.message || t('common.operationFailed'));
} }
} catch (error) { } catch (error) {
console.error('审批操作失败:', error); console.error('审批操作失败:', error);
message.error('操作失败'); message.error(t('common.operationFailed'));
} }
}; };
@@ -443,27 +455,27 @@ const ApprovalManagement: React.FC = () => {
if (result.success) { if (result.success) {
// 从待审批列表中移除该申请 // 从待审批列表中移除该申请
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key)); setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`已退回:${selectedRecord.code}`); message.success(t('approval.rejectSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false); setDetailModalVisible(false);
} else { } else {
message.error(result.message || '操作失败'); message.error(result.message || t('common.operationFailed'));
} }
} catch (error) { } catch (error) {
console.error('审批操作失败:', error); console.error('审批操作失败:', error);
message.error('操作失败'); message.error(t('common.operationFailed'));
} }
}; };
// 处理撤回申请 // 处理撤回申请
const handleWithdraw = (record: any) => { const handleWithdraw = (record: any) => {
Modal.confirm({ Modal.confirm({
title: '撤回申请', title: t('approval.withdrawConfirm'),
content: `确认撤回申请 ${record.code} 吗?`, content: t('approval.withdrawConfirmMsg', { code: record.code }),
okText: '确认撤回', okText: t('approval.withdrawConfirmBtn'),
cancelText: '取消', cancelText: t('common.cancel'),
onOk: () => { onOk: () => {
setPendingData(pendingData.filter(item => item.key !== record.key)); 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 = () => { const handleEditSubmit = () => {
editForm.validateFields().then(values => { editForm.validateFields().then(values => {
message.success('修改成功,已重新提交审批'); message.success(t('approval.editResubmit'));
setEditModalVisible(false); setEditModalVisible(false);
}); });
}; };
@@ -544,22 +556,22 @@ const ApprovalManagement: React.FC = () => {
// 特殊分类映射 // 特殊分类映射
const specialCategories: Record<string, string> = { const specialCategories: Record<string, string> = {
// Project expense categories // Project expense categories
accommodation: '住宿', accommodation: t('approval.accommodation'),
food: '餐饮', food: t('approval.catering'),
fuel: '加油', fuel: t('approval.fuel'),
materials: '零散材料', materials: t('approval.scatteredMaterial'),
customer_relations: '客户关系', customer_relations: t('approval.customerRelation'),
subcontract_relations: '分包关系', subcontract_relations: t('approval.subcontractorRelation'),
edl_relations: 'EDL关系', edl_relations: t('approval.EDLRelation'),
extra_construction: '额外施工', extra_construction: t('approval.extraConstruction'),
// Company expense categories // Company expense categories
general_operations: '通用运营(房租/耗材)', general_operations: t('approval.generalOperation'),
transportation: '交通通勤', transportation: t('approval.commute'),
business_expansion: '业扩营销', business_expansion: t('approval.marketing'),
power_system_relations: '电力系统关系', power_system_relations: t('approval.powerSystem'),
employee_benefits: '员工福利', employee_benefits: t('approval.employeeBenefit'),
express_logistics: '快递物流', express_logistics: t('approval.expressLogistics'),
other: '其他' other: t('approval.other')
}; };
// 先检查特殊分类 // 先检查特殊分类
if (specialCategories[category]) return specialCategories[category]; if (specialCategories[category]) return specialCategories[category];
@@ -597,17 +609,17 @@ const ApprovalManagement: React.FC = () => {
<List.Item> <List.Item>
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}> <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> <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
</div> </div>
{item.category && ( {item.category && (
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}> <div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
<strong></strong>{getCategoryName(item.category)} <strong>{t('approval.categoryLabel')}</strong>{getCategoryName(item.category)}
</div> </div>
)} )}
{item.attachments && ( {item.attachments && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<span style={{ color: '#666', fontSize: 12 }}></span> <span style={{ color: '#666', fontSize: 12 }}>{t('approval.detailAttachment')}</span>
{renderAttachments(item.attachments)} {renderAttachments(item.attachments)}
</div> </div>
)} )}
@@ -620,18 +632,18 @@ const ApprovalManagement: React.FC = () => {
// 待审批列 // 待审批列
const pendingColumns = [ const pendingColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> }, { title: t('approval.subject'), 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: t('approval.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 }, { title: t('approval.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) }, { title: t('approval.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: '申请日期', dataIndex: 'date', key: 'date', width: 100 }, { title: t('approval.applicationDate'), dataIndex: 'date', key: 'date', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) }, { title: t('approval.status'), dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 }, { 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) => ( render: (_: any, record: any) => (
<Space> <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> </Space>
) )
} }
@@ -641,49 +653,49 @@ const ApprovalManagement: React.FC = () => {
// 审批记录列 // 审批记录列
const historyColumns = [ const historyColumns = [
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp', width: 140 }, { title: t('approval.time'), dataIndex: 'timestamp', key: 'timestamp', width: 140 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 100 }, { title: t('approval.operation'), dataIndex: 'action', key: 'action', width: 100 },
{ title: '申请编号', dataIndex: 'applyCode', key: 'applyCode', width: 140 }, { title: t('approval.applicationCode'), dataIndex: 'applyCode', key: 'applyCode', width: 140 },
{ title: '类型', dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) }, { title: t('approval.type'), dataIndex: 'applyType', key: 'applyType', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 }, { title: t('approval.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) }, { title: t('approval.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 }, { title: t('approval.operator'), dataIndex: 'operator', key: 'operator', width: 100 },
{ title: '备注/原因', dataIndex: 'remark', key: 'remark', ellipsis: true } { title: t('approval.note'), dataIndex: 'remark', key: 'remark', ellipsis: true }
]; ];
const tabItems = [ 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: '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> <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: '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 ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}> <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}> <Button type="primary" onClick={fetchPendingData} loading={loading}>
{t('approval.refresh')}
</Button> </Button>
</div> </div>
<p style={{ color: '#888', marginBottom: 0 }}></p> <p style={{ color: '#888', marginBottom: 0 }}>{t('approval.description')}</p>
</div> </div>
<Card><Tabs items={tabItems} /></Card> <Card><Tabs items={tabItems} /></Card>
{/* 详情模态框 */} {/* 详情模态框 */}
<Modal <Modal
title={`${selectedRecord?.type}详情:${selectedRecord?.code}`} title={t('approval.detailTitle', { type: selectedRecord?.type || '', code: selectedRecord?.code || '' })}
open={detailModalVisible} open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)} onCancel={() => setDetailModalVisible(false)}
width={900} width={900}
footer={ footer={
selectedRecord?.status === 'pending' ? ( selectedRecord?.status === 'pending' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setDetailModalVisible(false)}></Button> <Button onClick={() => setDetailModalVisible(false)}>{t('common.cancel')}</Button>
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退</Button> <Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('approval.reject')}</Button>
<Button type="primary" icon={<CheckOutlined />} onClick={handleApprove}></Button> <Button type="primary" icon={<CheckOutlined />} onClick={handleApprove}>{t('approval.pass')}</Button>
</div> </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 bordered column={2} size="small">
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item> <Descriptions.Item label={t('approval.applicationType')}>{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item> <Descriptions.Item label={t('approval.applicationCode')}>{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item> <Descriptions.Item label={t('approval.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期">{selectedRecord.date}</Descriptions.Item> <Descriptions.Item label={t('approval.applicationDate')}>{selectedRecord.date}</Descriptions.Item>
<Descriptions.Item label="金额"> <Descriptions.Item label={t('approval.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)} {formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && ( {selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span> <span style={{ color: '#999', marginLeft: 8 }}> ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item> <Descriptions.Item label={t('approval.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item> <Descriptions.Item label={t('approval.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
{/* 付款申请特有字段 */} {/* 付款申请特有字段 */}
{selectedRecord.type === '付款申请' && ( {selectedRecord.type === t('approval.paymentApply') && (
<> <>
<Descriptions.Item label="收款单位类型"> <Descriptions.Item label={t('approval.payeeType')}>
{fullDetail.payee_type === 'subcontractor' ? '分包商' : {fullDetail.payee_type === 'subcontractor' ? t('approval.counterpartySubcontractor') :
fullDetail.payee_type === 'supplier' ? '供应商' : fullDetail.payee_type === 'supplier' ? t('approval.counterpartySupplier') :
fullDetail.payee_type === 'customer' ? '客户' : '其他'} fullDetail.payee_type === 'customer' ? t('approval.counterpartyCustomer') : t('approval.counterpartyOther')}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item> <Descriptions.Item label={t('approval.payee')}>{fullDetail.payee || '-'}</Descriptions.Item>
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('approval.bankName')}>{fullDetail.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item> <Descriptions.Item label={t('approval.bankAccount')}>{fullDetail.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="支出类型"> <Descriptions.Item label={t('approval.expenseType')}>
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'} {fullDetail.expense_type === 'company' ? t('approval.companyExpense') : t('approval.projectExpense')}
</Descriptions.Item> </Descriptions.Item>
{fullDetail.expense_type === 'project' && fullDetail.project_id && ( {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' {fullDetail.expense_type === 'project'
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category) ? (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) : (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="支出类型"> <Descriptions.Item label={t('approval.expenseType')}>
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'} {fullDetail.expense_type === 'company' ? t('approval.companyExpense') : t('approval.projectExpense')}
</Descriptions.Item> </Descriptions.Item>
{fullDetail.project_id && ( {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={t('approval.relatedAdvance')}>{fullDetail.advance_code}</Descriptions.Item>
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item> <Descriptions.Item label={t('approval.advanceAmount')}>{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label="结算核销"> <Descriptions.Item label={t('approval.settlement')}>
<span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}> <span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}>
{fullDetail.settlement ? '是' : '否'} {fullDetail.settlement ? t('common.is') : t('common.no')}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="已核销金额">{formatAmount(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="剩余核销金额">{formatAmount((fullDetail.advance_amount || 0) - (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 && ( {fullDetail.settlement && fullDetail.settlement_amount && (
<Descriptions.Item label="核销结算金额" span={2}> <Descriptions.Item label={t('approval.settlementAmount')} span={2}>
{fullDetail.settlement_amount > 0 ? `退款 ${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `补款 ${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`} {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> </Descriptions.Item>
)} )}
</> </>
)} )}
{/* 采购申请特有字段 */} {/* 采购申请特有字段 */}
{selectedRecord.type === '采购申请' && ( {selectedRecord.type === t('approval.purchaseApply') && (
<> <>
<Descriptions.Item label="采购类型"> <Descriptions.Item label={t('approval.purchaseType')}>
{fullDetail.purchase_type === 'project' ? '项目采购' : '库存采购'} {fullDetail.purchase_type === 'project' ? t('approval.projectPurchase') : t('approval.stockPurchase')}
</Descriptions.Item> </Descriptions.Item>
{fullDetail.purchase_type === 'project' && fullDetail.project_id && ( {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={t('approval.supplier')}>{fullDetail.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label="支出分类"> <Descriptions.Item label={t('approval.expenseCategory')}>
{fullDetail.expense_category === 'material' ? '材料' : {fullDetail.expense_category === 'material' ? t('approval.material') :
fullDetail.expense_category === 'equipment' ? '设备' : fullDetail.expense_category === 'equipment' ? t('approval.equipment') :
fullDetail.expense_category === 'pole' ? '电杆' : '其他'} fullDetail.expense_category === 'pole' ? t('approval.pole') : t('approval.other')}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="币种">{fullDetail.currency}</Descriptions.Item> <Descriptions.Item label={t('approval.currency')}>{fullDetail.currency}</Descriptions.Item>
<Descriptions.Item label="申请日期">{fullDetail.request_date}</Descriptions.Item> <Descriptions.Item label={t('approval.applicationDate')}>{fullDetail.request_date}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item> <Descriptions.Item label={t('approval.subject')} span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
{fullDetail.remark && ( {fullDetail.remark && (
<Descriptions.Item label="备注" span={2}>{fullDetail.remark}</Descriptions.Item> <Descriptions.Item label={t('approval.remark')} span={2}>{fullDetail.remark}</Descriptions.Item>
)} )}
</> </>
)} )}
</Descriptions> </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 <List
size="small" size="small"
bordered bordered
@@ -805,8 +817,8 @@ const ApprovalManagement: React.FC = () => {
</span> </span>
</div> </div>
<div style={{ fontSize: 13, color: '#666' }}> <div style={{ fontSize: 13, color: '#666' }}>
: {item.specification || '-'} | : {item.unit || '-'} | {t('approval.specLabel')}{item.specification || '-'} | {t('approval.unitLabel')}{item.unit || '-'} |
: {item.quantity} | : {fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})} {t('approval.qtyLabel')}{item.quantity} | {t('approval.priceLabel')}{fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
</div> </div>
</div> </div>
</List.Item> </List.Item>
@@ -818,7 +830,7 @@ const ApprovalManagement: React.FC = () => {
{/* 明细清单 */} {/* 明细清单 */}
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && ( {fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
<> <>
<Divider></Divider> <Divider>{t('approval.detailList')}</Divider>
{renderDetailItems(fullDetail.detail_items)} {renderDetailItems(fullDetail.detail_items)}
</> </>
)} )}
@@ -826,7 +838,7 @@ const ApprovalManagement: React.FC = () => {
{/* 凭证附件或退款凭证 */} {/* 凭证附件或退款凭证 */}
{fullDetail.attachments && fullDetail.attachments.length > 0 && ( {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)} {renderAttachments(fullDetail.attachments)}
</> </>
)} )}
@@ -834,13 +846,13 @@ const ApprovalManagement: React.FC = () => {
{/* 审批备注表单 */} {/* 审批备注表单 */}
{selectedRecord.status === 'pending' && ( {selectedRecord.status === 'pending' && (
<> <>
<Divider></Divider> <Divider>{t('approval.approvalOpinion')}</Divider>
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="remark" label="审批备注"> <Form.Item name="remark" label={t('approval.approvalNote')}>
<TextArea rows={3} placeholder="可选:填写审批备注" /> <TextArea rows={3} placeholder={t('approval.approvalNotePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="rejectReason" label="退回原因" style={{ display: 'none' }}> <Form.Item name="rejectReason" label={t('approval.rejectReason')} style={{ display: 'none' }}>
<TextArea rows={3} placeholder="请填写退回原因" /> <TextArea rows={3} placeholder={t('approval.rejectReasonPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
</> </>
@@ -849,35 +861,35 @@ const ApprovalManagement: React.FC = () => {
)} )}
</Modal> </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 form={editForm} layout="vertical">
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item> <Form.Item label={t('approval.applicationType')}><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item> <Form.Item label={t('approval.applicant')}><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="amount" label={t('approval.amount')} 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 name="reason" label={t('approval.subject')} rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
</Form> </Form>
</Modal> </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 && ( {selectedRecord && (
<> <>
<Descriptions bordered column={2} size="small"> <Descriptions bordered column={2} size="small">
<Descriptions.Item label="申请编号">{selectedRecord.advance_code || selectedRecord.reimbursement_code}</Descriptions.Item> <Descriptions.Item label={t('approval.applicationCode')}>{selectedRecord.advance_code || selectedRecord.reimbursement_code}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item> <Descriptions.Item label={t('approval.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item> <Descriptions.Item label={t('approval.applicant')}>{selectedRecord.applicant}</Descriptions.Item>
<Descriptions.Item label="申请日期">{selectedRecord.advance_date || selectedRecord.reimbursement_date}</Descriptions.Item> <Descriptions.Item label={t('approval.applicationDate')}>{selectedRecord.advance_date || selectedRecord.reimbursement_date}</Descriptions.Item>
<Descriptions.Item label="金额"> <Descriptions.Item label={t('approval.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)} {formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && ( {selectedRecord.currency !== 'CNY' && selectedRecord.amount_cny && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span> <span style={{ color: '#999', marginLeft: 8 }}> ¥{selectedRecord.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item> <Descriptions.Item label={t('approval.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
</Descriptions> </Descriptions>
{Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && ( {Array.isArray(selectedRecord.attachments) && selectedRecord.attachments.length > 0 && (
<> <>
<Divider></Divider> <Divider>{t('approval.proofAttachment')}</Divider>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedRecord.attachments.map((url: string, index: number) => ( {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' }} /> <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 { 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 { CheckOutlined, CloseOutlined, EyeOutlined, DollarOutlined, EditOutlined, UndoOutlined, ClockCircleOutlined, FileImageOutlined, UploadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { TextArea } = Input; const { TextArea } = Input;
@@ -26,6 +27,15 @@ const COMPANY_EXPENSE_CATEGORIES = [
{ value: 'other', label: '其他支出' } { 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 { interface ExecutionRecord {
id: string; id: string;
@@ -45,6 +55,7 @@ interface ExecutionRecord {
} }
const ExecutionManagement: React.FC = () => { const ExecutionManagement: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false); const [detailModalVisible, setDetailModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false); const [editModalVisible, setEditModalVisible] = useState(false);
@@ -85,14 +96,14 @@ const ExecutionManagement: React.FC = () => {
rawData: item rawData: item
}))); })));
} else { } else {
message.error('获取待执行数据失败:数据格式错误'); message.error(t('execution.getPendingFailedFormat'));
} }
} else { } else {
message.error('获取待执行数据失败:' + response.statusText); message.error(t('execution.getPendingFailed') + response.statusText);
} }
} catch (error) { } catch (error) {
console.error('获取待执行数据错误:', error); console.error('获取待执行数据错误:', error);
message.error('网络错误,获取待执行数据失败'); message.error(t('execution.getPendingNetworkError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -135,14 +146,14 @@ const ExecutionManagement: React.FC = () => {
rawData: item rawData: item
}))); })));
} else { } else {
message.error('获取已执行数据失败:数据格式错误'); message.error(t('execution.getExecutedFailedFormat'));
} }
} else { } else {
message.error('获取已执行数据失败:' + response.statusText); message.error(t('execution.getExecutedFailed') + response.statusText);
} }
} catch (error) { } catch (error) {
console.error('获取已执行数据错误:', error); console.error('获取已执行数据错误:', error);
message.error('网络错误,获取已执行数据失败'); message.error(t('execution.getExecutedNetworkError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -169,15 +180,16 @@ const ExecutionManagement: React.FC = () => {
const getTypeTag = (type: string) => { const getTypeTag = (type: string) => {
const colors: Record<string, string> = { '预支申请': 'blue', '报销申请': 'green', '付款申请': 'orange', '核销申请': 'purple', '采购申请': 'cyan' }; 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 getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待执行' }, pending: { color: 'processing', text: t('execution.pendingExecution') },
executed: { color: 'success', text: '已执行' }, executed: { color: 'success', text: t('execution.executed') },
rejected: { color: 'error', text: '已退回' }, rejected: { color: 'error', text: t('execution.rejected') },
approved: { color: 'success', text: '已批准' }, approved: { color: 'success', text: t('execution.approved') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -201,7 +213,7 @@ const ExecutionManagement: React.FC = () => {
setIsRejecting(false); setIsRejecting(false);
// 获取完整详情 // 获取完整详情
if (record.type === '采购申请') { if (record.type === t('execution.purchaseApply')) {
try { try {
const response = await fetch(`/api/purchase-requests/${record.id}`); const response = await fetch(`/api/purchase-requests/${record.id}`);
const data = await response.json(); const data = await response.json();
@@ -225,7 +237,7 @@ const ExecutionManagement: React.FC = () => {
const handleExecute = async () => { const handleExecute = async () => {
try { try {
// 检查是否为核销申请 // 检查是否为核销申请
const isVerification = selectedRecord.type === '核销申请'; const isVerification = selectedRecord.type === t('execution.verificationApply');
// 检查是否为退款类型的核销申请 // 检查是否为退款类型的核销申请
const isRefundVerification = isVerification && fullDetail.settlement && fullDetail.settlement_amount > 0; const isRefundVerification = isVerification && fullDetail.settlement && fullDetail.settlement_amount > 0;
// 检查是否为非结算核销 // 检查是否为非结算核销
@@ -271,10 +283,10 @@ const ExecutionManagement: React.FC = () => {
} }
}; };
fetchExecutedData(); fetchExecutedData();
message.success(`执行成功:${selectedRecord.code}`); message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false); setDetailModalVisible(false);
} else { } else {
message.error('执行操作失败,请重试'); message.error(t('execution.executeFailed'));
} }
} else { } else {
// 其他类型的申请需要验证表单 // 其他类型的申请需要验证表单
@@ -282,7 +294,7 @@ const ExecutionManagement: React.FC = () => {
// 检查是否需要上传付款凭证 // 检查是否需要上传付款凭证
if (!isRefundVerification && (!voucherFiles || voucherFiles.length === 0)) { if (!isRefundVerification && (!voucherFiles || voucherFiles.length === 0)) {
message.error('请上传付款凭证'); message.error(t('execution.proofRequired'));
return; return;
} }
@@ -303,7 +315,7 @@ const ExecutionManagement: React.FC = () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
apply_id: selectedRecord.id, 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', action: 'execute',
execute_method: isRefundVerification ? 'refund' : values.execute_method, execute_method: isRefundVerification ? 'refund' : values.execute_method,
voucher_files: voucherFileUrls, voucher_files: voucherFileUrls,
@@ -332,15 +344,15 @@ const ExecutionManagement: React.FC = () => {
} }
}; };
fetchExecutedData(); fetchExecutedData();
message.success(`执行成功:${selectedRecord.code}`); message.success(t('execution.executeSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false); setDetailModalVisible(false);
} else { } else {
message.error('执行操作失败,请重试'); message.error(t('execution.executeFailed'));
} }
} }
} catch (error) { } catch (error) {
console.error('执行操作失败:', error); console.error('执行操作失败:', error);
message.error('网络错误,操作失败'); message.error(t('common.networkError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -361,7 +373,7 @@ const ExecutionManagement: React.FC = () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
apply_id: selectedRecord.id, 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', action: 'reject',
reject_reason: values.rejectReason reject_reason: values.rejectReason
}) })
@@ -369,14 +381,14 @@ const ExecutionManagement: React.FC = () => {
if (rejectResponse.ok) { if (rejectResponse.ok) {
setPendingData(pendingData.filter(item => item.key !== selectedRecord.key)); setPendingData(pendingData.filter(item => item.key !== selectedRecord.key));
message.success(`已退回:${selectedRecord.code},申请人可编辑后重新提交`); message.success(t('execution.rejectSuccess', { code: selectedRecord.code }));
setDetailModalVisible(false); setDetailModalVisible(false);
} else { } else {
message.error('退回操作失败,请重试'); message.error(t('execution.rejectFailed'));
} }
} catch (error) { } catch (error) {
console.error('退回操作失败:', error); console.error('退回操作失败:', error);
message.error('网络错误,操作失败'); message.error(t('common.networkError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -392,7 +404,7 @@ const ExecutionManagement: React.FC = () => {
const handleEditSubmit = () => { const handleEditSubmit = () => {
editForm.validateFields().then(values => { editForm.validateFields().then(values => {
message.success('修改成功,已重新提交审批'); message.success(t('execution.editSuccess'));
setEditModalVisible(false); setEditModalVisible(false);
}); });
}; };
@@ -448,22 +460,22 @@ const ExecutionManagement: React.FC = () => {
// 特殊分类映射 // 特殊分类映射
const specialCategories: Record<string, string> = { const specialCategories: Record<string, string> = {
// Project expense categories // Project expense categories
accommodation: '住宿', accommodation: t('execution.accommodation'),
food: '餐饮', food: t('execution.catering'),
fuel: '加油', fuel: t('execution.fuel'),
materials: '零散材料', materials: t('execution.scatteredMaterial'),
customer_relations: '客户关系', customer_relations: t('execution.customerRelation'),
subcontract_relations: '分包关系', subcontract_relations: t('execution.subcontractorRelation'),
edl_relations: 'EDL关系', edl_relations: t('execution.EDLRelation'),
extra_construction: '额外施工', extra_construction: t('execution.extraConstruction'),
// Company expense categories // Company expense categories
general_operations: '通用运营(房租/耗材)', general_operations: t('execution.generalOperation'),
transportation: '交通通勤', transportation: t('execution.commute'),
business_expansion: '业扩营销', business_expansion: t('execution.marketing'),
power_system_relations: '电力系统关系', power_system_relations: t('execution.powerSystem'),
employee_benefits: '员工福利', employee_benefits: t('execution.employeeBenefit'),
express_logistics: '快递物流', express_logistics: t('execution.expressLogistics'),
other: '其他' other: t('execution.otherCategory')
}; };
// 先检查特殊分类 // 先检查特殊分类
if (specialCategories[category]) return specialCategories[category]; if (specialCategories[category]) return specialCategories[category];
@@ -501,17 +513,17 @@ const ExecutionManagement: React.FC = () => {
<List.Item> <List.Item>
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}> <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> <span style={{ fontWeight: 'bold', color: '#1890ff' }}>{formatAmount(item.amount, item.currency)}</span>
</div> </div>
{item.category && ( {item.category && (
<div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}> <div style={{ marginBottom: 8, fontSize: 13, color: '#666' }}>
<strong></strong>{getCategoryName(item.category)} <strong>{t('execution.categoryLabel')}</strong>{getCategoryName(item.category)}
</div> </div>
)} )}
{item.attachments && ( {item.attachments && (
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<span style={{ color: '#666', fontSize: 12 }}></span> <span style={{ color: '#666', fontSize: 12 }}>{t('execution.detailAttachment')}</span>
{renderAttachments(item.attachments)} {renderAttachments(item.attachments)}
</div> </div>
)} )}
@@ -525,19 +537,19 @@ const ExecutionManagement: React.FC = () => {
const pendingColumns = [ const pendingColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v}</a> }, { title: t('execution.subject'), 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: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 }, { title: t('execution.applicant'), 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: 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: '收款方', dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant }, { title: t('execution.payee'), dataIndex: 'payee', key: 'payee', ellipsis: true, render: (v: string, r: any) => v || r.applicant },
{ title: '审批日期', dataIndex: 'approveDate', key: 'approveDate', width: 100 }, { title: t('execution.approvalDate'), dataIndex: 'approveDate', key: 'approveDate', width: 100 },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 }, { 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) => ( render: (_: any, record: any) => (
<Space wrap> <Space wrap>
<Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}></Button> <Button size="small" type="primary" icon={<DollarOutlined />} onClick={() => handleViewDetail(record)}>{t('execution.execute')}</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button> <Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>{t('execution.edit')}</Button>
</Space> </Space>
) )
} }
@@ -583,26 +595,26 @@ const ExecutionManagement: React.FC = () => {
}; };
const executedColumns = [ const executedColumns = [
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true, render: (v: string, r: any) => <a onClick={() => handleViewDetail(r)}>{v || '-'}</a> }, { title: t('execution.subject'), 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: t('execution.type'), dataIndex: 'type', key: 'type', width: 100, render: (v: string) => getTypeTag(v) },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant', width: 80 }, { title: t('execution.applicant'), dataIndex: 'applicant', key: 'applicant', width: 80 },
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => ( { title: t('execution.amount'), dataIndex: 'amount', key: 'amount', width: 140, render: (v: number, r: any) => (
<> <>
<div>{formatAmount(v, r.currency)}</div> <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>} {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: t('execution.executionDate'), dataIndex: 'executeDate', key: 'executeDate', width: 100, sorter: true, render: (v: string) => v || '-' },
{ title: '执行方式', dataIndex: 'executeMethod', key: 'executeMethod', width: 100, render: (v: string) => v || '-' }, { title: t('execution.executionMethod'), dataIndex: 'executeMethod', key: 'executeMethod', width: 100, render: (v: string) => v || '-' },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) }, { title: t('execution.status'), dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: '编号', dataIndex: 'code', key: 'code', width: 140 }, { title: t('execution.code'), dataIndex: 'code', key: 'code', width: 140 },
]; ];
// 已执行列表的筛选和排序控件 // 已执行列表的筛选和排序控件
const ExecutedListControls = () => ( const ExecutedListControls = () => (
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}> <div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input.Search <Input.Search
placeholder="搜索事由、编号或申请人" placeholder={t('execution.searchPlaceholder')}
value={searchKeyword} value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)} onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={(value) => setSearchKeyword(value)} onSearch={(value) => setSearchKeyword(value)}
@@ -610,20 +622,20 @@ const ExecutionManagement: React.FC = () => {
allowClear allowClear
/> />
<Select <Select
placeholder="筛选类型" placeholder={t('execution.filterType')}
value={filterType} value={filterType}
onChange={(value) => setFilterType(value)} onChange={(value) => setFilterType(value)}
style={{ width: 150 }} style={{ width: 150 }}
allowClear allowClear
> >
<Select.Option value="预支申请"></Select.Option> <Select.Option value={t('execution.advanceApply')}>{t('execution.advanceApply')}</Select.Option>
<Select.Option value="报销申请"></Select.Option> <Select.Option value={t('execution.reimburseApply')}>{t('execution.reimburseApply')}</Select.Option>
<Select.Option value="付款申请"></Select.Option> <Select.Option value={t('execution.paymentApply')}>{t('execution.paymentApply')}</Select.Option>
<Select.Option value="核销申请"></Select.Option> <Select.Option value={t('execution.verificationApply')}>{t('execution.verificationApply')}</Select.Option>
<Select.Option value="采购申请"></Select.Option> <Select.Option value={t('execution.purchaseApply')}>{t('execution.purchaseApply')}</Select.Option>
</Select> </Select>
<Select <Select
placeholder="排序方式" placeholder={t('execution.sortBy')}
value={`${sortField}_${sortOrder}`} value={`${sortField}_${sortOrder}`}
onChange={(value) => { onChange={(value) => {
const [field, order] = (value as string).split('_'); const [field, order] = (value as string).split('_');
@@ -632,17 +644,17 @@ const ExecutionManagement: React.FC = () => {
}} }}
style={{ width: 180 }} style={{ width: 180 }}
> >
<Select.Option value="executeDate_descend"></Select.Option> <Select.Option value="executeDate_descend">{t('execution.sortDateNew')}</Select.Option>
<Select.Option value="executeDate_ascend"></Select.Option> <Select.Option value="executeDate_ascend">{t('execution.sortDateOld')}</Select.Option>
<Select.Option value="amount_descend"></Select.Option> <Select.Option value="amount_descend">{t('execution.sortAmountHigh')}</Select.Option>
<Select.Option value="amount_ascend"></Select.Option> <Select.Option value="amount_ascend">{t('execution.sortAmountLow')}</Select.Option>
</Select> </Select>
</div> </div>
); );
const tabItems = [ 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: '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: '已执行', children: ( { key: 'executed', label: t('execution.executedTab'), children: (
<> <>
<ExecutedListControls /> <ExecutedListControls />
<Table <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 = { const uploadProps = {
name: 'file', name: 'file',
action: '/api/upload/single', action: '/api/upload/single',
headers: { headers: getUploadHeaders(),
authorization: 'authorization-text',
},
onChange(info: any) { onChange(info: any) {
// 更新文件列表状态 // 更新文件列表状态
setVoucherFiles(info.fileList); setVoucherFiles(info.fileList);
if (info.file.status === 'done') { if (info.file.status === 'done') {
message.success(`${info.file.name} 上传成功`); message.success(t('execution.uploadSuccess', { name: info.file.name }));
// 如果上传成功,将返回的URL添加到文件对象中 // 如果上传成功,将返回的URL添加到文件对象中
const updatedFileList = info.fileList.map((file: any) => { const updatedFileList = info.fileList.map((file: any) => {
if (file.uid === info.file.uid && file.response) { if (file.uid === info.file.uid && file.response) {
@@ -687,7 +709,7 @@ const ExecutionManagement: React.FC = () => {
}); });
setVoucherFiles(updatedFileList); setVoucherFiles(updatedFileList);
} else if (info.file.status === 'error') { } else if (info.file.status === 'error') {
message.error(`${info.file.name} 上传失败`); message.error(t('execution.uploadFailed', { name: info.file.name }));
} }
}, },
fileList: voucherFiles, fileList: voucherFiles,
@@ -695,24 +717,24 @@ const ExecutionManagement: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <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> <Card><Tabs items={tabItems} /></Card>
{/* 详情模态框 */} {/* 详情模态框 */}
<Modal <Modal
title={`${selectedRecord?.type}详情`} title={`${selectedRecord?.type}${t('common.detail')}`}
open={detailModalVisible} open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)} onCancel={() => setDetailModalVisible(false)}
width={900} width={900}
footer={ footer={
selectedRecord?.status === 'approved' || selectedRecord?.status === 'pending' ? ( selectedRecord?.status === 'approved' || selectedRecord?.status === 'pending' ? (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setDetailModalVisible(false)}></Button> <Button onClick={() => setDetailModalVisible(false)}>{t('execution.cancel')}</Button>
<Button danger icon={<CloseOutlined />} onClick={handleReject}>退</Button> <Button danger icon={<CloseOutlined />} onClick={handleReject}>{t('execution.pass')}</Button>
<Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}></Button> <Button type="primary" icon={<CheckOutlined />} onClick={handleExecute}>{t('execution.execute')}</Button>
</div> </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 bordered column={2} size="small">
<Descriptions.Item label="申请类型">{getTypeTag(selectedRecord.type)}</Descriptions.Item> <Descriptions.Item label={t('execution.applicationType')}>{getTypeTag(selectedRecord.type)}</Descriptions.Item>
<Descriptions.Item label="申请编号">{selectedRecord.code}</Descriptions.Item> <Descriptions.Item label={t('execution.applicationCode')}>{selectedRecord.code}</Descriptions.Item>
<Descriptions.Item label="申请人">{selectedRecord.applicant}</Descriptions.Item> <Descriptions.Item label={t('execution.applicant')}>{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={t('execution.approvalDate')}>{selectedRecord.date || fullDetail.advance_date || fullDetail.reimbursement_date || fullDetail.payment_date || fullDetail.verification_date}</Descriptions.Item>
<Descriptions.Item label="金额"> <Descriptions.Item label={t('execution.amount')}>
{formatAmount(selectedRecord.amount, selectedRecord.currency)} {formatAmount(selectedRecord.amount, selectedRecord.currency)}
{selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && ( {selectedRecord.currency !== 'CNY' && fullDetail.amount_cny > 0 && (
<span style={{ color: '#999', marginLeft: 8 }}> ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span> <span style={{ color: '#999', marginLeft: 8 }}> ¥{fullDetail.amount_cny.toLocaleString('zh-CN', {minimumFractionDigits: 2})}</span>
)} )}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(selectedRecord.status)}</Descriptions.Item> <Descriptions.Item label={t('execution.status')}>{getStatusTag(selectedRecord.status)}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{selectedRecord.reason}</Descriptions.Item> <Descriptions.Item label={t('execution.subject')} span={2}>{selectedRecord.reason}</Descriptions.Item>
{/* 付款申请特有字段 */} {/* 付款申请特有字段 */}
{selectedRecord.type === '付款申请' && ( {selectedRecord.type === t('execution.paymentApply') && (
<> <>
<Descriptions.Item label="收款单位类型"> <Descriptions.Item label={t('execution.payeeType')}>
{fullDetail.payee_type === 'subcontractor' ? '分包商' : {fullDetail.payee_type === 'subcontractor' ? t('execution.counterpartySubcontractor') :
fullDetail.payee_type === 'supplier' ? '供应商' : fullDetail.payee_type === 'supplier' ? t('execution.counterpartySupplier') :
fullDetail.payee_type === 'customer' ? '客户' : '其他'} fullDetail.payee_type === 'customer' ? t('execution.counterpartyCustomer') : t('execution.counterpartyOther')}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="收款方">{fullDetail.payee || '-'}</Descriptions.Item> <Descriptions.Item label={t('execution.payee')}>{fullDetail.payee || '-'}</Descriptions.Item>
<Descriptions.Item label="银行名称">{fullDetail.bank_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('execution.bankName')}>{fullDetail.bank_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{fullDetail.bank_account || '-'}</Descriptions.Item> <Descriptions.Item label={t('execution.bankAccount')}>{fullDetail.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="支出类型"> <Descriptions.Item label={t('execution.expenseType')}>
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'} {fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
</Descriptions.Item> </Descriptions.Item>
{fullDetail.expense_type === 'project' && fullDetail.project_id && ( {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' {fullDetail.expense_type === 'project'
? (PROJECT_EXPENSE_CATEGORIES.find(c => c.value === fullDetail.expense_category)?.label || fullDetail.expense_category) ? (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) : (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="支出类型"> <Descriptions.Item label={t('execution.expenseType')}>
{fullDetail.expense_type === 'company' ? '公司支出' : '项目支出'} {fullDetail.expense_type === 'company' ? t('execution.companyExpense') : t('execution.projectExpense')}
</Descriptions.Item> </Descriptions.Item>
{fullDetail.project_id && ( {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={t('execution.relatedAdvance')}>{fullDetail.advance_code}</Descriptions.Item>
<Descriptions.Item label="预支金额">{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item> <Descriptions.Item label={t('execution.advanceAmount')}>{formatAmount(fullDetail.advance_amount, fullDetail.currency)}</Descriptions.Item>
<Descriptions.Item label="结算核销"> <Descriptions.Item label={t('execution.settlement')}>
<span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}> <span style={{ fontWeight: 'bold', color: fullDetail.settlement ? '#52c41a' : '#fa8c16' }}>
{fullDetail.settlement ? '是' : '否'} {fullDetail.settlement ? t('common.is') : t('common.no')}
</span> </span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="已核销金额">{formatAmount(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="剩余核销金额">{formatAmount((fullDetail.advance_amount || 0) - (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 && ( {fullDetail.settlement && fullDetail.settlement_amount && (
<Descriptions.Item label="核销结算金额" span={2}> <Descriptions.Item label={t('execution.settlementAmount')} span={2}>
{fullDetail.settlement_amount > 0 ? `退款 ${formatAmount(fullDetail.settlement_amount, fullDetail.currency)}` : `补款 ${formatAmount(Math.abs(fullDetail.settlement_amount), fullDetail.currency)}`} {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> </Descriptions.Item>
)} )}
</> </>
)} )}
{/* 采购申请特有字段 */} {/* 采购申请特有字段 */}
{selectedRecord.type === '采购申请' && ( {selectedRecord.type === t('execution.purchaseApply') && (
<> <>
<Descriptions.Item label="采购类型"> <Descriptions.Item label={t('execution.purchaseType')}>
{fullDetail.purchase_type === 'project' ? '项目采购' : '库存采购'} {fullDetail.purchase_type === 'project' ? t('execution.projectPurchase') : t('execution.stockPurchase')}
</Descriptions.Item> </Descriptions.Item>
{fullDetail.purchase_type === 'project' && fullDetail.project_id && ( {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={t('execution.supplier')}>{fullDetail.supplier_name || '-'}</Descriptions.Item>
<Descriptions.Item label="支出分类"> <Descriptions.Item label={t('execution.expenseCategory')}>
{fullDetail.expense_category === 'material' ? '材料' : {fullDetail.expense_category === 'material' ? t('execution.material') :
fullDetail.expense_category === 'equipment' ? '设备' : fullDetail.expense_category === 'equipment' ? t('execution.equipment') :
fullDetail.expense_category === 'pole' ? '电杆' : '其他'} fullDetail.expense_category === 'pole' ? t('execution.pole') : t('execution.otherCategory')}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="币种">{fullDetail.currency}</Descriptions.Item> <Descriptions.Item label={t('execution.currency')}>{fullDetail.currency}</Descriptions.Item>
<Descriptions.Item label="申请日期">{fullDetail.request_date}</Descriptions.Item> <Descriptions.Item label={t('execution.approvalDate')}>{fullDetail.request_date}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item> <Descriptions.Item label={t('execution.subject')} span={2}>{fullDetail.brief_description || '-'}</Descriptions.Item>
{fullDetail.remark && ( {fullDetail.remark && (
<Descriptions.Item label="备注" span={2}>{fullDetail.remark}</Descriptions.Item> <Descriptions.Item label={t('execution.remark')} span={2}>{fullDetail.remark}</Descriptions.Item>
)} )}
</> </>
)} )}
</Descriptions> </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"> <Descriptions bordered column={2} size="small">
{fullDetail.supplier_payment_infos.filter((p: any) => p.is_primary).map((payment: any, index: number) => ( {fullDetail.supplier_payment_infos.filter((p: any) => p.is_primary).map((payment: any, index: number) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<Descriptions.Item label="收款户名">{payment.account_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('execution.accountName')}>{payment.account_name || '-'}</Descriptions.Item>
<Descriptions.Item label="银行账号">{payment.bank_account || '-'}</Descriptions.Item> <Descriptions.Item label={t('execution.bankAccount')}>{payment.bank_account || '-'}</Descriptions.Item>
<Descriptions.Item label="开户银行">{payment.bank_name || '-'}</Descriptions.Item> <Descriptions.Item label={t('execution.bankName')}>{payment.bank_name || '-'}</Descriptions.Item>
{payment.qr_code && ( {payment.qr_code && (
<Descriptions.Item label="收款码"> <Descriptions.Item label={t('execution.qrCode')}>
<img src={payment.qr_code} alt="收款码" style={{ width: 100, height: 100, objectFit: 'contain' }} /> <img src={payment.qr_code} alt={t('execution.qrCode')} style={{ width: 100, height: 100, objectFit: 'contain' }} />
</Descriptions.Item> </Descriptions.Item>
)} )}
</React.Fragment> </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 <List
size="small" size="small"
bordered bordered
@@ -855,8 +877,8 @@ const ExecutionManagement: React.FC = () => {
</span> </span>
</div> </div>
<div style={{ fontSize: 13, color: '#666' }}> <div style={{ fontSize: 13, color: '#666' }}>
: {item.specification || '-'} | : {item.unit || '-'} | {t('execution.specLabel')}{item.specification || '-'} | {t('execution.unitLabel')}{item.unit || '-'} |
: {item.quantity} | : {fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})} {t('execution.qtyLabel')}{item.quantity} | {t('execution.priceLabel')}{fullDetail.currency} {item.unit_price?.toLocaleString('zh-CN', {minimumFractionDigits: 2})}
</div> </div>
</div> </div>
</List.Item> </List.Item>
@@ -868,7 +890,7 @@ const ExecutionManagement: React.FC = () => {
{/* 明细清单 */} {/* 明细清单 */}
{fullDetail.detail_items && fullDetail.detail_items.length > 0 && ( {fullDetail.detail_items && fullDetail.detail_items.length > 0 && (
<> <>
<Divider></Divider> <Divider>{t('execution.detailList')}</Divider>
{renderDetailItems(fullDetail.detail_items)} {renderDetailItems(fullDetail.detail_items)}
</> </>
)} )}
@@ -876,7 +898,7 @@ const ExecutionManagement: React.FC = () => {
{/* 审批意见 */} {/* 审批意见 */}
{fullDetail.approval_remark && ( {fullDetail.approval_remark && (
<> <>
<Divider></Divider> <Divider>{t('execution.approvalOpinion')}</Divider>
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}> <div style={{ padding: '12px', background: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ margin: 0, color: '#666' }}>{fullDetail.approval_remark}</p> <p style={{ margin: 0, color: '#666' }}>{fullDetail.approval_remark}</p>
</div> </div>
@@ -886,7 +908,7 @@ const ExecutionManagement: React.FC = () => {
{/* 申请凭证附件或退款凭证 */} {/* 申请凭证附件或退款凭证 */}
{fullDetail.attachments && fullDetail.attachments.length > 0 && ( {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)} {renderAttachments(fullDetail.attachments)}
</> </>
)} )}
@@ -894,49 +916,49 @@ const ExecutionManagement: React.FC = () => {
{/* 执行表单 */} {/* 执行表单 */}
{(selectedRecord.status === 'approved' || selectedRecord.status === 'pending') && ( {(selectedRecord.status === 'approved' || selectedRecord.status === 'pending') && (
<> <>
<Divider></Divider> <Divider>{t('execution.executionInfo')}</Divider>
<Form form={form} layout="vertical"> <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%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
{/* 执行方式或收款方式 - 非结算核销不需要 */} {/* 执行方式或收款方式 - 非结算核销不需要 */}
{!(selectedRecord.type === '核销申请' && !fullDetail.settlement) && ( {!(selectedRecord.type === t('execution.verificationApply') && !fullDetail.settlement) && (
selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? ( selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="execute_method" label="收款方式" rules={[{ required: true }]}> <Form.Item name="execute_method" label={t('execution.paymentMethod')} rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'wechat', label: '微信' }, { value: 'other', label: '其他' }]} /> <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>
) : ( ) : (
<Form.Item name="execute_method" label="执行方式" rules={[{ required: true }]}> <Form.Item name="execute_method" label={t('execution.executionMethodLabel')} rules={[{ required: true }]}>
<Select options={[{ value: 'bank', label: '银行转账' }, { value: 'cash', label: '现金' }, { value: 'wechat', label: '微信' }, { value: 'other', label: '其他' }]} /> <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>
) )
)} )}
{/* 只有退款类型的核销申请和非结算核销不需要上传付款凭证,其他类型的申请都需要 */} {/* 只有退款类型的核销申请和非结算核销不需要上传付款凭证,其他类型的申请都需要 */}
{!(selectedRecord.type === '核销申请' && (fullDetail.settlement && fullDetail.settlement_amount > 0 || !fullDetail.settlement)) && ( {!(selectedRecord.type === t('execution.verificationApply') && (fullDetail.settlement && fullDetail.settlement_amount > 0 || !fullDetail.settlement)) && (
<Form.Item label="付款凭证" required> <Form.Item label={t('execution.proofOfPayment')} required>
<Upload {...uploadProps}> <Upload {...uploadProps}>
<Button icon={<UploadOutlined />}></Button> <Button icon={<UploadOutlined />}>{t('execution.uploadProof')}</Button>
</Upload> </Upload>
<div style={{ marginTop: 8, color: '#666', fontSize: 12 }}> <div style={{ marginTop: 8, color: '#666', fontSize: 12 }}>
PDF格式 {t('execution.proofUploadTip')}
</div> </div>
</Form.Item> </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' }}> <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> </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' }}> <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> </div>
)} )}
@@ -945,20 +967,20 @@ const ExecutionManagement: React.FC = () => {
// 执行操作时显示的字段 // 执行操作时显示的字段
<> <>
{/* 收款确认信息(仅退款类型)或备注 */} {/* 收款确认信息(仅退款类型)或备注 */}
{selectedRecord.type === '核销申请' && fullDetail.settlement && fullDetail.settlement_amount > 0 ? ( {selectedRecord.type === t('execution.verificationApply') && fullDetail.settlement && fullDetail.settlement_amount > 0 ? (
<Form.Item name="remark" label="收款确认信息" rules={[{ required: true, message: '请填写收款确认信息' }]}> <Form.Item name="remark" label={t('execution.paymentConfirmation')} rules={[{ required: true, message: t('execution.confirmRequired') }]}>
<TextArea rows={3} placeholder="请填写收款确认信息,如收款账号、收款时间等" /> <TextArea rows={3} placeholder={t('execution.confirmationPlaceholder')} />
</Form.Item> </Form.Item>
) : ( ) : (
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('execution.remark')}>
<TextArea rows={2} placeholder="可选:填写执行备注" /> <TextArea rows={2} placeholder={t('execution.remarkPlaceholder')} />
</Form.Item> </Form.Item>
)} )}
</> </>
) : ( ) : (
// 退回操作时显示的字段 // 退回操作时显示的字段
<Form.Item name="rejectReason" label="退回原因" rules={[{ required: true, message: '请填写退回原因' }]}> <Form.Item name="rejectReason" label={t('execution.returnReason')} rules={[{ required: true, message: t('execution.rejectReasonRequired') }]}>
<TextArea rows={3} placeholder="请填写退回原因" /> <TextArea rows={3} placeholder={t('execution.returnReason')} />
</Form.Item> </Form.Item>
)} )}
</Form> </Form>
@@ -968,12 +990,12 @@ const ExecutionManagement: React.FC = () => {
)} )}
</Modal> </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 form={editForm} layout="vertical">
<Form.Item label="申请类型"><Input value={selectedRecord?.type} disabled /></Form.Item> <Form.Item label={t('execution.applicationType')}><Input value={selectedRecord?.type} disabled /></Form.Item>
<Form.Item label="申请人"><Input value={selectedRecord?.applicant} disabled /></Form.Item> <Form.Item label={t('execution.applicant')}><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="amount" label={t('execution.amount')} 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 name="reason" label={t('execution.subject')} rules={[{ required: true }]}><TextArea rows={3} /></Form.Item>
</Form> </Form>
</Modal> </Modal>
+7 -7
View File
@@ -34,7 +34,7 @@ const LoginPage: React.FC = () => {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { login } = useAuthStore() const { login } = useAuthStore()
const { t } = useLanguageStore() const { t, currentLanguage } = useLanguageStore()
const handleSubmit = async (values: { username: string; password: string }) => { const handleSubmit = async (values: { username: string; password: string }) => {
setLoading(true) setLoading(true)
@@ -51,10 +51,10 @@ const LoginPage: React.FC = () => {
} }
const testAccounts = [ const testAccounts = [
{ username: 'admin', role: t('user.admin'), name: '系统管理员' }, { username: 'admin', role: t('user.admin'), name: t('common.systemAdmin') },
{ username: 'finance', role: t('user.finance'), name: '财务专员' }, { username: 'finance', role: t('user.finance'), name: t('user.finance') },
{ username: 'manager', role: t('user.manager'), name: '项目经理' }, { username: 'manager', role: t('user.manager'), name: t('user.manager') },
{ username: 'employee', role: t('user.employee'), name: '普通员工' } { username: 'employee', role: t('user.employee'), name: t('user.employee') }
] ]
const handleTestLogin = async (username: string) => { const handleTestLogin = async (username: string) => {
@@ -65,7 +65,7 @@ const LoginPage: React.FC = () => {
await login(username, QUICK_PASSWORD) await login(username, QUICK_PASSWORD)
navigate('/dashboard') navigate('/dashboard')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '登录失败') setError(err instanceof Error ? err.message : t('login.loginFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -107,7 +107,7 @@ const LoginPage: React.FC = () => {
border: '2px solid #1890ff' border: '2px solid #1890ff'
}}> }}>
<div style={{ marginBottom: 8, color: '#1890ff', fontWeight: 'bold' }}> <div style={{ marginBottom: 8, color: '#1890ff', fontWeight: 'bold' }}>
🌍 / Select Language {t('login.selectLanguage')}
</div> </div>
<LanguageSelector size="large" style={{ width: '200px' }} /> <LanguageSelector size="large" style={{ width: '200px' }} />
</div> </div>
@@ -6,6 +6,7 @@ import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import FileUpload from '../../components/FileUpload'; import FileUpload from '../../components/FileUpload';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
import useFormDraft from '../../hooks/useFormDraft'; import useFormDraft from '../../hooks/useFormDraft';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
@@ -34,9 +35,9 @@ const BudgetProjectCreate: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user: currentUser } = useAuthStore(); const { user: currentUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const isAdmin = currentUser?.role === 'admin'; const isAdmin = currentUser?.role === 'admin';
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({ const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form, form,
storageKey: 'budget_project_create', storageKey: 'budget_project_create',
@@ -46,12 +47,10 @@ const BudgetProjectCreate: React.FC = () => {
}, },
}); });
// 保存草稿(包含外部状态)
const handleFormChange = useCallback(() => { const handleFormChange = useCallback(() => {
saveDraft({ attachments, surveyPhotos }); saveDraft({ attachments, surveyPhotos });
}, [saveDraft, attachments, surveyPhotos]); }, [saveDraft, attachments, surveyPhotos]);
// 浏览器刷新/关闭拦截
useEffect(() => { useEffect(() => {
const handler = (e: BeforeUnloadEvent) => { const handler = (e: BeforeUnloadEvent) => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
@@ -62,7 +61,6 @@ const BudgetProjectCreate: React.FC = () => {
return () => window.removeEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler);
}, [form]); }, [form]);
// 移动端:页面切到后台时保存草稿
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
if (document.visibilityState === 'hidden' && form.isFieldsTouched()) { if (document.visibilityState === 'hidden' && form.isFieldsTouched()) {
@@ -80,14 +78,13 @@ const BudgetProjectCreate: React.FC = () => {
}; };
}, [form, saveDraft, attachments, surveyPhotos]); }, [form, saveDraft, attachments, surveyPhotos]);
// 页面加载时检查草稿
useEffect(() => { useEffect(() => {
if (hasDraft()) { if (hasDraft()) {
Modal.confirm({ Modal.confirm({
title: '发现未完成的草稿', title: t('common.draftFound'),
content: '检测到上次未提交的商谈项目,是否恢复?', content: t('common.draftRestore'),
okText: '恢复草稿', okText: t('common.restoreDraft'),
cancelText: '重新填写', cancelText: t('common.reFill'),
onOk: () => { onOk: () => {
restoreDraft(); restoreDraft();
}, },
@@ -98,17 +95,13 @@ const BudgetProjectCreate: React.FC = () => {
} }
}, []); }, []);
// 检查权限,如果不是管理员,重定向到列表页面
useEffect(() => { useEffect(() => {
if (!isAdmin) { if (!isAdmin) {
message.error('您没有权限访问此页面'); message.error(t('budget.noAccess'));
navigate('/budget-projects'); navigate('/budget-projects');
} }
}, [isAdmin, navigate]); }, [isAdmin, navigate]);
// const { user: currentUser } = useAuthStore();
// 表单监听值
const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form); const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form);
useEffect(() => { useEffect(() => {
@@ -160,7 +153,7 @@ const BudgetProjectCreate: React.FC = () => {
} }
}); });
if (res.data.success) { if (res.data.success) {
message.success('创建成功'); message.success(t('budget.createSuccess'));
clearDraft(); clearDraft();
navigate('/budget-projects'); navigate('/budget-projects');
} }
@@ -168,7 +161,7 @@ const BudgetProjectCreate: React.FC = () => {
if (error.response?.data?.error) { if (error.response?.data?.error) {
message.error(error.response.data.error); message.error(error.response.data.error);
} else { } else {
message.error('创建失败'); message.error(t('budget.createFailed'));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -184,10 +177,10 @@ const BudgetProjectCreate: React.FC = () => {
onClick={() => { onClick={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认离开', title: t('budget.leaveConfirm'),
content: '表单数据尚未保存,离开后可通过草稿恢复。确定离开吗?', content: t('budget.leaveConfirmMsg'),
okText: '离开', okText: t('budget.leave'),
cancelText: '继续编辑', cancelText: t('budget.continueEdit'),
onOk: () => { onOk: () => {
saveDraft({ attachments, surveyPhotos }); saveDraft({ attachments, surveyPhotos });
navigate('/budget-projects'); navigate('/budget-projects');
@@ -198,11 +191,11 @@ const BudgetProjectCreate: React.FC = () => {
} }
}} }}
> >
{t('budget.return')}
</Button> </Button>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}></Title> <Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}>{t('budget.createTitle')}</Title>
</div> </div>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('budget.createDesc')}</Paragraph>
</div> </div>
<Card> <Card>
@@ -212,32 +205,31 @@ const BudgetProjectCreate: React.FC = () => {
onValuesChange={handleFormChange} onValuesChange={handleFormChange}
initialValues={{ initialValues={{
intermediary_fee_type: 'fixed', intermediary_fee_type: 'fixed',
survey_date: dayjs(), // 勘察日期默认为当天 survey_date: dayjs(),
attachments: [], attachments: [],
survey_photos: [] survey_photos: []
}} }}
> >
{/* 基本信息 */} <Divider orientation="left">{t('budget.basicInfo')}</Divider>
<Divider orientation="left"></Divider>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item <Form.Item
name="name" name="name"
label="项目名称" label={t('budget.projectName')}
rules={[{ required: true, message: '请输入项目名称' }]} rules={[{ required: true, message: t('budget.projectNamePlaceholder') }]}
> >
<Input placeholder="请输入项目名称" size="large" /> <Input placeholder={t('budget.projectNamePlaceholder')} size="large" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item <Form.Item
name="customer_id" name="customer_id"
label="客户" label={t('budget.customer')}
rules={[{ required: true, message: '请选择客户' }]} rules={[{ required: true, message: t('budget.selectCustomer') }]}
> >
<Select <Select
placeholder="请选择客户" placeholder={t('budget.selectCustomer')}
showSearch showSearch
optionFilterProp="children" optionFilterProp="children"
size="large" size="large"
@@ -254,86 +246,83 @@ const BudgetProjectCreate: React.FC = () => {
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item <Form.Item
name="manager_id" name="manager_id"
label="业务经理" label={t('budget.businessManager')}
rules={[{ required: true, message: '请选择业务经理' }]} rules={[{ required: true, message: t('budget.selectManager') }]}
> >
<Select <Select
placeholder="请选择业务经理" placeholder={t('budget.selectManager')}
showSearch showSearch
optionFilterProp="children" optionFilterProp="children"
size="large" size="large"
> >
{users.map((u) => ( {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> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item name="location" label="项目地点"> <Form.Item name="location" label={t('budget.projectLocation')}>
<Input placeholder="请输入项目地点" size="large" /> <Input placeholder={t('budget.locationPlaceholder')} size="large" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={12}> <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" /> <DatePicker style={{ width: '100%' }} size="large" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
{/* 居间人信息 */} <Divider orientation="left">{t('budget.intermediary')}</Divider>
<Divider orientation="left"></Divider>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={8}> <Col xs={24} md={8}>
<Form.Item name="intermediary" label="居间人"> <Form.Item name="intermediary" label={t('budget.intermediaryName')}>
<Input placeholder="请输入居间人姓名" size="large" /> <Input placeholder={t('budget.intermediaryNamePlaceholder')} size="large" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={8}> <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.Group>
<Radio value="fixed"></Radio> <Radio value="fixed">{t('budget.fixedAmount')}</Radio>
<Radio value="percentage"></Radio> <Radio value="percentage">{t('budget.percentage')}</Radio>
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={8}> <Col xs={24} md={8}>
<Form.Item <Form.Item
name="intermediary_fee_value" name="intermediary_fee_value"
label={intermediaryFeeType === 'percentage' ? '居间费比例(%)' : '居间费金额'} label={intermediaryFeeType === 'percentage' ? t('budget.intermediaryRatio') : t('budget.intermediaryAmount')}
> >
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
size="large" size="large"
min={0} min={0}
precision={intermediaryFeeType === 'percentage' ? 2 : 0} precision={intermediaryFeeType === 'percentage' ? 2 : 0}
placeholder={intermediaryFeeType === 'percentage' ? '输入比例,如:5' : '输入金额'} placeholder={intermediaryFeeType === 'percentage' ? t('budget.intermediaryRatioPlaceholder') : t('budget.intermediaryAmountPlaceholder')}
/> />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
{/* 项目详情 */} <Divider orientation="left">{t('budget.projectDetail')}</Divider>
<Divider orientation="left"></Divider>
<Form.Item name="customer_requirements" label="客户要求"> <Form.Item name="customer_requirements" label={t('budget.customerRequirement')}>
<TextArea rows={4} placeholder="请输入客户的具体要求" /> <TextArea rows={4} placeholder={t('budget.requirementPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="project_overview" label="工程概况"> <Form.Item name="project_overview" label={t('budget.overview')}>
<TextArea rows={4} placeholder="请输入工程概况描述" /> <TextArea rows={4} placeholder={t('budget.overviewPlaceholder')} />
</Form.Item> </Form.Item>
{/* 附件上传 */} <Divider orientation="left">{t('budget.attachment')}</Divider>
<Divider orientation="left"></Divider>
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item label="附件上传"> <Form.Item label={t('budget.attachmentUpload')}>
<FileUpload <FileUpload
value={attachments} value={attachments}
onChange={(urls) => { setAttachments(urls); saveDraft({ attachments: urls, surveyPhotos }); }} onChange={(urls) => { setAttachments(urls); saveDraft({ attachments: urls, surveyPhotos }); }}
@@ -342,7 +331,7 @@ const BudgetProjectCreate: React.FC = () => {
</Form.Item> </Form.Item>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item label="勘察照片"> <Form.Item label={t('budget.surveyPhoto')}>
<FileUpload <FileUpload
value={surveyPhotos} value={surveyPhotos}
onChange={(urls) => { setSurveyPhotos(urls); saveDraft({ attachments, surveyPhotos: urls }); }} onChange={(urls) => { setSurveyPhotos(urls); saveDraft({ attachments, surveyPhotos: urls }); }}
@@ -352,16 +341,15 @@ const BudgetProjectCreate: React.FC = () => {
</Col> </Col>
</Row> </Row>
{/* 提交按钮 */}
<div style={{ marginTop: 24, textAlign: 'right' }}> <div style={{ marginTop: 24, textAlign: 'right' }}>
<Space> <Space>
<Button onClick={() => { <Button onClick={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认离开', title: t('budget.leaveConfirm'),
content: '表单数据尚未保存,离开后可通过草稿恢复。确定离开吗?', content: t('budget.leaveConfirmMsg'),
okText: '离开', okText: t('budget.leave'),
cancelText: '继续编辑', cancelText: t('budget.continueEdit'),
onOk: () => { onOk: () => {
saveDraft({ attachments, surveyPhotos }); saveDraft({ attachments, surveyPhotos });
navigate('/budget-projects'); navigate('/budget-projects');
@@ -370,14 +358,14 @@ const BudgetProjectCreate: React.FC = () => {
} else { } else {
navigate('/budget-projects'); navigate('/budget-projects');
} }
}}></Button> }}>{t('common.cancel')}</Button>
<Button <Button
type="primary" type="primary"
icon={<SaveOutlined />} icon={<SaveOutlined />}
loading={loading} loading={loading}
onClick={handleSubmit} onClick={handleSubmit}
> >
{t('common.save')}
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 { ArrowLeftOutlined, EyeOutlined, FileAddOutlined, FileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import QuotationCreateModal from './QuotationCreateModal'; import QuotationCreateModal from './QuotationCreateModal';
import ContractCreateModal from './ContractCreateModal'; import ContractCreateModal from './ContractCreateModal';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
@@ -65,6 +66,7 @@ const BudgetProjectDetail: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { user: currentUser } = useAuthStore(); const { user: currentUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const isAdmin = currentUser?.role === 'admin' || false; const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => { useEffect(() => {
@@ -81,7 +83,6 @@ const BudgetProjectDetail: React.FC = () => {
const res = await apiClient.get(`/budget-projects/${id}`); const res = await apiClient.get(`/budget-projects/${id}`);
if (res.data.success) { if (res.data.success) {
const projectData = res.data.data; const projectData = res.data.data;
// 后端已经解析了数据,直接使用
projectData.quotations = Array.isArray(projectData.quotations) ? projectData.quotations : []; projectData.quotations = Array.isArray(projectData.quotations) ? projectData.quotations : [];
projectData.attachments = Array.isArray(projectData.attachments) ? projectData.attachments : []; projectData.attachments = Array.isArray(projectData.attachments) ? projectData.attachments : [];
projectData.survey_photos = Array.isArray(projectData.survey_photos) ? projectData.survey_photos : []; projectData.survey_photos = Array.isArray(projectData.survey_photos) ? projectData.survey_photos : [];
@@ -89,7 +90,7 @@ const BudgetProjectDetail: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('获取项目详情失败:', error); console.error('获取项目详情失败:', error);
message.error('获取数据失败'); message.error(t('budget.getDataFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -97,9 +98,9 @@ const BudgetProjectDetail: React.FC = () => {
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: '商谈中' }, negotiating: { color: 'processing', text: t('budget.inNegotiation') },
signed: { color: 'success', text: '已签约' }, signed: { color: 'success', text: t('budget.signed') },
unsigned: { color: 'error', text: '未签约' }, unsigned: { color: 'error', text: t('budget.unsigned') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -107,10 +108,10 @@ const BudgetProjectDetail: React.FC = () => {
const getQuotationStatusTag = (status: string) => { const getQuotationStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' }, draft: { color: 'default', text: t('budget.draft') },
sent: { color: 'processing', text: '已发送' }, sent: { color: 'processing', text: t('budget.sent') },
approved: { color: 'success', text: '已通过' }, approved: { color: 'success', text: t('budget.approved') },
rejected: { color: 'error', text: '已拒绝' }, rejected: { color: 'error', text: t('budget.rejected') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -142,11 +143,11 @@ const BudgetProjectDetail: React.FC = () => {
} }
}); });
if (res.data.success) { if (res.data.success) {
message.success('标记未签约成功'); message.success(t('budget.signedSuccess'));
fetchProjectDetail(); fetchProjectDetail();
} }
} catch (error) { } catch (error) {
message.error('操作失败'); message.error(t('common.operationFailed'));
} }
}; };
@@ -159,9 +160,8 @@ const BudgetProjectDetail: React.FC = () => {
const handleQuotationDeleteConfirm = async () => { const handleQuotationDeleteConfirm = async () => {
if (!project || !quotationDeleteId) return; if (!project || !quotationDeleteId) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') { if (deletePassword !== 'X123c321@') {
message.error('密码错误'); message.error(t('budget.passwordError'));
return; return;
} }
@@ -173,12 +173,12 @@ const BudgetProjectDetail: React.FC = () => {
} }
}); });
if (res.data.success) { if (res.data.success) {
message.success('删除成功'); message.success(t('budget.deleteSuccess'));
setQuotationDeleteModalVisible(false); setQuotationDeleteModalVisible(false);
fetchProjectDetail(); fetchProjectDetail();
} }
} catch (error) { } catch (error) {
message.error('删除失败'); message.error(t('common.deleteFailed'));
} finally { } finally {
setDeleteLoading(false); setDeleteLoading(false);
} }
@@ -210,9 +210,8 @@ const BudgetProjectDetail: React.FC = () => {
const handleProjectDeleteConfirm = async () => { const handleProjectDeleteConfirm = async () => {
if (!project) return; if (!project) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') { if (deletePassword !== 'X123c321@') {
message.error('密码错误'); message.error(t('budget.passwordError'));
return; return;
} }
@@ -224,12 +223,12 @@ const BudgetProjectDetail: React.FC = () => {
} }
}); });
if (res.data.success) { if (res.data.success) {
message.success('删除成功'); message.success(t('budget.deleteSuccess'));
setDeleteModalVisible(false); setDeleteModalVisible(false);
navigate('/budget-projects'); navigate('/budget-projects');
} }
} catch (error) { } catch (error) {
message.error('删除失败'); message.error(t('common.deleteFailed'));
} finally { } finally {
setDeleteLoading(false); setDeleteLoading(false);
} }
@@ -247,9 +246,9 @@ const BudgetProjectDetail: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
<Card> <Card>
<Empty description="项目不存在" /> <Empty description={t('budget.notFound')} />
<Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}> <Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}>
{t('budget.return')}
</Button> </Button>
</Card> </Card>
</div> </div>
@@ -264,60 +263,58 @@ const BudgetProjectDetail: React.FC = () => {
icon={<ArrowLeftOutlined />} icon={<ArrowLeftOutlined />}
onClick={() => navigate('/budget-projects')} onClick={() => navigate('/budget-projects')}
> >
{t('budget.return')}
</Button> </Button>
<Title level={2} style={{ marginBottom: 0 }}></Title> <Title level={2} style={{ marginBottom: 0 }}>{t('budget.detailTitle')}</Title>
</div> </div>
<Paragraph type="secondary"></Paragraph> <Paragraph type="secondary">{t('budget.detailDesc')}</Paragraph>
</div> </div>
{/* 项目基本信息 */}
<Card style={{ marginBottom: 24 }}> <Card style={{ marginBottom: 24 }}>
<Title level={4}></Title> <Title level={4}>{t('budget.projectInfo')}</Title>
<Divider /> <Divider />
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Descriptions column={1} bordered> <Descriptions column={1} bordered>
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item> <Descriptions.Item label={t('budget.projectName')}>{project.name}</Descriptions.Item>
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item> <Descriptions.Item label={t('budget.customer')}>{project.customer_name}</Descriptions.Item>
<Descriptions.Item label="业务经理">{project.manager_name}</Descriptions.Item> <Descriptions.Item label={t('budget.businessManager')}>{project.manager_name}</Descriptions.Item>
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item> <Descriptions.Item label={t('budget.projectLocation')}>{project.location || '-'}</Descriptions.Item>
<Descriptions.Item label="勘察日期">{project.survey_date || '-'}</Descriptions.Item> <Descriptions.Item label={t('budget.surveyDate')}>{project.survey_date || '-'}</Descriptions.Item>
<Descriptions.Item label="状态">{getStatusTag(project.status)}</Descriptions.Item> <Descriptions.Item label={t('common.status')}>{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('common.create')}>{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
</Descriptions> </Descriptions>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Descriptions column={1} bordered> <Descriptions column={1} bordered>
<Descriptions.Item label="居间人">{project.intermediary || '-'}</Descriptions.Item> <Descriptions.Item label={t('budget.intermediaryName')}>{project.intermediary || '-'}</Descriptions.Item>
<Descriptions.Item label="居间费类型"> <Descriptions.Item label={t('budget.intermediaryType')}>
{project.intermediary_fee_type === 'fixed' ? '固定金额' : project.intermediary_fee_type === 'percentage' ? '百分比' : '-'} {project.intermediary_fee_type === 'fixed' ? t('budget.fixedAmount') : project.intermediary_fee_type === 'percentage' ? t('budget.percentage') : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="居间费"> <Descriptions.Item label={t('budget.intermediaryAmount')}>
{project.intermediary_fee_value ? {project.intermediary_fee_value ?
project.intermediary_fee_type === 'percentage' ? project.intermediary_fee_type === 'percentage' ?
`${project.intermediary_fee_value}%` : `${project.intermediary_fee_value}%` :
formatAmount(project.intermediary_fee_value, 'CNY') formatAmount(project.intermediary_fee_value, 'CNY')
: '-'} : '-'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="客户要求">{project.customer_requirements || '-'}</Descriptions.Item> <Descriptions.Item label={t('budget.customerRequirement')}>{project.customer_requirements || '-'}</Descriptions.Item>
<Descriptions.Item label="工程概况">{project.project_overview || '-'}</Descriptions.Item> <Descriptions.Item label={t('budget.overview')}>{project.project_overview || '-'}</Descriptions.Item>
</Descriptions> </Descriptions>
</Col> </Col>
</Row> </Row>
</Card> </Card>
{/* 附件和照片 */}
<Card style={{ marginBottom: 24 }}> <Card style={{ marginBottom: 24 }}>
<Title level={4}></Title> <Title level={4}>{t('budget.attachment')}</Title>
<Divider /> <Divider />
<Row gutter={16}> <Row gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Text strong>:</Text> <Text strong>{t('budget.attachmentUpload')}:</Text>
{project.attachments && project.attachments.length > 0 ? ( {project.attachments && project.attachments.length > 0 ? (
<List <List
style={{ marginTop: 8 }} style={{ marginTop: 8 }}
@@ -342,7 +339,7 @@ const BudgetProjectDetail: React.FC = () => {
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={handleView} onClick={handleView}
> >
{t('common.view')}
</Button> </Button>
</Space> </Space>
</List.Item> </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> </div>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Text strong>:</Text> <Text strong>{t('budget.surveyPhoto')}:</Text>
{project.survey_photos && project.survey_photos.length > 0 ? ( {project.survey_photos && project.survey_photos.length > 0 ? (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{project.survey_photos.map((url, index) => ( {project.survey_photos.map((url, index) => (
@@ -369,30 +366,29 @@ const BudgetProjectDetail: React.FC = () => {
onClick={() => window.open(url, '_blank')} 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' }}> <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> </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> </div>
</Col> </Col>
</Row> </Row>
</Card> </Card>
{/* 报价版本列表 */}
<Card style={{ marginBottom: 24 }}> <Card style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <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' && ( {isAdmin && project.status === 'negotiating' && (
<Button <Button
type="primary" type="primary"
icon={<FileAddOutlined />} icon={<FileAddOutlined />}
onClick={openQuotationModal} onClick={openQuotationModal}
> >
{t('budget.addVersion')}
</Button> </Button>
)} )}
</div> </div>
@@ -424,7 +420,7 @@ const BudgetProjectDetail: React.FC = () => {
onClick={handleViewFile} onClick={handleViewFile}
disabled={!quotation.file_url} disabled={!quotation.file_url}
> >
{t('common.view')}
</Button>, </Button>,
isAdmin && ( isAdmin && (
<Button <Button
@@ -432,24 +428,24 @@ const BudgetProjectDetail: React.FC = () => {
danger danger
onClick={() => handleDeleteQuotation(quotation.id)} onClick={() => handleDeleteQuotation(quotation.id)}
> >
{t('common.delete')}
</Button> </Button>
) )
].filter(Boolean)} ].filter(Boolean)}
> >
<List.Item.Meta <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={ title={
<Space> <Space>
<Text strong>V{quotation.version}</Text> <Text strong>{t('budget.version')}V{quotation.version}</Text>
{getQuotationStatusTag(quotation.status)} {getQuotationStatusTag(quotation.status)}
</Space> </Space>
} }
description={ description={
<Space direction="vertical"> <Space direction="vertical">
<Text>: {dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text> <Text>{t('budget.versionDate')}{dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
<Text>: {formatAmount(quotation.amount, quotation.currency)}</Text> <Text>{t('budget.versionAmount')}{formatAmount(quotation.amount, quotation.currency)}</Text>
{quotation.remark && <Text>: {quotation.remark}</Text>} {quotation.remark && <Text>{t('budget.versionRemark')}{quotation.remark}</Text>}
</Space> </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>
{/* 操作按钮 */}
<Card> <Card>
<Title level={4}></Title> <Title level={4}>{t('common.action')}</Title>
<Divider /> <Divider />
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
@@ -475,14 +470,14 @@ const BudgetProjectDetail: React.FC = () => {
icon={<CheckCircleOutlined />} icon={<CheckCircleOutlined />}
onClick={handleSign} onClick={handleSign}
> >
{t('budget.markSigned')}
</Button> </Button>
<Button <Button
danger danger
icon={<CloseCircleOutlined />} icon={<CloseCircleOutlined />}
onClick={handleUnsigned} onClick={handleUnsigned}
> >
{t('budget.markUnsigned')}
</Button> </Button>
</> </>
)} )}
@@ -492,7 +487,7 @@ const BudgetProjectDetail: React.FC = () => {
type="primary" type="primary"
onClick={goToProjectManagement} onClick={goToProjectManagement}
> >
{t('budget.enterProject')}
</Button> </Button>
)} )}
@@ -501,13 +496,12 @@ const BudgetProjectDetail: React.FC = () => {
danger danger
onClick={handleDeleteProject} onClick={handleDeleteProject}
> >
{t('budget.deleteProject')}
</Button> </Button>
)} )}
</div> </div>
</Card> </Card>
{/* 新增报价版本弹窗 */}
<QuotationCreateModal <QuotationCreateModal
visible={quotationModalVisible} visible={quotationModalVisible}
project={project} project={project}
@@ -515,7 +509,6 @@ const BudgetProjectDetail: React.FC = () => {
onSuccess={handleQuotationSuccess} onSuccess={handleQuotationSuccess}
/> />
{/* 合同信息录入弹窗 */}
<ContractCreateModal <ContractCreateModal
visible={contractModalVisible} visible={contractModalVisible}
projectId={project?.id || 0} projectId={project?.id || 0}
@@ -524,44 +517,42 @@ const BudgetProjectDetail: React.FC = () => {
onSuccess={handleContractSuccess} onSuccess={handleContractSuccess}
/> />
{/* 删除项目确认模态框 */}
<Modal <Modal
title="删除确认" title={t('budget.deleteConfirm')}
open={deleteModalVisible} open={deleteModalVisible}
onOk={handleProjectDeleteConfirm} onOk={handleProjectDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)} onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading} confirmLoading={deleteLoading}
okText="确认删除" okText={t('common.deleteConfirm')}
cancelText="取消" cancelText={t('common.cancel')}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p></p> <p>{t('budget.deleteConfirmMsg')}</p>
<p></p> <p>{t('budget.deletePassMsg')}</p>
</div> </div>
<Input.Password <Input.Password
placeholder="请输入管理员密码" placeholder={t('budget.deletePass')}
value={deletePassword} value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)} onChange={(e) => setDeletePassword(e.target.value)}
size="large" size="large"
/> />
</Modal> </Modal>
{/* 删除报价版本确认模态框 */}
<Modal <Modal
title="删除确认" title={t('budget.deleteConfirm')}
open={quotationDeleteModalVisible} open={quotationDeleteModalVisible}
onOk={handleQuotationDeleteConfirm} onOk={handleQuotationDeleteConfirm}
onCancel={() => setQuotationDeleteModalVisible(false)} onCancel={() => setQuotationDeleteModalVisible(false)}
confirmLoading={deleteLoading} confirmLoading={deleteLoading}
okText="确认删除" okText={t('common.deleteConfirm')}
cancelText="取消" cancelText={t('common.cancel')}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p></p> <p>{t('budget.versionDeleteConfirm')}</p>
<p></p> <p>{t('budget.deletePassMsg')}</p>
</div> </div>
<Input.Password <Input.Password
placeholder="请输入管理员密码" placeholder={t('budget.deletePass')}
value={deletePassword} value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)} onChange={(e) => setDeletePassword(e.target.value)}
size="large" size="large"
+34 -38
View File
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
@@ -63,6 +64,7 @@ const BudgetProjectList: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user: currentUser } = useAuthStore(); const { user: currentUser } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
const isAdmin = currentUser?.role === 'admin' || false; const isAdmin = currentUser?.role === 'admin' || false;
useEffect(() => { useEffect(() => {
@@ -81,7 +83,6 @@ const BudgetProjectList: React.FC = () => {
try { try {
const res = await apiClient.get('/budget-projects'); const res = await apiClient.get('/budget-projects');
if (res.data.success) { if (res.data.success) {
// 后端已经解析了数据,直接使用
const projectsWithParsedData = res.data.data.map((project: any) => { const projectsWithParsedData = res.data.data.map((project: any) => {
return { return {
...project, ...project,
@@ -94,7 +95,7 @@ const BudgetProjectList: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('获取预算项目失败:', error); console.error('获取预算项目失败:', error);
message.error('获取数据失败'); message.error(t('budget.getDataFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -106,9 +107,9 @@ const BudgetProjectList: React.FC = () => {
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: '商谈中' }, negotiating: { color: 'processing', text: t('budget.inNegotiation') },
signed: { color: 'success', text: '已签约' }, signed: { color: 'success', text: t('budget.signed') },
unsigned: { color: 'error', text: '未签约' }, unsigned: { color: 'error', text: t('budget.unsigned') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -116,10 +117,10 @@ const BudgetProjectList: React.FC = () => {
const getQuotationStatusTag = (status: string) => { const getQuotationStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' }, draft: { color: 'default', text: t('budget.draft') },
sent: { color: 'processing', text: '已发送' }, sent: { color: 'processing', text: t('budget.sent') },
approved: { color: 'success', text: '已通过' }, approved: { color: 'success', text: t('budget.approved') },
rejected: { color: 'error', text: '已拒绝' }, rejected: { color: 'error', text: t('budget.rejected') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
@@ -140,9 +141,8 @@ const BudgetProjectList: React.FC = () => {
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!deleteProjectId) return; if (!deleteProjectId) return;
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
if (deletePassword !== 'X123c321@') { if (deletePassword !== 'X123c321@') {
message.error('密码错误'); message.error(t('budget.passwordError'));
return; return;
} }
@@ -154,12 +154,12 @@ const BudgetProjectList: React.FC = () => {
} }
}); });
if (res.data.success) { if (res.data.success) {
message.success('删除成功'); message.success(t('budget.deleteSuccess'));
setDeleteModalVisible(false); setDeleteModalVisible(false);
fetchProjects(); fetchProjects();
} }
} catch (error) { } catch (error) {
message.error('删除失败'); message.error(t('common.deleteFailed'));
} finally { } finally {
setDeleteLoading(false); setDeleteLoading(false);
} }
@@ -170,8 +170,8 @@ const BudgetProjectList: React.FC = () => {
<div style={{ marginBottom: isMobile ? 12 : 24 }}> <div style={{ marginBottom: isMobile ? 12 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div> <div>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title> <Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('budget.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph> <Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('budget.description')}</Paragraph>
</div> </div>
{isAdmin && ( {isAdmin && (
<Button <Button
@@ -180,34 +180,32 @@ const BudgetProjectList: React.FC = () => {
onClick={() => navigate('/budget-projects/create')} onClick={() => navigate('/budget-projects/create')}
size={isMobile ? 'middle' : 'large'} size={isMobile ? 'middle' : 'large'}
> >
{t('budget.newProject')}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
{/* 状态筛选 */}
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
<Space> <Space>
<Text strong>:</Text> <Text strong>{t('budget.statusFilter')}</Text>
<Radio.Group <Radio.Group
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
optionType="button" optionType="button"
buttonStyle="solid" buttonStyle="solid"
> >
<Radio.Button value="all"></Radio.Button> <Radio.Button value="all">{t('common.all')}</Radio.Button>
<Radio.Button value="negotiating"></Radio.Button> <Radio.Button value="negotiating">{t('budget.inNegotiation')}</Radio.Button>
<Radio.Button value="signed"></Radio.Button> <Radio.Button value="signed">{t('budget.signed')}</Radio.Button>
<Radio.Button value="unsigned"></Radio.Button> <Radio.Button value="unsigned">{t('budget.unsigned')}</Radio.Button>
</Radio.Group> </Radio.Group>
</Space> </Space>
</Card> </Card>
{/* 项目列表 */}
<Card loading={loading}> <Card loading={loading}>
{filteredProjects.length === 0 ? ( {filteredProjects.length === 0 ? (
<Empty description="暂无数据" /> <Empty description={t('common.noData')} />
) : ( ) : (
<div> <div>
{filteredProjects.map((project) => ( {filteredProjects.map((project) => (
@@ -220,7 +218,6 @@ const BudgetProjectList: React.FC = () => {
overflow: 'hidden' overflow: 'hidden'
}} }}
> >
{/* 项目头部 */}
<div <div
style={{ style={{
padding: '16px 20px', padding: '16px 20px',
@@ -236,7 +233,7 @@ const BudgetProjectList: React.FC = () => {
</Space> </Space>
<Space> <Space>
{getStatusTag(project.status)} {getStatusTag(project.status)}
<Text type="secondary">{project.days_in_status}</Text> <Text type="secondary">{project.days_in_status}{t('common.days')}</Text>
{isAdmin && ( {isAdmin && (
<Button <Button
danger danger
@@ -246,7 +243,7 @@ const BudgetProjectList: React.FC = () => {
handleDeleteProject(project.id); handleDeleteProject(project.id);
}} }}
> >
{t('common.delete')}
</Button> </Button>
)} )}
</Space> </Space>
@@ -254,13 +251,13 @@ const BudgetProjectList: React.FC = () => {
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}> <Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary">: {project.customer_name}</Text> <Text type="secondary">{t('budget.customerLabel')}{project.customer_name}</Text>
<Text type="secondary">: {project.manager_name}</Text> <Text type="secondary">{t('budget.managerLabel')}{project.manager_name}</Text>
{project.intermediary && ( {project.intermediary && (
<Text type="secondary"> <Text type="secondary">
: {project.intermediary} {t('budget.intermediaryLabel')}{project.intermediary}
{project.intermediary_fee_value && ( {project.intermediary_fee_value && (
<span> : {formatAmount(project.intermediary_fee_value, 'CNY')}</span> <span> {t('budget.intermediaryFee')}{formatAmount(project.intermediary_fee_value, 'CNY')}</span>
)} )}
</Text> </Text>
)} )}
@@ -275,22 +272,21 @@ const BudgetProjectList: React.FC = () => {
)} )}
</Card> </Card>
{/* 删除确认模态框 */}
<Modal <Modal
title="删除确认" title={t('budget.deleteConfirm')}
open={deleteModalVisible} open={deleteModalVisible}
onOk={handleDeleteConfirm} onOk={handleDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)} onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading} confirmLoading={deleteLoading}
okText="确认删除" okText={t('common.deleteConfirm')}
cancelText="取消" cancelText={t('common.cancel')}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p></p> <p>{t('budget.deleteConfirmMsg')}</p>
<p></p> <p>{t('budget.deletePassMsg')}</p>
</div> </div>
<Input.Password <Input.Password
placeholder="请输入管理员密码" placeholder={t('budget.deletePass')}
value={deletePassword} value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)} onChange={(e) => setDeletePassword(e.target.value)}
size="large" 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 { Modal, Form, Input, DatePicker, InputNumber, Select, Space, message } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
import useFormDraft from '../../hooks/useFormDraft'; import useFormDraft from '../../hooks/useFormDraft';
interface ContractCreateModalProps { interface ContractCreateModalProps {
@@ -21,8 +22,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
}) => { }) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [contractAmount, setContractAmount] = useState(0); const [contractAmount, setContractAmount] = useState(0);
const { t, currentLanguage } = useLanguageStore();
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({ const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form, form,
storageKey: 'contract_create', storageKey: 'contract_create',
@@ -32,7 +33,6 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
saveDraft() saveDraft()
}, [saveDraft]) }, [saveDraft])
// 生成默认的合同编号(包含时间戳确保唯一性)
const today = dayjs(); const today = dayjs();
const dateStr = today.format('YYYYMMDD'); const dateStr = today.format('YYYYMMDD');
const timeStr = today.format('HHmmss'); const timeStr = today.format('HHmmss');
@@ -52,14 +52,10 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
} }
}, [visible, form, projectName]); }, [visible, form, projectName]);
// 处理工期变化
const handlePeriodChange = (value: number) => { const handlePeriodChange = (value: number) => {
// 只需要设置工期天数,不需要计算开始和结束日期
}; };
// 提交表单
const handleSubmit = async (values: any) => { const handleSubmit = async (values: any) => {
// 构建提交数据(简化版)
const submitData = { const submitData = {
contract_code: values.contract_code, contract_code: values.contract_code,
project_name: values.project_name, project_name: values.project_name,
@@ -67,9 +63,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
currency: values.currency || 'CNY', currency: values.currency || 'CNY',
contract_amount: values.contract_amount || 0, contract_amount: values.contract_amount || 0,
contract_period: values.contract_period || 180, contract_period: values.contract_period || 180,
warranty_deposit_percentage: 5, // 默认5% warranty_deposit_percentage: 5,
warranty_period: 12, // 默认12个月 warranty_period: 12,
// 其他字段留空,后续在项目管理中补充
project_overview: '', project_overview: '',
other_requirements: '', other_requirements: '',
contract_file: null, contract_file: null,
@@ -81,39 +76,39 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
try { try {
const res = await apiClient.put(`/budget-projects/${projectId}/sign`, submitData, { const res = await apiClient.put(`/budget-projects/${projectId}/sign`, submitData, {
headers: { headers: {
'x-user-role': 'admin' // 签约操作需要管理员权限 'x-user-role': 'admin'
} }
}); });
if (res.data.success) { if (res.data.success) {
message.success('签约成功,项目已自动创建'); message.success(t('budget.quickSignSuccess'));
clearDraft() clearDraft()
onSuccess(); onSuccess();
onCancel(); onCancel();
} else { } else {
message.error(res.data.message || '操作失败'); message.error(res.data.message || t('common.operationFailed'));
} }
} catch (error: any) { } catch (error: any) {
console.error('签约失败:', error); console.error('签约失败:', error);
console.error('错误响应:', error.response); 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); message.error(errorMessage);
} }
}; };
return ( return (
<Modal <Modal
title="快速签约" title={t('budget.quickSign')}
open={visible} open={visible}
onOk={() => form.submit()} onOk={() => form.submit()}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft() saveDraft()
onCancel() onCancel()
@@ -124,8 +119,8 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
} }
}} }}
width={600} width={600}
okText="确认签约" okText={t('budget.markSigned')}
cancelText="取消" cancelText={t('common.cancel')}
maskClosable={false} maskClosable={false}
> >
<Form <Form
@@ -134,44 +129,43 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
onFinish={handleSubmit} onFinish={handleSubmit}
onValuesChange={handleFormChange} onValuesChange={handleFormChange}
> >
{/* 基本信息 */}
<Form.Item <Form.Item
name="contract_code" name="contract_code"
label="合同编号" label={t('budget.contractNo')}
rules={[{ required: true, message: '请输入合同编号' }]} rules={[{ required: true, message: t('budget.contractNoPlaceholder') }]}
> >
<Input placeholder="请输入合同编号" /> <Input placeholder={t('budget.contractNoPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="project_name" name="project_name"
label="项目名称" label={t('budget.projectName')}
rules={[{ required: true, message: '请输入项目名称' }]} rules={[{ required: true, message: t('budget.projectNamePlaceholder') }]}
> >
<Input placeholder="请输入项目名称" /> <Input placeholder={t('budget.projectNamePlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="contract_method" name="contract_method"
label="承包方式" label={t('budget.contractType')}
rules={[{ required: true, message: '请选择承包方式' }]} rules={[{ required: true, message: t('budget.selectContractType') }]}
> >
<Select <Select
placeholder="请选择承包方式" placeholder={t('budget.selectContractType')}
options={[ options={[
{ value: 'lump_sum', label: '总价包干' }, { value: 'lump_sum', label: t('budget.fixedAmount') },
{ value: 'unit_price', label: '单价结算' } { value: 'unit_price', label: t('budget.percentage') }
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="currency" name="currency"
label="币种" label={t('common.currency')}
rules={[{ required: true, message: '请选择币种' }]} rules={[{ required: true, message: t('common.pleaseSelect') }]}
> >
<Select <Select
placeholder="请选择币种" placeholder={t('common.pleaseSelect')}
options={[ options={[
{ value: 'CNY', label: '人民币' }, { value: 'CNY', label: '人民币' },
{ value: 'USD', label: '美元' }, { value: 'USD', label: '美元' },
@@ -183,41 +177,40 @@ const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
<Form.Item <Form.Item
name="contract_amount" name="contract_amount"
label="总价" label={t('budget.totalPrice')}
rules={[ rules={[
{ {
required: true, required: true,
message: '请输入总价' message: t('budget.totalPricePlaceholder')
} }
]} ]}
> >
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
placeholder="请输入总价" placeholder={t('budget.totalPricePlaceholder')}
formatter={(value) => `¥ ${value}`} formatter={(value) => `¥ ${value}`}
parser={(value) => value.replace(/¥\s?|(,*)/g, '')} parser={(value) => value.replace(/¥\s?|(,*)/g, '')}
onChange={(value) => setContractAmount(value || 0)} onChange={(value) => setContractAmount(value || 0)}
/> />
</Form.Item> </Form.Item>
{/* 工期 */}
<Form.Item <Form.Item
name="contract_period" name="contract_period"
label="工期(天)" label={t('budget.durationDays')}
rules={[{ required: true, message: '请输入工期' }]} rules={[{ required: true, message: t('budget.durationPlaceholder') }]}
> >
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={1} min={1}
placeholder="请输入工期(天)" placeholder={t('budget.durationDaysPlaceholder')}
onChange={handlePeriodChange} onChange={handlePeriodChange}
/> />
</Form.Item> </Form.Item>
<div style={{ marginTop: 16, padding: 16, background: '#f5f5f5', borderRadius: 8 }}> <div style={{ marginTop: 16, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
<p style={{ margin: 0, fontSize: 14, color: '#666' }}> <p style={{ margin: 0, fontSize: 14, color: '#666' }}>
{t('budget.quickSignNote')}
</p> </p>
</div> </div>
</Form> </Form>
@@ -3,6 +3,7 @@ import { Modal, Form, Input, DatePicker, InputNumber, Select, Upload, Button, me
import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons'; import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
import useFormDraft from '../../hooks/useFormDraft'; import useFormDraft from '../../hooks/useFormDraft';
const { Option } = Select; const { Option } = Select;
@@ -47,8 +48,8 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null); const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null);
const { t, currentLanguage } = useLanguageStore();
// 表单草稿保护
const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({ const { save: saveDraft, restore: restoreDraft, clear: clearDraft, hasDraft } = useFormDraft({
form, form,
storageKey: 'quotation_create', storageKey: 'quotation_create',
@@ -61,7 +62,6 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
saveDraft({ uploadedFile }) saveDraft({ uploadedFile })
}, [saveDraft, uploadedFile]) }, [saveDraft, uploadedFile])
// 计算下一个版本号
const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0 const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0
? Math.max(...project.quotations.map(q => q.version || 0)) + 1 ? Math.max(...project.quotations.map(q => q.version || 0)) + 1
: 1; : 1;
@@ -85,23 +85,33 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
formData.append('file', file); formData.append('file', file);
try { 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', { const response = await fetch('/api/upload/single', {
method: 'POST', method: 'POST',
body: formData, body: formData,
headers: authHeader ? { Authorization: authHeader } : {},
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
message.success('上传成功'); message.success(t('budget.uploadSuccess'));
setUploadedFile({ url: result.data.url, name: file.name }); setUploadedFile({ url: result.data.url, name: file.name });
onUploadSuccess(result.data, file); onUploadSuccess(result.data, file);
} else { } else {
message.error(result.error || '上传失败'); message.error(result.error || t('common.operationFailed'));
onError?.(new Error(result.error)); onError?.(new Error(result.error));
} }
} catch (error: any) { } catch (error: any) {
message.error('上传失败'); message.error(t('common.operationFailed'));
onError?.(error); onError?.(error);
} }
}; };
@@ -126,11 +136,11 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
const res = await apiClient.post(`/budget-projects/${project.id}/quotations`, quotationData, { const res = await apiClient.post(`/budget-projects/${project.id}/quotations`, quotationData, {
headers: { headers: {
'x-user-role': 'admin' // 创建报价版本需要管理员权限 'x-user-role': 'admin'
} }
}); });
if (res.data.success) { if (res.data.success) {
message.success('新增报价版本成功'); message.success(t('budget.addVersion'));
clearDraft() clearDraft()
onSuccess(); onSuccess();
} }
@@ -138,7 +148,7 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
if (error.response?.data?.error) { if (error.response?.data?.error) {
message.error(error.response.data.error); message.error(error.response.data.error);
} else { } else {
message.error('创建失败'); message.error(t('budget.createFailed'));
} }
} finally { } finally {
setLoading(false); setLoading(false);
@@ -164,16 +174,16 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
return ( return (
<Modal <Modal
title="新增报价版本" title={t('budget.newQuotation')}
open={visible} open={visible}
onOk={handleSubmit} onOk={handleSubmit}
onCancel={() => { onCancel={() => {
if (form.isFieldsTouched()) { if (form.isFieldsTouched()) {
Modal.confirm({ Modal.confirm({
title: '确认关闭', title: t('common.closeConfirm'),
content: '表单数据尚未保存,关闭后可通过草稿恢复。确定关闭吗?', content: t('common.closeConfirmMsg'),
okText: '关闭', okText: t('common.close'),
cancelText: '继续编辑', cancelText: t('common.continueEdit'),
onOk: () => { onOk: () => {
saveDraft({ uploadedFile }) saveDraft({ uploadedFile })
onCancel() onCancel()
@@ -185,12 +195,11 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
}} }}
width={600} width={600}
confirmLoading={loading} confirmLoading={loading}
okText="保存" okText={t('common.save')}
cancelText="取消" cancelText={t('common.cancel')}
maskClosable={false} maskClosable={false}
> >
<Form form={form} layout="vertical" onValuesChange={handleFormChange}> <Form form={form} layout="vertical" onValuesChange={handleFormChange}>
{/* 项目信息展示 */}
<div style={{ <div style={{
padding: 16, padding: 16,
background: '#f5f5f5', background: '#f5f5f5',
@@ -198,46 +207,46 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
marginBottom: 24 marginBottom: 24
}}> }}>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<span style={{ color: '#666' }}>: </span> <span style={{ color: '#666' }}>{t('budget.projectLabel')}</span>
<span style={{ fontWeight: 500 }}>{project?.name}</span> <span style={{ fontWeight: 500 }}>{project?.name}</span>
</div> </div>
<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={{ fontWeight: 500 }}>V{nextVersion - 1}</span>
<span style={{ color: '#999', marginLeft: 8 }}> <span style={{ color: '#999', marginLeft: 8 }}>
( V{nextVersion}) ({t('budget.newQuotation')} V{nextVersion})
</span> </span>
</div> </div>
</div> </div>
<Form.Item <Form.Item
name="quotation_date" name="quotation_date"
label="报价日期" label={t('budget.quotationDate')}
rules={[{ required: true, message: '请选择报价日期' }]} rules={[{ required: true, message: t('budget.selectDate') }]}
> >
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="amount" name="amount"
label="报价金额" label={t('budget.quotationAmount')}
rules={[{ required: true, message: '请输入报价金额' }]} rules={[{ required: true, message: t('budget.amountPlaceholder') }]}
> >
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
precision={2} precision={2}
placeholder="请输入报价金额" placeholder={t('budget.amountPlaceholder')}
addonAfter="元" addonAfter={t('common.yuan')}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="currency" name="currency"
label="币种" label={t('common.currency')}
rules={[{ required: true, message: '请选择币种' }]} rules={[{ required: true, message: t('common.pleaseSelect') }]}
> >
<Select placeholder="请选择币种"> <Select placeholder={t('common.pleaseSelect')}>
{CURRENCIES.map((c) => ( {CURRENCIES.map((c) => (
<Option key={c.value} value={c.value}> <Option key={c.value} value={c.value}>
{c.label} ({c.symbol}) {c.label} ({c.symbol})
@@ -246,14 +255,14 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item label="报价文件"> <Form.Item label={t('budget.quotationFile')}>
{uploadedFile ? ( {uploadedFile ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{getFileIcon()} {getFileIcon()}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{uploadedFile.name}</div> <div style={{ fontWeight: 500 }}>{uploadedFile.name}</div>
<a href={uploadedFile.url} target="_blank" rel="noopener noreferrer"> <a href={uploadedFile.url} target="_blank" rel="noopener noreferrer">
{t('budget.viewFile')}
</a> </a>
</div> </div>
<Button <Button
@@ -262,7 +271,7 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
onClick={handleRemoveFile} onClick={handleRemoveFile}
size="small" size="small"
> >
{t('common.delete')}
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -272,13 +281,13 @@ const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
showUploadList={false} showUploadList={false}
maxCount={1} maxCount={1}
> >
<Button icon={<UploadOutlined />}></Button> <Button icon={<UploadOutlined />}>{t('budget.uploadFile')}</Button>
</Upload> </Upload>
)} )}
</Form.Item> </Form.Item>
<Form.Item name="remark" label="备注"> <Form.Item name="remark" label={t('common.remark')}>
<Input.TextArea rows={3} placeholder="请输入备注信息" /> <Input.TextArea rows={3} placeholder={t('budget.remarkPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -5,25 +5,16 @@ import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
// 天气图标映射
const WEATHER_ICONS: Record<string, string> = { const WEATHER_ICONS: Record<string, string> = {
sunny: '☀️', sunny: '☀️',
cloudy: '⛅ 多云', cloudy: '⛅',
rainy: '🌧️', rainy: '🌧️',
stormy: '⛈️ 雷暴', stormy: '⛈️',
windy: '💨 大风', 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: '已取消' },
}; };
interface Project { interface Project {
@@ -52,6 +43,15 @@ const ConstructionList: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuthStore(); 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(() => { useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768); const checkMobile = () => setIsMobile(window.innerWidth <= 768);
@@ -73,7 +73,7 @@ const ConstructionList: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('获取项目列表失败:', error); console.error('获取项目列表失败:', error);
message.error('获取项目列表失败'); message.error(t('construction.getListFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -94,6 +94,18 @@ const ConstructionList: React.FC = () => {
return dayjs(dateStr).isSame(dayjs(), 'day'); 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 renderProjectCard = (project: Project) => {
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending; const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date); 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 } }} styles={{ body: { padding: isMobile ? 16 : 20 } }}
> >
{/* 项目头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}> <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> <Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
</div> </div>
<Text type="secondary" style={{ fontSize: 13 }}> <Text type="secondary" style={{ fontSize: 13 }}>
: {project.customer_name || '未指定'} {t('construction.customerLabel')}{project.customer_name || t('common.notSet')}
</Text> </Text>
</div> </div>
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}> <Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
@@ -124,10 +135,9 @@ const ConstructionList: React.FC = () => {
</Tag> </Tag>
</div> </div>
{/* 进度条 */}
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <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> <Text strong style={{ fontSize: 12 }}>{Math.round((project.progress_percentage || 0))}%</Text>
</div> </div>
<Progress <Progress
@@ -141,7 +151,6 @@ const ConstructionList: React.FC = () => {
/> />
</div> </div>
{/* 最新日志状态 */}
{project.status === 'active' && ( {project.status === 'active' && (
<div style={{ <div style={{
padding: '8px 12px', padding: '8px 12px',
@@ -156,13 +165,13 @@ const ConstructionList: React.FC = () => {
<> <>
<span></span> <span></span>
<Text style={{ fontSize: 13 }}> <Text style={{ fontSize: 13 }}>
: {project.latest_log?.work_content?.substring(0, 30)}... {t('construction.todayLog')}{project.latest_log?.work_content?.substring(0, 30)}...
</Text> </Text>
</> </>
) : ( ) : (
<> <>
<span></span> <span></span>
<Text type="warning" style={{ fontSize: 13 }}>今日日志: 未填写</Text> <Text type="warning" style={{ fontSize: 13 }}>{t('construction.todayLogEmpty')}</Text>
</> </>
)} )}
</div> </div>
@@ -170,7 +179,6 @@ const ConstructionList: React.FC = () => {
<Divider style={{ margin: '12px 0' }} /> <Divider style={{ margin: '12px 0' }} />
{/* 操作按钮 */}
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
<Button <Button
@@ -181,7 +189,7 @@ const ConstructionList: React.FC = () => {
size={isMobile ? 'large' : 'middle'} size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }} style={{ borderRadius: 8 }}
> >
{project.status === 'active' && !hasTodayLog ? '📝 写今日日志' : '📝 施工日志'} {project.status === 'active' && !hasTodayLog ? `📝 ${t('construction.writeLog')}` : `📝 ${t('construction.constructionLog')}`}
</Button> </Button>
</Col> </Col>
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
@@ -192,7 +200,7 @@ const ConstructionList: React.FC = () => {
size={isMobile ? 'large' : 'middle'} size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }} style={{ borderRadius: 8 }}
> >
📷 {`📷 ${t('construction.uploadPhoto')}`}
</Button> </Button>
</Col> </Col>
<Col xs={24} sm={8}> <Col xs={24} sm={8}>
@@ -203,7 +211,7 @@ const ConstructionList: React.FC = () => {
size={isMobile ? 'large' : 'middle'} size={isMobile ? 'large' : 'middle'}
style={{ borderRadius: 8 }} style={{ borderRadius: 8 }}
> >
📋 {`📋 ${t('construction.milestoneProgress')}`}
</Button> </Button>
</Col> </Col>
</Row> </Row>
@@ -217,42 +225,40 @@ const ConstructionList: React.FC = () => {
maxWidth: 1200, maxWidth: 1200,
margin: '0 auto' margin: '0 auto'
}}> }}>
{/* 页面标题 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}> <div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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 <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={fetchProjects} onClick={fetchProjects}
loading={loading} loading={loading}
> >
{t('common.refresh')}
</Button> </Button>
</div> </div>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}> <Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
{t('construction.description')}
</Paragraph> </Paragraph>
</div> </div>
{/* 项目列表 */}
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 16 }}>...</Paragraph> <Paragraph type="secondary" style={{ marginTop: 16 }}>{t('common.loading')}</Paragraph>
</div> </div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<Card style={{ borderRadius: 12 }}> <Card style={{ borderRadius: 12 }}>
<Empty <Empty
description="暂无施工项目" description={t('construction.noConstructionProjects')}
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
> >
<Text type="secondary"></Text> <Text type="secondary">{t('construction.contactAdmin')}</Text>
</Empty> </Empty>
</Card> </Card>
) : ( ) : (
<div> <div>
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}> <Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
({projects.length}) {t('construction.myProjects')} ({projects.length})
</Text> </Text>
{projects.map(project => renderProjectCard(project))} {projects.map(project => renderProjectCard(project))}
</div> </div>
@@ -11,18 +11,18 @@ import {
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
const { Option } = Select; const { Option } = Select;
// 天气选项
const WEATHER_OPTIONS = [ const WEATHER_OPTIONS = [
{ value: 'sunny', label: '☀️ 晴', icon: '☀️' }, { value: 'sunny', icon: '☀️' },
{ value: 'cloudy', label: '⛅ 多云', icon: '⛅' }, { value: 'cloudy', icon: '⛅' },
{ value: 'rainy', label: '🌧️ 雨', icon: '🌧️' }, { value: 'rainy', icon: '🌧️' },
{ value: 'stormy', label: '⛈️ 雷暴', icon: '⛈️' }, { value: 'stormy', icon: '⛈️' },
{ value: 'windy', label: '💨 大风', icon: '💨' }, { value: 'windy', icon: '💨' },
]; ];
interface Log { interface Log {
@@ -57,6 +57,7 @@ const ConstructionLog: React.FC = () => {
const [projectInfo, setProjectInfo] = useState<any>(null); const [projectInfo, setProjectInfo] = useState<any>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const { user } = useAuthStore(); const { user } = useAuthStore();
const { t, currentLanguage } = useLanguageStore();
useEffect(() => { useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768); const checkMobile = () => setIsMobile(window.innerWidth <= 768);
@@ -81,7 +82,7 @@ const ConstructionLog: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('获取日志列表失败:', error); console.error('获取日志列表失败:', error);
message.error('获取日志列表失败'); message.error(t('construction.getLogFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -107,18 +108,18 @@ const ConstructionLog: React.FC = () => {
log_date: values.log_date.format('YYYY-MM-DD'), log_date: values.log_date.format('YYYY-MM-DD'),
weather: values.weather, weather: values.weather,
work_content: values.work_content, work_content: values.work_content,
photos: '', // 暂时为空,后续添加照片上传功能 photos: '',
}); });
if (res.data.success) { if (res.data.success) {
message.success('日志添加成功'); message.success(t('construction.logAddSuccess'));
setModalVisible(false); setModalVisible(false);
form.resetFields(); form.resetFields();
fetchLogs(); fetchLogs();
} }
} catch (error) { } catch (error) {
console.error('添加日志失败:', error); console.error('添加日志失败:', error);
message.error('添加日志失败'); message.error(t('construction.logAddFailed'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -128,18 +129,17 @@ const ConstructionLog: React.FC = () => {
try { try {
const res = await apiClient.delete(`/construction-logs/${logId}`); const res = await apiClient.delete(`/construction-logs/${logId}`);
if (res.data.success) { if (res.data.success) {
message.success('日志删除成功'); message.success(t('construction.logDeleteSuccess'));
fetchLogs(); fetchLogs();
} }
} catch (error) { } catch (error) {
console.error('删除日志失败:', error); console.error('删除日志失败:', error);
message.error('删除日志失败'); message.error(t('construction.logDeleteFailed'));
} }
}; };
// 按日期分组
const groupedLogs = logs.reduce((acc, log) => { 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]) { if (!acc[month]) {
acc[month] = []; acc[month] = [];
} }
@@ -149,7 +149,16 @@ const ConstructionLog: React.FC = () => {
const getWeatherLabel = (value: string) => { const getWeatherLabel = (value: string) => {
const option = WEATHER_OPTIONS.find(o => o.value === value); 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) => { const formatFileSize = (bytes: number) => {
@@ -168,62 +177,57 @@ const ConstructionLog: React.FC = () => {
}} }}
styles={{ body: { padding: isMobile ? 16 : 20 } }} styles={{ body: { padding: isMobile ? 16 : 20 } }}
> >
{/* 日志头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Space> <Space>
<CalendarOutlined style={{ color: '#1890ff' }} /> <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> <Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
</Space> </Space>
<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 <Popconfirm
title="确定删除此日志?" title={t('construction.deleteLogConfirmTitle')}
description="删除后无法恢复" description={t('construction.deleteLogConfirmDesc')}
onConfirm={() => handleDeleteLog(log.id)} onConfirm={() => handleDeleteLog(log.id)}
okText="确定" okText={t('common.confirm')}
cancelText="取消" cancelText={t('common.cancel')}
> >
<Button type="text" danger size="small" icon={<DeleteOutlined />} /> <Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
</div> </div>
{/* 工作内容 */}
{log.work_content && ( {log.work_content && (
<div style={{ marginBottom: 12 }}> <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' }}> <Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.work_content} {log.work_content}
</Paragraph> </Paragraph>
</div> </div>
)} )}
{/* 明日计划 */}
{log.next_plan && ( {log.next_plan && (
<div style={{ marginBottom: 12 }}> <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' }}> <Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
{log.next_plan} {log.next_plan}
</Paragraph> </Paragraph>
</div> </div>
)} )}
{/* 问题记录 */}
{log.issues && ( {log.issues && (
<div style={{ marginBottom: 12 }}> <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' }}> <Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
{log.issues} {log.issues}
</Paragraph> </Paragraph>
</div> </div>
)} )}
{/* 照片展示 */}
{log.photos && log.photos.length > 0 && ( {log.photos && log.photos.length > 0 && (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}> <Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
({log.photos.length}): {t('construction.constructionPhoto')} ({log.photos.length}{t('common.sheet')}):
</Text> </Text>
<Image.PreviewGroup> <Image.PreviewGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
@@ -266,7 +270,6 @@ const ConstructionLog: React.FC = () => {
maxWidth: 800, maxWidth: 800,
margin: '0 auto' margin: '0 auto'
}}> }}>
{/* 页面头部 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}> <div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button <Button
@@ -275,7 +278,7 @@ const ConstructionLog: React.FC = () => {
/> />
<div> <div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}> <Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{t('construction.constructionLog')}
</Title> </Title>
{projectInfo && ( {projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}> <Text type="secondary" style={{ fontSize: 13 }}>
@@ -286,16 +289,15 @@ const ConstructionLog: React.FC = () => {
</div> </div>
</div> </div>
{/* 日志列表 */}
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : logs.length === 0 ? ( ) : logs.length === 0 ? (
<Card style={{ borderRadius: 12 }}> <Card style={{ borderRadius: 12 }}>
<Empty description="暂无施工日志"> <Empty description={t('construction.noLog')}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
{t('construction.addFirstLog')}
</Button> </Button>
</Empty> </Empty>
</Card> </Card>
@@ -312,7 +314,6 @@ const ConstructionLog: React.FC = () => {
</div> </div>
)} )}
{/* 底部添加按钮 */}
<div style={{ <div style={{
position: 'fixed', position: 'fixed',
bottom: 24, bottom: 24,
@@ -332,19 +333,18 @@ const ConstructionLog: React.FC = () => {
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)' boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
}} }}
> >
{t('construction.newLog')}
</Button> </Button>
</div> </div>
{/* 新增日志弹窗 */}
<Modal <Modal
title="新增施工日志" title={t('construction.newConstructionLog')}
open={modalVisible} open={modalVisible}
onOk={handleSubmit} onOk={handleSubmit}
onCancel={() => setModalVisible(false)} onCancel={() => setModalVisible(false)}
confirmLoading={submitting} confirmLoading={submitting}
okText="提交" okText={t('common.submit')}
cancelText="取消" cancelText={t('common.cancel')}
width={isMobile ? '95%' : 500} width={isMobile ? '95%' : 500}
style={{ top: 20 }} style={{ top: 20 }}
> >
@@ -360,8 +360,8 @@ const ConstructionLog: React.FC = () => {
<Col span={12}> <Col span={12}>
<Form.Item <Form.Item
name="log_date" name="log_date"
label="日期" label={t('common.date')}
rules={[{ required: true, message: '请选择日期' }]} rules={[{ required: true, message: t('construction.selectDate') }]}
> >
<DatePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -374,12 +374,12 @@ const ConstructionLog: React.FC = () => {
<Form.Item <Form.Item
name="weather" name="weather"
label="天气" label="天气"
rules={[{ required: true, message: '请选择天气' }]} rules={[{ required: true, message: t('construction.selectWeather') }]}
> >
<Select size="large"> <Select size="large">
{WEATHER_OPTIONS.map(opt => ( {WEATHER_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}> <Option key={opt.value} value={opt.value}>
{opt.label} {opt.icon} {t(`construction.${opt.value}`)}
</Option> </Option>
))} ))}
</Select> </Select>
@@ -389,33 +389,33 @@ const ConstructionLog: React.FC = () => {
<Form.Item <Form.Item
name="work_content" name="work_content"
label="今日工作" label={t('construction.todayWorkLabel')}
rules={[{ required: true, message: '请填写今日工作内容' }]} rules={[{ required: true, message: t('construction.inputTodayWork') }]}
> >
<TextArea <TextArea
rows={3} rows={3}
placeholder="描述今日完成的施工工作..." placeholder={t('construction.todayWorkPlaceholder')}
size="large" size="large"
/> />
</Form.Item> </Form.Item>
<Form.Item name="next_plan" label="明日计划"> <Form.Item name="next_plan" label={t('construction.tomorrowPlanLabel')}>
<TextArea <TextArea
rows={2} rows={2}
placeholder="明日工作计划..." placeholder={t('construction.tomorrowPlanPlaceholder')}
size="large" size="large"
/> />
</Form.Item> </Form.Item>
<Form.Item name="issues" label="问题记录"> <Form.Item name="issues" label={t('construction.issueRecordLabel')}>
<TextArea <TextArea
rows={2} rows={2}
placeholder="遇到的问题或需要协调的事项..." placeholder={t('construction.issuePlaceholder')}
size="large" size="large"
/> />
</Form.Item> </Form.Item>
<Form.Item label="上传照片"> <Form.Item label={t('construction.uploadPhoto')}>
<Upload <Upload
listType="picture-card" listType="picture-card"
multiple multiple
@@ -425,11 +425,11 @@ const ConstructionLog: React.FC = () => {
> >
<div> <div>
<CameraOutlined style={{ fontSize: 20 }} /> <CameraOutlined style={{ fontSize: 20 }} />
<div style={{ marginTop: 4, fontSize: 12 }}></div> <div style={{ marginTop: 4, fontSize: 12 }}>{t('construction.addPhoto')}</div>
</div> </div>
</Upload> </Upload>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
9 {t('construction.multiPhotoSupport')}
</Text> </Text>
</Form.Item> </Form.Item>
</Form> </Form>
@@ -9,42 +9,10 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph, Text } = Typography; 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 { interface Milestone {
id: number; id: number;
node_name: string; node_name: string;
@@ -62,6 +30,39 @@ const ConstructionMilestones: React.FC = () => {
const [milestones, setMilestones] = useState<Milestone[]>([]); const [milestones, setMilestones] = useState<Milestone[]>([]);
const [projectInfo, setProjectInfo] = useState<any>(null); const [projectInfo, setProjectInfo] = useState<any>(null);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768); const checkMobile = () => setIsMobile(window.innerWidth <= 768);
@@ -102,7 +103,6 @@ const ConstructionMilestones: React.FC = () => {
} }
}; };
// 计算进度
const completedCount = milestones.filter(m => m.status === 'completed').length; const completedCount = milestones.filter(m => m.status === 'completed').length;
const totalCount = milestones.length; const totalCount = milestones.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
@@ -147,7 +147,7 @@ const ConstructionMilestones: React.FC = () => {
</div> </div>
{milestone.due_date && ( {milestone.due_date && (
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}> <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> </Text>
)} )}
</Card> </Card>
@@ -161,7 +161,6 @@ const ConstructionMilestones: React.FC = () => {
maxWidth: 800, maxWidth: 800,
margin: '0 auto' margin: '0 auto'
}}> }}>
{/* 页面头部 */}
<div style={{ marginBottom: isMobile ? 16 : 24 }}> <div style={{ marginBottom: isMobile ? 16 : 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Button <Button
@@ -170,7 +169,7 @@ const ConstructionMilestones: React.FC = () => {
/> />
<div> <div>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}> <Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{t('construction.milestoneProgress')}
</Title> </Title>
{projectInfo && ( {projectInfo && (
<Text type="secondary" style={{ fontSize: 13 }}> <Text type="secondary" style={{ fontSize: 13 }}>
@@ -181,11 +180,10 @@ const ConstructionMilestones: React.FC = () => {
</div> </div>
</div> </div>
{/* 进度概览 */}
{!loading && milestones.length > 0 && ( {!loading && milestones.length > 0 && (
<Card style={{ marginBottom: 16, borderRadius: 12 }}> <Card style={{ marginBottom: 16, borderRadius: 12 }}>
<div style={{ textAlign: 'center', marginBottom: 16 }}> <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> <Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
</div> </div>
<Progress <Progress
@@ -199,31 +197,30 @@ const ConstructionMilestones: React.FC = () => {
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text> <Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
<br /> <br />
<Text type="secondary" style={{ fontSize: 12 }}></Text> <Text type="secondary" style={{ fontSize: 12 }}>{t('construction.completed')}</Text>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text> <Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
<br /> <br />
<Text type="secondary" style={{ fontSize: 12 }}></Text> <Text type="secondary" style={{ fontSize: 12 }}>{t('construction.inProgress')}</Text>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text> <Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
<br /> <br />
<Text type="secondary" style={{ fontSize: 12 }}></Text> <Text type="secondary" style={{ fontSize: 12 }}>{t('construction.totalNodes')}</Text>
</div> </div>
</div> </div>
</Card> </Card>
)} )}
{/* 节点时间线 */}
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 60 }}> <div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : milestones.length === 0 ? ( ) : milestones.length === 0 ? (
<Card style={{ borderRadius: 12 }}> <Card style={{ borderRadius: 12 }}>
<Empty description="暂无施工节点"> <Empty description={t('construction.noMilestones')}>
<Text type="secondary"></Text> <Text type="secondary">{t('construction.milestoneConfigured')}</Text>
</Empty> </Empty>
</Card> </Card>
) : ( ) : (
@@ -3,6 +3,7 @@ import { Card, Button, Progress, Tag, List, Spin, message, Empty, Space, Typogra
import { ArrowLeftOutlined, RightOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, RightOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -28,6 +29,7 @@ const ConstructionOverview: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { t, currentLanguage } = useLanguageStore();
useEffect(() => { fetchProjects(); }, []); useEffect(() => { fetchProjects(); }, []);
@@ -45,7 +47,7 @@ const ConstructionOverview: React.FC = () => {
})); }));
setProjects(enriched); setProjects(enriched);
} }
} catch (e) { message.error('获取项目列表失败'); } } catch (e) { message.error(t('construction.getListFailed')); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
@@ -55,7 +57,7 @@ const ConstructionOverview: React.FC = () => {
}; };
const getStatusText = (status: string) => { 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; return map[status] || status;
}; };
@@ -64,28 +66,28 @@ const ConstructionOverview: React.FC = () => {
return ( return (
<div style={{ padding: 24 }}> <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}> <Spin spinning={loading}>
{activeProjects.length === 0 && completedProjects.length === 0 ? ( {activeProjects.length === 0 && completedProjects.length === 0 ? (
<Empty description="暂无施工项目" /> <Empty description={t('construction.noProjects')} />
) : ( ) : (
<> <>
{activeProjects.length > 0 && ( {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 <List
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
dataSource={activeProjects} dataSource={activeProjects}
renderItem={(project) => ( renderItem={(project) => (
<List.Item <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 <List.Item.Meta
title={<Space><Text strong>{project.name}</Text><Tag color={getStatusColor(project.status)}>{getStatusText(project.status)}</Tag></Space>} title={<Space><Text strong>{project.name}</Text><Tag color={getStatusColor(project.status)}>{getStatusText(project.status)}</Tag></Space>}
description={ description={
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<div style={{ marginBottom: 4 }}> <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>} {project.phases.length > 0 && <Text type="secondary" style={{ marginLeft: 16 }}>{project.phases.filter(p => p.status === 'completed').length}/{project.phases.length}</Text>}
</div> </div>
<Progress percent={project.phase_progress || 0} size="small" strokeColor="#1890ff" /> <Progress percent={project.phase_progress || 0} size="small" strokeColor="#1890ff" />
@@ -99,16 +101,16 @@ const ConstructionOverview: React.FC = () => {
)} )}
{completedProjects.length > 0 && ( {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 <List
style={{ marginTop: 16 }} style={{ marginTop: 16 }}
dataSource={completedProjects} dataSource={completedProjects}
renderItem={(project) => ( renderItem={(project) => (
<List.Item <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 <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" />} description={<Progress percent={100} size="small" />}
/> />
</List.Item> </List.Item>
File diff suppressed because it is too large Load Diff
+93 -40
View File
@@ -7,10 +7,12 @@ import {
TeamOutlined TeamOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import { useLanguageStore } from '../../store/languageStore';
const { Title } = Typography; const { Title } = Typography;
const DashboardPage: React.FC = () => { const DashboardPage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [projectData, setProjectData] = useState<any[]>([]); const [projectData, setProjectData] = useState<any[]>([]);
const [stats, setStats] = useState({ const [stats, setStats] = useState({
@@ -32,83 +34,136 @@ const DashboardPage: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
// 从 API 获取真实项目数据 const fetchDashboardData = async () => {
const fetchProjects = async () => {
try { 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, key: project.id,
name: project.name, name: project.name,
status: project.status === 'planning' ? '规划中' : status: project.status,
project.status === 'active' ? '进行中' : budget: parseFloat(project.budget || project.contract_amount || 0),
project.status === 'completed' ? '已完成' : project.status, spent: parseFloat(project.total_expense || project.spent || 0)
budget: parseFloat(project.budget || 0),
spent: parseFloat(project.spent || 0)
})); }));
setProjectData(projects); 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) { } catch (error) {
console.error('获取项目列表失败:', error); console.error('获取仪表盘数据失败:', error);
} finally { } finally {
setLoading(false); 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 = [ const desktopColumns = [
{ title: '项目名称', dataIndex: 'name', key: 'name' }, { title: t('dashboard.projectName'), dataIndex: 'name', key: 'name' },
{ {
title: '状态', title: t('common.status'),
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (status: string) => { render: (status: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
'进行中': 'blue', planning: 'orange',
'已完成': 'green', active: 'blue',
'规划中': 'orange', 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', dataIndex: 'budget',
key: 'budget', key: 'budget',
render: (value: number) => `¥${value.toLocaleString()}` render: (value: number) => `¥${value.toLocaleString()}`
}, },
{ {
title: '已花费', title: t('dashboard.spent'),
dataIndex: 'spent', dataIndex: 'spent',
key: 'spent', key: 'spent',
render: (value: number) => `¥${value.toLocaleString()}` render: (value: number) => `¥${value.toLocaleString()}`
} }
]; ];
// 移动端简化表格列
const mobileColumns = [ 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', dataIndex: 'status',
key: 'status', key: 'status',
render: (status: string) => ( render: (status: string) => (
<Tag color={status === '已完成' ? 'green' : 'blue'} style={{ fontSize: 10 }}> <Tag color={status === 'completed' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
{status} {statusDisplayMap[status] || status}
</Tag> </Tag>
) )
}, },
{ {
title: '预算/花费', title: t('dashboard.budgetSpent'),
key: 'budget_spent', key: 'budget_spent',
render: (_: any, record: any) => ( render: (_: any, record: any) => (
<div style={{ fontSize: 12 }}> <div style={{ fontSize: 12 }}>
<div>¥{(record.budget / 10000).toFixed(0)}</div> <div>{t('dashboard.budgetLabel')}¥{(record.budget / 10000).toFixed(0)}{t('common.tenThousand')}</div>
<div style={{ color: '#888' }}>¥{(record.spent / 10000).toFixed(0)}</div> <div style={{ color: '#888' }}>{t('dashboard.spentLabel')}¥{(record.spent / 10000).toFixed(0)}{t('common.tenThousand')}</div>
</div> </div>
) )
} }
@@ -117,15 +172,14 @@ const DashboardPage: React.FC = () => {
return ( return (
<div style={{ padding: isMobile ? 8 : 24 }}> <div style={{ padding: isMobile ? 8 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: isMobile ? 12 : 24 }}> <Title level={isMobile ? 3 : 2} style={{ marginBottom: isMobile ? 12 : 24 }}>
📊 {t('dashboard.title')}
</Title> </Title>
{/* 统计卡片 - 移动端优化 */}
<Row gutter={[8, 8]} style={{ marginBottom: isMobile ? 12 : 24 }}> <Row gutter={[8, 8]} style={{ marginBottom: isMobile ? 12 : 24 }}>
<Col xs={12} sm={12} md={6}> <Col xs={12} sm={12} md={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
title={<span style={{ fontSize: 12 }}></span>} title={<span style={{ fontSize: 12 }}>{t('dashboard.inProgressProjects')}</span>}
value={stats.ongoingProjects} value={stats.ongoingProjects}
prefix={<ProjectOutlined />} prefix={<ProjectOutlined />}
valueStyle={{ color: '#1890ff', fontSize: isMobile ? 18 : undefined }} valueStyle={{ color: '#1890ff', fontSize: isMobile ? 18 : undefined }}
@@ -135,18 +189,18 @@ const DashboardPage: React.FC = () => {
<Col xs={12} sm={12} md={6}> <Col xs={12} sm={12} md={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
title={<span style={{ fontSize: 12 }}></span>} title={<span style={{ fontSize: 12 }}>{t('dashboard.monthlyReimburse')}</span>}
value={stats.monthlyReimbursements} value={stats.monthlyReimbursements}
prefix={<DollarOutlined />} prefix={<DollarOutlined />}
valueStyle={{ color: '#52c41a', fontSize: isMobile ? 18 : undefined }} valueStyle={{ color: '#52c41a', fontSize: isMobile ? 18 : undefined }}
suffix="元" suffix={t('common.yuan')}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={12} md={6}> <Col xs={12} sm={12} md={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
title={<span style={{ fontSize: 12 }}></span>} title={<span style={{ fontSize: 12 }}>{t('dashboard.pendingApproval')}</span>}
value={stats.pendingApprovals} value={stats.pendingApprovals}
prefix={<FileTextOutlined />} prefix={<FileTextOutlined />}
valueStyle={{ color: '#faad14', fontSize: isMobile ? 18 : undefined }} valueStyle={{ color: '#faad14', fontSize: isMobile ? 18 : undefined }}
@@ -156,7 +210,7 @@ const DashboardPage: React.FC = () => {
<Col xs={12} sm={12} md={6}> <Col xs={12} sm={12} md={6}>
<Card size="small"> <Card size="small">
<Statistic <Statistic
title={<span style={{ fontSize: 12 }}></span>} title={<span style={{ fontSize: 12 }}>{t('dashboard.teamMembers')}</span>}
value={stats.teamMembers} value={stats.teamMembers}
prefix={<TeamOutlined />} prefix={<TeamOutlined />}
valueStyle={{ color: '#722ed1', fontSize: isMobile ? 18 : undefined }} valueStyle={{ color: '#722ed1', fontSize: isMobile ? 18 : undefined }}
@@ -165,16 +219,15 @@ const DashboardPage: React.FC = () => {
</Col> </Col>
</Row> </Row>
{/* 项目列表 */}
<Card <Card
title="最近项目" title={t('dashboard.recentProjects')}
size="small" size="small"
styles={{ body: { padding: isMobile ? 8 : 24 } }} styles={{ body: { padding: isMobile ? 8 : 24 } }}
> >
{loading ? ( {loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}> <div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" /> <Spin size="large" />
<div style={{ marginTop: 16, color: '#666' }}>...</div> <div style={{ marginTop: 16, color: '#666' }}>{t('common.loadingData')}</div>
</div> </div>
) : ( ) : (
<Table <Table
@@ -183,7 +236,7 @@ const DashboardPage: React.FC = () => {
pagination={false} pagination={false}
scroll={isMobile ? { x: 400 } : undefined} scroll={isMobile ? { x: 400 } : undefined}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
locale={{ emptyText: '暂无项目数据' }} locale={{ emptyText: t('common.noProjectData') }}
/> />
)} )}
</Card> </Card>
+390 -150
View File
@@ -1,39 +1,103 @@
import React, { useState, useEffect } from 'react'; 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 } from 'antd'; 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 } from '@ant-design/icons'; import { DollarOutlined, RiseOutlined, FallOutlined, PlusOutlined, DownloadOutlined, DeleteOutlined, UploadOutlined, FundOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph } = Typography; const { Title } = Typography;
const { RangePicker } = DatePicker; 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 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 [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 }); const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
const [byCategory, setByCategory] = useState<any[]>([]); const [byCategory, setByCategory] = useState<any[]>([]);
@@ -45,12 +109,24 @@ const FinancePage: React.FC = () => {
const [addModalVisible, setAddModalVisible] = useState(false); const [addModalVisible, setAddModalVisible] = useState(false);
const [addLoading, setAddLoading] = useState(false); const [addLoading, setAddLoading] = useState(false);
const [addModalType, setAddModalType] = useState<'income' | 'expense'>('income');
const [form] = Form.useForm(); 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 [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 [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(() => { useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768); const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile(); checkMobile();
@@ -65,6 +141,12 @@ const FinancePage: React.FC = () => {
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => { apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setProjects(res.data.data || res.data.projects || []); if (res.data.success) setProjects(res.data.data || res.data.projects || []);
}).catch(() => {}); }).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 => { apiClient.get('/exchange-rates/latest').then(res => {
if (res.data.success && res.data.data) { if (res.data.success && res.data.data) {
const rates: Record<string, number> = { CNY: 1 }; const rates: Record<string, number> = { CNY: 1 };
@@ -110,42 +192,53 @@ const FinancePage: React.FC = () => {
setLoading(false); 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.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); setAddModalVisible(true);
}; };
const handleTxnTypeChange = (v: string) => { const handleAddExpense = () => {
form.setFieldsValue({ category_level1: undefined, category_level2: undefined }); setAddModalType('expense');
if (v === 'income') { form.resetFields();
form.setFieldsValue({ category_level1: 'income' }); setAmountVal(0);
} setRateVal(1);
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1, txn_type: 'expense', category_level1: 'project' });
setAddModalVisible(true);
}; };
const handleLevel1Change = () => { const handleLevel1Change = () => {
form.setFieldsValue({ category_level2: undefined }); form.setFieldsValue({ category_level2: undefined, project_id: undefined });
}; };
const handleCurrencyChange = (currency: string) => { const handleCurrencyChange = (currency: string) => {
const rate = exchangeRates[currency] || 1; const rate = exchangeRates[currency] || 1;
setRateVal(rate);
form.setFieldsValue({ exchange_rate: 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 () => { const handleSave = async () => {
@@ -153,37 +246,63 @@ const FinancePage: React.FC = () => {
const values = await form.validateFields(); const values = await form.validateFields();
setAddLoading(true); setAddLoading(true);
const payload = { 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'), record_date: values.record_date?.format('YYYY-MM-DD'),
amount_cny: Math.round((values.amount_original || 0) * (values.exchange_rate || 1) * 100) / 100, counterparty_name: values.counterparty_name,
source: 'manual', 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') { if (!['project', 'income'].includes(payload.category_level1)) {
payload.project_id = undefined; delete payload.project_id;
} }
await apiClient.post('/financial-records', payload); await apiClient.post('/cash-management', payload);
message.success('录入成功'); message.success(t('finance.recordSuccess'));
setAddModalVisible(false); setAddModalVisible(false);
fetchSummary(); fetchSummary();
fetchRecords(1); fetchRecords(1);
if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1);
} catch (e: any) { } catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message); if (e.response?.data?.message) message.error(e.response.data.message);
} }
setAddLoading(false); 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 () => { const handleExport = async () => {
try { 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 } }); const res = await apiClient.get('/financial-records', { params: { page: 1, pageSize: 5000 } });
if (!res.data.success) return; if (!res.data.success) return;
const data = res.data.data; const data = res.data.data;
const wb = XLSX.utils.book_new(); 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) => [ const rows = data.map((r: any) => [
r.record_date, 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, LEVEL1_LABELS[r.category_level1] || r.category_level1,
LEVEL2_LABELS[r.category_level2] || r.category_level2, LEVEL2_LABELS[r.category_level2] || r.category_level2,
r.project_name || '', r.project_name || '',
@@ -195,22 +314,25 @@ const FinancePage: React.FC = () => {
COUNTERPARTY_TYPES.find(c => c.value === r.counterparty_type)?.label || r.counterparty_type || '', COUNTERPARTY_TYPES.find(c => c.value === r.counterparty_type)?.label || r.counterparty_type || '',
r.user_name || '', r.user_name || '',
r.description || '', r.description || '',
r.voucher_no || '', SOURCE_MAP[r.source] || r.source || '',
]); ]);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]); const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
ws['!cols'] = headers.map(() => ({ wch: 14 })); ws['!cols'] = headers.map(() => ({ wch: 14 }));
XLSX.utils.book_append_sheet(wb, ws, '财务记账'); XLSX.utils.book_append_sheet(wb, ws, t('finance.sheetName'));
XLSX.writeFile(wb, `财务记录_${dayjs().format('YYYYMMDD')}.xlsx`); XLSX.writeFile(wb, `${t('finance.title')}_${dayjs().format('YYYYMMDD')}.xlsx`);
message.success({ content: '导出成功', key: 'export' }); message.success({ content: t('finance.exportSuccess'), key: 'export' });
} catch (e) { } catch (e) {
message.error({ content: '导出失败', key: 'export' }); message.error({ content: t('finance.exportFailed'), key: 'export' });
} }
}; };
const getLevel1Options = () => { const getLevel1Options = (txnType: string) => {
const txnType = form.getFieldValue('txn_type'); if (txnType === 'income') return [{ value: 'income', label: t('finance.incomeCategory') }];
if (txnType === 'income') return [{ value: 'income', label: '收入' }]; return [
return [{ value: 'project', label: '项目支出' }, { value: 'company', label: '公司支出' }]; { value: 'project', label: t('finance.projectExpense') },
{ value: 'company', label: t('finance.companyExpense') },
{ value: 'finance', label: t('cash.financeExpense') },
];
}; };
const getLevel2Options = () => { const getLevel2Options = () => {
@@ -221,69 +343,93 @@ const FinancePage: React.FC = () => {
const projectOptions = projects.map((p: any) => ({ value: p.id, label: p.name })); 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 = [ const desktopColumns = [
{ title: '日期', dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date) }, { 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: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> }, { 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: '一级分类', dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v }, { title: t('finance.level1Category'), 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: t('finance.level2Category'), dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '项目', dataIndex: 'project_name', width: 140, ellipsis: true }, { title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
{ title: '原始金额', dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const }, { title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: '币种', dataIndex: 'currency', width: 50 }, { title: t('finance.currency'), 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: 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: '人员', dataIndex: 'user_name', width: 70 }, { title: t('finance.personName'), dataIndex: 'user_name', width: 70 },
{ title: '描述', dataIndex: 'description', ellipsis: true }, { title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
{ title: '来源', dataIndex: 'source', width: 80, render: (v: string) => { { title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_MAP[v] || v}</Tag> },
const m: Record<string, string> = { manual: '手动', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' };
return <Tag>{m[v] || v}</Tag>;
}},
]; ];
const mobileColumns = [ const mobileColumns = [
{ title: '日期', dataIndex: 'record_date', width: 80 }, { title: t('finance.date'), dataIndex: 'record_date', width: 80, render: (v: string) => v?.slice(0, 10) },
{ title: '收支', dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> }, { title: t('finance.incomeType'), 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: t('finance.level2Category'), 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.amount'), dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
]; ];
return ( const cashColumns = [
<div> { title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}> { title: t('finance.level1Category'), dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
<div> { title: t('finance.level2Category'), dataIndex: 'category_level2', width: 110, render: (v: string) => LEVEL2_LABELS[v] || v },
<Title level={3} style={{ marginBottom: 0 }}></Title> { title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
</div> { title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
<Space> { title: t('finance.currency'), dataIndex: 'currency', width: 50 },
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}> { title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const },
{isMobile ? '新增' : '新增记录'} { title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
</Button> { title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
<Button icon={<DownloadOutlined />} onClick={handleExport}> {
{isMobile ? '导出' : '导出Excel'} title: '', width: 50, render: (_: any, record: any) => (
</Button> <Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
</Space> <Button type="text" danger size="small" icon={<DeleteOutlined />} />
</div> </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 }}> <Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col xs={8} sm={8}> <Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}> <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 }} /> valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
</Card> </Card>
</Col> </Col>
<Col xs={8} sm={8}> <Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}> <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 }} /> valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card> </Card>
</Col> </Col>
<Col xs={8} sm={8}> <Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}> <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 }} /> valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card> </Card>
</Col> </Col>
</Row> </Row>
{byCategory.length > 0 && ( {byCategory.length > 0 && (
<Card title="支出分类汇总" size="small" style={{ marginBottom: 16 }}> <Card title={t('finance.expenseSummary')} size="small" style={{ marginBottom: 16 }}>
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
{byCategory.filter(c => c.category_level1 !== 'income').map((c, i) => ( {byCategory.filter(c => c.category_level1 !== 'income').map((c, i) => (
<Col xs={12} sm={8} md={6} key={i}> <Col xs={12} sm={8} md={6} key={i}>
@@ -295,13 +441,13 @@ const FinancePage: React.FC = () => {
</Card> </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={ extra={
<Space size="small" wrap> <Space size="small" wrap>
<RangePicker size="small" onChange={(dates) => setDateRange(dates as any)} /> <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} 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> </Space>
} }
> >
@@ -318,92 +464,186 @@ const FinancePage: React.FC = () => {
total: pagination.total, total: pagination.total,
onChange: (page) => fetchRecords(page), onChange: (page) => fetchRecords(page),
size: isMobile ? 'small' : 'default', size: isMobile ? 'small' : 'default',
showTotal: (total) => `${total}` showTotal: (total) => t('finance.totalRecords', { total })
}} }}
/> />
</Spin> </Spin>
</Card> </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 <Modal
title="新增财务记录" title={addModalType === 'income' ? t('cash.addIncome') : t('cash.addExpense')}
open={addModalVisible} open={addModalVisible}
onOk={handleSave} onOk={handleSave}
onCancel={() => setAddModalVisible(false)} onCancel={() => setAddModalVisible(false)}
confirmLoading={addLoading} confirmLoading={addLoading}
okText="保存" okText={t('common.save')}
cancelText="取消" cancelText={t('common.cancel')}
width={isMobile ? '95%' : 600} width={isMobile ? '95%' : 640}
style={{ top: isMobile ? 10 : 40 }} style={{ top: isMobile ? 10 : 40 }}
destroyOnClose destroyOnClose
> >
<Form form={form} layout={isMobile ? 'vertical' : 'horizontal'} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }} size="middle"> <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%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="txn_type" label="收支类型" rules={[{ required: true, message: '请选择' }]}> <Form.Item name="category_level1" label={t('finance.level1Category')} rules={[{ required: true, message: t('finance.selectLevel1') }]}>
<Select onChange={handleTxnTypeChange} options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} placeholder="请选择" /> <Select onChange={handleLevel1Change} options={getLevel1Options(addModalType)} placeholder={t('finance.selectLevel1')} />
</Form.Item> </Form.Item>
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}> <Form.Item name="category_level2" label={t('finance.level2Category')} rules={[{ required: true, message: t('finance.selectLevel2') }]}>
<Select onChange={handleLevel1Change} options={getLevel1Options()} placeholder="请选择" /> <Select options={getLevel2Options()} placeholder={t('finance.selectLevel2')} notFoundContent={t('finance.selectLevel2')} />
</Form.Item> </Form.Item>
<Form.Item name="category_level2" label="二级分类" rules={[{ required: true, message: '请选择' }]}> <Form.Item name="project_id" label={t('finance.projectName')}>
<Select options={getLevel2Options()} placeholder="请先选择一级分类" notFoundContent="请先选择一级分类" /> <Select showSearch optionFilterProp="label" options={projectOptions} placeholder={t('finance.selectProject')} allowClear />
</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> </Form.Item>
<Row gutter={8}> <Row gutter={8}>
<Col span={10}> <Col span={10}>
<Form.Item name="amount_original" label={isMobile ? '金额' : '金额'} labelCol={{ span: isMobile ? 24 : 8 }} wrapperCol={{ span: isMobile ? 24 : 16 }} rules={[{ required: true, message: '请输入' }]}> <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={handleAmountChange} placeholder="0" /> <InputNumber style={{ width: '100%' }} min={0} precision={2} onChange={(v) => setAmountVal(v || 0)} placeholder={t('finance.amountPlaceholder')} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={7}> <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} /> <Select options={CURRENCIES} onChange={handleCurrencyChange} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={7}> <Col span={7}>
<Form.Item name="exchange_rate" label={isMobile ? '汇率' : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}> <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={handleRateChange} /> <InputNumber style={{ width: '100%' }} min={0} step={0.0001} precision={6} onChange={(v) => setRateVal(v || 1)} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item label="等效人民币"> <Form.Item label={t('finance.equivalentCNY')}>
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}> <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> </span>
</Form.Item> </Form.Item>
<Divider style={{ margin: '8px 0' }} /> <Divider style={{ margin: '8px 0' }} />
<Form.Item name="counterparty_name" label="对方名称" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}> <Form.Item name="counterparty_type" label={t('finance.counterpartyType')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input placeholder="收款方/付款方名称" /> <Select options={COUNTERPARTY_TYPES} placeholder={t('finance.selectType')} allowClear onChange={() => form.setFieldsValue({ counterparty_id: undefined })} />
</Form.Item> </Form.Item>
<Form.Item name="counterparty_type" label="对方类型" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}> {getCounterpartyOptions().length > 0 && (
<Select options={COUNTERPARTY_TYPES} placeholder="选择类型" allowClear /> <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>
<Form.Item name="user_name" label="人员姓名" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}> <Form.Item name="description" label={t('finance.desc')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input placeholder="关联员工姓名" /> <Input.TextArea rows={2} placeholder={t('finance.descPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="description" label="描述" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}> <Form.Item name="voucher_url" label={t('cash.voucherUpload')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input.TextArea rows={2} placeholder="补充说明" /> <Upload
</Form.Item> maxCount={1}
action="/api/upload/single"
<Form.Item name="voucher_no" label="凭证编号" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}> headers={{ Authorization: `Bearer ${JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.token || ''}` }}
<Input placeholder="发票号/收据号" /> 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.Item>
</Form> </Form>
</Modal> </Modal>
File diff suppressed because it is too large Load Diff
+71 -60
View File
@@ -4,6 +4,7 @@ import { Card, Typography, Button, Space, Table, Tag, message, Spin, Modal, Inpu
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useLanguageStore } from '../../store/languageStore';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
@@ -28,6 +29,7 @@ interface Project {
const ProjectsPage: React.FC = () => { const ProjectsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, currentLanguage } = useLanguageStore();
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -41,6 +43,7 @@ const ProjectsPage: React.FC = () => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [customers, setCustomers] = useState<any[]>([]); const [customers, setCustomers] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [templates, setTemplates] = useState<any[]>([]);
const { user: currentUser } = useAuthStore(); const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin' || false; const isAdmin = currentUser?.role === 'admin' || false;
@@ -62,13 +65,13 @@ const ProjectsPage: React.FC = () => {
...p, ...p,
key: p.id.toString(), key: p.id.toString(),
progress: p.status === 'completed' ? 100 : (p.phase_progress || 0), progress: p.status === 'completed' ? 100 : (p.phase_progress || 0),
manager_name: p.manager_name || '未分配' manager_name: p.manager_name || t('project.unassigned')
}))); })));
} else { } else {
message.error(`获取项目列表失败: ${response.data.message}`); message.error(`${t('project.getListFailed')}: ${response.data.message}`);
} }
} catch (error) { } catch (error) {
message.error('获取项目列表失败'); message.error(t('project.getListFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -76,12 +79,14 @@ const ProjectsPage: React.FC = () => {
const fetchCreateOptions = async () => { const fetchCreateOptions = async () => {
try { try {
const [custRes, userRes] = await Promise.all([ const [custRes, userRes, tplRes] = await Promise.all([
apiClient.get('/customers'), apiClient.get('/customers'),
apiClient.get('/users'), apiClient.get('/users'),
apiClient.get('/process-templates'),
]); ]);
if (custRes.data.success) setCustomers(custRes.data.data || []); if (custRes.data.success) setCustomers(custRes.data.data || []);
if (userRes.data.success) setUsers(userRes.data.data || []); if (userRes.data.success) setUsers(userRes.data.data || []);
if (tplRes.data.success) setTemplates(tplRes.data.data || []);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
}; };
@@ -99,17 +104,18 @@ const ProjectsPage: React.FC = () => {
const payload = { const payload = {
...values, ...values,
manager_id: values.project_manager_id, manager_id: values.project_manager_id,
type_template_id: values.type_template_id || undefined,
start_date: values.start_date?.format('YYYY-MM-DD'), start_date: values.start_date?.format('YYYY-MM-DD'),
end_date: values.end_date?.format('YYYY-MM-DD') || null, end_date: values.end_date?.format('YYYY-MM-DD') || null,
contract_amount: values.contract_amount || 0, contract_amount: values.contract_amount || 0,
}; };
const res = await apiClient.post('/projects', payload); const res = await apiClient.post('/projects', payload);
if (res.data.success) { if (res.data.success) {
message.success('项目创建成功'); message.success(t('project.createSuccess'));
setCreateModalVisible(false); setCreateModalVisible(false);
fetchProjects(); fetchProjects();
} else { } else {
message.error(res.data.message || '创建失败'); message.error(res.data.message || t('project.createFailed'));
} }
} catch (e: any) { } catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message); if (e.response?.data?.message) message.error(e.response.data.message);
@@ -126,7 +132,7 @@ const ProjectsPage: React.FC = () => {
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!deleteProjectId) return; if (!deleteProjectId) return;
if (deletePassword !== 'X123c321@') { if (deletePassword !== 'X123c321@') {
message.error('密码错误'); message.error(t('project.passwordError'));
return; return;
} }
setDeleteLoading(true); setDeleteLoading(true);
@@ -135,14 +141,14 @@ const ProjectsPage: React.FC = () => {
headers: { 'x-user-role': 'admin' } headers: { 'x-user-role': 'admin' }
}); });
if (response.data.success) { if (response.data.success) {
message.success('项目删除成功'); message.success(t('project.deleteSuccess'));
setDeleteModalVisible(false); setDeleteModalVisible(false);
fetchProjects(); fetchProjects();
} else { } else {
message.error(response.data.message || '删除失败'); message.error(response.data.message || t('common.deleteFailed'));
} }
} catch (error) { } catch (error) {
message.error('删除项目失败'); message.error(t('common.operationFailed'));
} finally { } finally {
setDeleteLoading(false); setDeleteLoading(false);
} }
@@ -150,21 +156,21 @@ const ProjectsPage: React.FC = () => {
const desktopColumns = [ 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) => ( render: (text: string, record: Project) => (
<a onClick={() => navigate(`/projects/${record.id}`)}>{text}</a> <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) => { render: (amount: string) => {
const val = parseFloat(amount || '0'); 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) => ( render: (progress: number) => (
<div style={{ width: 100 }}> <div style={{ width: 100 }}>
<div style={{ background: '#f0f0f0', borderRadius: 10, height: 8 }}> <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) => { render: (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划中' }, in_progress: { color: 'processing', text: '进行中' }, planning: { color: 'blue', text: t('project.planning') }, in_progress: { color: 'processing', text: t('project.inProgress') },
completed: { color: 'success', text: '已完成' }, suspended: { color: 'warning', text: '已暂停' }, completed: { color: 'success', text: t('project.completed') }, suspended: { color: 'warning', text: t('project.paused') },
active: { color: 'processing', text: '进行中' }, active: { color: 'processing', text: t('project.inProgress') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; 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) => ( render: (_: unknown, record: Project) => (
<Space> <Space>
<Button size="small" onClick={() => navigate(`/projects/${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)}></Button>} {isAdmin && <Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteProject(record.id)}>{t('common.delete')}</Button>}
</Space> </Space>
), ),
}, },
@@ -202,13 +208,13 @@ const ProjectsPage: React.FC = () => {
const mobileColumns = [ const mobileColumns = [
{ {
title: '项目', dataIndex: 'name', key: 'name', ellipsis: true, title: t('dashboard.project'), dataIndex: 'name', key: 'name', ellipsis: true,
render: (text: string, record: Project) => ( render: (text: string, record: Project) => (
<a onClick={() => navigate(`/projects/${record.id}`)}>{text}</a> <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) => ( render: (progress: number) => (
<div style={{ width: 60 }}> <div style={{ width: 60 }}>
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 6 }}> <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) => { render: (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = { const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划' }, in_progress: { color: 'processing', text: '进行中' }, planning: { color: 'blue', text: t('project.plan') }, in_progress: { color: 'processing', text: t('project.inProgress') },
completed: { color: 'success', text: '完成' }, suspended: { color: 'warning', text: '暂停' }, completed: { color: 'success', text: t('project.complete') }, suspended: { color: 'warning', text: t('project.pause') },
active: { color: 'processing', text: '进行中' }, active: { color: 'processing', text: t('project.inProgress') },
}; };
const config = statusMap[status] || { color: 'default', text: status }; const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color} style={{ fontSize: 10 }}>{config.text}</Tag>; 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) => ( 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={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}> <div style={{ marginBottom: isMobile ? 12 : 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div> <div>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title> <Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('project.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph> <Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('project.description')}</Paragraph>
</div> </div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateClick}> <Button type="primary" icon={<PlusOutlined />} onClick={handleCreateClick}>
{isMobile ? '新建' : '新建项目'} {isMobile ? t('common.create') : t('project.newProject')}
</Button> </Button>
</div> </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 ? ( {loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div> <div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : ( ) : (
@@ -268,32 +274,37 @@ const ProjectsPage: React.FC = () => {
</Card> </Card>
<Modal <Modal
title="快速新建项目" title={t('project.quickCreate')}
open={createModalVisible} open={createModalVisible}
onOk={handleCreateSubmit} onOk={handleCreateSubmit}
onCancel={() => setCreateModalVisible(false)} onCancel={() => setCreateModalVisible(false)}
confirmLoading={createLoading} confirmLoading={createLoading}
okText="创建" okText={t('common.create')}
cancelText="取消" cancelText={t('common.cancel')}
width={isMobile ? '95%' : 560} width={isMobile ? '95%' : 560}
style={{ top: isMobile ? 10 : 40 }} style={{ top: isMobile ? 10 : 40 }}
destroyOnClose destroyOnClose
> >
<Form form={form} layout="vertical" size="middle"> <Form form={form} layout="vertical" size="middle">
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请输入项目名称' }]}> <Form.Item name="name" label={t('project.projectName')} rules={[{ required: true, message: t('common.inputPlaceholder') + t('project.projectName') }]}>
<Input placeholder="如:万象省赛塔尼县22kV线路工程" /> <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> </Form.Item>
<Row gutter={8}> <Row gutter={8}>
<Col span={12}> <Col span={12}>
<Form.Item name="customer_id" label="客户"> <Form.Item name="customer_id" label={t('project.customer')}>
<Select showSearch optionFilterProp="label" allowClear placeholder="选择客户" <Select showSearch optionFilterProp="label" allowClear placeholder={t('project.selectCustomer')}
options={customers.map((c: any) => ({ value: c.id, label: c.name }))} /> options={customers.map((c: any) => ({ value: c.id, label: c.name }))} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="project_manager_id" label="项目经理"> <Form.Item name="project_manager_id" label={t('project.projectManager')}>
<Select showSearch optionFilterProp="label" allowClear placeholder="选择项目经理" <Select showSearch optionFilterProp="label" allowClear placeholder={t('project.selectManager')}
options={users.map((u: any) => ({ value: u.id, label: u.name || u.username }))} /> options={users.map((u: any) => ({ value: u.id, label: u.name || u.username }))} />
</Form.Item> </Form.Item>
</Col> </Col>
@@ -301,16 +312,16 @@ const ProjectsPage: React.FC = () => {
<Row gutter={8}> <Row gutter={8}>
<Col span={12}> <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" /> <InputNumber style={{ width: '100%' }} min={0} placeholder="0" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="status" label="项目状态"> <Form.Item name="status" label={t('project.projectStatus')}>
<Select options={[ <Select options={[
{ value: 'planning', label: '规划中' }, { value: 'planning', label: t('project.planning') },
{ value: 'in_progress', label: '进行中' }, { value: 'in_progress', label: t('project.inProgress') },
{ value: 'completed', label: '已完成(补录历史项目)' }, { value: 'completed', label: t('project.completedHistory') },
]} /> ]} />
</Form.Item> </Form.Item>
</Col> </Col>
@@ -318,41 +329,41 @@ const ProjectsPage: React.FC = () => {
<Row gutter={8}> <Row gutter={8}>
<Col span={12}> <Col span={12}>
<Form.Item name="start_date" label="开始日期"> <Form.Item name="start_date" label={t('project.startDate')}>
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item name="end_date" label="结束日期"> <Form.Item name="end_date" label={t('project.endDate')}>
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item name="location" label="项目地点"> <Form.Item name="location" label={t('project.location')}>
<Input placeholder="如:老挝万象省" /> <Input placeholder={t('project.locationPlaceholder')} />
</Form.Item> </Form.Item>
<Form.Item name="description" label="项目描述"> <Form.Item name="description" label={t('project.description')}>
<Input.TextArea rows={2} placeholder="简要描述项目内容" /> <Input.TextArea rows={2} placeholder={t('project.descriptionPlaceholder')} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal <Modal
title="删除确认" title={t('project.deleteConfirm')}
open={deleteModalVisible} open={deleteModalVisible}
onOk={handleDeleteConfirm} onOk={handleDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)} onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading} confirmLoading={deleteLoading}
okText="确认删除" okText={t('common.deleteConfirm')}
cancelText="取消" cancelText={t('common.cancel')}
> >
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p></p> <p>{t('project.deleteConfirmMsg')}</p>
<p></p> <p>{t('project.deletePassMsg')}</p>
</div> </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> </Modal>
</div> </div>
); );
File diff suppressed because it is too large Load Diff
+61 -35
View File
@@ -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 { Card, Typography, Table, DatePicker, Row, Col, Statistic, Tag, Spin } from 'antd';
import { RiseOutlined, FallOutlined, DollarOutlined } from '@ant-design/icons'; import { RiseOutlined, FallOutlined, DollarOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request'; import apiClient from '../../utils/request';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useLanguageStore } from '../../store/languageStore';
const { Title, Paragraph } = Typography; 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 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 [loading, setLoading] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 }); const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
@@ -57,31 +83,31 @@ const ReportsPage: React.FC = () => {
})); }));
const desktopColumns = [ const desktopColumns = [
{ title: '月份', dataIndex: 'month', key: 'month' }, { title: t('reports.month'), dataIndex: 'month', key: 'month' },
{ title: '总收入', dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` }, { title: t('reports.totalIncome'), dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
{ title: '总支出', dataIndex: 'expense', key: 'expense', 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> render: (v: number) => <span style={{ color: v >= 0 ? 'green' : 'red', fontWeight: 'bold' }}>¥{v.toLocaleString()}</span>
}, },
]; ];
const mobileColumns = [ const mobileColumns = [
{ title: '月份', dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') }, { title: t('reports.month'), 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: t('reports.totalIncome'), dataIndex: 'income', key: 'income', render: (v: number) => <span style={{ color: 'green' }}>¥{(parseFloat(v) / 10000).toFixed(1)}{t('common.tenThousand')}</span> },
{ title: '支出', dataIndex: 'expense', key: 'expense', render: (v: number) => <span style={{ color: 'red' }}>¥{(parseFloat(v) / 10000).toFixed(1)}</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', title: t('reports.netProfit'), dataIndex: 'profit', key: 'profit',
render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}</b> render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}{t('common.tenThousand')}</b>
}, },
]; ];
const categoryColumns = [ const categoryColumns = [
{ title: '分类', dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v }, { title: t('finance.level2Category'), 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: t('finance.amount'), dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: '笔数', dataIndex: 'count', align: 'center' 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); 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)}%` : '-'; return total > 0 ? `${(parseFloat(r.total_amount) / total * 100).toFixed(1)}%` : '-';
} }
@@ -95,30 +121,30 @@ const ReportsPage: React.FC = () => {
return ( return (
<div style={{ padding: isMobile ? 8 : 24 }}> <div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}> <div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title> <Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>{t('reports.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph> <Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('reports.description')}</Paragraph>
</div> </div>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}> <Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col xs={8} sm={8}> <Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}> <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> </Card>
</Col> </Col>
<Col xs={8} sm={8}> <Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}> <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> </Card>
</Col> </Col>
<Col xs={8} sm={8}> <Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}> <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> </Card>
</Col> </Col>
</Row> </Row>
<Card title="月度财务报表" size="small" style={{ marginBottom: 16 }} <Card title={t('reports.monthlyReport')} size="small" style={{ marginBottom: 16 }}
extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder="选择月份" />} extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder={t('reports.selectMonth')} />}
> >
<Spin spinning={loading}> <Spin spinning={loading}>
<Table <Table
@@ -133,7 +159,7 @@ const ReportsPage: React.FC = () => {
return ( return (
<Table.Summary fixed> <Table.Summary fixed>
<Table.Summary.Row> <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={1}><strong>¥{ti.toLocaleString()}</strong></Table.Summary.Cell>
<Table.Summary.Cell index={2}><strong>¥{te.toLocaleString()}</strong></Table.Summary.Cell> <Table.Summary.Cell index={2}><strong>¥{te.toLocaleString()}</strong></Table.Summary.Cell>
<Table.Summary.Cell index={3}> <Table.Summary.Cell index={3}>
@@ -150,21 +176,21 @@ const ReportsPage: React.FC = () => {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{incomeCategories.length > 0 && ( {incomeCategories.length > 0 && (
<Col xs={24} md={8}> <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" /> <Table dataSource={incomeCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card> </Card>
</Col> </Col>
)} )}
{projectCategories.length > 0 && ( {projectCategories.length > 0 && (
<Col xs={24} md={8}> <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" /> <Table dataSource={projectCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card> </Card>
</Col> </Col>
)} )}
{companyCategories.length > 0 && ( {companyCategories.length > 0 && (
<Col xs={24} md={8}> <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" /> <Table dataSource={companyCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card> </Card>
</Col> </Col>
+5 -1
View File
@@ -10,6 +10,8 @@ export interface User {
role: 'admin' | 'manager' | 'user' | 'finance' role: 'admin' | 'manager' | 'user' | 'finance'
department?: string department?: string
avatar?: string avatar?: string
passport?: string
driverLicense?: string
token?: string token?: string
} }
@@ -93,7 +95,9 @@ export const useAuthStore = create<AuthState>()(
email: userData.email, email: userData.email,
role: userData.role, role: userData.role,
department: userData.department, department: userData.department,
avatar: userData.avatar avatar: userData.avatar,
passport: userData.passport,
driverLicense: userData.driverLicense
} }
set({ set({
+12 -4
View File
@@ -7,7 +7,7 @@ interface LanguageState {
currentLanguage: LanguageCode currentLanguage: LanguageCode
setLanguage: (code: LanguageCode) => void setLanguage: (code: LanguageCode) => void
getLanguageInfo: () => any getLanguageInfo: () => any
t: (key: string) => string t: (key: string, params?: Record<string, string | number>) => string
} }
export const useLanguageStore = create<LanguageState>()( export const useLanguageStore = create<LanguageState>()(
@@ -33,7 +33,7 @@ export const useLanguageStore = create<LanguageState>()(
return getLanguage(get().currentLanguage) return getLanguage(get().currentLanguage)
}, },
t: (key: string): string => { t: (key: string, params?: Record<string, string | number>): string => {
const translation = getTranslation(get().currentLanguage) const translation = getTranslation(get().currentLanguage)
const keys = key.split('.') const keys = key.split('.')
let result: any = translation let result: any = translation
@@ -42,11 +42,19 @@ export const useLanguageStore = create<LanguageState>()(
if (result && typeof result === 'object') { if (result && typeof result === 'object') {
result = result[k] result = result[k]
} else { } 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
} }
}), }),
{ {